Compare commits

...
32 Commits
Author SHA1 Message Date
gib 25be69fa30 Watch: review fixes for action errors that vanished, feed refreshes dropped or overwritten around a change, a fresh thread screen per route, errors cleared on sign out, notification actions only while paired, cache writes off the main thread and capped at 30 threads, Ask before acting and Load earlier races, two-letter search, C# headings, and strictly encoded query values 2026-11-02 15:29:15 -05:00
gib 99b300855e Gateway: review fixes for stale summaries, history cursors that survive a moving window, timeouts on T3 calls, push size, T3's snooze rules, and input checks
Gateway / check (push) Successful in 9s
Gateway / image (push) Successful in 21s
2026-11-02 14:59:32 -05:00
gib edc4b88990 Watch: snooze finished threads, name the files a file approval would edit, Settle on finished notifications, an instant launch from the saved feed, plain Loading states, and no more cancelled errors
Gateway / check (push) Successful in 9s
Gateway / image (push) Successful in 22s
Gateway: an approval push for a file change says which files it would edit
2026-11-02 14:45:34 -05:00
gib 4dc4e57dac 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
2026-11-02 14:20:39 -05:00
gib d10df56cc9 Watch: the Subagents section on the thread screen collapses behind a header with the count and how many wait or work, and opens on its own when one waits 2026-11-02 14:12:45 -05:00
gib 480db90e98 Docs: question notification actions and the Phase 4 screenshots 2026-11-02 14:06:20 -05:00
gib c34b046aae Docs: setup guide says where the watch's team and bundle IDs live 2026-11-02 14:01:28 -05:00
gib bfcbb69550 Watch: what changed on Recent cards and the thread screen, search in All threads, a switching complication with Allow and Deny in the Smart Stack, and question options on notifications 2026-11-02 13:56:10 -05:00
gib f8241e42bb Docs: public README, setup guide and architecture, generic deploy files, and the T3 Code notice
Gateway / check (push) Successful in 9s
Gateway / image (push) Successful in 20s
2026-11-02 13:51:19 -05:00
gib b08fd9bdb1 Gateway: changed files, search across hosts, a richer complication summary, and question options in pushes
Gateway / check (push) Successful in 10s
Gateway / image (push) Successful in 23s
2026-11-02 13:39:49 -05:00
gib 45251b1a43 Watch: history pages can hold more than 20 messages 2026-11-02 13:10:08 -05:00
gib d916bfbab5 Watch: activity line on working cards, All threads browsing, a thread's subagents, Load earlier, and markdown messages 2026-11-02 13:10:02 -05:00
gib abd2fd0e13 Gateway: keep whole T3 history pages, and show a command's first line as its activity
Gateway / check (push) Successful in 8s
Gateway / image (push) Successful in 19s
2026-11-02 12:44:08 -05:00
gib 52210d0223 Gateway: activity line, thread history paging, a project's threads, and a thread's subagents
Gateway / check (push) Successful in 9s
Gateway / image (push) Successful in 21s
2026-11-02 12:42:44 -05:00
gib 6bc9cac6ca Watch: notification switches, a model list and Ask before acting on new threads, and Ask before acting on a thread 2026-11-02 12:10:48 -05:00
gib 3cc7e40852 Gateway: per-watch notification switches, model list for new threads, and Ask before acting
Gateway / check (push) Successful in 9s
Gateway / image (push) Successful in 21s
2026-11-02 12:02:53 -05:00
gib a5222e14e8 Watch: keep Recent past 10 behind an N more row with a full list, and settle all in one call 2026-11-01 22:32:19 -05:00
gib 2a127327bf Gateway: keep finished threads in Recent until settled, with a full list and settle-all route
Gateway / check (push) Successful in 9s
Gateway / image (push) Successful in 23s
2026-11-01 22:26:41 -05:00
gib cf8995e63f Watch: show subagent requests on the parent's card, and settle threads by swipe, Settle all and the thread screen 2026-11-01 20:26:38 -05:00
gib a4754b65b0 Gateway: show subagent requests on the parent's card, and settle threads
Gateway / check (push) Successful in 8s
Gateway / image (push) Successful in 21s
2026-11-01 20:15:22 -05:00
gib a8b6375afe Mocks: three directions for subagent requests and settling 2026-11-01 20:07:32 -05:00
gib 442036b22f Gateway: hide delegated threads unless they wait on the user
Gateway / check (push) Successful in 10s
Gateway / image (push) Successful in 22s
2026-11-01 19:54:08 -05:00
gib 221622b574 Watch: draw the status gear in black so it shows on its tinted button 2026-11-01 17:59:34 -05:00
gib 7ca0362745 Watch: status, environment and notifications screens
A gear on the feed opens a status overview that leads with what is wrong:
offline hosts, hosts on a fallback route, tokens expiring within 7 days,
and push problems, with healthy hosts folded into one row. Each host opens
its route ladder with Reconnect. The notifications page shows registration,
the last push, Send test and Unpair. Demo mode gains a Laptop on its VPN
fallback, and snapshot tests render the three screens.
2026-11-01 15:16:18 -05:00
gib 9707daf1fe Gateway: status route with per-route results, test push, reconnect and unpair
Gateway / check (push) Successful in 9s
Gateway / image (push) Successful in 24s
2026-11-01 15:04:30 -05:00
gib e6fd2b8f51 Mocks: three directions for the settings and status screens 2026-11-01 13:36:24 -05:00
gib 277683d5ee Renew hosts with direct WireGuard routes first and public URLs as fallbacks 2026-11-01 13:25:39 -05:00
gib 42b6a4920c Gateway: fallback routes per host, for the laptop's VPN address
Gateway / check (push) Successful in 9s
Gateway / image (push) Successful in 22s
2026-11-01 13:20:02 -05:00
gib f46c803ad4 Move the gateway to junior.gib: CI deploy, compose copy, renewal script, docs
Gateway / check (push) Successful in 7s
Gateway / image (push) Successful in 19s
2026-11-01 13:07:18 -05:00
gibandClaude Opus 5.5 27469dbc77 Watch: share with the complication through a Keychain group, not an App Group
Automatic signing with an App Store Connect API key registers bundle IDs and
profiles but cannot create or assign App Groups, so the archive failed. A
keychain-access-groups entry needs no portal setup. The demo flag and last
summary move into the same group; tests cover the round trip and the
migration of a pre-complication token.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:15:31 -05:00
gibandClaude Opus 5.5 67992dbf43 Watch: snapshot tests render the complication and multi-select screenshots
ImageRenderer draws the real views with demo data, since the watch simulator
is not drivable here. README covers the widget target and how to add it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:06:38 -05:00
gibandClaude Opus 5.5 0994fd7d5a Watch: waiting-count complication and multi-select questions
A WidgetKit extension (accessoryCircular, Rectangular, Inline, Corner) shows
GET /v1/summary. The device token moves to the App Group's Keychain access
group on launch so the extension can read it without re-pairing; the app
reloads the timeline when its feed counts change and on notifications.
Multi-select questions toggle options and send an array of values.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:06:38 -05:00
116 changed files with 10244 additions and 639 deletions

No files matched your search

+8 -7
View File
@@ -1,11 +1,11 @@
name: Gateway name: Gateway
# Every push touching the gateway: typecheck and tests. On main, also build the image, push it to # Every push touching the gateway: typecheck and tests. On main, also build the image, push it to
# the Gitea registry tagged with the commit and `latest`, and ask server-gib to deploy it now. # the Gitea registry tagged with the commit and `latest`, and ask junior.gib (the VPS) to deploy it
# Watchtower's nightly pull there is the fallback when the deploy trigger fails. # now. Its nightly update-containers timer is the fallback when the deploy trigger fails.
# #
# Uses the user-level secrets REGISTRY_USER and REGISTRY_PASSWORD, and the repo secret # Uses the user-level secrets REGISTRY_USER and REGISTRY_PASSWORD, and the repo secret
# DEPLOY_SSH_KEY_HOME_B64, a base64 encoded key that server-gib only accepts for # DEPLOY_SSH_KEY_VPS_B64, a base64 encoded key that junior.gib only accepts for
# ~/Server/ci-deploy.sh. # ~/Server/ci-deploy.sh.
on: on:
@@ -79,10 +79,11 @@ jobs:
docker push "$IMAGE:latest" docker push "$IMAGE:latest"
- name: Deploy - name: Deploy
# The secret is the private key base64 encoded on one line. The runner prints each step's # 192.168.2.2 is junior's WireGuard address, reachable from every runner. The secret is the
# private key base64 encoded on one line. The runner prints each step's
# env, and Gitea only masks a secret it can match in full, which a multi-line key defeats. # env, and Gitea only masks a secret it can match in full, which a multi-line key defeats.
env: env:
DEPLOY_SSH_KEY_B64: ${{ secrets.DEPLOY_SSH_KEY_HOME_B64 }} DEPLOY_SSH_KEY_B64: ${{ secrets.DEPLOY_SSH_KEY_VPS_B64 }}
run: | run: |
install -m600 /dev/null "$HOME/.deploy_key" install -m600 /dev/null "$HOME/.deploy_key"
printf '%s' "$DEPLOY_SSH_KEY_B64" | base64 -d > "$HOME/.deploy_key" printf '%s' "$DEPLOY_SSH_KEY_B64" | base64 -d > "$HOME/.deploy_key"
@@ -90,6 +91,6 @@ jobs:
-o BatchMode=yes \ -o BatchMode=yes \
-o StrictHostKeyChecking=accept-new \ -o StrictHostKeyChecking=accept-new \
-o ConnectTimeout=15 \ -o ConnectTimeout=15 \
[email protected].1.2 wristcode \ [email protected].2.2 wristcode \
|| echo "::warning::deploy trigger failed; Watchtower will deploy at 04:00 instead" || echo "::warning::deploy trigger failed; the nightly update timer will deploy instead"
rm -f "$HOME/.deploy_key" rm -f "$HOME/.deploy_key"
+2
View File
@@ -12,3 +12,5 @@ DerivedData/
build/ build/
*.p8 *.p8
gateway/data/ gateway/data/
# Your machines for scripts/renew-hosts.sh
scripts/hosts.conf
+26
View File
@@ -0,0 +1,26 @@
WristCode includes code ported from T3 Code (https://github.com/pingdotgg/t3code):
gateway/src/awareness.ts is ported from packages/shared/src/agentAwareness.ts.
T3 Code is released under the MIT License:
MIT License
Copyright (c) 2026 T3 Tools Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+47 -36
View File
@@ -1,58 +1,69 @@
# WristCode # WristCode
An Apple Watch client for [T3 Code](https://t3.codes). Browse threads across every T3 Code An Apple Watch app for [T3 Code](https://t3.codes). It shows every T3 Code environment you run on
environment you run, reply by dictation, start new threads, and get a notification when an agent your wrist: which agents need you, what each one is doing right now, and what finished. You can
finishes. It works over the watch's own cellular connection, with no phone nearby. approve commands, answer questions, reply by dictation and start new threads. It works over the
watch's own cellular connection, with no phone nearby.
Early work in progress. <p>
<img src="watch/screenshots/feed-activity.png" width="200" alt="Working threads with what each agent is doing">
<img src="watch/screenshots/thread-approval.png" width="200" alt="A command waiting for approval">
<img src="watch/screenshots/thread-earlier.png" width="200" alt="A finished thread with its messages">
</p>
## What it does
- A feed of every thread across your machines. Threads waiting on you come first, with Allow and
Deny right on the card. Working threads show the step the agent is on, such as the command it is
running or the file it is editing. Finished threads say how many files and lines they changed.
- Notifications when a thread needs you, finishes or fails. Approvals and single-choice questions
can be answered from the notification. You choose which kinds you get.
- Replies and new threads by dictation. A new thread can use any model the host offers, and can
start in a mode that asks before every action.
- Every thread, settled ones too, by host and project, and search across all of them.
- Subagent requests show up on the card of the thread that started them.
- Complications and a Smart Stack widget with the request that has waited longest, or what is
running now.
- A status screen with each host's connection and route, and a test notification.
## How it fits together ## How it fits together
watchOS only allows WebSockets for audio streaming apps, and T3 Code clients talk to their server watchOS only lets audio apps use WebSockets, and T3 Code clients talk to their server over one. So
over a WebSocket. So the watch talks HTTPS to a small gateway, and the gateway talks to each T3 Code the watch talks HTTPS to a small gateway, and the gateway keeps a WebSocket open to each T3 Code
server. server.
``` ```
Apple Watch --HTTPS--> gateway --WebSocket--> T3 Code server (one per environment) Apple Watch --HTTPS--> gateway --WebSocket--> T3 Code server (one per machine)
| |
+--APNs--> notifications on the watch +--APNs--> notifications on the watch
``` ```
- `gateway/` is the gateway: Bun and TypeScript, shipped as a container image. - `gateway/` is the gateway, in Bun and TypeScript, shipped as a container image.
- `watch/` is the watchOS app: SwiftUI, watch only, no iPhone companion. - `watch/` is the watchOS app in SwiftUI. It is watch only, with no iPhone app.
- `docs/` holds the design notes and the T3 Code protocol reference. - `deploy/` has a compose file and an example `.env` for the gateway.
- `mocks/` holds static UI mocks of the watch screens. - `scripts/renew-hosts.sh` renews the gateway's T3 sessions.
- `docs/` has the [setup guide](docs/setup.md), the [architecture](docs/architecture.md), the
[gateway API](docs/api.md) and the [T3 Code protocol notes](docs/t3-protocol.md).
- `mocks/` has the static HTML mocks I drew before building each screen.
## Running the gateway ## Setting it up
The gateway is one container with one SQLite file in `/data`. [`deploy/compose.yml`](deploy/compose.yml) You need:
is the stack on server-gib, and [`gateway/Containerfile`](gateway/Containerfile) builds the image.
CI builds and deploys every push to `main` that touches `gateway/`.
Operators use the `wristcode` CLI inside the container: - One or more machines running T3 Code.
- A machine to run the gateway container, reachable from the internet over HTTPS so the watch can
use it on cellular. It needs to reach each T3 Code server, over your LAN, a VPN or a public URL.
- An Apple developer account, to build the watch app and to send notifications.
```sh [docs/setup.md](docs/setup.md) walks through running the gateway, connecting your T3 Code
# Connect a T3 Code server. Make the one-time credential on that server first: machines, building the app and pairing your watch.
t3 auth pairing create --scope orchestration:read --scope orchestration:operate --label "WristCode gateway"
docker exec -it wristcode-gateway wristcode host add https://t3mac.gbrown.org <credential> --label "Mac mini"
# Pair a watch: enter the printed code on the watch within 5 minutes. ## Status
docker exec -it wristcode-gateway wristcode pair
docker exec -it wristcode-gateway wristcode host list I use it every day with five machines. It is young, and the gateway API can change between
docker exec -it wristcode-gateway wristcode device list versions, so update the gateway and the app together.
docker exec -it wristcode-gateway wristcode device revoke <id or name>
```
The gateway exchanges the credential for a 30 day T3 session limited to reading and driving
threads, so the long-lived token never leaves the gateway. T3 sessions cannot be refreshed. A week
before one expires, the watch gets a notification. [`scripts/renew-hosts.sh`](scripts/renew-hosts.sh)
renews every host in one go over SSH, passing each one-time credential straight to the gateway
without printing it. Edit its host list to match your machines.
For local development, run `bun --watch src/main.ts` in `gateway/` with `WRISTCODE_DB` pointing at
a scratch file. Without APNs settings, pushes are logged instead of sent.
## License ## License
MIT MIT. See [LICENSE](LICENSE). `gateway/src/awareness.ts` is ported from T3 Code, which is also MIT
licensed. See [NOTICE](NOTICE).
+12
View File
@@ -0,0 +1,12 @@
# Copy to .env beside compose.yml. Without the APNS_ settings the gateway still runs, but it only
# logs notifications instead of sending them.
# The APNs auth key from your Apple developer account (Keys, with Apple Push Notifications on).
APNS_KEY_ID=ABC123DEFG
APNS_TEAM_ID=ABCDE12345
# The .p8 file, placed in ./data so the container sees it at /data.
APNS_KEY_PATH=/data/apns.p8
# The watch app's bundle ID. Pushes are addressed to it.
APNS_TOPIC=com.example.wristcode.watchkitapp
TZ=UTC
+18 -21
View File
@@ -1,27 +1,24 @@
# The gateway on server-gib, at ~/Server/Wristcode/compose.yml. Nginx Proxy Manager serves # The WristCode gateway as one container with one SQLite file. Put it behind a reverse proxy that
# wristcode.gbrown.org and forwards to wristcode-gateway:8080 over the nginx-bridge network. # serves HTTPS on a public name, such as wristcode.example.com, and forwards to port 8080. The
# CI deploys new images through ~/Server/ci-deploy.sh. Watchtower's nightly pull is the fallback. # watch needs that name reachable over cellular.
# #
# .env beside this file (mode 600) holds the APNs settings: # .env beside this file (mode 600). See .env.example.
# APNS_KEY_ID=... the key's ID from the Apple Developer portal #
# APNS_TEAM_ID=GQJNM5YK2M # Operator CLI: docker compose exec gateway wristcode <host add|pair|host list|device list|...>
# APNS_KEY_PATH=/data/apns.p8 the .p8 file, placed in ./data
name: wristcode
networks:
nginx-bridge:
external: true
services: services:
wristcode-gateway: gateway:
image: git.gbrown.org/gib/wristcode-gateway:latest image: ${WRISTCODE_IMAGE:-wristcode-gateway:latest}
build:
context: ../gateway
dockerfile: Containerfile
container_name: wristcode-gateway container_name: wristcode-gateway
hostname: wristcode-gateway
networks: ['nginx-bridge']
env_file: [.env] env_file: [.env]
environment: ports:
- TZ=America/New_York - "127.0.0.1:8080:8080" # for a proxy on the same machine. Drop it if the proxy shares a network.
volumes: volumes:
- ./data:/data - ./data:/data # the image runs as uid 1000, which must be able to write here
labels: ['com.centurylinklabs.watchtower.enable=true']
restart: unless-stopped restart: unless-stopped
healthcheck:
test: wget -qO- http://127.0.0.1:8080/v1/health || exit 1
interval: 30s
start_period: 10s
+243 -16
View File
@@ -4,7 +4,7 @@ The watch app talks to the gateway over HTTPS with JSON bodies. The TypeScript t
[`gateway/src/contract.ts`](../gateway/src/contract.ts) are the source of truth, and the watch [`gateway/src/contract.ts`](../gateway/src/contract.ts) are the source of truth, and the watch
mirrors them in `watch/WristCode/API/Models.swift`. Change both together. mirrors them in `watch/WristCode/API/Models.swift`. Change both together.
The base URL is `https://wristcode.gbrown.org`. Every route except `GET /v1/health` and The base URL is your gateway's public HTTPS address, such as `https://wristcode.example.com`. Every route except `GET /v1/health` and
`POST /v1/pair` needs `Authorization: Bearer <deviceToken>`. Timestamps are ISO 8601 strings in UTC. `POST /v1/pair` needs `Authorization: Bearer <deviceToken>`. Timestamps are ISO 8601 strings in UTC.
Errors return a non-2xx status with `{ "error": "<message for a person>" }`. Errors return a non-2xx status with `{ "error": "<message for a person>" }`.
@@ -42,6 +42,13 @@ PUT /v1/device/push
Debug builds use `sandbox`, TestFlight and App Store builds use `production`. Debug builds use `sandbox`, TestFlight and App Store builds use `production`.
To unpair, the watch revokes its own token:
```
DELETE /v1/device
204
```
## Feed ## Feed
``` ```
@@ -51,13 +58,17 @@ GET /v1/feed
"needsYou": [FeedItem], "needsYou": [FeedItem],
"working": [FeedItem], "working": [FeedItem],
"recent": [FeedItem], "recent": [FeedItem],
"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 in the last 24 threads, most recently updated first. `recent` holds threads that completed or failed and are not
hours, at most 10. 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`. `incompatible`.
``` ```
@@ -68,14 +79,33 @@ FeedItem {
"phase": "waiting_for_approval", "phase": "waiting_for_approval",
"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.",
"request": PendingRequest | null "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
} }
``` ```
Threads an agent started for another thread (subagents and `delegate_task` children) do not get
their own cards. When one waits on you, its request moves onto the card of the thread that started
it. `via` names the subagent, `request` and `phase` are the subagent's, and the answer goes to the
subagent: `POST /v1/threads/<via.hostId>/<via.threadId>/respond`. A subagent keeps its own card
when the gateway cannot find its parent, or the parent already shows a request.
`phase` is one of `starting`, `running`, `waiting_for_approval`, `waiting_for_input`, `completed`, or `phase` is one of `starting`, `running`, `waiting_for_approval`, `waiting_for_input`, `completed`, or
`failed`. `lastMessage` is the agent's latest message trimmed to about 280 characters. It can be null `failed`. `lastMessage` is the agent's latest message trimmed to about 280 characters. It can be null
for a moment after a thread changes. for a moment after a thread changes.
`activity` is the newest step the agent has in progress. `kind` is `command`, `edit`, `search`,
`web`, `subagent`, `thinking` or `tool`, and `text` is the command's first line, the file name (without its
directory), the search, the subagent's title or the tool's name, trimmed to about 80 characters.
Monitors are left out, since they run for hours. It is null when no step is in progress. The gateway
rereads a running thread at most every 5 seconds, so it can lag that much.
`changes` totals the files the thread's latest run changed, counting only edits that ran. It is null when the run changed no
files, and, like `lastMessage`, until the gateway has read the thread.
``` ```
PendingRequest { PendingRequest {
"id": "...", "id": "...",
@@ -83,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 }`.
@@ -96,11 +133,28 @@ 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 } 200 { "item": FeedItem, "messages": [Message], "canStop": true, "askFirst": false,
"earlierCursor": "w12" | null, "subagents": [ThreadRow], "changedFiles": [ChangedFile] }
ChangedFile { "path": "gateway/Containerfile", "name": "Containerfile", "additions": 31, "deletions": 0 }
ThreadRow { "hostId": "...", "threadId": "...", "title": "Check PATH", "phase": "running" | null,
"settled": false, "updatedAt": "..." }
Message { "id": "...", "role": "assistant", "text": "...", "createdAt": "..." } Message { "id": "...", "role": "assistant", "text": "...", "createdAt": "..." }
``` ```
`messages` holds the last 20, oldest first, each trimmed to 2000 characters. `messages` holds the last 20, oldest first, each trimmed to 2000 characters. `changedFiles` lists
the files the latest run changed, most lines changed first. `subagents` lists the
threads this one started, its own subagents and `delegate_task` children on any host, newest first.
A ThreadRow's `phase` is null for a thread that never ran or was stopped.
```
GET /v1/threads/:hostId/:threadId/history?cursor=<earlierCursor>
200 { "messages": [Message], "earlierCursor": "..." | null }
```
The messages before the ones the watch has, oldest first: 20 at a time at first, then one T3 history
page at a time, which can hold more. Pass the returned `earlierCursor` for
the page before that. It is null at the start of the thread. Cursors are opaque.
``` ```
POST /v1/threads/:hostId/:threadId/messages POST /v1/threads/:hostId/:threadId/messages
@@ -126,18 +180,109 @@ POST /v1/threads/:hostId/:threadId/stop
202 202
``` ```
```
POST /v1/threads/:hostId/:threadId/settle
202
```
Settles a thread, as in T3. A settled thread that finished or failed leaves the feed.
```
POST /v1/threads/:hostId/:threadId/mode
{ "askFirst": true }
202
```
Turns Ask before acting on or off for a thread. On sets T3's `approval-required` mode. Off sets the
host's default mode, or `full-access` when that default is also `approval-required`. `askFirst` in
the thread detail says which mode the thread is in.
```
GET /v1/recent
200 { "items": [FeedItem] }
```
Every unsettled finished thread, newest first. `lastMessage` is null for threads the gateway has not
read yet, since it does not fetch them all for this list.
```
POST /v1/recent/settle
200 { "settled": 11, "failed": 0 }
```
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,
and one that fails after it was snoozed comes back to Recent, the same rules T3 uses. A snoozed
thread that finishes again does not push.
Thread detail fills `request` and `via` the same way as the feed when a subagent waits on you.
## 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
```
GET /v1/projects/:hostId/:projectId/threads
200 { "items": [ThreadRow] }
```
A project's newest 50 threads, settled ones included, newest first. Subagents are left out and
listed under their parents instead. Hosts and projects come from `GET /v1/projects`.
## New thread ## New thread
@@ -145,33 +290,115 @@ GET /v1/summary
GET /v1/projects GET /v1/projects
200 { "hosts": [{ "id": "...", "label": "Mac mini", "status": "online", "error": null, 200 { "hosts": [{ "id": "...", "label": "Mac mini", "status": "online", "error": null,
"projects": [{ "id": "...", "title": "wristcode", "model": "claude-opus-5-5" }] }] } "projects": [{ "id": "...", "title": "wristcode", "model": "claude-opus-5-5", "instanceId": "claudeAgent" }],
"providers": [AgentProvider] }] }
AgentProvider { "instanceId": "claudeAgent", "name": "Claude", "canAskFirst": true,
"models": [{ "slug": "claude-opus-5-5", "name": "Claude Opus 5.5" }] }
``` ```
A project with `"model": null` has no default model, so the watch should not offer it. `model` and `instanceId` are the project's default, or null when it has none. `providers` lists the
providers that can start a thread now, without legacy models. `canAskFirst` is false when a provider
cannot run in `approval-required` mode.
``` ```
POST /v1/threads POST /v1/threads
{ "hostId": "...", "projectId": "...", "text": "Add a complication for the waiting count" } { "hostId": "...", "projectId": "...", "text": "Add a complication for the waiting count",
"model": { "instanceId": "claudeAgent", "slug": "claude-sonnet-5-5" }, "askFirst": true }
201 { "hostId": "...", "threadId": "..." } 201 { "hostId": "...", "threadId": "..." }
``` ```
The new thread uses the project's default model, or the host's, and the host's default runtime mode. `model` and `askFirst` are optional. Without `model`, the thread uses the project's default model or
the host's, with its options, such as reasoning effort. A picked model the host does not offer is a
400. `askFirst` picks the mode the same way as the mode route above.
## Notifications ## Notifications
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`. approvals and questions, `requestId`. An `input` push that asks one single-choice question also
carries `question: { id, options: [{ label, value }], allowCustomAnswer }`, with at most 4 options,
so the notification can offer them as buttons. Answer with `{ "<id>": "<value>" }` on the respond
route. A `test` push carries no custom keys. A subagent's push is titled with the thread that started it,
its body starts with "Subagent <title>:", and its `hostId` and `threadId` are the subagent's.
| Category | When | Actions the watch registers | | Category | When | Actions the watch registers |
| --- | --- | --- | | --- | --- | --- |
| `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 | 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 |
Each watch picks which pushes it gets. The status route returns its choices, and this route changes
them:
```
PUT /v1/device/notifications
{ "subagents": false }
200 { "needsMe": true, "finished": true, "failed": true, "subagents": false }
```
Send any of the four. The ones left out keep their value, and all four start on. `needsMe` covers
`approval` and `input` pushes from your own threads, and `subagents` covers them from subagents.
`finished` and `failed` match their categories. `host` and `test` pushes always go out.
## Status
Everything the settings screens show: each host and its routes, whether the gateway can push, and
whether this watch registered for pushes.
```
GET /v1/status
200 {
"hosts": [HostStatusDetail],
"gateway": { "startedAt": "...", "pushConfigured": true, "lastPush": PushResult | null },
"device": { "name": "Apple Watch", "pairedAt": "...", "pushRegistered": true, "apnsEnvironment": "production",
"notifications": NotificationPrefs }
}
HostStatusDetail {
"id": "...", "label": "Laptop", "status": "online", "error": null,
"activeUrl": "http://laptop-vpn.lan:3773",
"routes": [
{ "url": "http://laptop.lan:3773", "kind": "direct", "result": "failed", "error": "no answer in 5 s", "checkedAt": "..." },
{ "url": "http://laptop-vpn.lan:3773", "kind": "vpn", "result": "ok", "error": null, "checkedAt": "..." },
{ "url": "https://t3-laptop.example.com", "kind": "public", "result": "untried", "error": null, "checkedAt": null }
],
"onlineSince": "...", "lastOnlineAt": null, "nextRetryAt": null,
"tokenExpiresAt": "2026-12-01T00:00:00.000Z", "serverVersion": "0.1.5"
}
PushResult { "ok": true, "at": "...", "status": 200, "reason": null }
```
`routes` are in the order the gateway tries them, and their results come from its latest connect.
A route is `untried` when an earlier one answered first. `kind` is a guess from the URL: `public`
for HTTPS, `vpn` when the hostname contains "vpn", and `direct` otherwise. `activeUrl` is null unless
the host is online. `nextRetryAt` is set while the gateway waits to try again. `lastPush` is Apple's
answer to the most recent push to any watch. A `status` of 0 means the push never reached Apple.
```
POST /v1/hosts/:hostId/reconnect
202
```
Connects again from the first route, skipping any backoff wait. Use it when a better route may be
back, such as the LAN after getting home. The gateway otherwise stays on a working fallback until
that connection drops.
```
POST /v1/device/test-push
200 PushResult
```
Sends a test notification to the watch asking and returns Apple's answer. A failure names its cause,
for example `BadDeviceToken`, which usually means a push environment mismatch.
## Health ## Health
+100
View File
@@ -0,0 +1,100 @@
# Architecture
WristCode puts every T3 Code environment on an Apple Watch. From the watch you can see which agents
need you, answer their requests, reply, start and browse threads, and get notifications. It runs
over the watch's own cellular connection, so no phone is needed.
How a client talks to a T3 server is in [t3-protocol.md](t3-protocol.md), and the watch's API is in
[api.md](api.md).
## Shape
```
watchOS app --HTTPS + device token--> gateway (Bun) --WebSocket + bearer--> T3 server x N
|
+--HTTP/2--> APNs --> watch notifications
```
- The watch never talks to T3 directly. watchOS only allows WebSockets for audio apps, and every
T3 write goes over a WebSocket.
- The gateway keeps one `orchestration.subscribeShell` stream open per T3 server and holds every
thread shell in memory. It serves lists from memory, so the watch never waits on a T3 round trip
to draw the feed. Thread text comes from each thread's bounded snapshot, read on demand and
cached until the thread changes.
- The gateway is the only place that holds T3 credentials. The watch holds only its own device
token.
## Gateway
Bun and TypeScript, with no framework: `Bun.serve` for HTTP, `bun:sqlite` for state, and
`node:http2` for APNs. The T3 RPC client is hand-written and needs no Effect runtime or vendored
contracts.
| File | Job |
| --- | --- |
| `src/t3/rpc.ts` | Effect RPC JSON framing: request ids, pending calls, Ack on every Chunk, pings, and a silence timeout |
| `src/t3/host.ts` | One T3 server: route choice, ticket mint, connect, reconnect with backoff, shell state, and the HTTP reads |
| `src/t3/types.ts` | Narrow, lenient types for the fields the gateway reads. Unknown fields and values pass through |
| `src/hub.ts` | Every host in the database, kept connected and rereading the table for changes |
| `src/awareness.ts` | A port of T3's own awareness logic, which maps a thread shell to a phase |
| `src/views.ts` | The feed, thread detail, browse, search and summary responses |
| `src/notify.ts` | Watches phase changes and decides what to push |
| `src/push.ts` | Sends pushes to each paired watch that wants that kind |
| `src/apns.ts` | ES256 provider JWT and an HTTP/2 client for APNs |
| `src/api.ts` | The watch's REST API |
| `src/db.ts` | SQLite schema and queries |
| `src/cli.ts` | The operator CLI: `host add`, `host list`, `host remove`, `pair`, `device list`, `device revoke` |
### T3 credentials
`host add` takes a T3 pairing credential, not a long-lived token. The gateway exchanges it for a
30 day session limited to reading and driving threads, and stores that in SQLite. A pairing
credential works once and expires in minutes, so the session token never appears in a terminal.
T3 sessions cannot be refreshed, so the gateway checks each one daily and pushes a warning to the
watch a week before it expires. `scripts/renew-hosts.sh` renews them all in one go.
A host can have several routes, such as a LAN address, a VPN address and a public URL. The
gateway tries them in order on every connect and stays on the first that answers.
### Notifications
The gateway pushes when a thread starts waiting for you, finishes, or fails. On a cold start it
loads state without pushing, so a restart never replays old news. Each watch picks which kinds it
wants. A subagent's request is pushed under the thread that started it.
### Storage
```
hosts(id, label, url, alt_urls, environment_id, token, token_expires_at, created_at)
devices(id, name, token_hash, apns_token, apns_env, notifications, created_at, last_seen_at, revoked_at)
pairing_codes(code_hash, expires_at, used_at)
```
Device tokens and pairing codes are stored as SHA-256 hashes.
### Testing
Unit tests cover the parts that carry logic: RPC framing against an in-process fake server, route
choice and reconnects, the awareness port, feed ordering and subagent roll-up, notification
diffing, push filtering, and the API shapes. Run them with `bun test` in `gateway/`.
## Watch app
SwiftUI, watch only (`WKRunsIndependentlyOfCompanionApp`). XcodeGen generates the project from
`watch/project.yml`. A widget extension provides the complications and the Smart Stack widget. The
watch makes plain `URLSession` calls, and the device token lives in the Keychain, shared with the
widget.
The feed refreshes when it appears, when the app returns to the foreground, every 10 seconds while
it is on screen, and when a notification arrives.
[watch/README.md](../watch/README.md) covers building, the demo mode and the screenshot tests.
## Decisions
- Threads started from the watch use the project's default model and the host's default runtime
mode, unless the watch picks another model or turns on Ask before acting.
- An App Store app has to be an iOS app, so a watch-only app ships inside an empty iOS wrapper.
The watch app's own bundle ID is also its APNs topic.
- `delegate_task` children are not marked as subagents by T3, so the gateway recognizes them by
`delegatedFrom` and treats them as subagents of the thread that started them.
-205
View File
@@ -1,205 +0,0 @@
# WristCode implementation plan
WristCode puts every T3 Code environment on an Apple Watch. From the watch you can see which agents
need you, approve their requests, reply, start new threads, and get a notification when a turn
finishes. It runs over the watch's own cellular connection, so no phone is needed.
How a client talks to a T3 server is documented in [t3-protocol.md](t3-protocol.md). This plan
builds on it.
## Shape
```
watchOS app --HTTPS + device token--> gateway (Bun, server-gib) --WebSocket + bearer--> T3 server x N
|
+--HTTP/2--> APNs --> watch notifications
```
- The watch never talks to T3 directly. watchOS only allows WebSockets for audio apps, and every
T3 write goes over a WebSocket.
- The gateway keeps one `orchestration.subscribeShell` stream open per T3 server and holds every
thread shell in memory. The gateway serves list views from memory, so the watch never waits on a T3
round trip to draw the feed.
- The gateway is the only place that holds T3 credentials, and the watch only holds its own device
token.
## Gateway
Bun and TypeScript, with no framework. `Bun.serve` for HTTP, `bun:sqlite` for state, and
`node:http2` for APNs. The T3 RPC client is hand-written, about 150 lines, as the protocol doc
recommends. It needs no Effect and no vendored contracts.
### Modules
| File | Job |
| --- | --- |
| `src/t3/rpc.ts` | Effect RPC JSON framing: request ids, pending calls, Ack on every Chunk, 5 s Ping, 15 s silence timeout |
| `src/t3/host.ts` | One T3 server: well-known check, ticket mint, connect, reconnect with jittered backoff, shell state, resume with `afterSequence` |
| `src/t3/types.ts` | Narrow, lenient types for the fields we read. Unknown fields and enum values pass through |
| `src/awareness.ts` | Port of upstream `resolveThreadAwarenessPhaseV2`. Maps a thread shell to a phase |
| `src/feed.ts` | Builds the watch feed across all hosts: needs you, then working, then recently finished |
| `src/notify.ts` | Diffs phases on every `thread.updated` and on resync snapshots, and decides what to push |
| `src/apns.ts` | ES256 provider JWT plus an HTTP/2 client for APNs |
| `src/api.ts` | The watch REST API |
| `src/db.ts` | SQLite schema and queries |
| `src/cli.ts` | `wristcode host add`, `host list`, `host remove`, `pair`, `device list`, `device revoke` |
### T3 credentials
`host add` takes a T3 pairing credential, not a long-lived token:
```sh
# on the T3 host
t3 auth pairing create --scope orchestration:read --scope orchestration:operate --label "WristCode gateway"
# on server-gib
docker exec -it wristcode-gateway wristcode host add https://t3mac.gbrown.org <pairing credential>
```
The gateway exchanges the credential at `POST /oauth/token` for a 30 day bearer session and stores
it in SQLite. A pairing credential is single use and expires in 5 minutes, so the long-lived token
never appears in a terminal, a transcript, or a chat. Renewing works the same way with a fresh
pairing credential.
T3 sessions cannot be refreshed. The gateway checks `GET /api/auth/session` daily, and seven days
before a token expires it sends a push to the watch that names the host.
### Watch API
Every route except pairing and health takes `Authorization: Bearer <device token>`. The gateway
stores device tokens as SHA-256 hashes.
| Route | Purpose |
| --- | --- |
| `GET /v1/health` | Liveness, plus each host's connection state |
| `POST /v1/pair` | `{ code, deviceName }` returns `{ deviceToken }`. `wristcode pair` makes the codes. They are 6 digits, last 5 minutes, work once, and are rate limited |
| `PUT /v1/device/push` | `{ apnsToken, apnsEnvironment }` |
| `GET /v1/feed` | `{ needsYou, working, recent, hosts }`. Each item has host, project, title, phase, the last message trimmed to about 280 characters, the pending request summary, and `updatedAt` |
| `GET /v1/threads/:host/:thread` | The last 20 messages from `/bounded`, trimmed, plus the full pending request (prompt, command, options) |
| `POST /v1/threads/:host/:thread/messages` | `{ text }` sends `message.dispatch` with `queue_after_active` and `creationSource: "mobile"` |
| `POST /v1/threads/:host/:thread/respond` | `{ requestId, decision }` or `{ requestId, answers }` |
| `POST /v1/threads/:host/:thread/stop` | `thread.stop`, or `run.interrupt` when the host lacks `threadStop` |
| `GET /v1/projects` | Hosts, their projects, and each project's resolved default model, for the new thread screen |
| `POST /v1/threads` | `{ host, projectId, text }` calls `launchThread` with the resolved defaults |
Responses are JSON with stable field names. The watch decodes them with `Codable`, so this API is the
contract between the two halves and gets its own tests.
### Notifications
The gateway pushes when a top-level thread's phase changes to `completed`, `failed`,
`waiting_for_approval` or `waiting_for_input`. Each notification has a category the watch registers
actions for:
| Category | Actions |
| --- | --- |
| `approval` | Allow, Deny (calls `/respond`) |
| `finished` | Reply (text input, calls `/messages`), Open |
| `failed` | Open |
| `input` | Open |
On a cold start, the gateway loads state without pushing, so a restart never replays old news. After a
disconnect, the gateway diffs the resync snapshot against the last known state, so a turn that
finished while the socket was down still notifies once.
### Storage
```sql
hosts(id, label, url, environment_id, token, token_expires_at, last_sequence)
devices(id, name, token_hash, apns_token, apns_env, created_at, last_seen_at, revoked_at)
pairing_codes(code_hash, expires_at, used_at)
```
### Testing
Unit tests cover the parts that carry logic. They exercise RPC framing against an in-process fake WebSocket server
(including Ack flow and reconnect), the awareness port against upstream's own test cases, feed
ordering, notification diffing (cold start, resync, debouncing), and the API contract. The only end
to end check is a manual run against server-gib's own T3 before deploying.
## Container, CI and deploy
- `gateway/Containerfile` uses the official Bun image and a non-root user, and `/data` holds the
SQLite file. It is a plain OCI build, the same under podman and docker.
- `.gitea/workflows/gateway.yml` follows `secure-market`. On pushes touching `gateway/`, it runs
typecheck and tests on the `build` runner. On `main`, it also builds the image and pushes
`git.gbrown.org/gib/wristcode-gateway:<sha>` and `:latest`. It needs the repo secrets
`REGISTRY_USER` and `REGISTRY_PASSWORD`.
- On server-gib the stack lives in `~/Server/Wristcode/compose.yml`, modeled on `~/Server/Gbrown`.
It joins `nginx-bridge`, publishes no host ports, bind mounts `./data`, and carries the watchtower
label. The APNs key and other config come from a mode 600 `.env`.
- Nginx Proxy Manager gets a proxy host for `wristcode.gbrown.org` that points at
`wristcode-gateway:8080`, using the `*.gbrown.org` certificate. DNS and the Cloudflare tunnel
already cover the subdomain.
- Deploys: after pushing the image, CI calls `~/Server/ci-deploy.sh wristcode` on server-gib over
SSH with a deploy key, which pulls and restarts the stack. Watchtower's nightly pull is the
fallback.
- Local development: `bun --watch src/main.ts` against real T3 hosts, with no local image builds.
## Watch app
SwiftUI, watch only (`WKRunsIndependentlyOfCompanionApp`), with the newest watchOS as the
deployment target. XcodeGen generates the project from `watch/project.yml`. The team is
`GQJNM5YK2M`. The Mac mini builds it with `xcodebuild` and runs it in the watch simulator, and
TestFlight installs it on the watch using the App Store Connect key already on the Mac mini.
### Screens (mock B)
| Screen | Content |
| --- | --- |
| Pair | Gateway URL (prefilled with `https://wristcode.gbrown.org`) and a 6 digit code. Shown until paired |
| Feed | Count of threads waiting on you, then cards: needs you (with Allow and Deny on the card), working (with the agent's last line), recent (greyed) |
| Thread | The agent's latest message, large. Older messages are one scroll up. Reply by dictation or Scribble. Stop while running |
| Approval | Inline on the feed card and on the thread: the command or file, and the server's options |
| New thread | Host and project (defaults to the last used), model shown read only, task by dictation |
The feed refreshes on appear, when the app returns to the foreground, every 10 seconds while it is
on screen, and when a notification arrives. The watch makes plain `URLSession` HTTPS calls, and the
device token lives in the Keychain.
### Code layout
```
watch/
project.yml
WristCode/
WristCodeApp.swift app entry, notification registration
API/GatewayClient.swift async URLSession calls, Codable models
API/Models.swift
Auth/DeviceStore.swift Keychain
Views/PairView.swift
Views/FeedView.swift
Views/ThreadView.swift
Views/NewThreadView.swift
Notifications/NotificationHandler.swift categories and actions
```
## Phases
Each phase ends with something usable.
1. **Gateway read path.** RPC client, host connection, shell state, awareness, `host add` through
pairing, and `GET /v1/feed`. Checked against server-gib's T3 from the Fedora desktop.
2. **Gateway write path and pairing.** Send, respond, stop, launch, the projects route, watch
pairing codes, and device tokens. Checked with curl against a real thread.
3. **Ship the gateway.** Containerfile, CI, registry, the compose stack on server-gib, and the NPM
proxy host. All four hosts are added.
4. **Watch app MVP.** Pair, feed, thread, reply, approvals, and new thread, running in the simulator
against the deployed gateway.
5. **Notifications and the real watch.** An APNs key in the Apple Developer portal, the gateway's
APNs sender, notification actions, and a TestFlight build installed on the watch.
6. **Later.** Complications showing the count of threads waiting on you, a Smart Stack widget, token
expiry warnings, and multiple gateways.
Phases 1 and 2 happen on the Fedora desktop. Phase 4 runs on the Mac mini in parallel once the
phase 2 API is fixed. The gateway owns `gateway/`, the Mac mini owns `watch/`, and both pull before
they push to `docs/`.
## Decisions
- Threads started from the watch inherit the host's default runtime mode (`full-access` on these
hosts).
- CI deploys right away through a `wristcode` entry in `~/Server/ci-deploy.sh`, with Watchtower's
nightly pull as the fallback.
- The App Store record uses `org.gbrown.wristcode`, an empty iOS wrapper the App Store requires
around a watch-only app. The watch app itself is `org.gbrown.wristcode.watchkitapp`, which is also
its APNs topic.
+113
View File
@@ -0,0 +1,113 @@
# Setting up WristCode
This guide runs the gateway, connects your T3 Code machines to it, builds the watch app, and pairs
your watch. It takes about an hour, most of it in Apple's developer portal.
## 1. Run the gateway
The gateway is one container with one SQLite file in `/data`. Run it on a machine that is always
on and can reach every T3 Code server.
```sh
cd deploy
cp .env.example .env # fill in after step 2, or leave the APNS_ lines empty for now
mkdir data
docker compose up -d --build
```
`compose.yml` builds the image from `gateway/` and listens on `127.0.0.1:8080`. Podman works the
same with `podman compose`. The image runs as uid 1000, which needs to be able to write `data/`.
The watch needs to reach the gateway over cellular, so put it behind a reverse proxy that serves
HTTPS on a public name, such as `wristcode.example.com`, and forwards to port 8080. Check it with:
```sh
curl https://wristcode.example.com/v1/health
```
Every route except health and pairing needs a device token, which only pairing hands out.
## 2. Set up notifications
The gateway sends notifications through Apple's push service (APNs) with a key from your developer
account.
1. In [Certificates, IDs & Profiles](https://developer.apple.com/account/resources/authkeys/list),
make a key with Apple Push Notifications service enabled, and download its `.p8` file.
2. Put the file in `deploy/data/apns.p8`.
3. Fill in `deploy/.env`: the key's ID, your team ID, and `APNS_TOPIC`, which is the watch app's
bundle ID from step 4.
4. Restart the gateway with `docker compose up -d`.
Without these settings the gateway still works. It logs each notification instead of sending it.
## 3. Connect your T3 Code machines
The gateway logs in to each T3 Code server with a one-time pairing credential. On the T3 Code
machine:
```sh
t3 auth pairing create --scope orchestration:read --scope orchestration:operate --label "WristCode gateway"
```
Then, within a few minutes, on the gateway machine:
```sh
docker compose exec gateway wristcode host add https://t3-desktop.example.com <credential> --label "Desktop"
```
The gateway trades the credential for a 30 day session that can read and drive threads, and the
credential stops working. The label is what the watch shows.
A machine you can reach several ways can list fallbacks with `--alt-url`, preferred first. The
gateway uses the first route that answers, so put a LAN or VPN address first and a public URL
last:
```sh
docker compose exec gateway wristcode host add http://desktop.lan:3773 <credential> \
--label "Desktop" --alt-url https://t3-desktop.example.com
```
`wristcode host list` shows each host and when its session expires. T3 sessions cannot be renewed,
so a week before one expires the watch gets a notification. To renew, run `host add` again with a
fresh credential, or let `scripts/renew-hosts.sh` do every host over SSH. Copy
`scripts/hosts.conf.example` to `scripts/hosts.conf` and list your machines in it first.
## 4. Build the watch app
You need a Mac with Xcode and [XcodeGen](https://github.com/yonaskolb/XcodeGen).
1. Use your own team and bundle IDs. The project ships with mine: the team `GQJNM5YK2M` and the
bundle ID prefix `org.gbrown`. Replace both everywhere they appear under `watch/`, which
`grep -rn -e GQJNM5YK2M -e org.gbrown watch` lists. They are in `project.yml`, the entitlements,
the `Info.plist` files, `DeviceStore.swift`, the tests and `scripts/testflight.sh`. The watch
app's bundle ID, `<prefix>.wristcode.watchkitapp`, is the `APNS_TOPIC` from step 2.
2. Generate the project and open it:
```sh
cd watch
xcodegen generate
open WristCode.xcodeproj
```
3. Run the `WristCode` scheme on your watch. Xcode registers the bundle IDs and the push
capability for you with automatic signing.
To install it without Xcode attached, upload a build to TestFlight with
`watch/scripts/testflight.sh`. It needs an App Store Connect API key and an app record for
`<prefix>.wristcode`. [watch/README.md](../watch/README.md) has the details, plus a demo mode
that runs without a gateway.
## 5. Pair your watch
On the gateway machine:
```sh
docker compose exec gateway wristcode pair
```
It prints a 6 digit code that works once, for 5 minutes. On the watch, enter your gateway's URL
in place of the one filled in, and the code. The watch then asks to send notifications. Allow it, and send a test notification
from the gear button, then Notifications, to check the whole chain.
`wristcode device list` shows paired watches, and `wristcode device revoke <name>` unpairs one.
+1 -1
View File
@@ -9,7 +9,7 @@ WORKDIR /app
COPY package.json tsconfig.json ./ COPY package.json tsconfig.json ./
COPY src ./src COPY src ./src
# `wristcode <command>` for operators: docker exec -it wristcode-gateway wristcode pair # `wristcode <command>` for operators: podman exec -it wristcode-gateway wristcode pair
RUN printf '#!/bin/sh\nexec bun /app/src/cli.ts "$@"\n' > /usr/local/bin/wristcode \ RUN printf '#!/bin/sh\nexec bun /app/src/cli.ts "$@"\n' > /usr/local/bin/wristcode \
&& chmod +x /usr/local/bin/wristcode \ && chmod +x /usr/local/bin/wristcode \
&& mkdir -p /data && chown bun:bun /data && mkdir -p /data && chown bun:bun /data
+226 -13
View File
@@ -6,24 +6,50 @@ import type {
ErrorResponse, ErrorResponse,
NewThreadRequest, NewThreadRequest,
NewThreadResponse, NewThreadResponse,
NotificationPrefs,
PairRequest, PairRequest,
PairResponse, PairResponse,
ProjectsResponse, ProjectsResponse,
PushRegistration, PushRegistration,
RespondRequest, RespondRequest,
PushResult,
SettleRecentResponse,
SnoozeRequest,
SendMessageRequest, SendMessageRequest,
StatusResponse,
ThreadListResponse,
ThreadModeRequest,
} from "./contract.ts"; } from "./contract.ts";
import type { Db, DeviceRow } from "./db.ts"; import type { Db, DeviceRow } from "./db.ts";
import type { Hub } from "./hub.ts"; import type { Hub } from "./hub.ts";
import { notificationPrefs, type Pusher } from "./push.ts";
import { HostError, type T3Host } from "./t3/host.ts"; import { HostError, type T3Host } from "./t3/host.ts";
import { RpcError } from "./t3/rpc.ts"; import { RpcError } from "./t3/rpc.ts";
import type { ApprovalDecision, ModelSelection, ThreadShell } from "./t3/types.ts"; import type { ApprovalDecision, ModelSelection, ThreadShell } from "./t3/types.ts";
import { buildFeed, buildSummary, buildThreadDetail, hostSummary, type SummaryCache } from "./views.ts"; import {
agentProviders,
ASK_FIRST,
buildFeed,
buildRecent,
buildSnoozed,
buildSummary,
buildThreadDetail,
feedCandidates,
hostStatusDetail,
hostSummary,
loadHistory,
projectThreads,
searchThreads,
type SummaryCache,
} from "./views.ts";
export interface ApiDeps { export interface ApiDeps {
db: Db; db: Db;
hub: Hub; hub: Hub;
cache: SummaryCache; cache: SummaryCache;
pusher: Pusher;
/** When the gateway started, for the status screen. */
startedAt: string;
log?: (message: string) => void; log?: (message: string) => void;
} }
@@ -47,10 +73,12 @@ 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 });
export function createRoutes({ db, hub, cache, log = () => {} }: ApiDeps) { export function createRoutes({ db, hub, cache, pusher, startedAt, log = () => {} }: ApiDeps) {
let pairFailures: number[] = []; let pairFailures: number[] = [];
/** Wraps a handler with device auth and error mapping. */ /** Wraps a handler with device auth and error mapping. */
@@ -94,6 +122,29 @@ export function createRoutes({ db, hub, cache, log = () => {} }: ApiDeps) {
const resolveModel = (host: T3Host, projectId: string): ModelSelection | null => const resolveModel = (host: T3Host, projectId: string): ModelSelection | null =>
host.projects.get(projectId)?.defaultModelSelection ?? host.serverConfig?.settings?.defaultModelSelection ?? null; host.projects.get(projectId)?.defaultModelSelection ?? host.serverConfig?.settings?.defaultModelSelection ?? null;
/** Ask first, or else the host's default mode, or full access when that default also asks first. */
const runtimeMode = (host: T3Host, askFirst: boolean): string => {
const fallback = host.serverConfig?.settings?.defaultRuntimeMode ?? "full-access";
return askFirst ? ASK_FIRST : fallback === ASK_FIRST ? "full-access" : fallback;
};
/**
* The model for a new thread: the one the watch picked, if the host offers it, or the project's
* default. The default's options, such as reasoning effort, carry over only when it is kept.
*/
const pickModel = (host: T3Host, projectId: string, picked: NewThreadRequest["model"]): ModelSelection => {
const fallback = resolveModel(host, projectId);
if (picked && !(picked.instanceId === fallback?.instanceId && picked.slug === fallback.model)) {
const offered = agentProviders(host)
.find((provider) => provider.instanceId === picked.instanceId)
?.models.some((model) => model.slug === picked.slug);
if (!offered) throw new HttpError(400, "That model is not available on this host.");
return { instanceId: picked.instanceId, model: picked.slug };
}
if (!fallback) throw new HttpError(400, "That project has no default model. Pick one, or set one in T3 Code.");
return fallback;
};
return { return {
"/v1/health": { "/v1/health": {
GET: () => json({ ok: true, hosts: hub.list().map(hostSummary) }), GET: () => json({ ok: true, hosts: hub.list().map(hostSummary) }),
@@ -131,25 +182,127 @@ export function createRoutes({ db, hub, cache, log = () => {} }: ApiDeps) {
}), }),
}, },
"/v1/device": {
/** Unpairs the watch asking. */
DELETE: authed<"/v1/device">((_req, device) => {
db.devices.revoke(device.id);
log(`unpaired device ${device.name} (${device.id})`);
return new Response(null, { status: 204 });
}),
},
"/v1/device/notifications": {
PUT: authed<"/v1/device/notifications">(async (req, device) => {
const body = await readBody<Partial<NotificationPrefs>>(req);
const prefs = notificationPrefs(device);
for (const key of Object.keys(prefs) as Array<keyof NotificationPrefs>) {
if (typeof body[key] === "boolean") prefs[key] = body[key];
}
db.devices.setNotifications(device.id, JSON.stringify(prefs));
return json(prefs satisfies NotificationPrefs);
}),
},
"/v1/device/test-push": {
POST: authed<"/v1/device/test-push">(async (_req, device) =>
json(
(await pusher.sendTo(device, {
title: "WristCode",
body: "Test notification. Pushes reach this watch.",
category: "test",
data: {},
collapseId: "test",
})) satisfies PushResult,
),
),
},
"/v1/status": {
GET: authed((_req, device) => {
const expiries = new Map(hub.rows().map((row) => [row.id, row.token_expires_at]));
return json({
hosts: hub.list().map((host) => hostStatusDetail(host, expiries.get(host.id) ?? null)),
gateway: { startedAt, pushConfigured: pusher.configured, lastPush: pusher.last },
device: {
name: device.name,
pairedAt: device.created_at,
pushRegistered: device.apns_token !== null,
apnsEnvironment: device.apns_env,
notifications: notificationPrefs(device),
},
} satisfies StatusResponse);
}),
},
"/v1/hosts/:hostId/reconnect": {
POST: authed<"/v1/hosts/:hostId/reconnect">((req) => {
findHost(req.params.hostId).reconnect();
return accepted();
}),
},
"/v1/feed": { "/v1/feed": {
GET: authed(async () => json(await buildFeed(hub.list(), cache))), GET: authed(async () => json(await buildFeed(hub.list(), cache))),
}, },
"/v1/recent": {
GET: authed(() => json(buildRecent(hub.list(), cache))),
},
/** Settles every completed thread in Recent. Failed ones stay for the user to look at. */
"/v1/recent/settle": {
POST: authed(async () => {
const completed = feedCandidates(hub.list()).recent.filter(({ phase }) => phase === "completed");
const results = await Promise.allSettled(
completed.map(({ host, thread }) =>
host.dispatch({ type: "thread.settle", commandId: crypto.randomUUID(), threadId: thread.id }),
),
);
const failed = results.filter((result) => result.status === "rejected").length;
return json({ settled: results.length - failed, failed } satisfies SettleRecentResponse);
}),
},
"/v1/snoozed": {
GET: authed(() => json(buildSnoozed(hub.list(), cache))),
},
"/v1/summary": { "/v1/summary": {
GET: authed(() => json(buildSummary(hub.list()))), GET: authed(async () => json(await buildSummary(hub.list(), cache))),
}, },
"/v1/projects": { "/v1/projects": {
GET: authed(() => GET: authed(async () => {
json({ await Promise.all(hub.list().map((host) => host.refreshConfig()));
return json({
hosts: hub.list().map((host) => ({ hosts: hub.list().map((host) => ({
...hostSummary(host), ...hostSummary(host),
projects: [...host.projects.values()] projects: [...host.projects.values()]
.sort((a, b) => a.title.localeCompare(b.title)) .sort((a, b) => a.title.localeCompare(b.title))
.map((project) => ({ id: project.id, title: project.title, model: resolveModel(host, project.id)?.model ?? null })), .map((project) => {
const model = resolveModel(host, project.id);
return { id: project.id, title: project.title, model: model?.model ?? null, instanceId: model?.instanceId ?? null };
}),
providers: agentProviders(host),
})), })),
} satisfies ProjectsResponse), } satisfies ProjectsResponse);
), }),
},
"/v1/search": {
GET: authed(async (req) => {
const query = (new URL(req.url).searchParams.get("q") ?? "").trim().slice(0, 200);
if (query.length < 2) throw new HttpError(400, "Search for at least two letters.");
return json(await searchThreads(hub.list(), query));
}),
},
"/v1/projects/:hostId/:projectId/threads": {
GET: authed<"/v1/projects/:hostId/:projectId/threads">((req) => {
const host = findHost(req.params.hostId);
if (!host.projects.has(req.params.projectId)) throw new HttpError(404, "Unknown project.");
return json({ items: projectThreads(host, req.params.projectId) } satisfies ThreadListResponse);
}),
}, },
"/v1/threads": { "/v1/threads": {
@@ -159,8 +312,7 @@ export function createRoutes({ db, hub, cache, log = () => {} }: ApiDeps) {
if (!text) throw new HttpError(400, "Say what the thread should do."); if (!text) throw new HttpError(400, "Say what the thread should do.");
const host = findHost(String(body.hostId)); const host = findHost(String(body.hostId));
if (!host.projects.has(String(body.projectId))) throw new HttpError(404, "Unknown project."); if (!host.projects.has(String(body.projectId))) throw new HttpError(404, "Unknown project.");
const modelSelection = resolveModel(host, body.projectId); const modelSelection = pickModel(host, body.projectId, body.model);
if (!modelSelection) throw new HttpError(400, "That project has no default model. Set one in T3 Code.");
const result = await host.launchThread({ const result = await host.launchThread({
commandId: crypto.randomUUID(), commandId: crypto.randomUUID(),
creationSource: "mobile", creationSource: "mobile",
@@ -168,7 +320,7 @@ export function createRoutes({ db, hub, cache, log = () => {} }: ApiDeps) {
title: titleFrom(text), title: titleFrom(text),
generateTitle: true, generateTitle: true,
modelSelection, modelSelection,
runtimeMode: host.serverConfig?.settings?.defaultRuntimeMode ?? "full-access", runtimeMode: runtimeMode(host, body.askFirst === true),
interactionMode: "default", interactionMode: "default",
workspaceStrategy: { type: "root" }, workspaceStrategy: { type: "root" },
initialMessage: { messageId: crypto.randomUUID(), text, attachments: [] }, initialMessage: { messageId: crypto.randomUUID(), text, attachments: [] },
@@ -180,7 +332,16 @@ export function createRoutes({ db, hub, cache, log = () => {} }: ApiDeps) {
"/v1/threads/:hostId/:threadId": { "/v1/threads/:hostId/:threadId": {
GET: authed<"/v1/threads/:hostId/:threadId">(async (req) => { GET: authed<"/v1/threads/:hostId/:threadId">(async (req) => {
const { host, thread } = findThread(req.params.hostId, req.params.threadId); const { host, thread } = findThread(req.params.hostId, req.params.threadId);
return json(await buildThreadDetail(host, thread, cache)); return json(await buildThreadDetail(host, thread, cache, hub.list()));
}),
},
"/v1/threads/:hostId/:threadId/history": {
GET: authed<"/v1/threads/:hostId/:threadId/history">(async (req) => {
const { host, thread } = findThread(req.params.hostId, req.params.threadId);
const cursor = new URL(req.url).searchParams.get("cursor") ?? "";
if (!/^(w.{1,256}|h.{1,4096})$/.test(cursor)) throw new HttpError(400, "Send the earlierCursor from the thread.");
return json(await loadHistory(host, thread, cursor));
}), }),
}, },
@@ -225,6 +386,54 @@ export function createRoutes({ db, hub, cache, log = () => {} }: ApiDeps) {
}), }),
}, },
"/v1/threads/:hostId/:threadId/settle": {
POST: authed<"/v1/threads/:hostId/:threadId/settle">(async (req) => {
const { host, thread } = findThread(req.params.hostId, req.params.threadId);
await host.dispatch({ type: "thread.settle", commandId: crypto.randomUUID(), threadId: thread.id });
return accepted();
}),
},
"/v1/threads/:hostId/:threadId/mode": {
POST: authed<"/v1/threads/:hostId/:threadId/mode">(async (req) => {
const { host, thread } = findThread(req.params.hostId, req.params.threadId);
const { askFirst } = await readBody<ThreadModeRequest>(req);
if (typeof askFirst !== "boolean") throw new HttpError(400, "Send askFirst as true or false.");
await host.dispatch({
type: "thread.runtime-mode.set",
commandId: crypto.randomUUID(),
threadId: thread.id,
runtimeMode: runtimeMode(host, askFirst),
});
return accepted();
}),
},
"/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);
@@ -253,12 +462,16 @@ const isAnswers = (value: unknown): value is Record<string, string | string[]> =
(answer) => typeof answer === "string" || (Array.isArray(answer) && answer.every((item) => typeof item === "string")), (answer) => typeof answer === "string" || (Array.isArray(answer) && answer.every((item) => typeof item === "string")),
); );
/** The request's JSON object. Anything else, such as `null` or an array, is a 400. */
async function readBody<T>(req: Request): Promise<Partial<T> & T> { async function readBody<T>(req: Request): Promise<Partial<T> & T> {
let body: unknown;
try { try {
return (await req.json()) as Partial<T> & T; body = await req.json();
} catch { } catch {
throw new HttpError(400, "The body must be JSON."); throw new HttpError(400, "The body must be JSON.");
} }
if (typeof body !== "object" || body === null || Array.isArray(body)) throw new HttpError(400, "The body must be a JSON object.");
return body as Partial<T> & T;
} }
/** A thread title from the first line of the prompt. T3 replaces it with a generated one. */ /** A thread title from the first line of the prompt. T3 replaces it with a generated one. */
+23 -2
View File
@@ -14,6 +14,7 @@ export type Phase =
type AwarenessFields = Pick< type AwarenessFields = Pick<
ThreadShell, ThreadShell,
| "activityRunStatus" | "activityRunStatus"
| "delegatedFrom"
| "lineage" | "lineage"
| "latestRunStopRequested" | "latestRunStopRequested"
| "pendingBackgroundTasks" | "pendingBackgroundTasks"
@@ -24,12 +25,14 @@ type AwarenessFields = Pick<
/** /**
* The phase to show for a thread, or null when the watch should not show it: subagent children, * The phase to show for a thread, or null when the watch should not show it: subagent children,
* threads that never ran or were stopped, and finished work the user already settled. * threads that never ran or were stopped, and finished work the user already settled. Delegated
* threads show only while they wait on the user, so their approvals still reach the watch.
*/ */
export function threadPhase(thread: AwarenessFields): Phase | null { export function threadPhase(thread: AwarenessFields): Phase | null {
if (thread.lineage.relationshipToParent === "subagent") return null; if (thread.lineage.relationshipToParent === "subagent") return null;
const phase = resolvePhase(thread); const phase = resolvePhase(thread);
if (phase === null) return null; if (phase === null) return null;
if (thread.delegatedFrom && phase !== "waiting_for_approval" && phase !== "waiting_for_input") return null;
if (phase === "completed" && thread.latestRunStopRequested === true) return null; if (phase === "completed" && thread.latestRunStopRequested === true) return null;
if ( if (
thread.settledOverride === "settled" && thread.settledOverride === "settled" &&
@@ -41,7 +44,8 @@ export function threadPhase(thread: AwarenessFields): Phase | null {
return phase; return phase;
} }
function resolvePhase(thread: AwarenessFields): Phase | null { /** A thread's phase with none of threadPhase's hiding, for lists. Null when it never ran or was stopped. */
export function resolvePhase(thread: AwarenessFields): Phase | null {
const runStatus = thread.activityRunStatus ?? thread.status; const runStatus = thread.activityRunStatus ?? thread.status;
const request = thread.pendingRuntimeRequest; const request = thread.pendingRuntimeRequest;
if ( if (
@@ -71,6 +75,23 @@ function resolvePhase(thread: AwarenessFields): Phase | null {
// Commands and monitors are as often a dev server or its watchdog as a wait, so they hold // Commands and monitors are as often a dev server or its watchdog as a wait, so they hold
// completion only when the agent marks them awaited. // completion only when the agent marks them awaited.
/**
* Whether a snooze still hides the thread, as T3's own `isSnoozed` decides: until its time, unless
* the thread waits on someone, or failed after it was snoozed.
*/
export function isSnoozed(
thread: Pick<ThreadShell, "snoozedUntil" | "snoozedAt" | "latestRunCompletedAt" | "status" | "pendingRuntimeRequest" | "pendingGuestMessageCount">,
now = Date.now(),
): boolean {
const until = thread.snoozedUntil ? Date.parse(thread.snoozedUntil) : NaN;
if (!(until > now)) return false;
if (thread.pendingRuntimeRequest !== null || (thread.pendingGuestMessageCount ?? 0) > 0) return false;
const snoozedAt = thread.snoozedAt ? Date.parse(thread.snoozedAt) : null;
const completedAt = thread.latestRunCompletedAt ? Date.parse(thread.latestRunCompletedAt) : null;
const wokeOnError = thread.status === "failed" && (snoozedAt === null || (completedAt !== null && completedAt > snoozedAt));
return !wokeOnError;
}
function backgroundWorkHoldsCompletion(tasks: NonNullable<ThreadShell["pendingBackgroundTasks"]>): boolean { function backgroundWorkHoldsCompletion(tasks: NonNullable<ThreadShell["pendingBackgroundTasks"]>): boolean {
return tasks.some((task) => task.awaited === true || task.kind === "subagent" || task.kind === "background_task"); return tasks.some((task) => task.awaited === true || task.kind === "subagent" || task.kind === "background_task");
} }
+17 -6
View File
@@ -1,17 +1,20 @@
#!/usr/bin/env bun #!/usr/bin/env bun
// Operator commands. They edit the same SQLite file the running gateway reads, so changes apply // Operator commands. They edit the same SQLite file the running gateway reads, so changes apply
// within a few seconds. In the container: `docker exec -it wristcode-gateway wristcode <command>`. // within a few seconds. In the container: `podman exec -it wristcode-gateway wristcode <command>`.
import { loadConfig } from "./config.ts"; import { loadConfig } from "./config.ts";
import { openDb } from "./db.ts"; import { openDb } from "./db.ts";
import { hostUrls } from "./hub.ts";
import { ORCHESTRATION_PROTOCOL_VERSION, type EnvironmentDescriptor } from "./t3/types.ts"; import { ORCHESTRATION_PROTOCOL_VERSION, type EnvironmentDescriptor } from "./t3/types.ts";
const USAGE = `wristcode <command> const USAGE = `wristcode <command>
host add <url> <pairing-credential> [--label <name>] host add <url> <pairing-credential> [--label <name>] [--alt-url <url>]...
Connect a T3 Code server. Make the credential on that host with: Connect a T3 Code server. Make the credential on that host with:
t3 auth pairing create --scope orchestration:read --scope orchestration:operate --label "WristCode gateway" t3 auth pairing create --scope orchestration:read --scope orchestration:operate --label "WristCode gateway"
Run it again with a fresh credential to renew the gateway's token. Run it again with a fresh credential to renew the gateway's token.
--alt-url adds a fallback route to the same server, such as a VPN address, tried in order
when <url> does not answer. Repeat it for more.
host list host list
host remove <id or label> host remove <id or label>
pair pair
@@ -39,7 +42,7 @@ async function run() {
return hostAdd(rest); return hostAdd(rest);
case "host list": case "host list":
for (const host of db.hosts.list()) { for (const host of db.hosts.list()) {
console.log(`${host.label}\t${host.url}\t${host.id}\ttoken expires ${host.token_expires_at ?? "unknown"}`); console.log(`${host.label}\t${hostUrls(host).join(" ")}\t${host.id}\ttoken expires ${host.token_expires_at ?? "unknown"}`);
} }
return; return;
case "host remove": case "host remove":
@@ -69,9 +72,16 @@ async function run() {
/** Exchanges a T3 pairing credential for a bearer session and saves the host. */ /** Exchanges a T3 pairing credential for a bearer session and saves the host. */
async function hostAdd(args: string[]) { async function hostAdd(args: string[]) {
const labelFlag = args.indexOf("--label"); const positional: string[] = [];
const label = labelFlag === -1 ? undefined : args[labelFlag + 1]; const altUrls: string[] = [];
const [rawUrl, credential] = args.filter((_, index) => labelFlag === -1 || (index !== labelFlag && index !== labelFlag + 1)); let label: string | undefined;
for (let index = 0; index < args.length; index++) {
const arg = args[index]!;
if (arg === "--label") label = args[++index];
else if (arg === "--alt-url") altUrls.push(new URL(args[++index] ?? "").origin);
else positional.push(arg);
}
const [rawUrl, credential] = positional;
if (!rawUrl || !credential) throw new Error(USAGE); if (!rawUrl || !credential) throw new Error(USAGE);
const url = new URL(rawUrl).origin; const url = new URL(rawUrl).origin;
@@ -104,6 +114,7 @@ async function hostAdd(args: string[]) {
id: descriptor.environmentId, id: descriptor.environmentId,
label: label ?? descriptor.label, label: label ?? descriptor.label,
url, url,
alt_urls: altUrls.length > 0 ? JSON.stringify(altUrls) : null,
environment_id: descriptor.environmentId, environment_id: descriptor.environmentId,
token: token.access_token, token: token.access_token,
token_expires_at: token.expires_in ? new Date(Date.now() + token.expires_in * 1000).toISOString() : null, token_expires_at: token.expires_in ? new Date(Date.now() + token.expires_in * 1000).toISOString() : null,
+223 -4
View File
@@ -13,6 +13,76 @@ export interface HostSummary {
error: string | null; error: string | null;
} }
/**
* How a route reaches its host, guessed from the URL: `public` for HTTPS, `vpn` when the hostname
* says vpn, and `direct` for the rest.
*/
export type RouteKind = "direct" | "vpn" | "public";
export interface Route {
url: string;
kind: RouteKind;
/** From the latest connect. `untried` when an earlier route answered first. */
result: "ok" | "failed" | "untried";
/** Why it failed, such as `refused` or `no answer in 5 s`. */
error: string | null;
checkedAt: string | null;
}
/** One host as the status screens show it. */
export interface HostStatusDetail extends HostSummary {
/** The route in use. Null unless online. */
activeUrl: string | null;
/** Preferred first, the order the gateway tries them. */
routes: Route[];
onlineSince: string | null;
lastOnlineAt: string | null;
/** When the gateway tries again, while it waits to retry. */
nextRetryAt: string | null;
tokenExpiresAt: string | null;
serverVersion: string | null;
}
/** What Apple answered for one push. */
export interface PushResult {
ok: boolean;
at: string;
/** Apple's HTTP status, or 0 when the push never reached Apple. */
status: number;
/** Apple's reason, such as `BadDeviceToken`, or why the gateway could not send. */
reason: string | null;
}
export interface StatusResponse {
hosts: HostStatusDetail[];
gateway: {
startedAt: string;
/** False when the gateway has no APNs key, so nothing can be pushed. */
pushConfigured: boolean;
lastPush: PushResult | null;
};
/** The watch asking. */
device: {
name: string;
pairedAt: string;
/** True once the watch has sent its APNs token. */
pushRegistered: boolean;
apnsEnvironment: "sandbox" | "production" | null;
notifications: NotificationPrefs;
};
}
/**
* Which pushes a watch wants. `needsMe` covers approvals and questions on your own threads, and
* `subagents` covers the same from subagents. Host problems and test pushes always go out.
*/
export interface NotificationPrefs {
needsMe: boolean;
finished: boolean;
failed: boolean;
subagents: boolean;
}
/** Something a thread is waiting on: an approval, or questions for the user. */ /** Something a thread is waiting on: an approval, or questions for the user. */
export interface PendingRequest { export interface PendingRequest {
id: string; id: string;
@@ -23,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;
@@ -35,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;
@@ -46,7 +130,78 @@ export interface FeedItem {
updatedAt: string; updatedAt: string;
/** The agent's latest message, trimmed for the watch. Null until the gateway has read it. */ /** The agent's latest message, trimmed for the watch. Null until the gateway has read it. */
lastMessage: string | null; lastMessage: string | null;
/** What a working thread is doing right now. Null when nothing is in progress or not read yet. */
activity: Activity | null;
/** What the latest run changed, in total. Null when it changed no files or is not read yet. */
changes: ChangeTotals | null;
/** 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
* itself. Answer the request on that thread. `phase` is then the subagent's.
*/
via: { hostId: string; threadId: string; title: string } | null;
}
/** One step a thread is taking now. */
export interface Activity {
kind: "command" | "edit" | "search" | "web" | "subagent" | "thinking" | "tool";
/** The command line, file name, search, subagent title or tool name, trimmed. */
text: string;
/** When the step started, for its elapsed time. */
startedAt: string | null;
}
export interface ChangeTotals {
files: number;
additions: number;
deletions: number;
}
/** A file the latest run changed. `name` is the last part of `path`. */
export interface ChangedFile {
path: string;
name: string;
additions: number;
deletions: number;
}
/** A thread search hit: the thread, and where the words matched when it was inside a message. */
export interface SearchHit {
thread: ThreadRow;
projectTitle: string;
hostLabel: string;
/** Text around the match in a message, or null when only the title matched. */
snippet: string | null;
}
export interface SearchResponse {
hits: SearchHit[];
/** Labels of hosts that were offline or failed, so their threads were not searched. */
unsearched: string[];
}
/** A thread in a list: a project's threads, or a thread's subagents. */
export interface ThreadRow {
hostId: string;
threadId: string;
title: string;
/** Null for a thread that never ran or was stopped. */
phase: Phase | null;
settled: boolean;
updatedAt: string;
}
export interface ThreadListResponse {
items: ThreadRow[];
}
export interface HistoryResponse {
/** Oldest first, just before the messages the watch already has. */
messages: Message[];
/** Pass to the history route for the page before these. Null at the start of the thread. */
earlierCursor: string | null;
} }
/** Counts for the watch face complication. Cheap: no thread pages are read. */ /** Counts for the watch face complication. Cheap: no thread pages are read. */
@@ -54,7 +209,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;
} }
@@ -63,11 +227,26 @@ export interface Feed {
needsYou: FeedItem[]; needsYou: FeedItem[];
/** Starting or running. Most recently updated first. */ /** Starting or running. Most recently updated first. */
working: FeedItem[]; working: FeedItem[];
/** Completed or failed in the last 24 hours. Most recent first, at most 10. */ /** Completed or failed and not yet settled. Most recent first, at most 10. */
recent: FeedItem[]; 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[]; hosts: HostSummary[];
} }
export interface RecentResponse {
/** Every unsettled finished thread, most recent first. */
items: FeedItem[];
}
export interface SettleRecentResponse {
settled: number;
/** Threads T3 refused to settle, or whose host was offline. */
failed: number;
}
export interface Message { export interface Message {
id: string; id: string;
role: "user" | "assistant" | "system"; role: "user" | "assistant" | "system";
@@ -81,6 +260,23 @@ export interface ThreadDetail {
messages: Message[]; messages: Message[];
/** True while a run is active, so Stop makes sense. */ /** True while a run is active, so Stop makes sense. */
canStop: boolean; canStop: boolean;
/** True when the thread runs in approval-required mode, asking before each action. */
askFirst: boolean;
/** Pass to the history route for older messages. Null when `messages` starts the thread. */
earlierCursor: string | null;
/** Subagents and delegated threads this thread started, newest first. */
subagents: ThreadRow[];
/** The files the latest run changed, most lines changed first. */
changedFiles: ChangedFile[];
}
export interface SnoozeRequest {
/** When the thread comes back, in the future and at most 30 days away. */
until: string;
}
export interface ThreadModeRequest {
askFirst: boolean;
} }
export interface ProjectsResponse { export interface ProjectsResponse {
@@ -91,11 +287,24 @@ export interface ProjectsResponse {
title: string; title: string;
/** The model a new thread will use, such as `claude-opus-5-5`. Null when none is configured. */ /** The model a new thread will use, such as `claude-opus-5-5`. Null when none is configured. */
model: string | null; model: string | null;
/** The provider instance `model` belongs to, matching an entry in `providers`. */
instanceId: string | null;
}>; }>;
/** The providers on this host that can start a thread now, each with its models. */
providers: AgentProvider[];
} }
>; >;
} }
export interface AgentProvider {
instanceId: string;
/** Such as `Claude` or `Codex`. */
name: string;
/** False when the provider cannot run in approval-required mode. */
canAskFirst: boolean;
models: Array<{ slug: string; name: string }>;
}
export interface PairRequest { export interface PairRequest {
code: string; code: string;
deviceName: string; deviceName: string;
@@ -124,6 +333,10 @@ export interface NewThreadRequest {
hostId: string; hostId: string;
projectId: string; projectId: string;
text: string; text: string;
/** A model from the host's `providers`. Absent uses the project's default. */
model?: { instanceId: string; slug: string };
/** Starts the thread in approval-required mode. Absent or false uses the host's default mode. */
askFirst?: boolean;
} }
export interface NewThreadResponse { export interface NewThreadResponse {
@@ -137,11 +350,17 @@ export interface ErrorResponse {
/** Custom keys on every push notification, beside `aps`. */ /** Custom keys on every push notification, beside `aps`. */
export interface PushPayload { export interface PushPayload {
hostId: string; /** Absent on `test` pushes. */
hostId?: string;
/** 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. */
export type PushCategory = "approval" | "finished" | "failed" | "input" | "host"; export type PushCategory = "approval" | "finished" | "failed" | "input" | "host" | "test";
+14 -4
View File
@@ -8,6 +8,8 @@ export interface HostRow {
id: string; id: string;
label: string; label: string;
url: string; url: string;
/** Fallback routes to the same server, as a JSON array, tried in order after `url`. */
alt_urls: string | null;
environment_id: string; environment_id: string;
token: string; token: string;
token_expires_at: string | null; token_expires_at: string | null;
@@ -20,6 +22,8 @@ export interface DeviceRow {
token_hash: string; token_hash: string;
apns_token: string | null; apns_token: string | null;
apns_env: "sandbox" | "production" | null; apns_env: "sandbox" | "production" | null;
/** The watch's NotificationPrefs as JSON. Null until the watch changes one. */
notifications: string | null;
created_at: string; created_at: string;
last_seen_at: string | null; last_seen_at: string | null;
revoked_at: string | null; revoked_at: string | null;
@@ -61,17 +65,21 @@ export function openDb(path: string) {
db.run("pragma journal_mode = wal"); db.run("pragma journal_mode = wal");
db.run("pragma busy_timeout = 5000"); db.run("pragma busy_timeout = 5000");
db.run(SCHEMA); db.run(SCHEMA);
const hostColumns = db.query<{ name: string }, []>("pragma table_info(hosts)").all().map((column) => column.name);
if (!hostColumns.includes("alt_urls")) db.run("alter table hosts add column alt_urls text");
const deviceColumns = db.query<{ name: string }, []>("pragma table_info(devices)").all().map((column) => column.name);
if (!deviceColumns.includes("notifications")) db.run("alter table devices add column notifications text");
return { return {
hosts: { hosts: {
list: () => db.query<HostRow, []>("select * from hosts order by created_at").all(), list: () => db.query<HostRow, []>("select * from hosts order by created_at").all(),
upsert(host: Omit<HostRow, "created_at">) { upsert(host: Omit<HostRow, "created_at">) {
db.query( db.query(
`insert into hosts (id, label, url, environment_id, token, token_expires_at, created_at) `insert into hosts (id, label, url, alt_urls, environment_id, token, token_expires_at, created_at)
values ($id, $label, $url, $environment_id, $token, $token_expires_at, $created_at) values ($id, $label, $url, $alt_urls, $environment_id, $token, $token_expires_at, $created_at)
on conflict(environment_id) do update set on conflict(environment_id) do update set
label = excluded.label, url = excluded.url, token = excluded.token, label = excluded.label, url = excluded.url, alt_urls = excluded.alt_urls,
token_expires_at = excluded.token_expires_at`, token = excluded.token, token_expires_at = excluded.token_expires_at`,
).run({ ...host, created_at: now() }); ).run({ ...host, created_at: now() });
}, },
remove: (idOrLabel: string) => remove: (idOrLabel: string) =>
@@ -103,6 +111,8 @@ export function openDb(path: string) {
apnsEnv, apnsEnv,
}); });
}, },
setNotifications: (id: string, json: string) =>
db.query("update devices set notifications = $json where id = $id").run({ id, json }),
clearPush: (apnsToken: string) => clearPush: (apnsToken: string) =>
db.query("update devices set apns_token = null where apns_token = $apnsToken").run({ apnsToken }), db.query("update devices set apns_token = null where apns_token = $apnsToken").run({ apnsToken }),
revoke: (idOrName: string) => revoke: (idOrName: string) =>
+14 -2
View File
@@ -19,13 +19,19 @@ export class Hub {
for (const row of rows) { for (const row of rows) {
seen.add(row.id); seen.add(row.id);
const current = this.hosts.get(row.id); const current = this.hosts.get(row.id);
if (current && current.row.url === row.url && current.row.token === row.token && current.row.label === row.label) { if (
current &&
current.row.url === row.url &&
current.row.alt_urls === row.alt_urls &&
current.row.token === row.token &&
current.row.label === row.label
) {
current.row = row; current.row = row;
continue; continue;
} }
current?.host.stop(); current?.host.stop();
const host = new T3Host( const host = new T3Host(
{ id: row.id, label: row.label, url: row.url, token: row.token, environmentId: row.environment_id }, { id: row.id, label: row.label, urls: hostUrls(row), token: row.token, environmentId: row.environment_id },
this.options, this.options,
); );
this.hosts.set(row.id, { host, row }); this.hosts.set(row.id, { host, row });
@@ -55,3 +61,9 @@ export class Hub {
this.hosts.clear(); this.hosts.clear();
} }
} }
/** A host's routes, preferred first. */
export function hostUrls(row: Pick<HostRow, "url" | "alt_urls">): string[] {
const alternates: unknown = row.alt_urls ? JSON.parse(row.alt_urls) : [];
return [row.url, ...(Array.isArray(alternates) ? alternates.filter((url) => typeof url === "string") : [])];
}
+15 -33
View File
@@ -9,7 +9,8 @@ import { createRoutes } from "./api.ts";
import { loadConfig } from "./config.ts"; import { loadConfig } from "./config.ts";
import { openDb } from "./db.ts"; import { openDb } from "./db.ts";
import { Hub } from "./hub.ts"; import { Hub } from "./hub.ts";
import { Notifier, type SendPush } from "./notify.ts"; import { Notifier } from "./notify.ts";
import { Pusher } from "./push.ts";
import { SummaryCache } from "./views.ts"; import { SummaryCache } from "./views.ts";
const HOST_SYNC_MS = 15_000; const HOST_SYNC_MS = 15_000;
@@ -17,6 +18,7 @@ const TOKEN_CHECK_MS = 6 * 60 * 60_000;
const log = (message: string) => console.log(`${new Date().toISOString()} ${message}`); const log = (message: string) => console.log(`${new Date().toISOString()} ${message}`);
const startedAt = new Date().toISOString();
const config = loadConfig(); const config = loadConfig();
mkdirSync(dirname(config.dbPath), { recursive: true }); mkdirSync(dirname(config.dbPath), { recursive: true });
const db = openDb(config.dbPath); const db = openDb(config.dbPath);
@@ -24,38 +26,18 @@ const cache = new SummaryCache(log);
const apns = config.apns ? new Apns(config.apns) : null; const apns = config.apns ? new Apns(config.apns) : null;
if (!apns) log("APNs is not configured. Pushes will only be logged."); if (!apns) log("APNs is not configured. Pushes will only be logged.");
const sendPush: SendPush = async (push) => { const pusher = new Pusher(db, apns, log);
const devices = db.devices.active().filter((device) => device.apns_token && device.apns_env);
if (!apns || devices.length === 0) {
log(`push (not sent): [${push.category}] ${push.title}: ${push.body}`);
return;
}
const payload = {
aps: {
alert: { title: push.title, ...(push.subtitle ? { subtitle: push.subtitle } : {}), body: push.body },
sound: "default",
category: push.category,
"thread-id": push.data.threadId ?? push.data.hostId,
},
...push.data,
};
await Promise.all(
devices.map(async (device) => {
const result = await apns.send({
deviceToken: device.apns_token!,
environment: device.apns_env!,
payload,
collapseId: push.collapseId,
});
if (result.ok) return;
log(`APNs refused a push to ${device.name}: ${result.status} ${result.reason}`);
if (result.tokenInvalid) db.devices.clearPush(device.apns_token!);
}),
);
};
const notifier = new Notifier(sendPush, cache, log); const notifier = new Notifier(pusher.sendAll, cache, log);
const hub = new Hub(db, { onThreadChange: notifier.onThreadChange, onStatus: notifier.onHostStatus, log }); const hub = new Hub(db, {
onThreadChange: (change) => {
// A removed thread's summary would otherwise stay cached for the gateway's lifetime.
if (!change.next && change.previous) cache.forget(change.host, change.previous.id);
notifier.onThreadChange(change);
},
onStatus: notifier.onHostStatus,
log,
});
hub.sync(); hub.sync();
setInterval(() => hub.sync(), HOST_SYNC_MS); setInterval(() => hub.sync(), HOST_SYNC_MS);
notifier.checkTokenExpiry(hub.rows()); notifier.checkTokenExpiry(hub.rows());
@@ -63,7 +45,7 @@ setInterval(() => notifier.checkTokenExpiry(hub.rows()), TOKEN_CHECK_MS);
const server = Bun.serve({ const server = Bun.serve({
port: config.port, port: config.port,
routes: createRoutes({ db, hub, cache, log }), routes: createRoutes({ db, hub, cache, pusher, startedAt, log }),
fetch: () => Response.json({ error: "Not found." }, { status: 404 }), fetch: () => Response.json({ error: "Not found." }, { status: 404 }),
}); });
log(`WristCode gateway listening on :${server.port} with ${hub.list().length} host(s)`); log(`WristCode gateway listening on :${server.port} with ${hub.list().length} host(s)`);
+60 -9
View File
@@ -3,8 +3,8 @@
// thread already waiting counts as a change, so each request notifies once. Nothing notifies while // thread already waiting counts as a change, so each request notifies once. Nothing notifies while
// a host loads its first snapshot, so a restart never replays old news. // a host loads its first snapshot, so a restart never replays old news.
import { threadPhase, type Phase } from "./awareness.ts"; import { isSnoozed, threadPhase, type Phase } from "./awareness.ts";
import type { PushCategory, PushPayload } from "./contract.ts"; import type { PendingRequest, PushCategory, PushPayload } from "./contract.ts";
import type { HostRow } from "./db.ts"; import type { HostRow } from "./db.ts";
import type { T3Host, ThreadChange } from "./t3/host.ts"; import type { T3Host, ThreadChange } from "./t3/host.ts";
import { trimText, type SummaryCache, type ThreadSummary } from "./views.ts"; import { trimText, type SummaryCache, type ThreadSummary } from "./views.ts";
@@ -14,6 +14,8 @@ export interface Push {
subtitle?: string; subtitle?: string;
body: string; body: string;
category: PushCategory; category: PushCategory;
/** True for a subagent's request, which watches can mute apart from their own threads'. */
fromSubagent?: boolean;
data: PushPayload; data: PushPayload;
/** Replaces an earlier push about the same thing on the watch. */ /** Replaces an earlier push about the same thing on the watch. */
collapseId: string; collapseId: string;
@@ -23,6 +25,9 @@ export type SendPush = (push: Push) => Promise<void>;
const NOTIFYING: ReadonlySet<Phase> = new Set(["waiting_for_approval", "waiting_for_input", "completed", "failed"]); const NOTIFYING: ReadonlySet<Phase> = new Set(["waiting_for_approval", "waiting_for_input", "completed", "failed"]);
const BODY_CHARS = 180; const BODY_CHARS = 180;
const TITLE_CHARS = 80;
/** An option value longer than this can't be sent whole, so the question goes without buttons. */
const OPTION_VALUE_CHARS = 200;
const SUMMARY_WAIT_MS = 3_000; const SUMMARY_WAIT_MS = 3_000;
const TOKEN_WARNING_MS = 7 * 24 * 60 * 60_000; const TOKEN_WARNING_MS = 7 * 24 * 60 * 60_000;
@@ -49,6 +54,8 @@ export class Notifier {
const before = this.keys.get(id); const before = this.keys.get(id);
this.keys.set(id, key); this.keys.set(id, key);
if (initial || phase === null || !NOTIFYING.has(phase) || key === before) return; if (initial || phase === null || !NOTIFYING.has(phase) || key === before) return;
// T3 keeps a snoozed thread asleep through background wakes, so its finishing again is not news.
if (phase === "completed" && isSnoozed(next)) return;
void this.notify(host, next, phase).catch((error: unknown) => void this.notify(host, next, phase).catch((error: unknown) =>
this.log(`push for ${id} failed: ${error instanceof Error ? error.message : String(error)}`), this.log(`push for ${id} failed: ${error instanceof Error ? error.message : String(error)}`),
); );
@@ -91,17 +98,41 @@ export class Notifier {
]); ]);
const project = host.projects.get(thread.projectId)?.title; const project = host.projects.get(thread.projectId)?.title;
const requestId = thread.pendingRuntimeRequest?.id; const requestId = thread.pendingRuntimeRequest?.id;
// A subagent's push is about the thread that delegated to it, which is the one the user knows.
const parent = thread.delegatedFrom?.title;
const text = body(phase, thread.lastError ?? null, summary);
await this.send({ await this.send({
title: thread.title, title: trimText(parent ?? thread.title, TITLE_CHARS),
subtitle: project ? `${host.label} · ${project}` : host.label, subtitle: project ? `${host.label} · ${project}` : host.label,
body: body(phase, thread.lastError ?? null, summary), body: parent ? `Subagent ${trimText(thread.title, TITLE_CHARS)}: ${text}` : text,
category: category(phase), category: category(phase),
data: { hostId: host.id, threadId: thread.id, ...(requestId ? { requestId } : {}) }, fromSubagent: parent !== undefined,
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 {};
// A cut value would answer with a string that matches no option.
if (question.options.some(({ value }) => value.length > OPTION_VALUE_CHARS)) return {};
return {
question: {
id: question.id,
options: question.options.slice(0, 4).map(({ label, value }) => ({ label: trimText(label, 40), value })),
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":
@@ -115,12 +146,32 @@ function category(phase: Phase): PushCategory {
} }
} }
function body(phase: Phase, lastError: string | null, summary: ThreadSummary | undefined): string { /**
switch (phase) { * An approval push's body: the files a file-change approval would edit, as "Wants to edit
case "waiting_for_approval": { * compose.yml +9 −6" or "Wants to edit 3 files: a.ts, b.ts, c.ts", or else the command it wants to
const prompt = summary?.request?.prompt; * run. The watch's cards word it the same way.
*/
export function approvalBody(request: PendingRequest | null): string {
const files = request?.kind === "file-change" ? request.files : [];
const [file] = files;
if (files.length === 1 && file) {
const counts = [
file.additions === null ? null : `+${file.additions}`,
file.deletions === null ? null : `\u2212${file.deletions}`,
].filter((count) => count !== null);
return trimText(["Wants to edit", file.name, ...counts].join(" "), BODY_CHARS);
}
if (files.length > 1) {
return trimText(`Wants to edit ${files.length} files: ${files.map((file) => file.name).join(", ")}`, BODY_CHARS);
}
const prompt = request?.prompt;
return prompt ? `Wants to run: ${trimText(prompt, BODY_CHARS)}` : "Needs your approval."; return prompt ? `Wants to run: ${trimText(prompt, BODY_CHARS)}` : "Needs your approval.";
} }
function body(phase: Phase, lastError: string | null, summary: ThreadSummary | undefined): string {
switch (phase) {
case "waiting_for_approval":
return approvalBody(summary?.request ?? null);
case "waiting_for_input": { case "waiting_for_input": {
const question = summary?.request?.questions[0]?.question; const question = summary?.request?.questions[0]?.question;
return question ? trimText(question, BODY_CHARS) : "Has a question for you."; return question ? trimText(question, BODY_CHARS) : "Has a question for you.";
+108
View File
@@ -0,0 +1,108 @@
// Delivers pushes to paired watches through APNs, and remembers Apple's last answer so the watch's
// status screen can show whether pushes are getting through. Each watch only gets the kinds of push
// it asked for. Without APNs settings it only logs.
import type { Apns } from "./apns.ts";
import type { NotificationPrefs, PushResult } from "./contract.ts";
import type { Db, DeviceRow } from "./db.ts";
import type { Push } from "./notify.ts";
import { trimText } from "./views.ts";
const ALL_ON: NotificationPrefs = { needsMe: true, finished: true, failed: true, subagents: true };
/** A watch's choices, with every kind it never set left on. */
export function notificationPrefs(device: DeviceRow): NotificationPrefs {
const saved: Partial<NotificationPrefs> = device.notifications ? JSON.parse(device.notifications) : {};
return { ...ALL_ON, ...saved };
}
/** Whether a watch with these choices wants this push. Host problems and tests always go out. */
export function wants(prefs: NotificationPrefs, push: Push): boolean {
switch (push.category) {
case "approval":
case "input":
return push.fromSubagent ? prefs.subagents : prefs.needsMe;
case "finished":
return prefs.finished;
case "failed":
return prefs.failed;
default:
return true;
}
}
/** APNs refuses a payload over 4096 bytes. This leaves room for the JSON APNs adds. */
const PAYLOAD_LIMIT = 3900;
/**
* The APNs payload for a push, kept under APNs' size limit. Long titles and bodies in multi-byte
* scripts can overflow it, so the question's buttons go first, then the body is cut.
*/
export function apnsPayload(push: Push) {
const build = (data: Push["data"], body: string) => ({
aps: {
alert: { title: push.title, ...(push.subtitle ? { subtitle: push.subtitle } : {}), body },
sound: "default",
category: push.category,
"thread-id": data.threadId ?? data.hostId ?? push.category,
},
...data,
});
const fits = (payload: object) => Buffer.byteLength(JSON.stringify(payload)) <= PAYLOAD_LIMIT;
let payload = build(push.data, push.body);
if (fits(payload)) return payload;
const { question: _, ...plain } = push.data;
payload = build(plain, push.body);
for (let chars = 120; !fits(payload) && chars >= 20; chars -= 50) payload = build(plain, trimText(push.body, chars));
return payload;
}
export class Pusher {
/** Apple's answer to the most recent push sent to any watch. */
last: PushResult | null = null;
constructor(
private readonly db: Db,
private readonly apns: Apns | null,
private readonly log: (message: string) => void = () => {},
) {}
get configured() {
return this.apns !== null;
}
/** Sends to every paired watch that registered for pushes and wants this kind. */
sendAll = async (push: Push): Promise<void> => {
const devices = this.db.devices
.active()
.filter((device) => device.apns_token && device.apns_env && wants(notificationPrefs(device), push));
if (!this.apns || devices.length === 0) {
this.log(`push (not sent): [${push.category}] ${push.title}: ${push.body}`);
return;
}
await Promise.all(devices.map((device) => this.sendTo(device, push)));
};
/** Sends to one watch and returns what Apple said. */
async sendTo(device: DeviceRow, push: Push): Promise<PushResult> {
const at = new Date().toISOString();
if (!this.apns) return { ok: false, at, status: 0, reason: "The gateway has no APNs key configured." };
if (!device.apns_token || !device.apns_env) {
return { ok: false, at, status: 0, reason: "This watch has not registered for notifications." };
}
const payload = apnsPayload(push);
const answer = await this.apns.send({
deviceToken: device.apns_token,
environment: device.apns_env,
payload,
collapseId: push.collapseId,
});
const result: PushResult = answer.ok ? { ok: true, at, status: 200, reason: null } : { ok: false, at, status: answer.status, reason: answer.reason };
this.last = result;
if (!answer.ok) {
this.log(`APNs refused a push to ${device.name}: ${answer.status} ${answer.reason}`);
if (answer.tokenInvalid) this.db.devices.clearPush(device.apns_token);
}
return result;
}
}
+138 -6
View File
@@ -7,6 +7,8 @@ import {
type BoundedThread, type BoundedThread,
type Command, type Command,
type EnvironmentDescriptor, type EnvironmentDescriptor,
type HistoryPage,
type SearchMatch,
type LaunchThreadInput, type LaunchThreadInput,
type Project, type Project,
type ServerConfig, type ServerConfig,
@@ -26,12 +28,22 @@ export type HostStatus =
export interface HostConfig { export interface HostConfig {
id: string; id: string;
label: string; label: string;
/** The server's HTTP origin, such as `https://t3mac.gbrown.org`. */ /**
url: string; * The server's HTTP origins, preferred first, such as a LAN route then a VPN route. Every one
* must reach the same T3 server, since they share the environment ID and the token.
*/
urls: string[];
token: string; token: string;
environmentId: string; environmentId: string;
} }
/** What happened the last time the gateway tried one route. */
export interface RouteAttempt {
ok: boolean;
error: string | null;
at: string;
}
export interface ThreadChange { export interface ThreadChange {
host: T3Host; host: T3Host;
previous: ThreadShell | undefined; previous: ThreadShell | undefined;
@@ -49,6 +61,8 @@ export interface HostOptions {
const BACKOFF_MIN_MS = 1_000; const BACKOFF_MIN_MS = 1_000;
const BACKOFF_MAX_MS = 5 * 60_000; const BACKOFF_MAX_MS = 5 * 60_000;
const AUTH_RETRY_MS = 10 * 60_000; const AUTH_RETRY_MS = 10 * 60_000;
/** How long one route gets to answer before the next is tried. */
const ROUTE_TIMEOUT_MS = 5_000;
export class HostError extends Error { export class HostError extends Error {
constructor( constructor(
@@ -60,12 +74,24 @@ export class HostError extends Error {
} }
} }
/** How long a read from a T3 server may take before the gateway gives up on it. */
const HTTP_TIMEOUT_MS = 15_000;
export class T3Host { export class T3Host {
status: HostStatus = "connecting"; status: HostStatus = "connecting";
lastError: string | null = null; lastError: string | null = null;
readonly projects = new Map<string, Project>(); readonly projects = new Map<string, Project>();
readonly threads = new Map<string, ThreadShell>(); readonly threads = new Map<string, ThreadShell>();
serverConfig: ServerConfig | null = null; serverConfig: ServerConfig | null = null;
serverVersion: string | null = null;
/** Results of the latest connect, by route. Routes after the one that answered were not tried. */
readonly routeAttempts = new Map<string, RouteAttempt>();
/** When the current connection came up. Null when not online. */
onlineSince: string | null = null;
/** When the last connection ended. Null if it never connected or is online now. */
lastOnlineAt: string | null = null;
/** When the next connect is scheduled, while waiting to retry. */
nextRetryAt: string | null = null;
private socket: RpcSocket | null = null; private socket: RpcSocket | null = null;
private sequence: number | null = null; private sequence: number | null = null;
@@ -73,11 +99,19 @@ export class T3Host {
private stopped = false; private stopped = false;
private attempt = 0; private attempt = 0;
private retryTimer: ReturnType<typeof setTimeout> | null = null; private retryTimer: ReturnType<typeof setTimeout> | null = null;
/** Set by reconnect() so a deliberate close connects again at once, with no backoff. */
private reconnecting = false;
/** The route this connection uses. Chosen afresh, preferred first, on every connect. */
private activeUrl: string;
constructor( constructor(
readonly config: HostConfig, readonly config: HostConfig,
private readonly options: HostOptions = {}, private readonly options: HostOptions = {},
) {} ) {
const [first] = config.urls;
if (!first) throw new Error(`${config.label} has no URL`);
this.activeUrl = first;
}
get id() { get id() {
return this.config.id; return this.config.id;
@@ -87,6 +121,11 @@ export class T3Host {
return this.config.label; return this.config.label;
} }
/** The route in use, or the one that will be tried first. */
get url() {
return this.activeUrl;
}
start() { start() {
this.stopped = false; this.stopped = false;
void this.connect(); void this.connect();
@@ -98,11 +137,40 @@ export class T3Host {
this.socket?.close(); this.socket?.close();
} }
/**
* Connects again now, starting from the preferred route. For when a better route may be back,
* such as the LAN after getting home, or to skip a backoff wait. Does nothing mid-connect.
*/
reconnect() {
if (this.stopped || this.status === "connecting") return;
this.attempt = 0;
if (this.socket) {
this.reconnecting = true;
this.socket.close();
return;
}
if (this.retryTimer) clearTimeout(this.retryTimer);
void this.connect();
}
/** Sends one orchestration command and resolves with its sequence. */ /** Sends one orchestration command and resolves with its sequence. */
async dispatch(command: Command): Promise<{ sequence: number }> { async dispatch(command: Command): Promise<{ sequence: number }> {
return this.requireSocket().call("orchestration.dispatchCommand", command); return this.requireSocket().call("orchestration.dispatchCommand", command);
} }
/**
* Rereads the server config, for current provider and model lists. Keeps the old one when the
* server fails or takes over 3 s.
*/
async refreshConfig(): Promise<void> {
if (!this.socket) return;
const fresh = await Promise.race([
this.socket.call<ServerConfig>("server.getConfig", {}).catch(() => null),
Bun.sleep(3_000).then(() => null),
]);
if (fresh) this.serverConfig = fresh;
}
async launchThread(input: LaunchThreadInput): Promise<{ threadId: string }> { async launchThread(input: LaunchThreadInput): Promise<{ threadId: string }> {
return this.requireSocket().call("orchestration.launchThread", input); return this.requireSocket().call("orchestration.launchThread", input);
} }
@@ -112,6 +180,23 @@ 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`. */
history(threadId: string, cursor: string): Promise<HistoryPage> {
const query = new URLSearchParams({ cursor, view: "conversation" });
return this.http<HistoryPage>(`/api/orchestration/threads/${encodeURIComponent(threadId)}/history?${query}`);
}
/** The session's expiry, from `/api/auth/session`. */ /** The session's expiry, from `/api/auth/session`. */
async sessionExpiresAt(): Promise<string | null> { async sessionExpiresAt(): Promise<string | null> {
const session = await this.http<{ expiresAt?: string }>("/api/auth/session"); const session = await this.http<{ expiresAt?: string }>("/api/auth/session");
@@ -130,7 +215,8 @@ export class T3Host {
} }
private async http<T>(path: string, init: RequestInit = {}): Promise<T> { private async http<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(new URL(path, this.config.url), { const response = await fetch(new URL(path, this.activeUrl), {
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
...init, ...init,
headers: { headers: {
authorization: `Bearer ${this.config.token}`, authorization: `Bearer ${this.config.token}`,
@@ -146,6 +232,11 @@ export class T3Host {
} }
private setStatus(status: HostStatus, error: string | null = null) { private setStatus(status: HostStatus, error: string | null = null) {
if (status === "online") this.onlineSince = new Date().toISOString();
else if (this.status === "online") {
this.lastOnlineAt = new Date().toISOString();
this.onlineSince = null;
}
this.status = status; this.status = status;
this.lastError = error; this.lastError = error;
this.options.onStatus?.(this); this.options.onStatus?.(this);
@@ -153,9 +244,10 @@ export class T3Host {
private async connect() { private async connect() {
if (this.stopped) return; if (this.stopped) return;
this.nextRetryAt = null;
this.setStatus("connecting", this.lastError); this.setStatus("connecting", this.lastError);
try { try {
const descriptor = await this.http<EnvironmentDescriptor>("/.well-known/t3/environment"); const descriptor = await this.pickRoute();
const protocol = descriptor.orchestrationProtocolVersion ?? 1; const protocol = descriptor.orchestrationProtocolVersion ?? 1;
if (protocol !== ORCHESTRATION_PROTOCOL_VERSION) { if (protocol !== ORCHESTRATION_PROTOCOL_VERSION) {
throw new HostError(`speaks orchestration protocol ${protocol}, the gateway speaks ${ORCHESTRATION_PROTOCOL_VERSION}`, 426); throw new HostError(`speaks orchestration protocol ${protocol}, the gateway speaks ${ORCHESTRATION_PROTOCOL_VERSION}`, 426);
@@ -164,7 +256,7 @@ export class T3Host {
throw new HostError(`is environment ${descriptor.environmentId}, expected ${this.config.environmentId}`, 409); throw new HostError(`is environment ${descriptor.environmentId}, expected ${this.config.environmentId}`, 409);
} }
const { ticket } = await this.http<{ ticket: string }>("/api/auth/websocket-ticket/rpc", { method: "POST" }); const { ticket } = await this.http<{ ticket: string }>("/api/auth/websocket-ticket/rpc", { method: "POST" });
const wsUrl = new URL("/ws", this.config.url); const wsUrl = new URL("/ws", this.activeUrl);
wsUrl.protocol = wsUrl.protocol === "https:" ? "wss:" : "ws:"; wsUrl.protocol = wsUrl.protocol === "https:" ? "wss:" : "ws:";
wsUrl.searchParams.set("wsTicket", ticket); wsUrl.searchParams.set("wsTicket", ticket);
wsUrl.searchParams.set("orchestrationProtocol", String(ORCHESTRATION_PROTOCOL_VERSION)); wsUrl.searchParams.set("orchestrationProtocol", String(ORCHESTRATION_PROTOCOL_VERSION));
@@ -176,6 +268,11 @@ export class T3Host {
this.setStatus("online"); this.setStatus("online");
await socket.closed; await socket.closed;
this.socket = null; this.socket = null;
if (this.reconnecting) {
this.reconnecting = false;
void this.connect();
return;
}
this.scheduleRetry("offline", "connection closed"); this.scheduleRetry("offline", "connection closed");
} catch (error) { } catch (error) {
this.socket?.close(); this.socket?.close();
@@ -187,6 +284,31 @@ export class T3Host {
} }
} }
/** Uses the first route whose well-known descriptor answers, and returns that descriptor. */
private async pickRoute(): Promise<EnvironmentDescriptor> {
const failures: string[] = [];
this.routeAttempts.clear();
for (const url of this.config.urls) {
try {
const response = await fetch(new URL("/.well-known/t3/environment", url), {
signal: AbortSignal.timeout(ROUTE_TIMEOUT_MS),
});
if (!response.ok) throw new Error(`returned ${response.status}`);
const descriptor = (await response.json()) as EnvironmentDescriptor;
if (url !== this.activeUrl) this.options.log?.(`${this.label}: using ${url}`);
this.activeUrl = url;
this.serverVersion = descriptor.serverVersion ?? null;
this.routeAttempts.set(url, { ok: true, error: null, at: new Date().toISOString() });
return descriptor;
} catch (error) {
const message = routeError(error);
this.routeAttempts.set(url, { ok: false, error: message, at: new Date().toISOString() });
failures.push(`${new URL(url).host} ${message}`);
}
}
throw new HostError(`no route answered (${failures.join(", ")})`, 503);
}
private subscribe(socket: RpcSocket) { private subscribe(socket: RpcSocket) {
const input = this.hasSnapshot && this.sequence !== null ? { afterSequence: this.sequence } : {}; const input = this.hasSnapshot && this.sequence !== null ? { afterSequence: this.sequence } : {};
const { done } = socket.stream("orchestration.subscribeShell", input, (value) => const { done } = socket.stream("orchestration.subscribeShell", input, (value) =>
@@ -210,6 +332,7 @@ export class T3Host {
status === "auth_invalid" || status === "incompatible" status === "auth_invalid" || status === "incompatible"
? AUTH_RETRY_MS ? AUTH_RETRY_MS
: Math.min(BACKOFF_MAX_MS, BACKOFF_MIN_MS * 2 ** this.attempt++) * (0.5 + Math.random() / 2); : Math.min(BACKOFF_MAX_MS, BACKOFF_MIN_MS * 2 ** this.attempt++) * (0.5 + Math.random() / 2);
this.nextRetryAt = new Date(Date.now() + delay).toISOString();
this.retryTimer = setTimeout(() => void this.connect(), delay); this.retryTimer = setTimeout(() => void this.connect(), delay);
} }
@@ -265,3 +388,12 @@ export class T3Host {
this.options.onThreadChange?.({ host: this, previous, next, initial }); this.options.onThreadChange?.({ host: this, previous, next, initial });
} }
} }
/** A short reason a route failed, worded for a watch screen. */
function routeError(error: unknown): string {
if (error instanceof DOMException && error.name === "TimeoutError") return `no answer in ${ROUTE_TIMEOUT_MS / 1000} s`;
const message = error instanceof Error ? error.message : String(error);
if (/ECONNREFUSED|Unable to connect|refused/i.test(message)) return "refused";
if (/ENOTFOUND|getaddrinfo|DNS/i.test(message)) return "name not found";
return message;
}
+24 -4
View File
@@ -26,6 +26,9 @@ export class RpcError extends Error {
} }
} }
/** How long a unary call waits for its answer. */
const CALL_TIMEOUT_MS = 20_000;
interface Pending { interface Pending {
onChunk?: (value: unknown) => void; onChunk?: (value: unknown) => void;
resolve: (value: unknown) => void; resolve: (value: unknown) => void;
@@ -83,11 +86,28 @@ export class RpcSocket {
}); });
} }
/** Calls a unary method and resolves with its success value. */ /** Calls a unary method and resolves with its success value. Fails after `timeoutMs`. */
call<T = unknown>(tag: string, payload: unknown): Promise<T> { call<T = unknown>(tag: string, payload: unknown, timeoutMs = CALL_TIMEOUT_MS): Promise<T> {
return new Promise<T>((resolve, reject) => { return new Promise<T>((resolve, reject) => {
const id = this.request(tag, payload, { resolve: resolve as (v: unknown) => void, reject }); let timer: ReturnType<typeof setTimeout> | undefined;
if (id === null) reject(new RpcError("socket is not open")); const id = this.request(tag, payload, {
resolve: (value) => {
clearTimeout(timer);
resolve(value as T);
},
reject: (error) => {
clearTimeout(timer);
reject(error);
},
});
if (id === null) {
reject(new RpcError("socket is not open"));
return;
}
timer = setTimeout(() => {
this.pending.delete(id);
reject(new RpcError(`${tag} got no answer in ${timeoutMs / 1000} s`));
}, timeoutMs);
}); });
} }
+67 -1
View File
@@ -68,8 +68,16 @@ export interface ThreadShell {
lastError?: string | null; lastError?: string | null;
modelSelection: ModelSelection; modelSelection: ModelSelection;
runtimeMode?: string; runtimeMode?: string;
lineage: { relationshipToParent: Open<"subagent"> | null }; lineage: { relationshipToParent: Open<"fork" | "subagent"> | null; parentThreadId?: string | null };
/** 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; settledOverride?: Open<"settled"> | null;
/** Set while the user has snoozed the thread. It stays set after the time passes. */
snoozedUntil?: string | null;
snoozedAt?: string | null;
latestRunCompletedAt?: string | null;
/** Messages from guests waiting for the thread. One wakes a snoozed thread. */
pendingGuestMessageCount?: number;
} }
export interface ShellSnapshot { export interface ShellSnapshot {
@@ -114,6 +122,27 @@ export interface UserInputQuestion {
export interface TurnItem { export interface TurnItem {
type: string; type: string;
title?: string | null; title?: string | null;
status?: Open<"idle" | "pending" | "running" | "waiting" | "completed" | "failed" | "cancelled" | "interrupted">;
startedAt?: string | null;
updatedAt?: string;
/** A command's input, on `command_execution`. */
input?: unknown;
/** Set on `command_execution` items that are monitors rather than commands. */
monitor?: boolean;
runId?: string | null;
/** The file, on `file_change`, with its line counts. */
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`. */
toolName?: string | null;
/** The message, on `user_message` and `assistant_message`. */
messageId?: string;
text?: string;
} }
export interface ApprovalRequestItem extends TurnItem { export interface ApprovalRequestItem extends TurnItem {
@@ -139,6 +168,9 @@ export const isUserInputRequest = (item: TurnItem): item is UserInputRequestItem
export interface BoundedThread { export interface BoundedThread {
snapshotSequence: number; snapshotSequence: number;
/** Where `GET /history` continues from, going back. */
historyCursor?: string | null;
hasMoreHistory?: boolean;
projection: { projection: {
thread: ThreadShell; thread: ThreadShell;
messages: ConversationMessage[]; messages: ConversationMessage[];
@@ -146,6 +178,22 @@ export interface BoundedThread {
}; };
} }
/** One hit from `orchestration.searchThreads`, best first. */
export interface SearchMatch {
threadId: string;
projectId: string;
source: Open<"user" | "assistant">;
snippet: string;
messageCreatedAt: string | null;
}
/** One page of `GET /api/orchestration/threads/:threadId/history`, older than the cursor. */
export interface HistoryPage {
items: Array<{ item: TurnItem }>;
nextCursor: string | null;
hasMoreHistory: boolean;
}
export interface EnvironmentDescriptor { export interface EnvironmentDescriptor {
environmentId: string; environmentId: string;
label: string; label: string;
@@ -156,6 +204,20 @@ export interface EnvironmentDescriptor {
export interface ServerConfig { export interface ServerConfig {
environment?: { capabilities?: Record<string, unknown> }; environment?: { capabilities?: Record<string, unknown> };
settings?: { defaultModelSelection?: ModelSelection | null; defaultRuntimeMode?: string }; settings?: { defaultModelSelection?: ModelSelection | null; defaultRuntimeMode?: string };
providers?: ServerProvider[];
}
/** The parts of a provider in `server.getConfig` the gateway reads. */
export interface ServerProvider {
instanceId: string;
driver: string;
displayName?: string;
enabled: boolean;
installed: boolean;
status: Open<"ready" | "warning" | "error" | "disabled">;
availability?: Open<"available" | "unavailable">;
supportedRuntimeModes?: string[];
models: Array<{ slug: string; name: string; shortName?: string; isLegacy?: boolean }>;
} }
/** Commands sent through `orchestration.dispatchCommand`. */ /** Commands sent through `orchestration.dispatchCommand`. */
@@ -180,6 +242,10 @@ export type Command =
answers?: Record<string, unknown>; answers?: Record<string, unknown>;
} }
| { type: "thread.stop"; commandId: string; threadId: string } | { 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 }; | { type: "run.interrupt"; commandId: string; threadId: string; runId: string };
export interface LaunchThreadInput { export interface LaunchThreadInput {
+519 -50
View File
@@ -3,20 +3,43 @@
// are large (hundreds of KB), so they are reduced to a small summary and cached per thread until // are large (hundreds of KB), so they are reduced to a small summary and cached per thread until
// the thread's `updatedAt` moves. // the thread's `updatedAt` moves.
import { threadPhase, type Phase } from "./awareness.ts"; import { isSnoozed, resolvePhase, threadPhase, type Phase } from "./awareness.ts";
import type { Feed, FeedItem, HostSummary, Message, PendingRequest, Summary, ThreadDetail } from "./contract.ts"; import type {
Activity,
AgentProvider,
ChangedFile,
PendingFile,
Feed,
FeedItem,
HostStatusDetail,
HostSummary,
Message,
PendingRequest,
RecentResponse,
RouteKind,
Summary,
ThreadDetail,
ThreadRow,
HistoryResponse,
SearchResponse,
} from "./contract.ts";
import type { T3Host } from "./t3/host.ts"; import type { T3Host } from "./t3/host.ts";
import { import {
isApprovalRequest, isApprovalRequest,
isUserInputRequest, isUserInputRequest,
type BoundedThread, type BoundedThread,
type ConversationMessage,
type ThreadShell, type ThreadShell,
type TurnItem,
} from "./t3/types.ts"; } from "./t3/types.ts";
const LAST_MESSAGE_CHARS = 280; const LAST_MESSAGE_CHARS = 280;
const MESSAGE_CHARS = 2000; const MESSAGE_CHARS = 2000;
const MESSAGES_PER_THREAD = 20; const MESSAGES_PER_THREAD = 20;
const RECENT_WINDOW_MS = 24 * 60 * 60_000; const ACTIVITY_CHARS = 80;
/** How long a running thread's summary stays fresh. Its steps change without moving `updatedAt`. */
const RUNNING_SUMMARY_MS = 5_000;
const THREAD_LIST_LIMIT = 50;
const RECENT_LIMIT = 10; const RECENT_LIMIT = 10;
/** How long a feed request waits for summaries it does not have yet. */ /** How long a feed request waits for summaries it does not have yet. */
const SUMMARY_WAIT_MS = 2_000; const SUMMARY_WAIT_MS = 2_000;
@@ -25,6 +48,11 @@ export interface ThreadSummary {
messages: Message[]; messages: Message[];
lastAssistantMessage: string | null; lastAssistantMessage: string | null;
request: PendingRequest | null; request: PendingRequest | null;
activity: Activity | null;
/** The files the latest run in the window changed. */
changedFiles: ChangedFile[];
/** The history route's cursor for messages before `messages`. */
earlierCursor: string | null;
} }
/** Trims text for a small screen: collapses whitespace and cuts at a word near `max`. */ /** Trims text for a small screen: collapses whitespace and cuts at a word near `max`. */
@@ -39,26 +67,133 @@ export function trimText(text: string, max: number): string {
/** Reduces a bounded thread snapshot to what the watch shows. */ /** Reduces a bounded thread snapshot to what the watch shows. */
export function summarize(bounded: BoundedThread, thread: ThreadShell): ThreadSummary { export function summarize(bounded: BoundedThread, thread: ThreadShell): ThreadSummary {
const { messages, turnItems } = bounded.projection; const { messages, turnItems } = bounded.projection;
const visible = messages.filter( const visible = visibleMessages(messages);
(message) => message.text.trim().length > 0 && (message.role === "user" || message.role === "assistant"),
);
const lastAssistant = visible.findLast((message) => message.role === "assistant"); const lastAssistant = visible.findLast((message) => message.role === "assistant");
const start = Math.max(0, visible.length - MESSAGES_PER_THREAD);
return { return {
messages: visible.slice(-MESSAGES_PER_THREAD).map((message) => ({ messages: visible.slice(start).map(toMessage),
lastAssistantMessage: lastAssistant ? trimText(lastAssistant.text, LAST_MESSAGE_CHARS) : null,
request: pendingRequest(thread, turnItems),
activity: currentActivity(turnItems),
changedFiles: latestChanges(turnItems),
earlierCursor: windowCursor(start, visible, bounded),
};
}
const visibleMessages = (messages: ConversationMessage[]) =>
messages.filter((message) => message.text.trim().length > 0 && (message.role === "user" || message.role === "assistant"));
const toMessage = (message: Pick<ConversationMessage, "id" | "role" | "text" | "createdAt">): Message => ({
id: message.id, id: message.id,
role: message.role as Message["role"], role: message.role as Message["role"],
text: message.text.length > MESSAGE_CHARS ? `${message.text.slice(0, MESSAGE_CHARS)}…` : message.text, text: message.text.length > MESSAGE_CHARS ? `${message.text.slice(0, MESSAGE_CHARS)}…` : message.text,
createdAt: message.createdAt, createdAt: message.createdAt,
})), });
lastAssistantMessage: lastAssistant ? trimText(lastAssistant.text, LAST_MESSAGE_CHARS) : null,
request: pendingRequest(thread, turnItems), // History cursors are the gateway's own. `w<messageId>` means the bounded window's messages before
}; // that message, which the thread detail leaves out past its 20. The window moves as the thread
// grows, so the cursor names a message, not a position. `h<cursor>` is T3's cursor for older pages.
/** The cursor for what comes before `visible[start]`. */
function windowCursor(start: number, visible: ConversationMessage[], bounded: BoundedThread): string | null {
if (start > 0) return `w${visible[start]!.id}`;
return bounded.hasMoreHistory && bounded.historyCursor ? `h${bounded.historyCursor}` : null;
}
/**
* The messages before `cursor`: 20 from the bounded window, or one T3 page, which can hold more.
* T3 pages without messages are skipped, three at most.
*/
export async function loadHistory(host: T3Host, thread: ThreadShell, cursor: string): Promise<HistoryResponse> {
if (cursor.startsWith("w")) {
const bounded = await host.bounded(thread.id);
const visible = visibleMessages(bounded.projection.messages);
const end = visible.findIndex((message) => message.id === cursor.slice(1));
if (end < 0) {
// The message left the window. T3's pages pick up before the window, and the watch drops
// any message it already has.
const before = windowCursor(0, visible, bounded);
return before ? loadHistory(host, thread, before) : { messages: [], earlierCursor: null };
}
const start = Math.max(0, end - MESSAGES_PER_THREAD);
return { messages: visible.slice(start, end).map(toMessage), earlierCursor: windowCursor(start, visible, bounded) };
}
let next: string | null = cursor.slice(1);
for (let pages = 0; next && pages < 3; pages++) {
const page = await host.history(thread.id, next);
next = page.hasMoreHistory ? page.nextCursor : null;
const messages = page.items
.map(({ item }) => item)
.filter(
(item) =>
(item.type === "user_message" || item.type === "assistant_message") && (item.text ?? "").trim().length > 0,
)
.map((item) =>
toMessage({
id: item.messageId ?? "",
role: item.type === "user_message" ? "user" : "assistant",
text: item.text ?? "",
createdAt: item.startedAt ?? item.updatedAt ?? "",
}),
);
if (messages.length > 0) 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. Only edits that ran count. */
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.status !== "completed" || !item.fileName) continue;
const file = files.get(item.fileName) ?? { path: item.fileName, name: item.fileName.split("/").pop()!, additions: 0, deletions: 0 };
file.additions += item.additions ?? 0;
file.deletions += item.deletions ?? 0;
files.set(item.fileName, file);
}
return [...files.values()].sort((a, b) => b.additions + b.deletions - (a.additions + a.deletions));
}
/** The newest step still in progress, said briefly. Monitors are left out, as they run for hours. */
export function currentActivity(turnItems: TurnItem[]): Activity | null {
for (let i = turnItems.length - 1; i >= 0; i--) {
const item = turnItems[i]!;
if (item.status !== "running" && item.status !== "pending") continue;
const step = describeStep(item);
if (step) return { ...step, text: trimText(step.text, ACTIVITY_CHARS), startedAt: item.startedAt ?? null };
}
return null;
}
function describeStep(item: TurnItem): Omit<Activity, "startedAt"> | null {
switch (item.type) {
case "command_execution":
return item.monitor || typeof item.input !== "string"
? null
: { kind: "command", text: item.input.split("\n").find((line) => line.trim()) ?? item.input };
case "file_change":
return item.fileName ? { kind: "edit", text: item.fileName.split("/").pop()! } : null;
case "file_search":
return { kind: "search", text: item.pattern ?? item.title ?? "files" };
case "web_search":
return { kind: "web", text: item.title ?? "the web" };
case "subagent":
return { kind: "subagent", text: item.title ?? "Subagent" };
case "reasoning":
return { kind: "thinking", text: "Thinking" };
case "dynamic_tool":
return item.toolName || item.title ? { kind: "tool", text: (item.toolName ?? item.title)! } : null;
default:
return 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,
); );
@@ -69,7 +204,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 {
@@ -96,22 +233,71 @@ 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; summary: ThreadSummary }>(); private readonly entries = new Map<string, { updatedAt: string; fetchedAt: number; summary: ThreadSummary }>();
private readonly inflight = new Map<string, Promise<ThreadSummary>>(); private readonly inflight = new Map<string, Promise<ThreadSummary>>();
constructor(private readonly log: (message: string) => void = () => {}) {} constructor(private readonly log: (message: string) => void = () => {}) {}
/** The cached summary, if it matches the thread's current version. */ /**
* The summary to show: one matching the thread's current version, or for a running thread the
* last one read, which is at most a few seconds behind. Its request can be stale, so callers
* match it against the thread's pending request.
*/
peek(host: T3Host, thread: ThreadShell): ThreadSummary | undefined { peek(host: T3Host, thread: ThreadShell): ThreadSummary | undefined {
const entry = this.entries.get(key(host, thread.id)); const entry = this.entries.get(key(host, thread.id));
return entry?.updatedAt === thread.updatedAt ? entry.summary : undefined; if (!entry) return undefined;
return entry.updatedAt === thread.updatedAt || thread.activeRunId !== null ? entry.summary : undefined;
}
/** Whether the cached summary is current: the same version and, while running, under 5 s old. */
fresh(host: T3Host, thread: ThreadShell): boolean {
const entry = this.entries.get(key(host, thread.id));
if (entry?.updatedAt !== thread.updatedAt) return false;
return thread.activeRunId === null || Date.now() - entry.fetchedAt <= RUNNING_SUMMARY_MS;
} }
get(host: T3Host, thread: ThreadShell): Promise<ThreadSummary> { get(host: T3Host, thread: ThreadShell): Promise<ThreadSummary> {
const cached = this.peek(host, thread); if (this.fresh(host, thread)) return Promise.resolve(this.entries.get(key(host, thread.id))!.summary);
if (cached) return Promise.resolve(cached);
const k = `${key(host, thread.id)}@${thread.updatedAt}`; const k = `${key(host, thread.id)}@${thread.updatedAt}`;
const running = this.inflight.get(k); const running = this.inflight.get(k);
if (running) return running; if (running) return running;
@@ -119,7 +305,7 @@ export class SummaryCache {
.bounded(thread.id) .bounded(thread.id)
.then((bounded) => { .then((bounded) => {
const summary = summarize(bounded, thread); const summary = summarize(bounded, thread);
this.entries.set(key(host, thread.id), { updatedAt: thread.updatedAt, summary }); this.entries.set(key(host, thread.id), { updatedAt: thread.updatedAt, fetchedAt: Date.now(), summary });
return summary; return summary;
}) })
.finally(() => this.inflight.delete(k)); .finally(() => this.inflight.delete(k));
@@ -141,7 +327,89 @@ export const hostSummary = (host: T3Host): HostSummary => ({
error: host.lastError, error: host.lastError,
}); });
function feedItem(host: T3Host, thread: ThreadShell, phase: Phase, summary: ThreadSummary | undefined): FeedItem { /** A host with its routes, for the status screens. `tokenExpiresAt` comes from the hosts table. */
export function hostStatusDetail(host: T3Host, tokenExpiresAt: string | null): HostStatusDetail {
return {
...hostSummary(host),
activeUrl: host.status === "online" ? host.url : null,
routes: host.config.urls.map((url) => {
const attempt = host.routeAttempts.get(url);
return {
url,
kind: routeKind(url),
result: attempt ? (attempt.ok ? "ok" : "failed") : "untried",
error: attempt?.error ?? null,
checkedAt: attempt?.at ?? null,
};
}),
onlineSince: host.onlineSince,
lastOnlineAt: host.lastOnlineAt,
nextRetryAt: host.nextRetryAt,
tokenExpiresAt,
serverVersion: host.serverVersion,
};
}
export function routeKind(url: string): RouteKind {
const { protocol, hostname } = new URL(url);
if (protocol === "https:") return "public";
return hostname.includes("vpn") ? "vpn" : "direct";
}
/** A thread on the feed. With `via`, the card shows that subagent's request on this thread. */
interface Candidate {
host: T3Host;
thread: ThreadShell;
phase: Phase;
via?: { host: T3Host; thread: ThreadShell; phase: Phase };
}
const isWaiting = (phase: Phase | null) => phase === "waiting_for_approval" || phase === "waiting_for_input";
/** The thread that delegated `child`, if the gateway is connected to its host. */
function delegator(hosts: T3Host[], child: ThreadShell): { host: T3Host; thread: ThreadShell } | null {
const from = child.delegatedFrom;
if (!from) return null;
const host = hosts.find((candidate) => candidate.config.environmentId === from.environmentId);
const thread = host?.threads.get(from.threadId);
return host && thread ? { host, thread } : null;
}
/**
* Moves each waiting subagent's request onto the card of the thread that delegated it, the oldest
* request first. A subagent keeps its own card when its parent is unknown, hidden, archived, or
* already showing a request.
*/
function attachDelegates(candidates: Candidate[], hosts: T3Host[]): Candidate[] {
const result = [...candidates];
const delegates = candidates
.filter(({ thread, phase }) => thread.delegatedFrom && isWaiting(phase))
.sort((a, b) => requestAge(a.thread).localeCompare(requestAge(b.thread)));
for (const child of delegates) {
const parent = delegator(hosts, child.thread);
const index = parent ? result.findIndex(({ host, thread }) => host === parent.host && thread.id === parent.thread.id) : -1;
const card = result[index];
const own = result.indexOf(child);
if (!card || own === -1 || card.via || isWaiting(card.phase)) continue;
result[index] = { ...card, phase: child.phase, via: { host: child.host, thread: child.thread, phase: child.phase } };
result.splice(own, 1);
}
return result;
}
const requestAge = (thread: ThreadShell) => thread.pendingRuntimeRequest?.createdAt ?? "";
/** The summary's request, if it is still the one the thread waits on. */
const currentRequest = (thread: ThreadShell, summary: ThreadSummary | undefined): PendingRequest | null =>
summary?.request && summary.request.id === thread.pendingRuntimeRequest?.id ? summary.request : null;
function feedItem(
host: T3Host,
thread: ThreadShell,
phase: Phase,
summary: ThreadSummary | undefined,
via?: { host: T3Host; thread: ThreadShell; summary: ThreadSummary | undefined },
): FeedItem {
return { return {
hostId: host.id, hostId: host.id,
hostLabel: host.label, hostLabel: host.label,
@@ -152,82 +420,283 @@ function feedItem(host: T3Host, thread: ThreadShell, phase: Phase, summary: Thre
phase, phase,
updatedAt: thread.updatedAt, updatedAt: thread.updatedAt,
lastMessage: summary?.lastAssistantMessage ?? null, lastMessage: summary?.lastAssistantMessage ?? null,
request: summary?.request ?? null, activity: summary?.activity ?? null,
snoozedUntil: isSnoozed(thread, Date.now()) ? thread.snoozedUntil! : 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 ? currentRequest(via.thread, via.summary) : currentRequest(thread, summary),
via: via ? { hostId: via.host.id, threadId: via.thread.id, title: via.thread.title } : null,
}; };
} }
/** The longest waiting subagent that `parent` delegated to, if any. */
function waitingDelegate(hosts: T3Host[], parentHost: T3Host, parent: ThreadShell): Candidate | undefined {
let oldest: Candidate | undefined;
for (const host of hosts) {
for (const thread of host.threads.values()) {
const from = thread.delegatedFrom;
if (from?.threadId !== parent.id || from.environmentId !== parentHost.config.environmentId) continue;
const phase = threadPhase(thread);
if (!isWaiting(phase) || thread.archivedAt || thread.deletedAt) continue;
if (!oldest || requestAge(thread) < requestAge(oldest.thread)) oldest = { host, thread, phase: phase! };
}
}
return oldest;
}
/** 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.fresh(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 && currentRequest(next.thread, cache.peek(next.host, next.thread));
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,
}; };
} }
/** Builds the feed across every host, waiting briefly for summaries it has not read yet. */ /** Every thread the feed shows, in feed order, with subagent requests on their parents' cards. */
export async function buildFeed(hosts: T3Host[], cache: SummaryCache, now = Date.now()): Promise<Feed> { export function feedCandidates(hosts: T3Host[], now = Date.now()) {
const candidates: Array<{ host: T3Host; thread: ThreadShell; phase: Phase }> = []; 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()) {
const phase = threadPhase(thread); const phase = threadPhase(thread);
if (phase === null || thread.archivedAt || thread.deletedAt) continue; if (phase === null || thread.archivedAt || thread.deletedAt) continue;
if ((phase === "completed" || phase === "failed") && now - Date.parse(thread.updatedAt) > RECENT_WINDOW_MS) continue; found.push({ host, thread, phase });
candidates.push({ host, thread, phase });
} }
} }
const candidates = attachDelegates(found, hosts);
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 needsYou = candidates const asker = (candidate: Candidate) => candidate.via?.thread ?? candidate.thread;
.filter(({ phase }) => phase === "waiting_for_approval" || phase === "waiting_for_input") const finished = candidates.filter(({ phase }) => phase === "completed" || phase === "failed");
.sort((a, b) => return {
(a.thread.pendingRuntimeRequest?.createdAt ?? "").localeCompare(b.thread.pendingRuntimeRequest?.createdAt ?? ""), needsYou: candidates
); .filter(({ phase }) => isWaiting(phase))
const working = candidates.filter(({ phase }) => phase === "starting" || phase === "running").sort(byNewest); .sort((a, b) => requestAge(asker(a)).localeCompare(requestAge(asker(b)))),
const recent = candidates working: candidates.filter(({ phase }) => phase === "starting" || phase === "running").sort(byNewest),
.filter(({ phase }) => phase === "completed" || phase === "failed") /** Finished and failed threads not yet settled or snoozed, however old. */
.sort(byNewest) recent: finished.filter(({ thread }) => !isSnoozed(thread, now)).sort(byNewest),
.slice(0, RECENT_LIMIT); /** 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!)),
};
}
const shown = [...needsYou, ...working, ...recent]; const toItem =
(cache: SummaryCache) =>
({ host, thread, phase, via }: Candidate): FeedItem =>
feedItem(host, thread, phase, cache.peek(host, thread), via && { ...via, summary: cache.peek(via.host, via.thread) });
/** 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, snoozed } = feedCandidates(hosts);
const recent = allRecent.slice(0, RECENT_LIMIT);
const shown = [...needsYou, ...working, ...recent].flatMap((candidate) => (candidate.via ? [candidate, candidate.via] : [candidate]));
const missing = shown const missing = shown
.filter(({ host, thread }) => !cache.peek(host, thread)) .filter(({ host, thread }) => !cache.fresh(host, thread))
.map(({ host, thread }) => cache.get(host, thread).catch(() => undefined)); .map(({ host, thread }) => cache.get(host, thread).catch(() => undefined));
if (missing.length > 0) await Promise.race([Promise.all(missing), Bun.sleep(SUMMARY_WAIT_MS)]); if (missing.length > 0) await Promise.race([Promise.all(missing), Bun.sleep(SUMMARY_WAIT_MS)]);
const toItem = ({ host, thread, phase }: (typeof candidates)[number]) =>
feedItem(host, thread, phase, cache.peek(host, thread));
return { return {
needsYou: needsYou.map(toItem), needsYou: needsYou.map(toItem(cache)),
working: working.map(toItem), working: working.map(toItem(cache)),
recent: recent.map(toItem), recent: recent.map(toItem(cache)),
recentTotal: allRecent.length,
snoozedTotal: snoozed.length,
hosts: hosts.map(hostSummary), hosts: hosts.map(hostSummary),
}; };
} }
/** One thread with its recent messages. Fetches the summary if it is not cached. */ /**
export async function buildThreadDetail(host: T3Host, thread: ThreadShell, cache: SummaryCache): Promise<ThreadDetail> { * Every unsettled finished thread, for the full Recent list. Uses only summaries already cached,
* since there can be many, so `lastMessage` is null for threads the gateway has not read.
*/
export function buildRecent(hosts: T3Host[], cache: SummaryCache): RecentResponse {
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.
*/
export async function buildThreadDetail(
host: T3Host,
thread: ThreadShell,
cache: SummaryCache,
hosts: T3Host[],
): Promise<ThreadDetail> {
const summary = await cache.get(host, thread); const summary = await cache.get(host, thread);
const phase = threadPhase(thread) ?? (thread.status === "failed" ? "failed" : "completed"); const own = threadPhase(thread) ?? (thread.status === "failed" ? "failed" : "completed");
const child = isWaiting(own) ? undefined : waitingDelegate(hosts, host, thread);
const via = child && { host: child.host, thread: child.thread, summary: await cache.get(child.host, child.thread).catch(() => undefined) };
return { return {
item: feedItem(host, thread, phase, summary), item: feedItem(host, thread, child?.phase ?? own, summary, via),
messages: summary.messages, messages: summary.messages,
canStop: thread.activeRunId !== null, canStop: thread.activeRunId !== null,
askFirst: thread.runtimeMode === ASK_FIRST,
earlierCursor: summary.earlierCursor,
subagents: subagents(hosts, host, thread),
changedFiles: summary.changedFiles,
}; };
} }
const threadRow = (host: T3Host, thread: ThreadShell): ThreadRow => ({
hostId: host.id,
threadId: thread.id,
title: thread.title,
phase: resolvePhase(thread),
settled: thread.settledOverride === "settled",
updatedAt: thread.updatedAt,
});
const newestFirst = (a: ThreadRow, b: ThreadRow) => b.updatedAt.localeCompare(a.updatedAt);
const isLive = (thread: ThreadShell) => !thread.archivedAt && !thread.deletedAt;
/** The subagents `parent` started, its own and the ones it delegated to on any host. */
function subagents(hosts: T3Host[], parentHost: T3Host, parent: ThreadShell): ThreadRow[] {
const rows: ThreadRow[] = [];
for (const host of hosts) {
for (const thread of host.threads.values()) {
if (!isLive(thread)) continue;
const native =
host === parentHost &&
thread.lineage.relationshipToParent === "subagent" &&
thread.lineage.parentThreadId === parent.id;
const from = thread.delegatedFrom;
const delegated = from?.threadId === parent.id && from.environmentId === parentHost.config.environmentId;
if (native || delegated) rows.push(threadRow(host, thread));
}
}
return rows.sort(newestFirst);
}
/**
* Searches every online host at once and merges the hits, newest match first. Hosts that are
* offline or fail are named in `unsearched`.
*/
export async function searchThreads(hosts: T3Host[], query: string): Promise<SearchResponse> {
const results = await Promise.all(
hosts.map(async (host) => {
if (host.status !== "online") return null;
try {
return { host, matches: await host.search(query) };
} catch {
return null;
}
}),
);
const hits = results.flatMap((result) =>
(result?.matches ?? []).flatMap((match) => {
const thread = result!.host.threads.get(match.threadId);
if (!thread || !isLive(thread)) return [];
const snippet = match.snippet.trim();
return [
{
thread: threadRow(result!.host, thread),
projectTitle: result!.host.projects.get(thread.projectId)?.title ?? "",
hostLabel: result!.host.label,
snippet: snippet && snippet !== thread.title ? trimText(snippet, 160) : null,
at: match.messageCreatedAt ?? thread.updatedAt,
},
];
}),
);
return {
hits: hits.sort((a, b) => b.at.localeCompare(a.at)).map(({ at: _, ...hit }) => hit),
unsearched: hosts.filter((host, i) => results[i] === null).map((host) => host.label),
};
}
/** A project's newest 50 threads, settled ones included. Subagents are listed under their parents. */
export function projectThreads(host: T3Host, projectId: string): ThreadRow[] {
return [...host.threads.values()]
.filter(
(thread) =>
thread.projectId === projectId &&
isLive(thread) &&
thread.lineage.relationshipToParent !== "subagent" &&
!thread.delegatedFrom,
)
.map((thread) => threadRow(host, thread))
.sort(newestFirst)
.slice(0, THREAD_LIST_LIMIT);
}
/** T3's runtime mode that asks before each action. */
export const ASK_FIRST = "approval-required";
/** The providers on a host that can start a thread now, leaving out legacy models. */
export function agentProviders(host: T3Host): AgentProvider[] {
return (host.serverConfig?.providers ?? [])
.filter(
(provider) =>
provider.enabled &&
provider.installed &&
provider.availability !== "unavailable" &&
(provider.status === "ready" || provider.status === "warning"),
)
.map((provider) => ({
instanceId: provider.instanceId,
name: provider.displayName ?? provider.driver,
canAskFirst: provider.supportedRuntimeModes?.includes(ASK_FIRST) ?? true,
models: provider.models.filter((model) => !model.isLegacy).map((model) => ({ slug: model.slug, name: model.name })),
}))
.filter((provider) => provider.models.length > 0);
}
+31 -1
View File
@@ -1,7 +1,7 @@
// Cases ported from T3 Code's agentAwareness.test.ts, so the gateway notifies when T3's own app would. // Cases ported from T3 Code's agentAwareness.test.ts, so the gateway notifies when T3's own app would.
import { describe, expect, it } from "bun:test"; import { describe, expect, it } from "bun:test";
import { threadPhase } from "../src/awareness.ts"; import { isSnoozed, threadPhase } from "../src/awareness.ts";
import type { ThreadShell } from "../src/t3/types.ts"; import type { ThreadShell } from "../src/t3/types.ts";
const thread = (overrides: Partial<ThreadShell> = {}): ThreadShell => ({ const thread = (overrides: Partial<ThreadShell> = {}): ThreadShell => ({
@@ -95,4 +95,34 @@ describe("threadPhase", () => {
it("hides threads that never ran", () => { it("hides threads that never ran", () => {
expect(threadPhase(thread({ status: "idle" }))).toBeNull(); expect(threadPhase(thread({ status: "idle" }))).toBeNull();
}); });
// Not from upstream: delegate_task children carry no subagent lineage, only delegatedFrom.
it("shows delegated threads only while they wait on the user", () => {
const delegatedFrom = { environmentId: "env-desktop", threadId: "thread-1", title: "Parent" };
expect(threadPhase(thread({ delegatedFrom }))).toBeNull();
expect(threadPhase(thread({ delegatedFrom, status: "completed" }))).toBeNull();
expect(threadPhase(thread({ delegatedFrom, pendingRuntimeRequest: request("command") }))).toBe("waiting_for_approval");
});
});
describe("isSnoozed", () => {
const now = Date.parse("2026-11-01T12:00:00.000Z");
const snoozed = {
snoozedUntil: "2026-11-01T18:00:00.000Z",
snoozedAt: "2026-11-01T10:00:00.000Z",
latestRunCompletedAt: "2026-11-01T09:00:00.000Z",
status: "completed",
pendingRuntimeRequest: null,
} as const;
it("hides a thread until its time, as T3 does", () => {
expect(isSnoozed(snoozed, now)).toBe(true);
expect(isSnoozed({ ...snoozed, snoozedUntil: "2026-11-01T11:00:00.000Z" }, now)).toBe(false);
});
it("wakes a thread that waits on someone or failed after the snooze", () => {
expect(isSnoozed({ ...snoozed, pendingRuntimeRequest: { id: "r", kind: "command", createdAt: "" } }, now)).toBe(false);
expect(isSnoozed({ ...snoozed, status: "failed", latestRunCompletedAt: "2026-11-01T11:00:00.000Z" }, now)).toBe(false);
expect(isSnoozed({ ...snoozed, status: "failed" }, now)).toBe(true);
});
}); });
+84
View File
@@ -0,0 +1,84 @@
// A host with several routes connects through the first one that answers.
import { afterEach, describe, expect, it } from "bun:test";
import type { Server } from "bun";
import { T3Host } from "../src/t3/host.ts";
const ENVIRONMENT_ID = "env-laptop";
let servers: Server<undefined>[] = [];
afterEach(() => {
for (const server of servers) server.stop(true);
servers = [];
});
/**
* Just enough of a T3 server: the descriptor, a ticket, config, and an empty shell snapshot.
* While `state.up` is false its descriptor returns 503, like a route that is down.
*/
function fakeT3(state = { up: true }) {
const server = Bun.serve({
port: 0,
fetch(req, srv) {
const { pathname } = new URL(req.url);
if (pathname === "/.well-known/t3/environment") {
if (!state.up) return new Response("down", { status: 503 });
return Response.json({ environmentId: ENVIRONMENT_ID, label: "framework-gib", serverVersion: "0.1.5", orchestrationProtocolVersion: 2 });
}
if (pathname === "/api/auth/websocket-ticket/rpc") return Response.json({ ticket: "t" });
if (pathname === "/ws" && srv.upgrade(req)) return undefined;
return new Response("not found", { status: 404 });
},
websocket: {
message(ws, data) {
const frame = JSON.parse(String(data)) as { _tag: string; id: string; tag: string };
if (frame._tag !== "Request") return;
if (frame.tag === "server.getConfig") {
ws.send(JSON.stringify({ _tag: "Exit", requestId: frame.id, exit: { _tag: "Success", value: {} } }));
} else if (frame.tag === "orchestration.subscribeShell") {
const snapshot = { snapshotSequence: 1, projects: [], threads: [] };
ws.send(JSON.stringify({ _tag: "Chunk", requestId: frame.id, values: [{ kind: "snapshot", snapshot }] }));
}
},
},
});
servers.push(server);
return `http://localhost:${server.port}`;
}
/** Resolves once `check` passes, polling every few milliseconds. */
async function until(check: () => boolean) {
while (!check()) await Bun.sleep(5);
}
describe("T3Host routes", () => {
it("falls back to the next route when the preferred one does not answer", async () => {
const working = fakeT3();
// Port 9 (discard) refuses connections, standing in for a LAN route that is down.
const refused = "http://127.0.0.1:9";
const spare = "https://t3laptop.example.org";
const host = new T3Host({ id: ENVIRONMENT_ID, label: "Laptop", urls: [refused, working, spare], token: "x", environmentId: ENVIRONMENT_ID });
host.start();
await until(() => host.status === "online");
expect(host.url).toBe(working);
expect(host.routeAttempts.get(refused)).toMatchObject({ ok: false, error: "refused" });
expect(host.routeAttempts.get(working)).toMatchObject({ ok: true, error: null });
expect(host.routeAttempts.has(spare)).toBe(false);
host.stop();
});
it("moves back to the preferred route on reconnect once it answers", async () => {
const preferredState = { up: false };
const preferred = fakeT3(preferredState);
const fallback = fakeT3();
const host = new T3Host({ id: ENVIRONMENT_ID, label: "Laptop", urls: [preferred, fallback], token: "x", environmentId: ENVIRONMENT_ID });
host.start();
await until(() => host.status === "online");
expect(host.url).toBe(fallback);
preferredState.up = true;
host.reconnect();
await until(() => host.status === "online" && host.url === preferred);
expect(host.lastOnlineAt).not.toBeNull();
host.stop();
});
});
+71 -2
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "bun:test"; import { describe, expect, it } from "bun:test";
import { Notifier, type Push } from "../src/notify.ts"; import { approvalBody, Notifier, type Push } from "../src/notify.ts";
import type { T3Host } from "../src/t3/host.ts"; import type { T3Host } from "../src/t3/host.ts";
import type { ThreadShell } from "../src/t3/types.ts"; import type { ThreadShell } from "../src/t3/types.ts";
import { SummaryCache } from "../src/views.ts"; import { SummaryCache } from "../src/views.ts";
@@ -94,7 +94,8 @@ describe("Notifier", () => {
const row = { const row = {
id: "host-1", id: "host-1",
label: "Mac mini", label: "Mac mini",
url: "https://t3mac.gbrown.org", url: "https://t3-mac.example.com",
alt_urls: null,
environment_id: "host-1", environment_id: "host-1",
token: "t", token: "t",
token_expires_at: new Date(Date.now() + 3 * 24 * 60 * 60_000).toISOString(), token_expires_at: new Date(Date.now() + 3 * 24 * 60 * 60_000).toISOString(),
@@ -107,3 +108,71 @@ 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,
});
});
});
describe("approvalBody", () => {
const approval = (kind: string, files: Array<{ name: string; additions: number | null; deletions: number | null }>) => ({
id: "r-1",
kind,
prompt: "Edit: deploy/compose.yml",
reason: null,
options: [],
questions: [],
files: files.map((file) => ({ ...file, path: `deploy/${file.name}`, preview: [] })),
});
it("names the file a file approval would edit, with counts only when known", () => {
expect(approvalBody(approval("file-change", [{ name: "compose.yml", additions: 9, deletions: 6 }]))).toBe(
"Wants to edit compose.yml +9 \u22126",
);
expect(approvalBody(approval("file-change", [{ name: "compose.yml", additions: null, deletions: null }]))).toBe(
"Wants to edit compose.yml",
);
});
it("counts several files and lists their names", () => {
const files = ["compose.yml", "Containerfile", "health.ts"].map((name) => ({ name, additions: null, deletions: null }));
expect(approvalBody(approval("file-change", files))).toBe("Wants to edit 3 files: compose.yml, Containerfile, health.ts");
});
it("falls back to the prompt without files", () => {
expect(approvalBody(approval("file-change", []))).toBe("Wants to run: Edit: deploy/compose.yml");
expect(approvalBody(null)).toBe("Needs your approval.");
});
});
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from "bun:test";
import type { DeviceRow } from "../src/db.ts";
import type { Push } from "../src/notify.ts";
import { apnsPayload, notificationPrefs, wants } from "../src/push.ts";
const push = (category: Push["category"], fromSubagent = false): Push => ({
title: "Receipt OCR",
body: "Finished.",
category,
fromSubagent,
data: { hostId: "host-1", threadId: "thread-1" },
collapseId: "host-1:thread-1",
});
describe("notificationPrefs", () => {
it("leaves every kind the watch never set on", () => {
const device = { notifications: JSON.stringify({ finished: false }) } as DeviceRow;
expect(notificationPrefs(device)).toEqual({ needsMe: true, finished: false, failed: true, subagents: true });
});
});
describe("wants", () => {
const prefs = { needsMe: true, finished: false, failed: true, subagents: false };
it("mutes a subagent's request apart from your own threads' requests", () => {
expect(wants(prefs, push("approval"))).toBe(true);
expect(wants(prefs, push("approval", true))).toBe(false);
expect(wants(prefs, push("input", true))).toBe(false);
});
it("follows the finished and failed switches, and always sends host problems", () => {
expect(wants(prefs, push("finished"))).toBe(false);
expect(wants(prefs, push("failed"))).toBe(true);
expect(wants({ needsMe: false, finished: false, failed: false, subagents: false }, push("host"))).toBe(true);
});
});
describe("apnsPayload", () => {
it("drops the question's buttons, then cuts the body, to stay under APNs' 4 KB", () => {
const wide = "界".repeat(400);
const big: Push = {
...push("input"),
title: "界".repeat(80),
body: wide,
data: {
hostId: "host-1",
threadId: "thread-1",
question: { id: "q", options: [1, 2, 3, 4].map((n) => ({ label: `${n}${"界".repeat(39)}`, value: "界".repeat(200) })), allowCustomAnswer: true },
},
};
const payload = apnsPayload(big);
expect(Buffer.byteLength(JSON.stringify(payload))).toBeLessThanOrEqual(3900);
expect("question" in payload).toBe(false);
expect(apnsPayload(push("finished"))).toMatchObject({ aps: { alert: { body: "Finished." } }, threadId: "thread-1" });
});
});
+275 -7
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 { buildSummary, summarize, 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: [],
}); });
}); });
@@ -107,25 +108,292 @@ 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" }),
thread({ id: "c", status: "completed" }), thread({ id: "c", status: "completed" }),
thread({ id: "d", status: "running", lineage: { relationshipToParent: "subagent" } }), thread({ id: "d", status: "running", lineage: { relationshipToParent: "subagent" } }),
]), ]),
host("LittleMan", "offline", [ host("VPS", "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: "VPS", threadId: "e", title: "Older ask", hostLabel: "VPS", request: null },
current: { hostId: "Mac mini", threadId: "b", title: "Receipt OCR", activity: null },
hostsOffline: 1, hostsOffline: 1,
}); });
}); });
}); });
describe("routeKind", () => {
it("tells direct, VPN and public routes apart", () => {
expect(routeKind("http://desktop.lan:3773")).toBe("direct");
expect(routeKind("http://10.0.0.2:3773")).toBe("direct");
expect(routeKind("http://laptop-vpn.lan:3773")).toBe("vpn");
expect(routeKind("https://t3-laptop-vpn.example.com")).toBe("public");
});
});
describe("buildFeed", () => {
const host = (label: string, threads: ThreadShell[]) =>
({
id: label,
label,
status: "online",
config: { environmentId: `env-${label}` },
projects: new Map(),
threads: new Map(threads.map((t) => [t.id, t])),
bounded: () => Promise.reject(new Error("not needed")),
}) as unknown as T3Host;
const ask = (id: string, minute: number) => ({ id, kind: "command", createdAt: `2026-11-01T09:${minute}:00.000Z` });
const delegatedFrom = { environmentId: "env-Desktop", threadId: "parent", title: "HomeLab Stats" };
it("shows a waiting subagent's request on the card of the thread that delegated it", async () => {
const feed = await buildFeed(
[
host("Desktop", [thread({ id: "parent", title: "HomeLab Stats" })]),
host("Mac mini", [
thread({ id: "child", title: "Check PATH", delegatedFrom, pendingRuntimeRequest: ask("r1", 10) }),
thread({ id: "busy", title: "Survey setup", delegatedFrom }),
]),
],
new SummaryCache(),
);
expect(feed.working).toEqual([]);
expect(feed.needsYou.map((item) => [item.threadId, item.phase, item.via])).toEqual([
["parent", "waiting_for_approval", { hostId: "Mac mini", threadId: "child", title: "Check PATH" }],
]);
});
it("keeps a second waiting subagent on its own card", async () => {
const feed = await buildFeed(
[
host("Desktop", [thread({ id: "parent", title: "HomeLab Stats" })]),
host("Mac mini", [
thread({ id: "later", delegatedFrom, pendingRuntimeRequest: ask("r2", 20) }),
thread({ id: "first", delegatedFrom, pendingRuntimeRequest: ask("r1", 10) }),
]),
],
new SummaryCache(),
);
expect(feed.needsYou.map((item) => [item.threadId, item.via?.threadId ?? null])).toEqual([
["parent", "first"],
["later", null],
]);
});
it("keeps finished threads in Recent until they are settled, however old", async () => {
const old = (id: string, day: number) => thread({ id, status: "completed", updatedAt: `2026-10-${day}T09:00:00.000Z` });
const feed = await buildFeed(
[host("Desktop", [...Array.from({ length: 11 }, (_, i) => old(`t${i}`, 10 + i)), { ...old("done", 29), settledOverride: "settled" }])],
new SummaryCache(),
);
expect(feed.recent.map((item) => item.threadId)).toEqual(["t10", "t9", "t8", "t7", "t6", "t5", "t4", "t3", "t2", "t1"]);
expect(feed.recentTotal).toBe(11);
});
});
describe("agentProviders", () => {
const provider = (overrides: object) => ({
instanceId: "claudeAgent",
driver: "claudeAgent",
displayName: "Claude",
enabled: true,
installed: true,
status: "ready",
models: [
{ slug: "claude-opus-5-5", name: "Claude Opus 5.5" },
{ slug: "claude-opus-4", name: "Claude Opus 4", isLegacy: true },
],
...overrides,
});
it("offers ready providers without their legacy models, and says which cannot ask first", () => {
const host = {
serverConfig: {
providers: [
provider({}),
provider({ instanceId: "codex", displayName: undefined, driver: "codex", supportedRuntimeModes: ["full-access"] }),
provider({ instanceId: "off", enabled: false }),
provider({ instanceId: "broken", status: "error" }),
provider({ instanceId: "away", availability: "unavailable" }),
],
},
} as unknown as T3Host;
expect(agentProviders(host)).toEqual([
{ instanceId: "claudeAgent", name: "Claude", canAskFirst: true, models: [{ slug: "claude-opus-5-5", name: "Claude Opus 5.5" }] },
{ instanceId: "codex", name: "codex", canAskFirst: false, models: [{ slug: "claude-opus-5-5", name: "Claude Opus 5.5" }] },
]);
});
});
describe("currentActivity", () => {
const item = (type: string, status: string, extra: object = {}) => ({ type, status, startedAt: "2026-11-01T09:00:00.000Z", ...extra });
it("says the newest step still in progress, leaving out monitors", () => {
expect(
currentActivity([
item("file_change", "running", { fileName: "gateway/src/host.ts" }),
item("command_execution", "running", { input: "\nbun test \\\n --bail" }),
item("command_execution", "running", { input: "tail -f log", monitor: true }),
item("assistant_message", "running"),
]),
).toEqual({ kind: "command", text: "bun test \\", startedAt: "2026-11-01T09:00:00.000Z" });
expect(currentActivity([item("file_change", "running", { fileName: "gateway/src/host.ts" })])?.text).toBe("host.ts");
});
it("is null once every step finished", () => {
expect(currentActivity([item("command_execution", "completed", { input: "bun test" })])).toBeNull();
});
});
describe("thread history", () => {
const messages = Array.from({ length: 45 }, (_, i) => message(i % 2 ? "assistant" : "user", `m${i}`));
const window = { snapshotSequence: 1, historyCursor: "t3-cursor", hasMoreHistory: true, projection: { thread: thread(), messages, turnItems: [] } };
const host = { bounded: () => Promise.resolve(window) } as unknown as T3Host;
it("pages back through the bounded window, then hands over to T3's cursor", async () => {
expect(summarize(window as BoundedThread, thread()).earlierCursor).toBe("wm25");
const first = await loadHistory(host, thread(), "wm25");
expect([first.messages[0]?.text, first.messages.at(-1)?.text, first.earlierCursor]).toEqual(["m5", "m24", "wm5"]);
const second = await loadHistory(host, thread(), "wm5");
expect([second.messages.length, second.earlierCursor]).toEqual([5, "ht3-cursor"]);
});
it("names a message, so new messages moving the window do not repeat or skip any", async () => {
const grown = { ...window, projection: { ...window.projection, messages: [...messages.slice(3), message("user", "m45"), message("assistant", "m46"), message("user", "m47")] } };
const moved = { bounded: () => Promise.resolve(grown) } as unknown as T3Host;
const page = await loadHistory(moved, thread(), "wm25");
expect([page.messages[0]?.text, page.messages.at(-1)?.text]).toEqual(["m5", "m24"]);
});
});
describe("projectThreads", () => {
it("lists a project's threads newest first, settled ones too, without subagents", () => {
const host = {
id: "Desktop",
threads: new Map(
[
thread({ id: "old", updatedAt: "2026-11-01T08:00:00.000Z", settledOverride: "settled", status: "completed" }),
thread({ id: "new", updatedAt: "2026-11-01T10:00:00.000Z" }),
thread({ id: "sub", lineage: { relationshipToParent: "subagent", parentThreadId: "new" } }),
thread({ id: "gone", archivedAt: "2026-11-01T09:30:00.000Z" }),
thread({ id: "elsewhere", projectId: "project-2" }),
].map((t) => [t.id, t]),
),
} as unknown as T3Host;
expect(projectThreads(host, "project-1").map((row) => [row.threadId, row.phase, row.settled])).toEqual([
["new", "running", false],
["old", "completed", true],
]);
});
});
describe("latestChanges", () => {
it("sums each file the latest run changed, most lines first", () => {
const change = (runId: string, fileName: string, additions: number, deletions: number, status = "completed") =>
({ type: "file_change", runId, status, 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),
change("run-2", "declined.ts", 80, 0, "cancelled"),
{ 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"]);
});
});
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"]);
});
});
+44
View File
@@ -0,0 +1,44 @@
<!doctype html><html><head><meta charset="utf-8"><title>WristCode delegates A: request on the parent card</title>
<style>
:root { --you:#ffb02e; --work:#4fd1c5; --done:#6b6b70; --card:#17171a; --bad:#ff6b5e; }
body { margin:0; font-family:"SF Pro Rounded", ui-rounded, system-ui, sans-serif; }
.grid { display:grid; grid-template-columns:repeat(2,1fr); gap:28px 16px; padding:8px 0 16px; justify-items:center; }
figure { margin:0; display:flex; flex-direction:column; align-items:center; gap:10px; }
figcaption { font-size:13px; color:var(--muted-foreground,#999); text-align:center; max-width:260px; line-height:1.35; font-family:system-ui,sans-serif; }
.bezel { background:#0b0b0c; padding:10px; border-radius:52px; box-shadow:0 0 0 1px #2a2a2c; }
.screen { width:198px; height:242px; background:#000; border-radius:42px; overflow:hidden; color:#fff; padding:0 9px; box-sizing:border-box; position:relative; }
.top { display:flex; justify-content:space-between; align-items:baseline; padding:12px 8px 4px; font-weight:600; font-size:15px; }
.card { background:var(--card); border-radius:18px; padding:9px 11px; margin-bottom:6px; border-left:4px solid var(--c); }
.meta { font-size:11px; color:var(--c); font-weight:600; }
.what { font-size:15px; font-weight:600; line-height:1.2; margin-top:1px; }
.why { font-size:12px; color:#aeaeb2; margin-top:3px; line-height:1.25; }
.mono { font-family:"SF Mono",ui-monospace,monospace; font-size:11px; }
.acts { display:flex; gap:6px; margin-top:7px; }
.acts span { flex:1; text-align:center; border-radius:14px; padding:6px 0; font-size:13px; font-weight:700; }
.big { display:block; text-align:center; border-radius:24px; padding:10px; font-size:15px; font-weight:700; margin-bottom:6px; }
.label { font-size:11px; color:#aeaeb2; padding:4px 6px 3px; font-weight:600; display:flex; justify-content:space-between; }
.sub { margin:-2px 0 6px 14px; background:#101012; border-radius:14px; padding:7px 9px; border-left:3px solid var(--you); }
.chip { display:inline-block; font-size:10px; font-weight:700; border-radius:7px; padding:1px 6px; background:#3d2f12; color:var(--you); }
.row { display:flex; align-items:center; gap:6px; }
.swipe { display:flex; margin-bottom:6px; }
.swipe .card { flex:1; margin:0; border-radius:18px 0 0 18px; }
.swipe .act { width:58px; background:var(--work); color:#000; border-radius:0 18px 18px 0; display:flex; flex-direction:column; align-items:center; justify-content:center; font-size:11px; font-weight:700; }
</style>
</head><body><div class="grid">
<figure><div class="bezel"><div class="screen"><div class="top"><span></span><span>9:41</span></div>
<div class="card" style="--c:var(--you)"><div class="meta">Mac mini · WristLab</div><div class="what">HomeLab Stats Apple Watch App</div>
<div class="why">Subagent <b style="color:#fff">Check Panama PATH</b> wants to run:</div><div class="mono" style="margin-top:3px">brew install jq</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.</b> The request sits on the parent's card. One line names the subagent that asked. Allow and Deny answer the subagent.</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">From subagent</div><div class="what" style="font-size:13px">Check Panama PATH</div><div class="mono" style="margin-top:3px">brew install jq</div>
<div class="acts"><span style="background:var(--you);color:#000">Allow</span><span style="background:#2c2c2e">Deny</span></div></div>
<div class="why" style="padding:0 6px">Delegated a PATH check to the Mac mini. Waiting on it before…</div></div></div><figcaption><b>Parent thread.</b> The subagent's request shows above the parent's messages, labeled with where it came from.</figcaption></figure>
<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">Desktop · wristcode</div><div class="what" style="font-size:14px">Status screens</div></div><div class="act">✓<br>Settle</div></div>
<div class="card" style="--c:var(--done)"><div class="meta">server-gib · Panama</div><div class="what" style="font-size:14px">Fix hyprlock crash</div></div>
<div class="card" style="--c:var(--bad)"><div class="meta">Laptop · bill-tracker</div><div class="what" style="font-size:14px">Receipt OCR</div></div></div></div><figcaption><b>Settle by swipe.</b> Swipe a Recent row left for Settle. It leaves the feed, the same as settling in T3.</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="why" style="padding:0 6px 6px">Done. Status, environment and notification screens are in TestFlight build 202611011516.</div>
<span class="big" style="background:var(--card)">Reply</span>
<span class="big" style="background:var(--work);color:#000">Settle</span></div></div><figcaption><b>Thread detail.</b> Finished threads get a Settle button under Reply.</figcaption></figure>
</div></body></html>
+44
View File
@@ -0,0 +1,44 @@
<!doctype html><html><head><meta charset="utf-8"><title>WristCode delegates B: nested under the parent</title>
<style>
:root { --you:#ffb02e; --work:#4fd1c5; --done:#6b6b70; --card:#17171a; --bad:#ff6b5e; }
body { margin:0; font-family:"SF Pro Rounded", ui-rounded, system-ui, sans-serif; }
.grid { display:grid; grid-template-columns:repeat(2,1fr); gap:28px 16px; padding:8px 0 16px; justify-items:center; }
figure { margin:0; display:flex; flex-direction:column; align-items:center; gap:10px; }
figcaption { font-size:13px; color:var(--muted-foreground,#999); text-align:center; max-width:260px; line-height:1.35; font-family:system-ui,sans-serif; }
.bezel { background:#0b0b0c; padding:10px; border-radius:52px; box-shadow:0 0 0 1px #2a2a2c; }
.screen { width:198px; height:242px; background:#000; border-radius:42px; overflow:hidden; color:#fff; padding:0 9px; box-sizing:border-box; position:relative; }
.top { display:flex; justify-content:space-between; align-items:baseline; padding:12px 8px 4px; font-weight:600; font-size:15px; }
.card { background:var(--card); border-radius:18px; padding:9px 11px; margin-bottom:6px; border-left:4px solid var(--c); }
.meta { font-size:11px; color:var(--c); font-weight:600; }
.what { font-size:15px; font-weight:600; line-height:1.2; margin-top:1px; }
.why { font-size:12px; color:#aeaeb2; margin-top:3px; line-height:1.25; }
.mono { font-family:"SF Mono",ui-monospace,monospace; font-size:11px; }
.acts { display:flex; gap:6px; margin-top:7px; }
.acts span { flex:1; text-align:center; border-radius:14px; padding:6px 0; font-size:13px; font-weight:700; }
.big { display:block; text-align:center; border-radius:24px; padding:10px; font-size:15px; font-weight:700; margin-bottom:6px; }
.label { font-size:11px; color:#aeaeb2; padding:4px 6px 3px; font-weight:600; display:flex; justify-content:space-between; }
.sub { margin:-2px 0 6px 14px; background:#101012; border-radius:14px; padding:7px 9px; border-left:3px solid var(--you); }
.chip { display:inline-block; font-size:10px; font-weight:700; border-radius:7px; padding:1px 6px; background:#3d2f12; color:var(--you); }
.row { display:flex; align-items:center; gap:6px; }
.swipe { display:flex; margin-bottom:6px; }
.swipe .card { flex:1; margin:0; border-radius:18px 0 0 18px; }
.swipe .act { width:58px; background:var(--work); color:#000; border-radius:0 18px 18px 0; display:flex; flex-direction:column; align-items:center; justify-content:center; font-size:11px; font-weight:700; }
</style>
</head><body><div class="grid">
<figure><div class="bezel"><div class="screen"><div class="top"><span></span><span>9:41</span></div>
<div class="card" style="--c:var(--you)"><div class="meta">Mac mini · WristLab</div><div class="what">HomeLab Stats Apple Watch App</div><div class="why">Waiting on 1 subagent</div></div>
<div class="sub"><div class="row"><span class="chip">↳ subagent</span><span style="font-size:11px;color:#aeaeb2">Check Panama PATH</span></div><div class="mono" style="margin-top:4px">brew install jq</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.</b> The parent keeps its own card. The subagent's request hangs under it, indented, so the parent and child read as two things.</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="label"><span>Subagents</span></div>
<div class="card" style="--c:var(--you)"><div class="what" style="font-size:13px">Check Panama PATH</div><div class="meta">Needs approval</div></div>
<div class="card" style="--c:var(--work)"><div class="what" style="font-size:13px">Survey Mac Claude setup</div><div class="meta">Working</div></div></div></div><figcaption><b>Parent thread.</b> A Subagents list at the top while any are active. Tap one to approve, or to reply if it was delegated.</figcaption></figure>
<figure><div class="bezel"><div class="screen"><div class="top"><span>Recent</span><span>9:41</span></div>
<div class="label"><span>Finished today</span><span style="color:var(--work)">Settle all</span></div>
<div class="card" style="--c:var(--done)"><div class="meta">Desktop · wristcode</div><div class="what" style="font-size:14px">Status screens</div></div>
<div class="card" style="--c:var(--done)"><div class="meta">server-gib · Panama</div><div class="what" style="font-size:14px">Fix hyprlock crash</div></div></div></div><figcaption><b>Settle all.</b> One tap clears every finished thread in Recent. Failed ones stay unless you settle them one by one.</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="why" style="padding:0 6px 6px">Done. Status, environment and notification screens are in TestFlight build 202611011516.</div>
<span class="big" style="background:var(--card)">Reply</span>
<span class="big" style="background:var(--work);color:#000">Settle</span></div></div><figcaption><b>Thread detail.</b> Same Settle button as A.</figcaption></figure>
</div></body></html>
+42
View File
@@ -0,0 +1,42 @@
<!doctype html><html><head><meta charset="utf-8"><title>WristCode delegates C: request as its own card</title>
<style>
:root { --you:#ffb02e; --work:#4fd1c5; --done:#6b6b70; --card:#17171a; --bad:#ff6b5e; }
body { margin:0; font-family:"SF Pro Rounded", ui-rounded, system-ui, sans-serif; }
.grid { display:grid; grid-template-columns:repeat(2,1fr); gap:28px 16px; padding:8px 0 16px; justify-items:center; }
figure { margin:0; display:flex; flex-direction:column; align-items:center; gap:10px; }
figcaption { font-size:13px; color:var(--muted-foreground,#999); text-align:center; max-width:260px; line-height:1.35; font-family:system-ui,sans-serif; }
.bezel { background:#0b0b0c; padding:10px; border-radius:52px; box-shadow:0 0 0 1px #2a2a2c; }
.screen { width:198px; height:242px; background:#000; border-radius:42px; overflow:hidden; color:#fff; padding:0 9px; box-sizing:border-box; position:relative; }
.top { display:flex; justify-content:space-between; align-items:baseline; padding:12px 8px 4px; font-weight:600; font-size:15px; }
.card { background:var(--card); border-radius:18px; padding:9px 11px; margin-bottom:6px; border-left:4px solid var(--c); }
.meta { font-size:11px; color:var(--c); font-weight:600; }
.what { font-size:15px; font-weight:600; line-height:1.2; margin-top:1px; }
.why { font-size:12px; color:#aeaeb2; margin-top:3px; line-height:1.25; }
.mono { font-family:"SF Mono",ui-monospace,monospace; font-size:11px; }
.acts { display:flex; gap:6px; margin-top:7px; }
.acts span { flex:1; text-align:center; border-radius:14px; padding:6px 0; font-size:13px; font-weight:700; }
.big { display:block; text-align:center; border-radius:24px; padding:10px; font-size:15px; font-weight:700; margin-bottom:6px; }
.label { font-size:11px; color:#aeaeb2; padding:4px 6px 3px; font-weight:600; display:flex; justify-content:space-between; }
.sub { margin:-2px 0 6px 14px; background:#101012; border-radius:14px; padding:7px 9px; border-left:3px solid var(--you); }
.chip { display:inline-block; font-size:10px; font-weight:700; border-radius:7px; padding:1px 6px; background:#3d2f12; color:var(--you); }
.row { display:flex; align-items:center; gap:6px; }
.swipe { display:flex; margin-bottom:6px; }
.swipe .card { flex:1; margin:0; border-radius:18px 0 0 18px; }
.swipe .act { width:58px; background:var(--work); color:#000; border-radius:0 18px 18px 0; display:flex; flex-direction:column; align-items:center; justify-content:center; font-size:11px; font-weight:700; }
</style>
</head><body><div class="grid">
<figure><div class="bezel"><div class="screen"><div class="top"><span></span><span>9:41</span></div>
<div class="card" style="--c:var(--you)"><div class="meta">Mac mini · WristLab · subagent</div><div class="what">brew install jq?</div><div class="why">For HomeLab Stats Apple Watch App</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.</b> The request is an ordinary needs-you card. The command is the headline, and the second line says which of your threads it is for.</figcaption></figure>
<figure><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--work)">‹ Request</span><span>9:41</span></div>
<div class="why" style="padding:0 6px">Subagent <b style="color:#fff">Check Panama PATH</b>, started by</div>
<div class="card" style="--c:var(--done);margin-top:6px"><div class="what" style="font-size:13px">HomeLab Stats Apple Watch App</div><div class="meta">Open parent ›</div></div>
<div class="mono" style="padding:0 6px">brew install jq</div></div></div><figcaption><b>Request detail.</b> Opens the subagent's request, with a link up to the parent thread.</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="row"><div style="flex:1"><div class="meta">Desktop · wristcode</div><div class="what" style="font-size:14px">Status screens</div></div><span style="width:26px;height:26px;border-radius:13px;background:#1f3b38;color:var(--work);display:flex;align-items:center;justify-content:center;font-weight:700">✓</span></div></div>
<div class="card" style="--c:var(--done)"><div class="row"><div style="flex:1"><div class="meta">server-gib · Panama</div><div class="what" style="font-size:14px">Fix hyprlock crash</div></div><span style="width:26px;height:26px;border-radius:13px;background:#1f3b38;color:var(--work);display:flex;align-items:center;justify-content:center;font-weight:700">✓</span></div></div></div></div><figcaption><b>Settle in place.</b> A check on every Recent row. Visible without knowing to swipe, but each row is busier.</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="why" style="padding:0 6px 6px">Done. Status, environment and notification screens are in TestFlight build 202611011516.</div>
<span class="big" style="background:var(--card)">Reply</span>
<span class="big" style="background:var(--work);color:#000">Settle</span></div></div><figcaption><b>Thread detail.</b> Same Settle button as A.</figcaption></figure>
</div></body></html>
+58
View File
@@ -0,0 +1,58 @@
<!doctype html><html><head><meta charset="utf-8"><title>WristCode Phase 2 A: Settings switches</title><style>
:root { --you:#ffb02e; --work:#4fd1c5; --done:#6b6b70; --card:#17171a; --bad:#ff6b5e; --dim:#aeaeb2; }
body { margin:0; font-family:"SF Pro Rounded", ui-rounded, system-ui, sans-serif; }
.grid { display:grid; grid-template-columns:repeat(2,1fr); gap:28px 16px; padding:8px 0 16px; justify-items:center; }
figure { margin:0; display:flex; flex-direction:column; align-items:center; gap:10px; }
figcaption { font-size:13px; color:var(--muted-foreground,#999); text-align:center; max-width:260px; line-height:1.35; font-family:system-ui,sans-serif; }
.bezel { background:#0b0b0c; padding:10px; border-radius:52px; box-shadow:0 0 0 1px #2a2a2c; }
.screen { width:198px; height:242px; background:#000; border-radius:42px; overflow:hidden; color:#fff; padding:0 9px; box-sizing:border-box; font-size:13px; }
.top { display:flex; justify-content:space-between; align-items:baseline; padding:12px 8px 6px; font-weight:600; font-size:15px; }
.card { background:var(--card); border-radius:16px; padding:8px 10px; margin-bottom:5px; border-left:4px solid var(--c, var(--card)); }
.row { display:flex; align-items:center; gap:7px; }
.name { font-weight:600; font-size:14px; }
.meta { font-size:11px; color:var(--dim); }
.small { font-size:11px; font-weight:600; color:var(--dim); }
.label { font-size:11px; color:var(--dim); padding:4px 6px 3px; font-weight:600; }
.big { display:block; text-align:center; border-radius:22px; padding:9px; font-size:14px; font-weight:700; margin:4px 0; }
.r { margin-left:auto; white-space:nowrap; }
.tog { width:30px; height:18px; border-radius:9px; background:#3a3a3c; position:relative; flex:none; margin-left:auto; }
.tog::after { content:""; position:absolute; top:2px; left:2px; width:14px; height:14px; border-radius:50%; background:#fff; }
.tog.on { background:var(--work); } .tog.on::after { left:14px; }
.check { color:var(--work); font-weight:700; margin-left:auto; }
.seg { display:flex; background:#2c2c2e; border-radius:12px; padding:2px; gap:2px; margin-top:5px; }
.seg span { flex:1; text-align:center; font-size:12px; font-weight:700; padding:4px 0; border-radius:10px; color:var(--dim); }
.seg span.sel { background:var(--you); color:#000; }
.chip { display:inline-block; font-size:11px; font-weight:700; border-radius:9px; padding:3px 8px; background:#2c2c2e; color:var(--dim); margin:0 3px 4px 0; }
.chip.sel { background:var(--work); color:#000; }
</style></head><body><div class="grid">
<figure><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--work)">‹ Notifications</span><span>9:41</span></div>
<div class="label">Notify me when</div>
<div class="card row"><span class="name" style="font-size:13px">Needs me</span><span class="tog on"></span></div>
<div class="card row"><span class="name" style="font-size:13px">Finished</span><span class="tog on"></span></div>
<div class="card row"><span class="name" style="font-size:13px">Failed</span><span class="tog on"></span></div>
<div class="card row"><span class="name" style="font-size:13px">Subagents ask</span><span class="tog"></span></div>
</div></div><figcaption><b>Notifications.</b> A switch per kind of push, above the existing Send test. Host problems always notify. With Subagents off, their requests still show on the parent's card, only without a buzz.</figcaption></figure>
<figure><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--you)">New thread</span><span>9:41</span></div>
<div class="card" style="--c:var(--done)"><div class="small">Where</div><div class="name">Mac mini › wristcode</div></div>
<div class="card" style="--c:var(--done)"><div class="small">Agent</div><div class="row"><span class="name">Claude Opus 5.5</span><span class="r meta">›</span></div></div>
<div class="card row" style="--c:var(--done)"><span class="name" style="font-size:13px">Ask before acting</span><span class="tog on" style="background:var(--you)"></span></div>
<span class="big" style="background:var(--you);color:#000">🎙 Say the task</span>
</div></div><figcaption><b>New thread.</b> The Agent row opens a model list. A switch under it starts the thread in approval-required mode. Both remember your last choice.</figcaption></figure>
<figure><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--work)">‹ Agent</span><span>9:41</span></div>
<div class="label">Claude</div>
<div class="card row"><span class="name" style="font-size:13px">Opus 5.5</span><span class="check">✓</span></div>
<div class="card row"><span class="name" style="font-size:13px">Sonnet 5.5</span></div>
<div class="card row"><span class="name" style="font-size:13px">Haiku 5.5</span></div>
<div class="label">Codex</div>
<div class="card row"><span class="name" style="font-size:13px">GPT-5.5</span></div>
</div></div><figcaption><b>Agent list.</b> Every model the host offers, grouped by provider, project default on top. Unavailable providers stay hidden.</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="meta" style="padding:0 6px 6px">Working on the gateway route for notification settings…</div>
<span class="big" style="background:var(--card)">Reply</span>
<div class="card row"><span class="name" style="font-size:13px">Ask before acting</span><span class="tog"></span></div>
<span class="big" style="background:#2c2c2e;color:var(--bad)">Stop</span>
</div></div><figcaption><b>Thread detail.</b> The same switch on a running thread, so you can rein one in from your wrist. It takes effect on the next tool call.</figcaption></figure>
</div></body></html>
+54
View File
@@ -0,0 +1,54 @@
<!doctype html><html><head><meta charset="utf-8"><title>WristCode Phase 2 B: Mute where the noise is</title><style>
:root { --you:#ffb02e; --work:#4fd1c5; --done:#6b6b70; --card:#17171a; --bad:#ff6b5e; --dim:#aeaeb2; }
body { margin:0; font-family:"SF Pro Rounded", ui-rounded, system-ui, sans-serif; }
.grid { display:grid; grid-template-columns:repeat(2,1fr); gap:28px 16px; padding:8px 0 16px; justify-items:center; }
figure { margin:0; display:flex; flex-direction:column; align-items:center; gap:10px; }
figcaption { font-size:13px; color:var(--muted-foreground,#999); text-align:center; max-width:260px; line-height:1.35; font-family:system-ui,sans-serif; }
.bezel { background:#0b0b0c; padding:10px; border-radius:52px; box-shadow:0 0 0 1px #2a2a2c; }
.screen { width:198px; height:242px; background:#000; border-radius:42px; overflow:hidden; color:#fff; padding:0 9px; box-sizing:border-box; font-size:13px; }
.top { display:flex; justify-content:space-between; align-items:baseline; padding:12px 8px 6px; font-weight:600; font-size:15px; }
.card { background:var(--card); border-radius:16px; padding:8px 10px; margin-bottom:5px; border-left:4px solid var(--c, var(--card)); }
.row { display:flex; align-items:center; gap:7px; }
.name { font-weight:600; font-size:14px; }
.meta { font-size:11px; color:var(--dim); }
.small { font-size:11px; font-weight:600; color:var(--dim); }
.label { font-size:11px; color:var(--dim); padding:4px 6px 3px; font-weight:600; }
.big { display:block; text-align:center; border-radius:22px; padding:9px; font-size:14px; font-weight:700; margin:4px 0; }
.r { margin-left:auto; white-space:nowrap; }
.tog { width:30px; height:18px; border-radius:9px; background:#3a3a3c; position:relative; flex:none; margin-left:auto; }
.tog::after { content:""; position:absolute; top:2px; left:2px; width:14px; height:14px; border-radius:50%; background:#fff; }
.tog.on { background:var(--work); } .tog.on::after { left:14px; }
.check { color:var(--work); font-weight:700; margin-left:auto; }
.seg { display:flex; background:#2c2c2e; border-radius:12px; padding:2px; gap:2px; margin-top:5px; }
.seg span { flex:1; text-align:center; font-size:12px; font-weight:700; padding:4px 0; border-radius:10px; color:var(--dim); }
.seg span.sel { background:var(--you); color:#000; }
.chip { display:inline-block; font-size:11px; font-weight:700; border-radius:9px; padding:3px 8px; background:#2c2c2e; color:var(--dim); margin:0 3px 4px 0; }
.chip.sel { background:var(--work); color:#000; }
</style></head><body><div class="grid">
<figure><div class="bezel"><div class="screen"><div class="top"><span>Feed</span><span>9:41</span></div>
<div style="display:flex;margin-bottom:5px"><div class="card" style="--c:var(--work);flex:1;margin:0;border-radius:16px 0 0 16px"><div class="small" style="color:var(--work)">Laptop · bill-tracker</div><div class="name">Receipt OCR</div></div><div style="width:58px;background:#3a3a3c;border-radius:0 16px 16px 0;display:flex;flex-direction:column;align-items:center;justify-content:center;font-size:11px;font-weight:700">🔕<br>Mute</div></div>
<div class="card" style="--c:var(--done)"><div class="small" style="color:var(--done)">Desktop · 4m ago</div><div class="name" style="color:var(--dim)">Status screens</div></div>
</div></div><figcaption><b>Mute by swipe.</b> Swipe any card left for Mute. It silences that thread's finished and failed pushes. Requests that need you still buzz.</figcaption></figure>
<figure><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--work)">‹ Notifications</span><span>9:41</span></div>
<div class="label">Notify me when</div>
<div class="seg" style="margin:0 0 6px"><span class="sel" style="background:var(--work)">All</span><span>Needs me</span></div>
<div class="label">Muted</div>
<div class="card row"><span class="name" style="font-size:13px">Receipt OCR</span><span class="r meta">Unmute</span></div>
<div class="card row"><span class="name" style="font-size:13px">bill-tracker</span><span class="r meta">Unmute</span></div>
</div></div><figcaption><b>Notifications.</b> One choice, All or Needs me, plus the list of what you muted so you can undo it. A project mutes from its thread's screen.</figcaption></figure>
<figure><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--you)">New thread</span><span>9:41</span></div>
<div class="card" style="--c:var(--done)"><div class="small">Where</div><div class="name">Mac mini › wristcode</div></div>
<div class="small" style="padding:2px 4px 3px">Agent</div>
<div style="padding:0 2px"><span class="chip sel">Opus 5.5</span><span class="chip">Sonnet</span><span class="chip">GPT-5.5</span></div>
<div class="seg" style="margin:2px 2px 6px"><span class="sel">Asks first</span><span>Full access</span></div>
<span class="big" style="background:var(--you);color:#000">🎙 Say the task</span>
</div></div><figcaption><b>New thread.</b> Models as chips you tap in place, the host's three most used first. Asks first or Full access as a two-way switch. No extra screen.</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(--work)"><div class="small" style="color:var(--work)">Opus 5.5 · Full access</div><div class="name">Receipt OCR</div></div>
<div class="row" style="gap:5px;margin-bottom:5px"><span class="big" style="flex:1;background:#2c2c2e;font-size:12px;margin:0">🔕 Mute</span><span class="big" style="flex:1;background:#2c2c2e;font-size:12px;margin:0">Asks first</span></div>
<span class="big" style="background:var(--card)">Reply</span>
</div></div><figcaption><b>Thread detail.</b> Model and mode on the header line. Mute and Asks first as buttons, each flips that thread only.</figcaption></figure>
</div></body></html>
+54
View File
@@ -0,0 +1,54 @@
<!doctype html><html><head><meta charset="utf-8"><title>WristCode Phase 2 C: Presets</title><style>
:root { --you:#ffb02e; --work:#4fd1c5; --done:#6b6b70; --card:#17171a; --bad:#ff6b5e; --dim:#aeaeb2; }
body { margin:0; font-family:"SF Pro Rounded", ui-rounded, system-ui, sans-serif; }
.grid { display:grid; grid-template-columns:repeat(2,1fr); gap:28px 16px; padding:8px 0 16px; justify-items:center; }
figure { margin:0; display:flex; flex-direction:column; align-items:center; gap:10px; }
figcaption { font-size:13px; color:var(--muted-foreground,#999); text-align:center; max-width:260px; line-height:1.35; font-family:system-ui,sans-serif; }
.bezel { background:#0b0b0c; padding:10px; border-radius:52px; box-shadow:0 0 0 1px #2a2a2c; }
.screen { width:198px; height:242px; background:#000; border-radius:42px; overflow:hidden; color:#fff; padding:0 9px; box-sizing:border-box; font-size:13px; }
.top { display:flex; justify-content:space-between; align-items:baseline; padding:12px 8px 6px; font-weight:600; font-size:15px; }
.card { background:var(--card); border-radius:16px; padding:8px 10px; margin-bottom:5px; border-left:4px solid var(--c, var(--card)); }
.row { display:flex; align-items:center; gap:7px; }
.name { font-weight:600; font-size:14px; }
.meta { font-size:11px; color:var(--dim); }
.small { font-size:11px; font-weight:600; color:var(--dim); }
.label { font-size:11px; color:var(--dim); padding:4px 6px 3px; font-weight:600; }
.big { display:block; text-align:center; border-radius:22px; padding:9px; font-size:14px; font-weight:700; margin:4px 0; }
.r { margin-left:auto; white-space:nowrap; }
.tog { width:30px; height:18px; border-radius:9px; background:#3a3a3c; position:relative; flex:none; margin-left:auto; }
.tog::after { content:""; position:absolute; top:2px; left:2px; width:14px; height:14px; border-radius:50%; background:#fff; }
.tog.on { background:var(--work); } .tog.on::after { left:14px; }
.check { color:var(--work); font-weight:700; margin-left:auto; }
.seg { display:flex; background:#2c2c2e; border-radius:12px; padding:2px; gap:2px; margin-top:5px; }
.seg span { flex:1; text-align:center; font-size:12px; font-weight:700; padding:4px 0; border-radius:10px; color:var(--dim); }
.seg span.sel { background:var(--you); color:#000; }
.chip { display:inline-block; font-size:11px; font-weight:700; border-radius:9px; padding:3px 8px; background:#2c2c2e; color:var(--dim); margin:0 3px 4px 0; }
.chip.sel { background:var(--work); color:#000; }
</style></head><body><div class="grid">
<figure><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--work)">‹ Notifications</span><span>9:41</span></div>
<div class="label">Buzz me for</div>
<div class="card row" style="--c:var(--work)"><div><div class="name" style="font-size:13px">Everything</div><div class="meta">Needs me, done, failed</div></div><span class="check">✓</span></div>
<div class="card row"><div><div class="name" style="font-size:13px">Needs me and failures</div></div></div>
<div class="card row"><div><div class="name" style="font-size:13px">Only needs me</div></div></div>
</div></div><figcaption><b>Notifications.</b> One pick from three levels, instead of switches. Subagent requests count as needs me in every level.</figcaption></figure>
<figure><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--you)">New thread</span><span>9:41</span></div>
<div class="card" style="--c:var(--done)"><div class="small">Where</div><div class="name">Mac mini › wristcode</div></div>
<div class="card" style="--c:var(--done)"><div class="small">Setup</div><div class="row"><span class="name" style="font-size:13px">Opus 5.5 · Asks first</span><span class="r meta">›</span></div></div>
<span class="big" style="background:var(--you);color:#000">🎙 Say the task</span>
</div></div><figcaption><b>New thread.</b> The Agent row becomes Setup and names both choices. One tap opens a sheet with the model and the mode.</figcaption></figure>
<figure><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--work)">‹ Setup</span><span>9:41</span></div>
<div class="label">Model</div>
<div class="card row"><span class="name" style="font-size:13px">Opus 5.5</span><span class="check">✓</span></div>
<div class="card row"><span class="name" style="font-size:13px">Sonnet 5.5</span></div>
<div class="label">Mode</div>
<div class="card row" style="--c:var(--you)"><div><div class="name" style="font-size:13px">Asks first</div><div class="meta">Approve each command</div></div><span class="check">✓</span></div>
</div></div><figcaption><b>Setup sheet.</b> Models, then all four T3 modes: Asks first, Auto-accept edits, Auto, Full access. Scrolls with the crown.</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="meta" style="padding:0 6px 6px">Working on the gateway route for notification settings…</div>
<span class="big" style="background:var(--card)">Reply</span>
<div class="card" style="--c:var(--done)"><div class="small">Mode</div><div class="row"><span class="name" style="font-size:13px">Full access</span><span class="r meta">›</span></div></div>
</div></div><figcaption><b>Thread detail.</b> A Mode row at the bottom opens the same four-way list for the running thread. The model stays as it started.</figcaption></figure>
</div></body></html>
+47
View File
@@ -0,0 +1,47 @@
<!doctype html><html><head><meta charset="utf-8"><title>WristCode Phase 3 A: Quiet additions</title><style>
:root { --you:#ffb02e; --work:#4fd1c5; --done:#6b6b70; --card:#17171a; --bad:#ff6b5e; --dim:#aeaeb2; }
body { margin:0; font-family:"SF Pro Rounded", ui-rounded, system-ui, sans-serif; }
.grid { display:grid; grid-template-columns:repeat(2,1fr); gap:28px 16px; padding:8px 0 16px; justify-items:center; }
figure { margin:0; display:flex; flex-direction:column; align-items:center; gap:10px; }
figcaption { font-size:13px; color:var(--muted-foreground,#999); text-align:center; max-width:260px; line-height:1.35; font-family:system-ui,sans-serif; }
.bezel { background:#0b0b0c; padding:10px; border-radius:52px; box-shadow:0 0 0 1px #2a2a2c; }
.screen { width:198px; height:242px; background:#000; border-radius:42px; overflow:hidden; color:#fff; padding:0 9px; box-sizing:border-box; font-size:13px; }
.top { display:flex; justify-content:space-between; align-items:baseline; padding:12px 8px 6px; font-weight:600; font-size:15px; }
.card { background:var(--card); border-radius:16px; padding:8px 10px; 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:14px; line-height:1.2; }
.meta { font-size:11px; color:var(--c, var(--dim)); font-weight:600; }
.line { font-size:12px; color:var(--dim); margin-top:3px; line-height:1.25; }
.mono { font-family:"SF Mono",ui-monospace,monospace; font-size:11px; }
.label { font-size:11px; color:var(--dim); padding:4px 6px 3px; font-weight:600; display:flex; justify-content:space-between; }
.big { display:block; text-align:center; border-radius:22px; padding:8px; font-size:13px; font-weight:700; margin:4px 0; }
.r { margin-left:auto; white-space:nowrap; }
.dot { width:7px; height:7px; border-radius:50%; flex:none; }
.msg { font-size:12px; color:#ddd; padding:2px 6px 6px; line-height:1.3; }
.icon { display:inline-block; width:14px; text-align:center; color:var(--work); font-weight:700; }
</style></head><body><div class="grid">
<figure><div class="bezel"><div class="screen"><div class="top"><span>3</span><span>9:41</span></div>
<div class="card" style="--c:var(--work)"><div class="meta">Mac mini · wristcode</div><div class="name">Phase 3 screens</div><div class="line"><span class="icon">›_</span><span class="mono">bun test</span></div></div>
<div class="card" style="--c:var(--work)"><div class="meta">Desktop · Panama</div><div class="name">Fix hyprlock crash</div><div class="line"><span class="icon">✎</span>Editing <span class="mono">lock.qml</span></div></div>
</div></div><figcaption><b>Working card.</b> The agent's last message is replaced by what it is doing right now: a command, a file it is editing, a search, or Thinking.</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 · 2h ago</div><div class="name" style="color:var(--dim)">Status screens</div></div>
<div class="card" style="--c:var(--done);opacity:.8"><div class="row"><span class="name" style="font-size:13px;color:var(--done)">4 more</span><span class="r" style="color:var(--done)">›</span></div></div>
<div class="card" style="--c:var(--done)"><div class="row"><span class="name" style="font-size:13px">All threads</span><span class="r meta">›</span></div></div>
</div></div><figcaption><b>All threads.</b> One row at the very bottom of the feed. It opens hosts, then projects, then that project's threads, settled ones included, newest first.</figcaption></figure>
<figure><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--work)">‹ wristcode</span><span>9:41</span></div>
<div class="card row"><span class="dot" style="background:var(--work)"></span><span class="name" style="font-size:13px">Phase 3 screens</span></div>
<div class="card row"><span class="dot" style="background:var(--done)"></span><span class="name" style="font-size:13px;color:var(--dim)">Phase 2 switches</span><span class="r meta">1d</span></div>
<div class="card row"><span class="dot" style="background:var(--done)"></span><span class="name" style="font-size:13px;color:var(--dim)">Gear icon fix</span><span class="r meta">2d</span></div>
<div class="card row"><span class="dot" style="background:var(--done)"></span><span class="name" style="font-size:13px;color:var(--dim)">Recent window</span><span class="r meta">2d</span></div>
</div></div><figcaption><b>A project's threads.</b> One line each, with a status dot and age. Tapping opens the usual thread screen.</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="msg">Delegated the watch screens to the Mac mini. Waiting on it…</div>
<div class="label">Subagents</div>
<div class="card row"><span class="dot" style="background:var(--work)"></span><span class="name" style="font-size:13px">Watch Phase 3</span><span class="r meta" style="--c:var(--work)">working</span></div>
<div class="card row"><span class="dot" style="background:var(--done)"></span><span class="name" style="font-size:13px;color:var(--dim)">Read T3 contracts</span><span class="r meta">done</span></div>
</div></div><figcaption><b>Subagents.</b> A section under the messages, above Reply. Each row opens that subagent's thread.</figcaption></figure>
</div></body></html>
+42
View File
@@ -0,0 +1,42 @@
<!doctype html><html><head><meta charset="utf-8"><title>WristCode Phase 3 B: Activity forward</title><style>
:root { --you:#ffb02e; --work:#4fd1c5; --done:#6b6b70; --card:#17171a; --bad:#ff6b5e; --dim:#aeaeb2; }
body { margin:0; font-family:"SF Pro Rounded", ui-rounded, system-ui, sans-serif; }
.grid { display:grid; grid-template-columns:repeat(2,1fr); gap:28px 16px; padding:8px 0 16px; justify-items:center; }
figure { margin:0; display:flex; flex-direction:column; align-items:center; gap:10px; }
figcaption { font-size:13px; color:var(--muted-foreground,#999); text-align:center; max-width:260px; line-height:1.35; font-family:system-ui,sans-serif; }
.bezel { background:#0b0b0c; padding:10px; border-radius:52px; box-shadow:0 0 0 1px #2a2a2c; }
.screen { width:198px; height:242px; background:#000; border-radius:42px; overflow:hidden; color:#fff; padding:0 9px; box-sizing:border-box; font-size:13px; }
.top { display:flex; justify-content:space-between; align-items:baseline; padding:12px 8px 6px; font-weight:600; font-size:15px; }
.card { background:var(--card); border-radius:16px; padding:8px 10px; 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:14px; line-height:1.2; }
.meta { font-size:11px; color:var(--c, var(--dim)); font-weight:600; }
.line { font-size:12px; color:var(--dim); margin-top:3px; line-height:1.25; }
.mono { font-family:"SF Mono",ui-monospace,monospace; font-size:11px; }
.label { font-size:11px; color:var(--dim); padding:4px 6px 3px; font-weight:600; display:flex; justify-content:space-between; }
.big { display:block; text-align:center; border-radius:22px; padding:8px; font-size:13px; font-weight:700; margin:4px 0; }
.r { margin-left:auto; white-space:nowrap; }
.dot { width:7px; height:7px; border-radius:50%; flex:none; }
.msg { font-size:12px; color:#ddd; padding:2px 6px 6px; line-height:1.3; }
.icon { display:inline-block; width:14px; text-align:center; color:var(--work); font-weight:700; }
</style></head><body><div class="grid">
<figure><div class="bezel"><div class="screen"><div class="top"><span>3</span><span>9:41</span></div>
<div class="card" style="--c:var(--work)"><div class="meta">Mac mini · wristcode</div><div class="name">Phase 3 screens</div><div class="row" style="margin-top:4px;background:#0f2523;border-radius:9px;padding:4px 7px"><span class="mono" style="color:var(--work)">bun test</span><span class="r" style="font-size:11px;color:var(--work)">42s</span></div><div class="line">2 subagents working</div></div>
</div></div><figcaption><b>Working card.</b> The current step sits in its own tinted strip with how long it has run. A second line counts working subagents.</figcaption></figure>
<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" style="--c:var(--done)"><div class="row"><span class="name" style="font-size:13px">wristcode</span><span class="r meta">12</span></div><div class="line" style="margin-top:1px">Mac mini · 1 working</div></div>
<div class="card" style="--c:var(--done)"><div class="row"><span class="name" style="font-size:13px">Panama</span><span class="r meta">31</span></div><div class="line" style="margin-top:1px">Desktop</div></div>
<div class="card" style="--c:var(--done)"><div class="row"><span class="name" style="font-size:13px">APSCA</span><span class="r meta">8</span></div><div class="line" style="margin-top:1px">Laptop · offline</div></div>
</div></div><figcaption><b>All threads.</b> A list button joins + at the top right. It opens every project across hosts in one list, busiest first, skipping the host step.</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(--work)"><div class="meta">Working · 4m</div><div class="name">Phase 3 screens</div><div class="row" style="margin-top:5px;gap:4px"><span style="font-size:11px;font-weight:700;background:#0f2523;color:var(--work);border-radius:8px;padding:2px 7px">2 subagents ›</span></div></div>
<div class="msg">Delegated the watch screens to the Mac mini. Waiting on it…</div>
</div></div><figcaption><b>Thread header.</b> A chip under the title counts subagents and opens a list of them. It only appears when the thread has any.</figcaption></figure>
<figure><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--work)">‹ Subagents</span><span>9:41</span></div>
<div class="card" style="--c:var(--work)"><div class="name" style="font-size:13px">Watch Phase 3</div><div class="line"><span class="mono">xcodebuild test</span></div></div>
<div class="card" style="--c:var(--done)"><div class="name" style="font-size:13px;color:var(--dim)">Read T3 contracts</div><div class="line">Done 3m ago</div></div>
</div></div><figcaption><b>Subagents list.</b> Each subagent shows its own activity line, so you can follow it without opening it.</figcaption></figure>
</div></body></html>
+46
View File
@@ -0,0 +1,46 @@
<!doctype html><html><head><meta charset="utf-8"><title>WristCode Phase 3 C: Timeline</title><style>
:root { --you:#ffb02e; --work:#4fd1c5; --done:#6b6b70; --card:#17171a; --bad:#ff6b5e; --dim:#aeaeb2; }
body { margin:0; font-family:"SF Pro Rounded", ui-rounded, system-ui, sans-serif; }
.grid { display:grid; grid-template-columns:repeat(2,1fr); gap:28px 16px; padding:8px 0 16px; justify-items:center; }
figure { margin:0; display:flex; flex-direction:column; align-items:center; gap:10px; }
figcaption { font-size:13px; color:var(--muted-foreground,#999); text-align:center; max-width:260px; line-height:1.35; font-family:system-ui,sans-serif; }
.bezel { background:#0b0b0c; padding:10px; border-radius:52px; box-shadow:0 0 0 1px #2a2a2c; }
.screen { width:198px; height:242px; background:#000; border-radius:42px; overflow:hidden; color:#fff; padding:0 9px; box-sizing:border-box; font-size:13px; }
.top { display:flex; justify-content:space-between; align-items:baseline; padding:12px 8px 6px; font-weight:600; font-size:15px; }
.card { background:var(--card); border-radius:16px; padding:8px 10px; 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:14px; line-height:1.2; }
.meta { font-size:11px; color:var(--c, var(--dim)); font-weight:600; }
.line { font-size:12px; color:var(--dim); margin-top:3px; line-height:1.25; }
.mono { font-family:"SF Mono",ui-monospace,monospace; font-size:11px; }
.label { font-size:11px; color:var(--dim); padding:4px 6px 3px; font-weight:600; display:flex; justify-content:space-between; }
.big { display:block; text-align:center; border-radius:22px; padding:8px; font-size:13px; font-weight:700; margin:4px 0; }
.r { margin-left:auto; white-space:nowrap; }
.dot { width:7px; height:7px; border-radius:50%; flex:none; }
.msg { font-size:12px; color:#ddd; padding:2px 6px 6px; line-height:1.3; }
.icon { display:inline-block; width:14px; text-align:center; color:var(--work); font-weight:700; }
</style></head><body><div class="grid">
<figure><div class="bezel"><div class="screen"><div class="top"><span>3</span><span>9:41</span></div>
<div class="card" style="--c:var(--work)"><div class="meta">Mac mini · wristcode</div><div class="name">Phase 3 screens</div>
<div class="line mono" style="color:var(--done)">✓ Edited Models.swift</div><div class="line mono" style="color:var(--done);margin-top:1px">✓ Read api.md</div><div class="line mono" style="color:var(--work);margin-top:1px">› bun test</div></div>
</div></div><figcaption><b>Working card.</b> The last three steps as a tiny log, finished ones dim, the current one in teal. More to read, and the card is taller.</figcaption></figure>
<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="label">Today</div>
<div class="card"><div class="meta">Mac mini · wristcode</div><div class="name" style="font-size:13px">Phase 3 screens</div></div>
<div class="label">Yesterday</div>
<div class="card"><div class="meta">Desktop · Panama</div><div class="name" style="font-size:13px;color:var(--dim)">Fix hyprlock crash</div></div>
</div></div><figcaption><b>All threads.</b> One long list by day, every host and project mixed, newest first. Opened from a row under the feed. Simple, but long.</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="msg">I'll split this into two subagents.</div>
<div class="card" style="--c:var(--work);margin-left:12px"><div class="meta" style="--c:var(--work)">↳ Subagent · working</div><div class="name" style="font-size:13px">Watch Phase 3</div></div>
<div class="card" style="--c:var(--done);margin-left:12px"><div class="meta">↳ Subagent · done</div><div class="name" style="font-size:13px;color:var(--dim)">Read T3 contracts</div></div>
</div></div><figcaption><b>Subagents in the timeline.</b> Each subagent shows where it was started among the messages, indented. Tap to open it.</figcaption></figure>
<figure><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--work)">‹ Thread</span><span>9:41</span></div>
<span class="big" style="background:var(--card);color:var(--dim)">Load earlier</span>
<div class="msg" style="background:#1c1c1e;border-radius:12px;padding:6px 8px;margin:0 0 5px 20px">Run the tests</div>
<div class="msg">All <b>48</b> pass. The fix is in <span class="mono" style="background:#222;border-radius:4px;padding:0 3px">host.ts</span>:<br>• bounded retry<br>• 3 s timeout</div>
</div></div><figcaption><b>Same in every direction.</b> Load earlier at the top of a thread, and markdown: bold, inline code and lists.</figcaption></figure>
</div></body></html>
+46
View File
@@ -0,0 +1,46 @@
<!doctype html><html><head><meta charset="utf-8"><title>WristCode Phase 4: What changed</title><style>
:root { --you:#ffb02e; --work:#4fd1c5; --done:#6b6b70; --card:#17171a; --bad:#ff6b5e; --dim:#aeaeb2; --add:#5ad17a; }
body { margin:0; font-family:"SF Pro Rounded", ui-rounded, system-ui, sans-serif; }
h3 { font-family:system-ui,sans-serif; font-size:14px; margin:6px 0 2px; color:var(--foreground,#ddd); text-align:center; grid-column:1/-1; }
.grid { display:grid; grid-template-columns:repeat(3,1fr); gap:22px 10px; padding:8px 0 16px; justify-items:center; }
figure { margin:0; display:flex; flex-direction:column; align-items:center; gap:8px; }
figcaption { font-size:12px; color:var(--muted-foreground,#999); text-align:center; max-width:220px; line-height:1.35; font-family:system-ui,sans-serif; }
.bezel { background:#0b0b0c; padding:9px; border-radius:48px; box-shadow:0 0 0 1px #2a2a2c; }
.screen { width:184px; height:225px; background:#000; border-radius:40px; overflow:hidden; color:#fff; padding:0 8px; box-sizing:border-box; font-size:13px; position:relative; }
.top { display:flex; justify-content:space-between; align-items:baseline; padding:11px 8px 5px; font-weight:600; font-size:14px; }
.card { background:var(--card); border-radius:15px; padding:7px 9px; margin-bottom:5px; border-left:4px solid var(--c, var(--card)); }
.row { display:flex; align-items:center; gap:6px; }
.name { font-weight:600; font-size:13px; line-height:1.2; }
.meta { font-size:10.5px; color:var(--c, var(--dim)); font-weight:600; }
.line { font-size:11.5px; color:var(--dim); margin-top:3px; line-height:1.25; }
.mono { font-family:"SF Mono",ui-monospace,monospace; font-size:10.5px; }
.label { font-size:10.5px; color:var(--dim); padding:4px 6px 3px; font-weight:600; }
.big { display:block; text-align:center; border-radius:20px; padding:7px; font-size:13px; font-weight:700; margin:4px 0; }
.r { margin-left:auto; white-space:nowrap; }
.add { color:var(--add); } .del { color:var(--bad); }
.dot { width:7px; height:7px; border-radius:50%; flex:none; }
.face { background:radial-gradient(circle at 50% 40%, #1a1a1d, #000 70%); }
.time { text-align:right; font-size:30px; font-weight:600; padding:10px 10px 0; letter-spacing:-.5px; }
</style></head><body><div class="grid">
<h3>Option 1: a line on the card, the files on the thread screen</h3>
<figure><div class="bezel"><div class="screen"><div class="top"><span>Recent</span><span>9:41</span></div>
<div class="card" style="--c:var(--done)"><div class="meta">Mac mini · 4m ago</div><div class="name" style="color:var(--dim)">Gateway Containerfile</div><div class="line mono"><span class="add">+42</span> <span class="del">−8</span> · 3 files</div></div>
<div class="card" style="--c:var(--done)"><div class="meta">Desktop · 1h ago</div><div class="name" style="color:var(--dim)">Explain the hub</div></div>
</div></div><figcaption><b>Recent card.</b> One line of totals under the title. A thread that changed nothing, like a question, shows no line.</figcaption></figure>
<figure><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--work)">‹ Thread</span><span>9:41</span></div>
<div class="line" style="padding:0 6px 4px;color:#ddd">Done. The image builds and the healthcheck passes.</div>
<div class="label">Changed</div>
<div class="card row"><span class="mono">Containerfile</span><span class="r mono"><span class="add">+31</span> <span class="del">−0</span></span></div>
<div class="card row"><span class="mono">compose.yml</span><span class="r mono"><span class="add">+9</span> <span class="del">−6</span></span></div>
<div class="card row"><span class="mono">health.ts</span><span class="r mono"><span class="add">+2</span> <span class="del">−2</span></span></div>
</div></div><figcaption><b>Thread screen.</b> A Changed section under the messages lists each file of the latest run with its counts. Read only.</figcaption></figure>
<figure><div class="bezel"><div class="screen"><div class="top"><span>Recent</span><span>9:41</span></div>
<div class="card" style="--c:var(--done)"><div class="meta">Mac mini · 4m ago</div><div class="name" style="color:var(--dim)">Gateway Containerfile</div><div class="line"><span class="add">●</span> 3 files changed</div></div>
</div></div><figcaption><b>Lighter card line.</b> Only the file count, no line counts. Same thread screen as the middle.</figcaption></figure>
<h3>Option 2: thread screen only</h3>
<figure style="grid-column:2"><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--work)">‹ Thread</span><span>9:41</span></div>
<div class="line" style="padding:0 6px 4px;color:#ddd">Done. The image builds and the healthcheck passes.</div>
<div class="card row"><span class="name" style="font-size:12px">3 files changed</span><span class="r mono"><span class="add">+42</span> <span class="del">−8</span> ›</span></div>
<span class="big" style="background:var(--card)">Reply</span>
</div></div><figcaption><b>One row on the thread screen.</b> Cards stay as they are. The row opens the file list.</figcaption></figure>
</div></body></html>
+42
View File
@@ -0,0 +1,42 @@
<!doctype html><html><head><meta charset="utf-8"><title>WristCode Phase 4: Search</title><style>
:root { --you:#ffb02e; --work:#4fd1c5; --done:#6b6b70; --card:#17171a; --bad:#ff6b5e; --dim:#aeaeb2; --add:#5ad17a; }
body { margin:0; font-family:"SF Pro Rounded", ui-rounded, system-ui, sans-serif; }
h3 { font-family:system-ui,sans-serif; font-size:14px; margin:6px 0 2px; color:var(--foreground,#ddd); text-align:center; grid-column:1/-1; }
.grid { display:grid; grid-template-columns:repeat(3,1fr); gap:22px 10px; padding:8px 0 16px; justify-items:center; }
figure { margin:0; display:flex; flex-direction:column; align-items:center; gap:8px; }
figcaption { font-size:12px; color:var(--muted-foreground,#999); text-align:center; max-width:220px; line-height:1.35; font-family:system-ui,sans-serif; }
.bezel { background:#0b0b0c; padding:9px; border-radius:48px; box-shadow:0 0 0 1px #2a2a2c; }
.screen { width:184px; height:225px; background:#000; border-radius:40px; overflow:hidden; color:#fff; padding:0 8px; box-sizing:border-box; font-size:13px; position:relative; }
.top { display:flex; justify-content:space-between; align-items:baseline; padding:11px 8px 5px; font-weight:600; font-size:14px; }
.card { background:var(--card); border-radius:15px; padding:7px 9px; margin-bottom:5px; border-left:4px solid var(--c, var(--card)); }
.row { display:flex; align-items:center; gap:6px; }
.name { font-weight:600; font-size:13px; line-height:1.2; }
.meta { font-size:10.5px; color:var(--c, var(--dim)); font-weight:600; }
.line { font-size:11.5px; color:var(--dim); margin-top:3px; line-height:1.25; }
.mono { font-family:"SF Mono",ui-monospace,monospace; font-size:10.5px; }
.label { font-size:10.5px; color:var(--dim); padding:4px 6px 3px; font-weight:600; }
.big { display:block; text-align:center; border-radius:20px; padding:7px; font-size:13px; font-weight:700; margin:4px 0; }
.r { margin-left:auto; white-space:nowrap; }
.add { color:var(--add); } .del { color:var(--bad); }
.dot { width:7px; height:7px; border-radius:50%; flex:none; }
.face { background:radial-gradient(circle at 50% 40%, #1a1a1d, #000 70%); }
.time { text-align:right; font-size:30px; font-weight:600; padding:10px 10px 0; letter-spacing:-.5px; }
</style></head><body><div class="grid">
<h3>Option 1: a Search row at the top of All threads</h3>
<figure><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--work)">‹ All threads</span><span>9:41</span></div>
<div class="card row" style="background:#1f1f22"><span style="color:var(--dim)">⌕</span><span style="color:var(--dim)">Search</span><span class="r" style="color:var(--work)">🎙</span></div>
<div class="card row"><span class="dot" style="background:var(--work)"></span><span class="name">Desktop</span></div>
<div class="card row"><span class="dot" style="background:var(--work)"></span><span class="name">Mac mini</span></div>
</div></div><figcaption><b>All threads.</b> A search field above the hosts. Tapping it opens dictation or the keyboard, as watchOS does for any text field.</figcaption></figure>
<figure><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--work)">‹ "containerfile"</span><span>9:41</span></div>
<div class="card"><div class="meta">Mac mini · wristcode</div><div class="name">Gateway Containerfile</div></div>
<div class="card"><div class="meta">server-gib · homelab</div><div class="name" style="color:var(--dim)">Jellyfin image update</div><div class="line">…rebuild from the <b style="color:#fff">Containerfile</b> and…</div></div>
</div></div><figcaption><b>Results.</b> Every host searched at once, best first. A match inside a message shows a snippet. Tapping opens the thread.</figcaption></figure>
<figure><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--work)">‹ "containerfile"</span><span>9:41</span></div>
<div class="line" style="padding:20px 10px;text-align:center">No threads match.<br><br><span style="font-size:10.5px">Laptop is offline, so its threads were not searched.</span></div>
</div></div><figcaption><b>No results.</b> Says which hosts it could not reach.</figcaption></figure>
<h3>Option 2: a search button on the feed</h3>
<figure style="grid-column:2"><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--dim)">⚙</span><span style="display:flex;gap:10px"><span>⌕</span><span>+</span></span></div>
<div style="font-size:30px;font-weight:700;color:var(--you);padding:0 8px">2</div><div class="line" style="padding:0 8px">waiting on you</div>
</div></div><figcaption><b>Feed toolbar.</b> Search sits beside + and opens dictation straight away. One tap less, but the top right gets crowded.</figcaption></figure>
</div></body></html>
+39
View File
@@ -0,0 +1,39 @@
<!doctype html><html><head><meta charset="utf-8"><title>WristCode Phase 4: Complication</title><style>
:root { --you:#ffb02e; --work:#4fd1c5; --done:#6b6b70; --card:#17171a; --bad:#ff6b5e; --dim:#aeaeb2; --add:#5ad17a; }
body { margin:0; font-family:"SF Pro Rounded", ui-rounded, system-ui, sans-serif; }
h3 { font-family:system-ui,sans-serif; font-size:14px; margin:6px 0 2px; color:var(--foreground,#ddd); text-align:center; grid-column:1/-1; }
.grid { display:grid; grid-template-columns:repeat(3,1fr); gap:22px 10px; padding:8px 0 16px; justify-items:center; }
figure { margin:0; display:flex; flex-direction:column; align-items:center; gap:8px; }
figcaption { font-size:12px; color:var(--muted-foreground,#999); text-align:center; max-width:220px; line-height:1.35; font-family:system-ui,sans-serif; }
.bezel { background:#0b0b0c; padding:9px; border-radius:48px; box-shadow:0 0 0 1px #2a2a2c; }
.screen { width:184px; height:225px; background:#000; border-radius:40px; overflow:hidden; color:#fff; padding:0 8px; box-sizing:border-box; font-size:13px; position:relative; }
.top { display:flex; justify-content:space-between; align-items:baseline; padding:11px 8px 5px; font-weight:600; font-size:14px; }
.card { background:var(--card); border-radius:15px; padding:7px 9px; margin-bottom:5px; border-left:4px solid var(--c, var(--card)); }
.row { display:flex; align-items:center; gap:6px; }
.name { font-weight:600; font-size:13px; line-height:1.2; }
.meta { font-size:10.5px; color:var(--c, var(--dim)); font-weight:600; }
.line { font-size:11.5px; color:var(--dim); margin-top:3px; line-height:1.25; }
.mono { font-family:"SF Mono",ui-monospace,monospace; font-size:10.5px; }
.label { font-size:10.5px; color:var(--dim); padding:4px 6px 3px; font-weight:600; }
.big { display:block; text-align:center; border-radius:20px; padding:7px; font-size:13px; font-weight:700; margin:4px 0; }
.r { margin-left:auto; white-space:nowrap; }
.add { color:var(--add); } .del { color:var(--bad); }
.dot { width:7px; height:7px; border-radius:50%; flex:none; }
.face { background:radial-gradient(circle at 50% 40%, #1a1a1d, #000 70%); }
.time { text-align:right; font-size:30px; font-weight:600; padding:10px 10px 0; letter-spacing:-.5px; }
</style></head><body><div class="grid">
<h3>Watch face (rectangular slot)</h3>
<figure><div class="bezel"><div class="screen face"><div class="time">9:41</div>
<div style="margin:30px 4px 0;background:#141416;border-radius:12px;padding:7px 9px"><div class="row"><span class="meta" style="--c:var(--you)">● 2 waiting</span><span class="r meta">1 working</span></div><div class="name" style="font-size:12px;margin-top:2px">HomeLab Stats</div><div class="line mono" style="margin-top:1px">brew install jq</div></div>
</div></div><figcaption><b>A. Next request.</b> The counts, then the thread that has waited longest and what it wants. Tap opens that thread.</figcaption></figure>
<figure><div class="bezel"><div class="screen face"><div class="time">9:41</div>
<div style="margin:30px 4px 0;background:#141416;border-radius:12px;padding:7px 9px"><div class="meta" style="--c:var(--work)">Phase 3 screens · 42s</div><div class="line mono" style="color:#fff;margin-top:2px">›_ bun test</div><div class="meta" style="margin-top:3px;--c:var(--you)">● 2 waiting</div></div>
</div></div><figcaption><b>B. Current activity.</b> What the busiest working thread is doing now, with the waiting count under it.</figcaption></figure>
<figure><div class="bezel"><div class="screen face"><div class="time">9:41</div>
<div style="margin:30px 4px 0;background:#141416;border-radius:12px;padding:7px 9px"><div class="row"><span class="meta" style="--c:var(--you)">● 2</span><span class="meta" style="--c:var(--work)">● 1</span><span class="meta" style="--c:var(--done)">● 4</span></div><div class="name" style="font-size:12px;margin-top:3px">HomeLab Stats</div><div class="line">wants to run a command</div></div>
</div></div><figcaption><b>C. Switching.</b> Shows A while anything waits on you, B otherwise. Recent count added as a third dot.</figcaption></figure>
<h3>Smart Stack</h3>
<figure style="grid-column:2"><div class="bezel"><div class="screen"><div class="time" style="font-size:22px;padding-top:8px">9:41</div>
<div style="margin:8px 0 0;background:#1c1c1f;border-radius:16px;padding:8px 10px"><div class="row"><span class="meta" style="--c:var(--you)">2 waiting</span><span class="r meta" style="--c:var(--work)">1 working</span></div><div class="name" style="font-size:12px;margin-top:3px">HomeLab Stats</div><div class="line mono">brew install jq</div><div class="row" style="gap:5px;margin-top:6px"><span class="big" style="flex:1;margin:0;padding:4px;background:var(--you);color:#000;font-size:11px">Allow</span><span class="big" style="flex:1;margin:0;padding:4px;background:#2c2c2e;font-size:11px">Deny</span></div></div>
</div></div><figcaption><b>Smart Stack widget.</b> The same content as the chosen face option, larger. With A, Allow and Deny work right in the stack.</figcaption></figure>
</div></body></html>
+38
View File
@@ -0,0 +1,38 @@
<!doctype html><html><head><meta charset="utf-8"><title>WristCode Phase 4: Answering from the notification</title><style>
:root { --you:#ffb02e; --work:#4fd1c5; --done:#6b6b70; --card:#17171a; --bad:#ff6b5e; --dim:#aeaeb2; --add:#5ad17a; }
body { margin:0; font-family:"SF Pro Rounded", ui-rounded, system-ui, sans-serif; }
h3 { font-family:system-ui,sans-serif; font-size:14px; margin:6px 0 2px; color:var(--foreground,#ddd); text-align:center; grid-column:1/-1; }
.grid { display:grid; grid-template-columns:repeat(3,1fr); gap:22px 10px; padding:8px 0 16px; justify-items:center; }
figure { margin:0; display:flex; flex-direction:column; align-items:center; gap:8px; }
figcaption { font-size:12px; color:var(--muted-foreground,#999); text-align:center; max-width:220px; line-height:1.35; font-family:system-ui,sans-serif; }
.bezel { background:#0b0b0c; padding:9px; border-radius:48px; box-shadow:0 0 0 1px #2a2a2c; }
.screen { width:184px; height:225px; background:#000; border-radius:40px; overflow:hidden; color:#fff; padding:0 8px; box-sizing:border-box; font-size:13px; position:relative; }
.top { display:flex; justify-content:space-between; align-items:baseline; padding:11px 8px 5px; font-weight:600; font-size:14px; }
.card { background:var(--card); border-radius:15px; padding:7px 9px; margin-bottom:5px; border-left:4px solid var(--c, var(--card)); }
.row { display:flex; align-items:center; gap:6px; }
.name { font-weight:600; font-size:13px; line-height:1.2; }
.meta { font-size:10.5px; color:var(--c, var(--dim)); font-weight:600; }
.line { font-size:11.5px; color:var(--dim); margin-top:3px; line-height:1.25; }
.mono { font-family:"SF Mono",ui-monospace,monospace; font-size:10.5px; }
.label { font-size:10.5px; color:var(--dim); padding:4px 6px 3px; font-weight:600; }
.big { display:block; text-align:center; border-radius:20px; padding:7px; font-size:13px; font-weight:700; margin:4px 0; }
.r { margin-left:auto; white-space:nowrap; }
.add { color:var(--add); } .del { color:var(--bad); }
.dot { width:7px; height:7px; border-radius:50%; flex:none; }
.face { background:radial-gradient(circle at 50% 40%, #1a1a1d, #000 70%); }
.time { text-align:right; font-size:30px; font-weight:600; padding:10px 10px 0; letter-spacing:-.5px; }
</style></head><body><div class="grid">
<h3>A question arrives</h3>
<figure><div class="bezel"><div class="screen"><div class="top"><span style="font-size:11px;color:var(--dim)">WRISTCODE</span><span></span></div>
<div class="name" style="padding:0 4px">Gateway Containerfile</div><div class="line" style="padding:0 4px">Which base image?</div>
<span class="big" style="background:#2c2c2e;margin-top:8px">bun:1-alpine</span><span class="big" style="background:#2c2c2e">bun:1-debian</span><span class="big" style="background:#2c2c2e">Open</span>
</div></div><figcaption><b>A. Options as buttons.</b> Each option is a button on the notification itself, then Open. A tap sends that answer.</figcaption></figure>
<figure><div class="bezel"><div class="screen"><div class="top"><span style="font-size:11px;color:var(--dim)">WRISTCODE</span><span></span></div>
<div class="name" style="padding:0 4px">Gateway Containerfile</div><div class="line" style="padding:0 4px">Which base image?</div>
<span class="big" style="background:#2c2c2e;margin-top:8px">bun:1-alpine</span><span class="big" style="background:#2c2c2e">bun:1-debian</span><span class="big" style="background:#2c2c2e">🎙 Say an answer</span>
</div></div><figcaption><b>B. Options and a spoken answer.</b> Like A, plus a dictated answer for questions that allow one. Open stays below.</figcaption></figure>
<figure><div class="bezel"><div class="screen"><div class="top"><span style="font-size:11px;color:var(--dim)">WRISTCODE</span><span></span></div>
<div class="name" style="padding:0 4px">Gateway Containerfile</div><div class="line" style="padding:0 4px">2 questions</div>
<span class="big" style="background:var(--you);color:#000;margin-top:8px">Answer</span><span class="big" style="background:#2c2c2e">Open</span>
</div></div><figcaption><b>Several questions.</b> In A and B, a request with more than one question shows Answer, which opens the app on the question screen.</figcaption></figure>
</div></body></html>
+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>
+63
View File
@@ -0,0 +1,63 @@
<!doctype html><html><head><meta charset="utf-8"><title>WristCode settings A: Sectioned list</title><style>
/* Direction A: a plain watchOS settings list in mock B's colors. Sections: Environments, Gateway,
This watch. Each environment row says which kind of route it is on. */
:root { --you:#ffb02e; --work:#4fd1c5; --done:#6b6b70; --card:#17171a; --bad:#ff6b5e; --dim:#aeaeb2; }
body { margin:0; font-family:"SF Pro Rounded", ui-rounded, system-ui, sans-serif; }
.grid { display:grid; grid-template-columns:repeat(2,1fr); gap:28px 16px; padding:8px 0 16px; justify-items:center; }
figure { margin:0; display:flex; flex-direction:column; align-items:center; gap:10px; }
figcaption { font-size:13px; color:var(--muted-foreground,#999); text-align:center; max-width:260px; line-height:1.35; font-family:system-ui,sans-serif; }
.bezel { background:#0b0b0c; padding:10px; border-radius:52px; box-shadow:0 0 0 1px #2a2a2c; }
.screen { width:198px; height:242px; background:#000; border-radius:42px; overflow:hidden; color:#fff; padding:0 9px; box-sizing:border-box; font-size:13px; }
.top { display:flex; justify-content:space-between; align-items:baseline; padding:12px 8px 6px; font-weight:600; font-size:15px; }
.card { background:var(--card); border-radius:16px; padding:8px 10px; margin-bottom:5px; }
.row { display:flex; align-items:center; gap:7px; }
.dot { width:8px; height:8px; border-radius:50%; flex:none; }
.name { font-weight:600; font-size:14px; }
.meta { font-size:11px; color:var(--dim); }
.mono { font-family:"SF Mono","JetBrainsMono NFM",ui-monospace,monospace; font-size:10.5px; }
.chip { display:inline-block; font-size:10px; font-weight:700; border-radius:7px; padding:1px 6px; }
.label { font-size:11px; color:var(--dim); padding:4px 6px 3px; font-weight:600; }
.big { display:block; text-align:center; border-radius:22px; padding:9px; font-size:14px; font-weight:700; margin:4px 0; }
.r { margin-left:auto; white-space:nowrap; }
.ladder-a .mono { font-size:9.5px; }
.ladder-a .meta { font-size:9.5px; }
</style></head><body><div class="grid">
<figure><div class="bezel"><div class="screen">
<div class="top"><span style="color:var(--work)">‹ Settings</span><span>9:41</span></div>
<div class="label">Environments</div>
<div class="card row"><span class="dot" style="background:var(--work)"></span><span class="name">Desktop</span><span class="chip r" style="background:#1f3b38;color:var(--work)">Direct</span></div>
<div class="card row"><span class="dot" style="background:var(--work)"></span><span class="name">Mac mini</span><span class="chip r" style="background:#1f3b38;color:var(--work)">Direct</span></div>
<div class="card row"><span class="dot" style="background:var(--you)"></span><span class="name">Laptop</span><span class="chip r" style="background:#3d2f12;color:var(--you)">VPN</span></div>
<div class="card row"><span class="dot" style="background:var(--work)"></span><span class="name">server-gib</span><span class="chip r" style="background:#1f3b38;color:var(--work)">Direct</span></div>
</div></div><figcaption><b>Settings.</b> Every environment and the route it is on. Amber means online but on a fallback route. Gateway and This watch sections follow below.</figcaption></figure>
<figure><div class="bezel"><div class="screen">
<div class="top"><span style="color:var(--work)">‹ Laptop</span><span>9:41</span></div>
<div class="card"><div class="row"><span class="dot" style="background:var(--you)"></span><span class="name">Online via VPN</span></div><div class="meta" style="margin-top:2px">Fallback 2 of 4 · since 9:12</div></div>
<div class="label">Routes, in order</div>
<div class="card ladder-a" style="padding:6px 9px">
<div class="row mono"><span style="color:var(--bad)">✕</span>laptop.gib:3773<span class="r meta">5 s</span></div>
<div class="row mono" style="margin-top:3px"><span style="color:var(--work)">●</span>laptopvpn.gib:3773<span class="r meta">now</span></div>
<div class="row mono" style="margin-top:3px;color:var(--done)"><span>○</span>t3laptop…<span class="r">–</span></div>
<div class="row mono" style="margin-top:3px;color:var(--done)"><span>○</span>t3laptopvpn…<span class="r">–</span></div>
</div>
</div></div><figcaption><b>Environment.</b> The route ladder: what failed, what is in use, what was not needed. Token expiry, T3 version and Reconnect sit below.</figcaption></figure>
<figure><div class="bezel"><div class="screen">
<div class="top"><span style="color:var(--work)">‹ Gateway</span><span>9:41</span></div>
<div class="card"><div class="name">wristcode.gbrown.org</div><div class="meta">junior.gib · up 3 h</div></div>
<div class="card row"><span class="name" style="font-size:13px">Push service</span><span class="r meta" style="color:var(--work)">Ready</span></div>
<div class="card row"><span class="name" style="font-size:13px">Last push</span><span class="r meta">2 min ago ✓</span></div>
<div class="card row"><span class="name" style="font-size:13px">Tokens expire</span><span class="r meta">in 29 days</span></div>
</div></div><figcaption><b>Gateway.</b> Whether pushes can go out and when the last one did, plus the next token renewal.</figcaption></figure>
<figure><div class="bezel"><div class="screen">
<div class="top"><span style="color:var(--work)">‹ This watch</span><span>9:41</span></div>
<div class="card"><div class="name">Apple Watch</div><div class="meta">Paired today · notifications on</div></div>
<span class="big" style="background:var(--you);color:#000">Send test notification</span>
<span class="big" style="background:var(--card)">Notification settings</span>
<span class="big" style="background:var(--card);color:var(--bad)">Unpair</span>
</div></div><figcaption><b>This watch.</b> The test button answers "why didn't it buzz?" in one tap.</figcaption></figure>
</div></body></html>
+67
View File
@@ -0,0 +1,67 @@
<!doctype html><html><head><meta charset="utf-8"><title>WristCode settings B: Status tiles</title><style>
/* Direction B: a status board first, settings second. A grid of environment tiles whose icon
is the route kind (house = LAN, shield = VPN, cloud = public), then a one-screen health card. */
:root { --you:#ffb02e; --work:#4fd1c5; --done:#6b6b70; --card:#17171a; --bad:#ff6b5e; --dim:#aeaeb2; }
body { margin:0; font-family:"SF Pro Rounded", ui-rounded, system-ui, sans-serif; }
.grid { display:grid; grid-template-columns:repeat(2,1fr); gap:28px 16px; padding:8px 0 16px; justify-items:center; }
figure { margin:0; display:flex; flex-direction:column; align-items:center; gap:10px; }
figcaption { font-size:13px; color:var(--muted-foreground,#999); text-align:center; max-width:260px; line-height:1.35; font-family:system-ui,sans-serif; }
.bezel { background:#0b0b0c; padding:10px; border-radius:52px; box-shadow:0 0 0 1px #2a2a2c; }
.screen { width:198px; height:242px; background:#000; border-radius:42px; overflow:hidden; color:#fff; padding:0 9px; box-sizing:border-box; font-size:13px; }
.top { display:flex; justify-content:space-between; align-items:baseline; padding:12px 8px 6px; font-weight:600; font-size:15px; }
.card { background:var(--card); border-radius:16px; padding:8px 10px; margin-bottom:5px; }
.row { display:flex; align-items:center; gap:7px; }
.dot { width:8px; height:8px; border-radius:50%; flex:none; }
.name { font-weight:600; font-size:14px; }
.meta { font-size:11px; color:var(--dim); }
.mono { font-family:"SF Mono","JetBrainsMono NFM",ui-monospace,monospace; font-size:10.5px; }
.chip { display:inline-block; font-size:10px; font-weight:700; border-radius:7px; padding:1px 6px; }
.label { font-size:11px; color:var(--dim); padding:4px 6px 3px; font-weight:600; }
.big { display:block; text-align:center; border-radius:22px; padding:9px; font-size:14px; font-weight:700; margin:4px 0; }
.r { margin-left:auto; }
.tiles { display:grid; grid-template-columns:1fr 1fr; gap:5px; }
.tile { background:var(--card); border-radius:16px; padding:8px; height:64px; box-sizing:border-box; position:relative; }
.tile .ic { font-size:15px; line-height:1; }
.tile .n { font-weight:700; font-size:12px; margin-top:4px; }
.tile .s { font-size:10px; color:var(--dim); }
.ladder { border-left:2px solid #333; margin-left:6px; padding-left:10px; }
.step { position:relative; margin-bottom:9px; }
.step:before { content:""; position:absolute; left:-15px; top:3px; width:8px; height:8px; border-radius:50%; background:var(--c); }
</style></head><body><div class="grid">
<figure><div class="bezel"><div class="screen">
<div class="top"><span>5 of 5 online</span><span>9:41</span></div>
<div class="tiles">
<div class="tile" style="border-left:3px solid var(--work)"><div class="ic">🏠</div><div class="n">Desktop</div><div class="s">direct</div></div>
<div class="tile" style="border-left:3px solid var(--work)"><div class="ic">🏠</div><div class="n">Mac mini</div><div class="s">direct</div></div>
<div class="tile" style="border-left:3px solid var(--you)"><div class="ic">🛡</div><div class="n">Laptop</div><div class="s">VPN fallback</div></div>
<div class="tile" style="border-left:3px solid var(--work)"><div class="ic">🏠</div><div class="n">server-gib</div><div class="s">direct</div></div>
</div>
</div></div><figcaption><b>Status.</b> A tile per environment. The icon is the route kind, so a laptop on the VPN or a host on its public URL stands out without reading.</figcaption></figure>
<figure><div class="bezel"><div class="screen">
<div class="top"><span style="color:var(--you)">‹ Laptop</span><span>9:41</span></div>
<div class="ladder">
<div class="step" style="--c:var(--bad)"><div class="mono">laptop.gib:3773</div><div class="meta">no answer in 5 s</div></div>
<div class="step" style="--c:var(--work)"><div class="mono" style="color:var(--work)">laptopvpn.gib:3773</div><div class="meta">connected 9:12</div></div>
<div class="step" style="--c:#333"><div class="mono" style="color:var(--done)">t3laptop.gbrown.org</div></div>
<div class="step" style="--c:#333"><div class="mono" style="color:var(--done)">t3laptopvpn.gbrown.org</div></div>
</div>
<span class="big" style="background:var(--card);margin-top:0">Reconnect</span>
</div></div><figcaption><b>Environment.</b> The routes as a vertical ladder the gateway walks down. Reconnect retries from the top, for when you get home.</figcaption></figure>
<figure><div class="bezel"><div class="screen">
<div class="top"><span>Health</span><span>9:41</span></div>
<div class="card"><div class="row"><span style="color:var(--work);font-size:16px">✓</span><span class="name">Gateway</span><span class="r meta">junior</span></div></div>
<div class="card"><div class="row"><span style="color:var(--work);font-size:16px">✓</span><span class="name">Pushes</span><span class="r meta">2 min ago</span></div></div>
<div class="card"><div class="row"><span style="color:var(--work);font-size:16px">✓</span><span class="name">Tokens</span><span class="r meta">29 days</span></div></div>
<div class="card"><div class="row"><span style="color:var(--work);font-size:16px">✓</span><span class="name">This watch</span><span class="r meta">registered</span></div></div>
</div></div><figcaption><b>Health.</b> One screen of checks, swiped to from Status. Each row opens its detail.</figcaption></figure>
<figure><div class="bezel"><div class="screen">
<div class="top"><span>‹ Pushes</span><span>9:41</span></div>
<div style="text-align:center;padding:10px 0 6px"><div style="font-size:30px;color:var(--work)">✓</div><div class="name">Delivered</div><div class="meta">Apple accepted it at 9:41:03</div></div>
<span class="big" style="background:var(--you);color:#000">Send another</span>
</div></div><figcaption><b>Test result.</b> The gateway shows what Apple answered. A failure names its cause, such as an expired device token or the wrong push environment.</figcaption></figure>
</div></body></html>
+59
View File
@@ -0,0 +1,59 @@
<!doctype html><html><head><meta charset="utf-8"><title>WristCode settings C: What's wrong first</title><style>
/* Direction C: the attention-feed idea applied to settings. The first page answers "is anything
wrong?" in one line. Problems are listed above the healthy things, which collapse to a count. */
:root { --you:#ffb02e; --work:#4fd1c5; --done:#6b6b70; --card:#17171a; --bad:#ff6b5e; --dim:#aeaeb2; }
body { margin:0; font-family:"SF Pro Rounded", ui-rounded, system-ui, sans-serif; }
.grid { display:grid; grid-template-columns:repeat(2,1fr); gap:28px 16px; padding:8px 0 16px; justify-items:center; }
figure { margin:0; display:flex; flex-direction:column; align-items:center; gap:10px; }
figcaption { font-size:13px; color:var(--muted-foreground,#999); text-align:center; max-width:260px; line-height:1.35; font-family:system-ui,sans-serif; }
.bezel { background:#0b0b0c; padding:10px; border-radius:52px; box-shadow:0 0 0 1px #2a2a2c; }
.screen { width:198px; height:242px; background:#000; border-radius:42px; overflow:hidden; color:#fff; padding:0 9px; box-sizing:border-box; font-size:13px; }
.top { display:flex; justify-content:space-between; align-items:baseline; padding:12px 8px 6px; font-weight:600; font-size:15px; }
.card { background:var(--card); border-radius:16px; padding:8px 10px; margin-bottom:5px; }
.row { display:flex; align-items:center; gap:7px; }
.dot { width:8px; height:8px; border-radius:50%; flex:none; }
.name { font-weight:600; font-size:14px; }
.meta { font-size:11px; color:var(--dim); }
.mono { font-family:"SF Mono","JetBrainsMono NFM",ui-monospace,monospace; font-size:10.5px; }
.chip { display:inline-block; font-size:10px; font-weight:700; border-radius:7px; padding:1px 6px; }
.label { font-size:11px; color:var(--dim); padding:4px 6px 3px; font-weight:600; }
.big { display:block; text-align:center; border-radius:22px; padding:9px; font-size:14px; font-weight:700; margin:4px 0; }
.r { margin-left:auto; }
.hero { padding:4px 8px 8px; }
.hero .h { font-size:22px; font-weight:800; line-height:1.1; }
.issue { background:var(--card); border-radius:16px; padding:8px 10px; margin-bottom:5px; border-left:4px solid var(--c); }
</style></head><body><div class="grid">
<figure><div class="bezel"><div class="screen">
<div class="top"><span></span><span>9:41</span></div>
<div class="hero"><div class="h" style="color:var(--work)">All connected</div><div class="meta">5 environments · pushes OK</div></div>
<div class="issue" style="--c:var(--you)"><div class="name" style="font-size:13px">Laptop is on its VPN route</div><div class="meta">laptop.gib did not answer</div></div>
<div class="issue" style="--c:var(--done)"><div class="name" style="font-size:13px">4 more, all direct</div></div>
</div></div><figcaption><b>Overview.</b> One headline, then only what deserves a look. When nothing does, it just says so.</figcaption></figure>
<figure><div class="bezel"><div class="screen">
<div class="top"><span style="color:#ff6b5e">Needs attention</span><span>9:41</span></div>
<div class="issue" style="--c:var(--bad)"><div class="name" style="font-size:13px">Mac mini offline</div><div class="meta">Both routes failed · 4 min</div></div>
<div class="issue" style="--c:var(--you)"><div class="name" style="font-size:13px">Tokens expire in 5 days</div><div class="meta">Run renew-hosts.sh</div></div>
<div class="issue" style="--c:var(--done)"><div class="name" style="font-size:13px">3 healthy</div></div>
</div></div><figcaption><b>Same screen, bad day.</b> Red for offline, amber for warnings. The fix is in the second line where there is one.</figcaption></figure>
<figure><div class="bezel"><div class="screen">
<div class="top"><span style="color:#ff6b5e">‹ Mac mini</span><span>9:41</span></div>
<div class="card mono" style="line-height:1.6">
<div><span style="color:var(--bad)">✕</span> mac.gib:3773 <span class="meta">refused</span></div>
<div><span style="color:var(--bad)">✕</span> t3mac.gbrown.org <span class="meta">502</span></div>
</div>
<div class="meta" style="padding:2px 6px 6px">Next try in 40 s · last online 9:37</div>
<span class="big" style="background:var(--card)">Try now</span>
</div></div><figcaption><b>Environment.</b> Each route with its actual error, and when the gateway tries again.</figcaption></figure>
<figure><div class="bezel"><div class="screen">
<div class="top"><span>‹ Notifications</span><span>9:41</span></div>
<div class="card row"><span class="name" style="font-size:13px">This watch</span><span class="r meta" style="color:var(--work)">registered</span></div>
<div class="card row"><span class="name" style="font-size:13px">Last push</span><span class="r meta">2 min ago ✓</span></div>
<span class="big" style="background:var(--you);color:#000">Send test</span>
<div class="meta" style="text-align:center">Gateway on junior.gib · Unpair</div>
</div></div><figcaption><b>Notifications.</b> Gateway and watch details fold into this one page, since the push path is what they are for.</figcaption></figure>
</div></body></html>
+15
View File
@@ -0,0 +1,15 @@
# Settings for renew-hosts.sh. Copy to hosts.conf, which git ignores, and fill in your machines.
# SSH target for the machine running the gateway container.
GATEWAY_SSH="gateway.example.lan"
# The container runtime there, podman or docker, and the container's name.
GATEWAY_RUNTIME="podman"
GATEWAY_CONTAINER="wristcode-gateway"
# label | SSH host, or "local" for the machine running the script | T3 URLs
# URLs are space separated, preferred first. The gateway uses the first one that answers, so put
# direct LAN or VPN routes first and a public URL last as the fallback.
HOSTS=(
"Desktop|local|http://desktop.lan:3773 https://t3-desktop.example.com"
"Mac mini|mac.lan|http://mac.lan:3773"
)
+13 -13
View File
@@ -4,21 +4,19 @@
# on the gateway, so the credential is never printed. Run it from a machine that can SSH to every # on the gateway, so the credential is never printed. Run it from a machine that can SSH to every
# host by name. The watch warns a week before a session expires. # host by name. The watch warns a week before a session expires.
# #
# scripts/renew-hosts.sh renew every host below # scripts/renew-hosts.sh renew every host in hosts.conf
# scripts/renew-hosts.sh "Mac mini" renew one host by label # scripts/renew-hosts.sh "Mac mini" renew one host by label
set -euo pipefail set -euo pipefail
GATEWAY_SSH="${GATEWAY_SSH:-server.gib}" # The host list and gateway settings live in hosts.conf beside this script, which git ignores.
# Copy hosts.conf.example to start one.
CONFIG="${RENEW_HOSTS_CONFIG:-$(dirname "$0")/hosts.conf}"
[ -f "$CONFIG" ] || { echo "No $CONFIG. Copy hosts.conf.example and fill it in." >&2; exit 1; }
# shellcheck source=hosts.conf.example
source "$CONFIG"
GATEWAY_RUNTIME="${GATEWAY_RUNTIME:-podman}"
GATEWAY_CONTAINER="${GATEWAY_CONTAINER:-wristcode-gateway}" GATEWAY_CONTAINER="${GATEWAY_CONTAINER:-wristcode-gateway}"
# label | SSH host, or "local" for the machine running this script | public T3 URL
HOSTS=(
"Desktop|local|https://t3desktop.gbrown.org"
"Mac mini|mac.gib|https://t3mac.gbrown.org"
"server-gib|server.gib|https://t3sr.gbrown.org"
"LittleMan|junior.gib|https://t3jr.gbrown.org"
)
# Prints a pairing credential. Runs on the T3 host. The CLI is not on PATH in non-login shells, # Prints a pairing credential. Runs on the T3 host. The CLI is not on PATH in non-login shells,
# so it looks where T3 Code installs it. # so it looks where T3 Code installs it.
MINT=' MINT='
@@ -34,7 +32,9 @@ done
' '
renew() { renew() {
local label="$1" ssh_host="$2" url="$3" credential local label="$1" ssh_host="$2" url alternates alternate credential alt_args=""
read -r url alternates <<<"$3"
for alternate in $alternates; do alt_args+=" --alt-url '$alternate'"; done
if [ "$ssh_host" = "local" ]; then if [ "$ssh_host" = "local" ]; then
credential="$(bash -c "$MINT")" credential="$(bash -c "$MINT")"
else else
@@ -46,7 +46,7 @@ renew() {
fi fi
# The credential travels on stdin, not on a command line other processes could read. # The credential travels on stdin, not on a command line other processes could read.
ssh -o BatchMode=yes "$GATEWAY_SSH" \ ssh -o BatchMode=yes "$GATEWAY_SSH" \
"read -r credential && docker exec $GATEWAY_CONTAINER wristcode host add '$url' \"\$credential\" --label '$label'" \ "read -r credential && $GATEWAY_RUNTIME exec $GATEWAY_CONTAINER wristcode host add '$url' \"\$credential\" --label '$label'$alt_args" \
<<<"$credential" <<<"$credential"
} }
@@ -57,5 +57,5 @@ for entry in "${HOSTS[@]}"; do
renew "$label" "$ssh_host" "$url" || failed=1 renew "$label" "$ssh_host" "$url" || failed=1
done done
ssh -o BatchMode=yes "$GATEWAY_SSH" "docker exec $GATEWAY_CONTAINER wristcode host list" | cut -f1,4 ssh -o BatchMode=yes "$GATEWAY_SSH" "$GATEWAY_RUNTIME exec $GATEWAY_CONTAINER wristcode host list" | cut -f1,4
exit "$failed" exit "$failed"
+168 -5
View File
@@ -22,6 +22,7 @@ targets, the same pair Xcode's "Watch-only App" template makes:
| --- | --- | --- | | --- | --- | --- |
| `WristCodeContainer` | `org.gbrown.wristcode` | iOS stub that owns the App Store Connect record and embeds the watch app | | `WristCodeContainer` | `org.gbrown.wristcode` | iOS stub that owns the App Store Connect record and embeds the watch app |
| `WristCode` | `org.gbrown.wristcode.watchkitapp` | The watch app, and the APNs topic | | `WristCode` | `org.gbrown.wristcode.watchkitapp` | The watch app, and the APNs topic |
| `WristCodeWidget` | `org.gbrown.wristcode.watchkitapp.widget` | The complication, embedded in the watch app |
```sh ```sh
scripts/testflight.sh # archive, export and upload to App Store Connect scripts/testflight.sh # archive, export and upload to App Store Connect
@@ -37,16 +38,168 @@ register their token with the gateway as `production`, Debug builds as `sandbox`
## Demo mode ## Demo mode
`DemoGateway` serves fixture data shaped like the docs/api.md examples, and it reacts to writes: `DemoGateway` serves fixture data shaped like the docs/api.md examples, and it reacts to writes:
Allow moves a thread to working, a reply restarts a finished thread. The SwiftUI previews use it, Allow moves a thread to working, a reply restarts a finished thread, and Settle hides a thread from
the feed. One card carries a subagent's request, so its Allow goes to the subagent's thread. Recent
holds 14 threads, so the feed shows its first 10 and a "4 more" row. The working cards show
activity lines, the Containerfile thread has two subagents, and "Fix hyprlock crash" has two older
pages behind Load earlier. Two cards are file approvals, one with line counts and a preview and one
with the file name alone, and two finished threads are snoozed. The SwiftUI previews use it,
the pairing screen has a "Try the demo" button, and launch arguments open a screen directly: the pairing screen has a "Try the demo" button, and launch arguments open a screen directly:
```sh ```sh
xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo # feed xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo # feed
xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo -thread littleman/t-next # thread xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo -thread littleman/t-next # thread
xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo -screen new # new thread xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo -screen new # new thread
xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo -screen recent # full Recent list
xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo -screen status # status overview
xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo -host laptop # one environment
xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo -screen notifications # notifications
xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo -screen threads # All threads
``` ```
`screenshots/` holds the screens captured this way. `screenshots/` holds the screens captured this way. The complication, the multi-select question, the
status screens, the subagent card and thread, Recent with Settle all and the "N more" row, the full
Recent list, a finished thread with Settle, a running thread with Ask before acting, New thread
with its model list, the working cards' activity lines, All threads' hosts and a project's threads,
a thread's subagents collapsed and open, Load earlier with markdown, the changes line and Changed
list, search and its empty state, the complication's two modes, the Smart Stack widget, a question
notification, the Snooze until picker (`snooze-picker.png`), the Snoozed list (`snoozed.png`) and its
row under Recent (`feed-snoozed.png`), file approvals on the card (`approval-file.png`) and the thread
screen (`thread-approval-file.png`), and the feed shown from the cache (`feed-cached.png`) are rendered
by `SnapshotTests` instead, which only run when given a directory:
```sh
TEST_RUNNER_WC_SCREENSHOT_DIR=$PWD/screenshots xcodebuild -scheme WristCode \
-destination 'platform=watchOS Simulator,name=Apple Watch Series 12 (46mm)' test \
-only-testing:WristCodeTests/SnapshotTests
```
In demo mode the complication shows the demo counts too.
## Subagents and settling
A thread an agent started for another thread has no card of its own. When it waits on you, the
gateway puts its request on the parent's card and names it in `via`. The card says "Subagent
<title> wants to run:" or "asks:", and the parent's thread screen shows the request first, labeled
"From subagent". Every answer goes to the subagent's thread.
Settle clears a finished or failed thread from the feed, as in T3. Recent keeps every finished or
failed thread until it is settled, however old. The feed shows the newest 10, then an "N more" row
that opens the full Recent list (`GET /v1/recent`, loaded when the screen appears).
Swipe a Recent row left, here or on the full list, or tap Settle under Reply on the thread screen. A
row leaves at once and comes back with the error if its call fails. Settle all, on the Recent header
and the full list, is one `POST /v1/recent/settle` for every completed thread in Recent, including
those past the first 10. Failed threads stay. The completed rows leave at once, and the header says
so when the call fails or some threads did not settle. A finished notification has Settle too,
beside Reply and Open. It settles in the background without opening the app. A background action
only runs while the watch is paired. After unpairing, or in demo mode, it posts a notification
saying the watch is not paired.
## Snooze
Snooze hides a finished thread from Recent until a time you pick, and the gateway brings it back on
its own. The Recent swipe has a violet Snooze beside Settle, and the thread screen has a Snooze
button beside Settle. Both open the Snooze until picker with four choices in the watch's time zone:
In 1 hour, This evening at 6 PM, Tomorrow at 9 AM, and Next week, which is next Monday at 9 AM. From
5 PM on, the picker leaves out This evening. Once `POST /v1/threads/:hostId/:threadId/snooze` succeeds, the thread leaves
Recent and the feed refreshes. When any thread is snoozed, a dim violet row under Recent counts them
and opens the Snoozed list (`GET /v1/snoozed`), where each card says when it wakes and swipes left
to Unsnooze.
## File approvals
A file-change approval names its files in place of the raw prompt, on the feed card, the thread
screen and the notification: "Wants to edit" and the file name, or "Wants to edit 3 files" with a
row for each. The line counts show on the right only when the gateway has them, and the thread
screen adds the first changed lines in red and green. Claude sends no diff before approval, so in
practice the name shows alone. Without files, the prompt shows as before.
## Loading and launch
Every screen that loads data shows a plain "Loading" until its first answer. Once a screen has
content, a refresh keeps it on screen, and a failed refresh only adds a small dim line under it.
The error and Try again replace the placeholder only when there is nothing to show. When a screen
goes away mid-request, the cancelled request shows no error.
A feed refresh asked for while another is out, as after Settle, Snooze or Allow, waits for it and
then runs once more. A feed answer to a request sent before a change, or before signing out, is
dropped, so a poll cannot put back a row the change removed. On the thread screen, a failed reply,
answer, Settle or Stop says why above Reply until the next action, and a reply that did not send
shows its text with Send again.
The app saves the last good feed as JSON in its caches folder, and each thread's detail when the
thread loads, off the main thread and only when it changed. It keeps the 30 threads saved most
recently. Launch shows the saved feed at once, with a dim "Updating" by the count until the first
refresh answers, and a thread opens on its saved detail while it loads. Unpairing clears both. Demo
mode never touches them.
## Activity, All threads and history
A working card shows the step the agent is on instead of its last message: a teal symbol for the
kind (command, edit, search, web, subagent, thinking or tool), the text, commands and file names in
monospace, and how long the step has run on the right. The time is worked out when the card renders,
so it moves on with the feed's 10 s refresh. Nothing ticks between refreshes. A card falls back to
the last message when the gateway names no step.
The feed's last row opens All threads: hosts from `GET /v1/projects` (offline ones dimmed), then a
host's projects by title, then the project's threads from
`GET /v1/projects/:hostId/:projectId/threads`, settled ones included. Each thread is one line with
a status dot and its age, and opens the usual thread screen. Each screen loads when it appears.
The search field at the top of All threads needs at least two letters, as the gateway does, and
says so under the field when given one.
A thread screen lists the thread's subagents under its messages, above Reply, each with a status
word. A row opens that subagent's thread. When the thread has messages before the latest 20, Load
earlier at the top fetches the page before them (`GET /v1/threads/:hostId/:threadId/history`), and
the next tap uses the cursor that page returned. The 10 s refresh replaces only the latest window.
Loaded pages stay, and from the first tap the window a refresh replaces is kept too. A message shows
once by id, as its newest copy, so one caught mid-stream gets its full text. Message text renders as markdown: bold, italics,
inline code and links inline, headings in bold, list items as bullets, and fenced code in monospace.
Against a gateway older than these fields, the cards show the last message, and Load earlier and
the subagents stay hidden.
## Status and settings
The gear at the top left of the feed opens the status overview (`GET /v1/status`). It leads with
"All connected" or "Needs attention", lists only the issues worst first (offline hosts, a host on a
fallback route, tokens expiring within 7 days, push problems), and folds the healthy hosts into one
row. Each host opens its route ladder with Reconnect. The Notifications row shows whether this watch
is registered, the last push, a switch per kind of push (Needs me, Finished, Failed, Subagents ask),
Send test, the gateway, and Unpair. A switch flips at once and sends only its own key with
`PUT /v1/device/notifications`. If that fails, it flips back and the error shows under the switches.
Host problems always notify. Each screen loads once when it appears; nothing polls.
## Models and Ask before acting
On New thread, the Agent row opens every model the host offers, grouped by provider, with the
project default's provider first. The watch remembers the last pick per host and falls back to the
project's default when the host stops offering it. It sends `model` only when the pick differs from
the default, so the default keeps its options, such as reasoning effort. The Ask before acting switch
under it remembers its last value. It is hidden, and the thread starts with `askFirst: false`, when
the provider cannot ask first.
A thread's screen has the same switch between Reply and Stop. It flips at once and posts
`/v1/threads/:hostId/:threadId/mode`. If that fails, it flips back and the error shows under it.
Against a gateway older than these routes, the switches and the model list stay hidden.
## Complication
`WristCodeWidget` shows how many threads wait on you, from `GET /v1/summary`, in the circular,
rectangular, inline and corner families. Tapping it opens the app, at the longest-waiting thread when
there is one (`wristcode://thread/<hostId>/<threadId>`). WidgetKit refreshes it about every 15 minutes;
the app reloads it sooner when its feed counts change, when a push arrives in the foreground, and
after a notification action.
The app and the extension share the Keychain access group `<team>.org.gbrown.wristcode.shared`, which
holds the device token, gateway URL, demo flag and last summary. It is not an App Group because
automatic signing with an App Store Connect API key can register bundle IDs and profiles but cannot
create or assign App Groups; a Keychain group needs no portal setup. Builds before the complication
kept the token in the app's own Keychain group; `DeviceStore.migrateToSharedGroup()` moves it on
launch, so a paired watch stays paired.
To add it: long-press the watch face, Edit, swipe to Complications, pick a slot, then WristCode.
## Layout ## Layout
@@ -54,9 +207,19 @@ xcrun simctl launch <udid> org.gbrown.wristcode.watchkitapp -demo -screen new
| --- | --- | | --- | --- |
| `WristCodeApp.swift` | App entry, root navigation, `WKApplicationDelegate` for APNs registration | | `WristCodeApp.swift` | App entry, root navigation, `WKApplicationDelegate` for APNs registration |
| `AppModel.swift` | Paired, demo or unpaired; navigation path; the feed; 401 sends you back to pairing | | `AppModel.swift` | Paired, demo or unpaired; navigation path; the feed; 401 sends you back to pairing |
| `FeedCache.swift` | The last feed and thread details on disk, for an instant launch |
| `API/GatewayClient.swift` | `GatewayAPI` protocol and the URLSession client | | `API/GatewayClient.swift` | `GatewayAPI` protocol and the URLSession client |
| `API/Models.swift` | Codable mirror of the contract | | `API/Models.swift` | Codable mirror of the contract |
| `Auth/DeviceStore.swift` | Device token and gateway URL in the Keychain | | `API/Route.swift` | Screens the app can open, and their `wristcode://` URLs |
| `Auth/DeviceStore.swift` | Device token and gateway URL in the shared Keychain group |
| `Shared/SharedState.swift` | Demo flag and last summary in the shared Keychain group, for the complication |
| `Demo/DemoGateway.swift` | In-memory gateway with fixtures | | `Demo/DemoGateway.swift` | In-memory gateway with fixtures |
| `Views/` | Pair, Feed, Thread, New thread, and the mock B theme | | `Views/` | Pair, Feed, full Recent list, Thread, New thread, Status, and the mock B theme |
| `Notifications/NotificationHandler.swift` | Categories `approval`, `finished`, `failed`, `input`, `host` and their actions | | `Views/BrowseView.swift` | All threads: hosts, a host's projects, a project's threads |
| `Views/SnoozeView.swift` | The Snooze until picker, the Snoozed row and the Snoozed list |
| `Views/FileEditRows.swift` | A file approval's files, counts and preview lines |
| `Views/Loading.swift` | Loading, and errors that never replace content |
| `Views/Markdown.swift` | Message text as styled text |
| `Views/StatusReport.swift` | What the status overview says: headline, issues worst first, healthy hosts |
| `Notifications/NotificationHandler.swift` | Categories `approval`, `finished`, `failed`, `input`, `host`, `test` and their actions |
| `../WristCodeWidget/` | The complication: timeline provider, and one view per family |
+123 -9
View File
@@ -44,13 +44,42 @@ enum GatewayError: LocalizedError, Equatable {
/// serves fixtures, so every screen works without a live gateway. /// serves fixtures, so every screen works without a live gateway.
protocol GatewayAPI: Sendable { protocol GatewayAPI: Sendable {
func feed() async throws -> Feed func feed() async throws -> Feed
func summary() async throws -> Summary
func thread(hostId: String, threadId: String) async throws -> ThreadDetail func thread(hostId: String, threadId: String) async throws -> ThreadDetail
/// The page of messages before `cursor`, a thread's `earlierCursor` or the last page's.
func history(hostId: String, threadId: String, cursor: String) async throws -> HistoryResponse
func sendMessage(hostId: String, threadId: String, text: String) async throws func sendMessage(hostId: String, threadId: String, text: String) async throws
func respond(hostId: String, threadId: String, request: RespondRequest) async throws func respond(hostId: String, threadId: String, request: RespondRequest) async throws
func stop(hostId: String, threadId: String) async throws func stop(hostId: String, threadId: String) async throws
/// Settles a thread, as in T3. A settled thread that finished or failed leaves the feed.
func settle(hostId: String, threadId: String) async throws
/// Turns Ask before acting on or off for a thread.
func setAskFirst(hostId: String, threadId: String, askFirst: Bool) async throws
/// Every unsettled finished thread, including those past the feed's first 10.
func recent() async throws -> RecentResponse
/// Settles every completed thread in Recent. Failed threads stay.
func settleRecent() async throws -> SettleRecentResponse
/// Finished threads snoozed for later, the soonest to come back first.
func snoozed() async throws -> RecentResponse
/// Hides a finished thread from Recent until `until`, at most 30 days away.
func snooze(hostId: String, threadId: String, until: Date) async throws
/// Puts a snoozed thread back in Recent now.
func unsnooze(hostId: String, threadId: String) async throws
func projects() async throws -> ProjectsResponse func projects() async throws -> ProjectsResponse
/// A project's newest 50 threads, settled ones included. Subagents are left out.
func projectThreads(hostId: String, projectId: String) async throws -> ThreadListResponse
/// Thread titles and messages on every online host matching `query`, which needs two characters.
func search(_ query: String) async throws -> SearchResponse
func newThread(_ request: NewThreadRequest) async throws -> NewThreadResponse func newThread(_ request: NewThreadRequest) async throws -> NewThreadResponse
func registerPush(_ registration: PushRegistration) async throws func registerPush(_ registration: PushRegistration) async throws
func status() async throws -> StatusResponse
/// Connects the host again from its first route.
func reconnect(hostId: String) async throws
func testPush() async throws -> PushResult
/// Turns one kind of push on or off for this watch, and returns all four switches.
func setNotification(_ key: NotificationPrefs.Key, _ on: Bool) async throws -> NotificationPrefs
/// Revokes this watch's device token.
func unpair() async throws
} }
struct GatewayClient: GatewayAPI { struct GatewayClient: GatewayAPI {
@@ -68,10 +97,18 @@ struct GatewayClient: GatewayAPI {
try await call("GET", ["v1", "feed"]) try await call("GET", ["v1", "feed"])
} }
func summary() async throws -> Summary {
try await call("GET", ["v1", "summary"])
}
func thread(hostId: String, threadId: String) async throws -> ThreadDetail { func thread(hostId: String, threadId: String) async throws -> ThreadDetail {
try await call("GET", ["v1", "threads", hostId, threadId]) try await call("GET", ["v1", "threads", hostId, threadId])
} }
func history(hostId: String, threadId: String, cursor: String) async throws -> HistoryResponse {
try await call("GET", ["v1", "threads", hostId, threadId, "history"], query: [URLQueryItem(name: "cursor", value: cursor)])
}
func sendMessage(hostId: String, threadId: String, text: String) async throws { func sendMessage(hostId: String, threadId: String, text: String) async throws {
try await callIgnoringBody("POST", ["v1", "threads", hostId, threadId, "messages"], body: SendMessageRequest(text: text)) try await callIgnoringBody("POST", ["v1", "threads", hostId, threadId, "messages"], body: SendMessageRequest(text: text))
} }
@@ -84,10 +121,46 @@ struct GatewayClient: GatewayAPI {
try await callIgnoringBody("POST", ["v1", "threads", hostId, threadId, "stop"], body: nil as SendMessageRequest?) try await callIgnoringBody("POST", ["v1", "threads", hostId, threadId, "stop"], body: nil as SendMessageRequest?)
} }
func settle(hostId: String, threadId: String) async throws {
try await callIgnoringBody("POST", ["v1", "threads", hostId, threadId, "settle"], body: nil as SendMessageRequest?)
}
func setAskFirst(hostId: String, threadId: String, askFirst: Bool) async throws {
try await callIgnoringBody("POST", ["v1", "threads", hostId, threadId, "mode"], body: ThreadModeRequest(askFirst: askFirst))
}
func recent() async throws -> RecentResponse {
try await call("GET", ["v1", "recent"])
}
func settleRecent() async throws -> SettleRecentResponse {
try await call("POST", ["v1", "recent", "settle"])
}
func snoozed() async throws -> RecentResponse {
try await call("GET", ["v1", "snoozed"])
}
func snooze(hostId: String, threadId: String, until: Date) async throws {
try await callIgnoringBody("POST", ["v1", "threads", hostId, threadId, "snooze"], body: SnoozeRequest(until: until))
}
func unsnooze(hostId: String, threadId: String) async throws {
try await callIgnoringBody("POST", ["v1", "threads", hostId, threadId, "unsnooze"], body: nil as SendMessageRequest?)
}
func projects() async throws -> ProjectsResponse { func projects() async throws -> ProjectsResponse {
try await call("GET", ["v1", "projects"]) try await call("GET", ["v1", "projects"])
} }
func projectThreads(hostId: String, projectId: String) async throws -> ThreadListResponse {
try await call("GET", ["v1", "projects", hostId, projectId, "threads"])
}
func search(_ query: String) async throws -> SearchResponse {
try await call("GET", ["v1", "search"], query: [URLQueryItem(name: "q", value: query)])
}
func newThread(_ request: NewThreadRequest) async throws -> NewThreadResponse { func newThread(_ request: NewThreadRequest) async throws -> NewThreadResponse {
try await call("POST", ["v1", "threads"], body: request) try await call("POST", ["v1", "threads"], body: request)
} }
@@ -96,16 +169,57 @@ struct GatewayClient: GatewayAPI {
try await callIgnoringBody("PUT", ["v1", "device", "push"], body: registration) try await callIgnoringBody("PUT", ["v1", "device", "push"], body: registration)
} }
func status() async throws -> StatusResponse {
try await call("GET", ["v1", "status"])
}
func reconnect(hostId: String) async throws {
try await callIgnoringBody("POST", ["v1", "hosts", hostId, "reconnect"], body: nil as SendMessageRequest?)
}
func testPush() async throws -> PushResult {
try await call("POST", ["v1", "device", "test-push"])
}
func setNotification(_ key: NotificationPrefs.Key, _ on: Bool) async throws -> NotificationPrefs {
try await call("PUT", ["v1", "device", "notifications"], body: [key.rawValue: on])
}
func unpair() async throws {
try await callIgnoringBody("DELETE", ["v1", "device"], body: nil as SendMessageRequest?)
}
// MARK: - Plumbing // MARK: - Plumbing
private func call<Response: Decodable>(_ method: String, _ path: [String]) async throws -> Response { /// A failed request as the app sees it. A request cancelled because its task was, as when a
try await call(method, path, body: nil as SendMessageRequest?) /// screen's `.task` ends or a refresh restarts, is a `CancellationError`, which no screen shows.
static func transportError(_ error: URLError) -> any Error {
error.code == .cancelled ? CancellationError() : GatewayError.transport(error.localizedDescription)
}
/// `path` under `baseURL`, then `query`. `appending(component:)` percent-encodes each segment,
/// slashes included. Query values are encoded strictly, so a `+`, `&` or `=` in a search or an
/// opaque history cursor reaches the gateway as sent.
static func url(_ baseURL: URL, _ path: [String], query: [URLQueryItem] = []) -> URL {
let url = path.reduce(baseURL) { $0.appending(component: $1) }
guard !query.isEmpty, var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return url }
let unreserved = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~")
components.percentEncodedQueryItems = query.map {
URLQueryItem(name: $0.name, value: $0.value?.addingPercentEncoding(withAllowedCharacters: unreserved))
}
return components.url ?? url
}
private func call<Response: Decodable>(
_ method: String, _ path: [String], query: [URLQueryItem] = []
) async throws -> Response {
try await call(method, path, query: query, body: nil as SendMessageRequest?)
} }
private func call<Body: Encodable, Response: Decodable>( private func call<Body: Encodable, Response: Decodable>(
_ method: String, _ path: [String], body: Body? _ method: String, _ path: [String], query: [URLQueryItem] = [], body: Body?
) async throws -> Response { ) async throws -> Response {
let data = try await perform(method, path, body: body) let data = try await perform(method, path, query: query, body: body)
do { do {
return try JSON.decoder().decode(Response.self, from: data) return try JSON.decoder().decode(Response.self, from: data)
} catch { } catch {
@@ -117,10 +231,10 @@ struct GatewayClient: GatewayAPI {
_ = try await perform(method, path, body: body) _ = try await perform(method, path, body: body)
} }
private func perform<Body: Encodable>(_ method: String, _ path: [String], body: Body?) async throws -> Data { private func perform<Body: Encodable>(
// appending(component:) percent-encodes each segment, slashes included. _ method: String, _ path: [String], query: [URLQueryItem] = [], body: Body?
let url = path.reduce(baseURL) { $0.appending(component: $1) } ) async throws -> Data {
var request = URLRequest(url: url, timeoutInterval: 20) var request = URLRequest(url: Self.url(baseURL, path, query: query), timeoutInterval: 20)
request.httpMethod = method request.httpMethod = method
request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("application/json", forHTTPHeaderField: "Accept")
if let token { if let token {
@@ -136,7 +250,7 @@ struct GatewayClient: GatewayAPI {
do { do {
(data, response) = try await session.data(for: request) (data, response) = try await session.data(for: request)
} catch let error as URLError { } catch let error as URLError {
throw GatewayError.transport(error.localizedDescription) throw Self.transportError(error)
} }
guard let http = response as? HTTPURLResponse else { guard let http = response as? HTTPURLResponse else {
+624 -3
View File
@@ -80,6 +80,8 @@ struct PendingRequest: Codable, Hashable, Sendable, Identifiable {
var question: String var question: String
var options: [Choice] var options: [Choice]
var allowCustomAnswer: Bool var allowCustomAnswer: Bool
/// When true, the answer is an array of option values.
var multiSelect = false
} }
var id: String var id: String
@@ -89,9 +91,19 @@ struct PendingRequest: Codable, Hashable, Sendable, Identifiable {
var reason: String? var reason: String?
var options: [Option] var options: [Option]
var questions: [Question] var questions: [Question]
/// On `file-change` approvals, the files the agent wants to change. Older gateways leave it out,
/// which decodes as none.
var files: [PendingFile] = []
var isQuestion: Bool { !questions.isEmpty } var isQuestion: Bool { !questions.isEmpty }
/// What a file-change approval would edit, for its card, thread screen and notification. Nil for
/// other requests and when the gateway names no files, which show the prompt instead.
var fileEdit: FileEdit? {
guard kind == "file-change" || kind == "file_change", !files.isEmpty else { return nil }
return FileEdit(files: files)
}
/// The Allow and Deny pair shown on a feed card, when the server offers both. /// The Allow and Deny pair shown on a feed card, when the server offers both.
var quickOptions: (allow: Option, deny: Option)? { var quickOptions: (allow: Option, deny: Option)? {
guard let allow = options.first(where: { $0.decision == .accept }), guard let allow = options.first(where: { $0.decision == .accept }),
@@ -113,7 +125,102 @@ struct PendingRequest: Codable, Hashable, Sendable, Identifiable {
} }
} }
extension PendingRequest {
private enum CodingKeys: String, CodingKey {
case id, kind, prompt, reason, options, questions, files
}
/// `files` is newer than the rest, so a gateway that leaves it out names no files.
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.init(
id: try container.decode(String.self, forKey: .id),
kind: try container.decode(String.self, forKey: .kind),
prompt: try container.decodeIfPresent(String.self, forKey: .prompt),
reason: try container.decodeIfPresent(String.self, forKey: .reason),
options: try container.decode([Option].self, forKey: .options),
questions: try container.decode([Question].self, forKey: .questions),
files: try container.decodeIfPresent([PendingFile].self, forKey: .files) ?? [])
}
}
/// A file a file-change approval would change. `name` is the last part of `path`. The gateway
/// usually has no diff before approval, so the counts are nil and `preview` is empty.
struct PendingFile: Codable, Hashable, Sendable, Identifiable {
var path: String
var name: String
var additions: Int?
var deletions: Int?
/// The first few changed lines, each starting with `+` or `-`.
var preview: [String]
var id: String { path }
/// "+9" and "−6", each nil when the gateway did not count it.
var counts: (additions: String?, deletions: String?) {
(additions.map { "+\($0)" }, deletions.map { "\u{2212}\($0)" })
}
}
/// The lines a file-change approval shows in place of its prompt: "Wants to edit" over one file, or
/// "Wants to edit 3 files" over a row for each. One file also shows its preview lines.
struct FileEdit: Hashable, Sendable {
/// A preview line and whether it adds or removes, which colors it green or red.
struct Line: Hashable, Sendable, Identifiable {
enum Change: Sendable { case added, removed, context }
var id: Int
var text: String
var change: Change
}
var files: [PendingFile]
var title: String { files.count == 1 ? "Wants to edit" : "Wants to edit \(files.count) files" }
/// The single file's first changed lines. Empty with several files, as their rows show no diff.
var preview: [Line] {
guard files.count == 1 else { return [] }
return files[0].preview.enumerated().map { index, text in
let change: Line.Change = text.hasPrefix("+") ? .added : text.hasPrefix("-") ? .removed : .context
return Line(id: index, text: text, change: change)
}
}
/// One line for a notification or the Smart Stack: "Wants to edit compose.yml" or "Wants to edit
/// 3 files: compose.yml, Containerfile, health.ts".
var sentence: String {
let names = files.map(\.name).joined(separator: ", ")
return files.count == 1 ? "\(title) \(names)" : "\(title): \(names)"
}
}
extension PendingRequest.Question {
private enum CodingKeys: String, CodingKey {
case id, header, question, options, allowCustomAnswer, multiSelect
}
/// `multiSelect` is newer than the rest, so a gateway that leaves it out means single-select.
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.init(
id: try container.decode(String.self, forKey: .id),
header: try container.decode(String.self, forKey: .header),
question: try container.decode(String.self, forKey: .question),
options: try container.decode([Choice].self, forKey: .options),
allowCustomAnswer: try container.decode(Bool.self, forKey: .allowCustomAnswer),
multiSelect: try container.decodeIfPresent(Bool.self, forKey: .multiSelect) ?? false)
}
}
struct FeedItem: Codable, Hashable, Sendable, Identifiable { struct FeedItem: Codable, Hashable, Sendable, Identifiable {
/// The subagent a request came from, when a thread delegated work and that work waits on you.
struct Via: Codable, Hashable, Sendable {
var hostId: String
var threadId: String
var title: String
}
var hostId: String var hostId: String
var hostLabel: String var hostLabel: String
var threadId: String var threadId: String
@@ -124,16 +231,253 @@ struct FeedItem: Codable, Hashable, Sendable, Identifiable {
var updatedAt: Date var updatedAt: Date
var lastMessage: String? var lastMessage: String?
var request: PendingRequest? var request: PendingRequest?
/// Set when `request` and `phase` belong to a subagent rather than this thread. Older gateways
/// leave it out, which decodes as nil.
var via: Via? = nil
/// What a working thread is doing right now. Nil when nothing is in progress, and from older
/// gateways.
var activity: Activity? = nil
/// What the latest run changed, in total. Nil when it changed no files, and from older gateways.
var changes: ChangeTotals? = nil
/// When a snoozed thread comes back to Recent. Nil when it is not snoozed, and from older gateways.
var snoozedUntil: Date? = nil
var id: String { "\(hostId)/\(threadId)" } var id: String { "\(hostId)/\(threadId)" }
var route: Route { .thread(hostId: hostId, threadId: threadId) } var route: Route { .thread(hostId: hostId, threadId: threadId) }
/// The thread that answers `request`: the subagent named in `via`, or this thread.
var requestThread: (hostId: String, threadId: String) {
via.map { ($0.hostId, $0.threadId) } ?? (hostId, threadId)
}
}
/// One step a thread is taking now, for the working card's activity line.
struct Activity: Codable, Hashable, Sendable {
struct Kind: RawRepresentable, Codable, Hashable, Sendable {
let rawValue: String
init(rawValue: String) { self.rawValue = rawValue }
static let command = Kind(rawValue: "command")
static let edit = Kind(rawValue: "edit")
static let search = Kind(rawValue: "search")
static let web = Kind(rawValue: "web")
static let subagent = Kind(rawValue: "subagent")
static let thinking = Kind(rawValue: "thinking")
static let tool = Kind(rawValue: "tool")
}
var kind: Kind
/// The command line, file name, search, subagent title or tool name, trimmed by the gateway.
var text: String
/// When the step started, for its elapsed time.
var startedAt: Date?
}
/// What a thread's latest run changed, in total, for the line on a Recent card.
struct ChangeTotals: Codable, Hashable, Sendable {
var files: Int
var additions: Int
var deletions: Int
/// "+42", "−8" and "3 files", the pieces of the card's "+42 −8 · 3 files".
var parts: (additions: String, deletions: String, files: String) {
("+\(additions)", "\u{2212}\(deletions)", files == 1 ? "1 file" : "\(files) files")
}
}
/// A file the latest run changed. `name` is the last part of `path`.
struct ChangedFile: Codable, Hashable, Sendable, Identifiable {
var path: String
var name: String
var additions: Int
var deletions: Int
var id: String { path }
}
/// A thread search hit: the thread, and the text around the match when it was inside a message.
struct SearchHit: Codable, Hashable, Sendable, Identifiable {
var thread: ThreadRow
var projectTitle: String
var hostLabel: String
/// Nil when only the title matched.
var snippet: String?
var id: String { thread.id }
}
/// `GET /v1/search?q=`: hits from every online host, newest match first.
struct SearchResponse: Codable, Hashable, Sendable {
var hits: [SearchHit]
/// Labels of hosts that were offline or failed, so their threads were not searched.
var unsearched: [String]
/// The line under the results naming the hosts left out, such as "Laptop is offline, so its
/// threads were not searched." `offline` holds the labels of hosts the watch knows are offline;
/// any other host failed or was too slow, which the gateway does not tell apart.
static func unsearchedNote(_ labels: [String], offline: Set<String>) -> String? {
guard !labels.isEmpty else { return nil }
let names: String = switch labels.count {
case 1: labels[0]
case 2: "\(labels[0]) and \(labels[1])"
default: labels.dropLast().joined(separator: ", ") + " and " + labels[labels.count - 1]
}
guard labels.allSatisfy(offline.contains) else { return "\(names) could not be searched." }
return labels.count == 1
? "\(names) is offline, so its threads were not searched."
: "\(names) are offline, so their threads were not searched."
}
}
/// A thread in a list: a project's threads, or a thread's subagents.
struct ThreadRow: Codable, Hashable, Sendable, Identifiable {
var hostId: String
var threadId: String
var title: String
/// Nil for a thread that never ran or was stopped.
var phase: Phase?
var settled: Bool
var updatedAt: Date
var id: String { "\(hostId)/\(threadId)" }
var route: Route { .thread(hostId: hostId, threadId: threadId) }
/// "working", "waiting", "settled", "failed" or "done", for a subagent's row.
var statusWord: String {
if phase?.isWorking == true { return "working" }
if phase?.needsYou == true { return "waiting" }
if settled { return "settled" }
return phase == .failed ? "failed" : "done"
}
/// False once it finished or settled, which dims its title.
var isActive: Bool { !settled && (phase?.isWorking == true || phase?.needsYou == true) }
}
/// `GET /v1/projects/:hostId/:projectId/threads`: a project's newest 50 threads, newest first.
struct ThreadListResponse: Codable, Hashable, Sendable {
var items: [ThreadRow]
}
/// `GET /v1/threads/:hostId/:threadId/history`: the page before the messages the watch has.
struct HistoryResponse: Codable, Hashable, Sendable {
/// Oldest first.
var messages: [Message]
/// The cursor for the page before these. Nil at the start of the thread.
var earlierCursor: String?
} }
struct Feed: Codable, Hashable, Sendable { struct Feed: Codable, Hashable, Sendable {
var needsYou: [FeedItem] var needsYou: [FeedItem]
var working: [FeedItem] var working: [FeedItem]
/// Finished or failed threads not settled yet, newest first and at most 10.
var recent: [FeedItem] var recent: [FeedItem]
/// How many threads Recent holds in all. `GET /v1/recent` lists the ones past `recent`.
var recentTotal: Int
/// Finished threads snoozed for later, which `GET /v1/snoozed` lists.
var snoozedTotal: Int
var hosts: [HostSummary] var hosts: [HostSummary]
/// `recentTotal` defaults to the threads in `recent`, as when nothing is left out.
init(
needsYou: [FeedItem], working: [FeedItem], recent: [FeedItem], recentTotal: Int? = nil, snoozedTotal: Int = 0,
hosts: [HostSummary]
) {
self.needsYou = needsYou
self.working = working
self.recent = recent
self.recentTotal = recentTotal ?? recent.count
self.snoozedTotal = snoozedTotal
self.hosts = hosts
}
/// Recent threads the feed leaves out, which the "N more" row counts.
var moreRecent: Int { max(0, recentTotal - recent.count) }
}
extension Feed {
private enum CodingKeys: String, CodingKey {
case needsYou, working, recent, recentTotal, snoozedTotal, hosts
}
/// `recentTotal` and `snoozedTotal` are newer than the rest, so a gateway that leaves them out has
/// nothing past `recent` and nothing snoozed.
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.init(
needsYou: try container.decode([FeedItem].self, forKey: .needsYou),
working: try container.decode([FeedItem].self, forKey: .working),
recent: try container.decode([FeedItem].self, forKey: .recent),
recentTotal: try container.decodeIfPresent(Int.self, forKey: .recentTotal),
snoozedTotal: try container.decodeIfPresent(Int.self, forKey: .snoozedTotal) ?? 0,
hosts: try container.decode([HostSummary].self, forKey: .hosts))
}
}
/// `GET /v1/recent`: every unsettled finished thread, newest first. Older ones may lack `lastMessage`.
struct RecentResponse: Codable, Hashable, Sendable {
var items: [FeedItem]
}
/// `POST /v1/threads/:hostId/:threadId/snooze`. `until` is in the future and at most 30 days away.
struct SnoozeRequest: Codable, Sendable {
var until: Date
}
/// `POST /v1/recent/settle`. `failed` counts threads T3 refused or whose host was offline.
struct SettleRecentResponse: Codable, Hashable, Sendable {
var settled: Int
var failed: Int
}
/// `GET /v1/summary`: what the complication and the Smart Stack widget show.
struct Summary: Codable, Hashable, Sendable {
struct Next: Codable, Hashable, Sendable {
var hostId: String
var threadId: String
var title: String
var hostLabel: String
/// The command it wants to run or its first question, trimmed. Nil until the gateway has read
/// it, and from older gateways.
var request: String? = nil
var route: Route { .thread(hostId: hostId, threadId: threadId) }
}
/// The working thread updated most recently, with what it is doing.
struct Current: Codable, Hashable, Sendable {
var hostId: String
var threadId: String
var title: String
var activity: Activity?
var route: Route { .thread(hostId: hostId, threadId: threadId) }
}
var needsYou: Int
var working: Int
/// The thread that has waited longest for you.
var next: Next?
/// Nil when nothing is working, and from older gateways.
var current: Current? = nil
var hostsOffline: Int
}
extension Summary {
/// The same summary worked out from a feed. The feed lists needs-you oldest request first, so its
/// first item is the one that has waited longest, and working newest first.
init(_ feed: Feed) {
let next = feed.needsYou.first
let current = feed.working.first
self.init(
needsYou: feed.needsYou.count,
working: feed.working.count,
next: next.map {
Next(hostId: $0.hostId, threadId: $0.threadId, title: $0.title, hostLabel: $0.hostLabel, request: $0.request?.summary)
},
current: current.map { Current(hostId: $0.hostId, threadId: $0.threadId, title: $0.title, activity: $0.activity) },
hostsOffline: feed.hosts.filter { $0.status != .online }.count)
}
} }
struct Message: Codable, Hashable, Sendable, Identifiable { struct Message: Codable, Hashable, Sendable, Identifiable {
@@ -156,6 +500,40 @@ struct ThreadDetail: Codable, Hashable, Sendable {
/// Oldest first, at most 20. /// Oldest first, at most 20.
var messages: [Message] var messages: [Message]
var canStop: Bool var canStop: Bool
/// True when the thread asks before each action (T3's approval-required mode). Older gateways
/// leave it out, which decodes as nil, and the screen hides the switch.
var askFirst: Bool? = nil
/// Pass to the history route for older messages. Nil when `messages` starts the thread, and from
/// older gateways, which hides Load earlier.
var earlierCursor: String? = nil
/// Subagents and delegated threads this thread started, newest first.
var subagents: [ThreadRow] = []
/// The files the latest run changed, most lines changed first.
var changedFiles: [ChangedFile] = []
}
extension ThreadDetail {
private enum CodingKeys: String, CodingKey {
case item, messages, canStop, askFirst, earlierCursor, subagents, changedFiles
}
/// `subagents` and `changedFiles` are newer than the rest, so a gateway that leaves them out lists none.
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.init(
item: try container.decode(FeedItem.self, forKey: .item),
messages: try container.decode([Message].self, forKey: .messages),
canStop: try container.decode(Bool.self, forKey: .canStop),
askFirst: try container.decodeIfPresent(Bool.self, forKey: .askFirst),
earlierCursor: try container.decodeIfPresent(String.self, forKey: .earlierCursor),
subagents: try container.decodeIfPresent([ThreadRow].self, forKey: .subagents) ?? [],
changedFiles: try container.decodeIfPresent([ChangedFile].self, forKey: .changedFiles) ?? [])
}
}
/// `POST /v1/threads/:hostId/:threadId/mode`.
struct ThreadModeRequest: Codable, Sendable {
var askFirst: Bool
} }
struct ProjectsResponse: Codable, Hashable, Sendable { struct ProjectsResponse: Codable, Hashable, Sendable {
@@ -165,20 +543,220 @@ struct ProjectsResponse: Codable, Hashable, Sendable {
var status: HostStatus var status: HostStatus
var error: String? var error: String?
var projects: [Project] var projects: [Project]
/// The providers that can start a thread now, each with its models. Empty from older
/// gateways, which only start threads on the project's default model.
var providers: [AgentProvider] = []
/// Projects a new thread can start in. A project without a default model is not offered. /// Projects a new thread can start in. A project without a default model is not offered.
var launchable: [Project] { projects.filter { $0.model != nil } } var launchable: [Project] { projects.filter { $0.model != nil } }
func provider(_ instanceId: String) -> AgentProvider? {
providers.first { $0.instanceId == instanceId }
}
/// True when this host still offers the model.
func offers(_ model: ModelRef) -> Bool {
provider(model.instanceId)?.models.contains { $0.slug == model.slug } ?? false
}
} }
struct Project: Codable, Hashable, Sendable, Identifiable { struct Project: Codable, Hashable, Sendable, Identifiable {
var id: String var id: String
var title: String var title: String
/// The model a new thread uses by default, such as `claude-opus-5-5`.
var model: String? var model: String?
/// The provider `model` belongs to. Older gateways leave it out.
var instanceId: String? = nil
/// The default model as a pick, when the gateway names its provider.
var defaultModel: ModelRef? {
guard let model, let instanceId else { return nil }
return ModelRef(instanceId: instanceId, slug: model)
}
} }
var hosts: [Host] var hosts: [Host]
} }
extension ProjectsResponse.Host {
private enum CodingKeys: String, CodingKey {
case id, label, status, error, projects, providers
}
/// `providers` is newer than the rest, so a gateway that leaves it out offers no model list.
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.init(
id: try container.decode(String.self, forKey: .id),
label: try container.decode(String.self, forKey: .label),
status: try container.decode(HostStatus.self, forKey: .status),
error: try container.decodeIfPresent(String.self, forKey: .error),
projects: try container.decode([ProjectsResponse.Project].self, forKey: .projects),
providers: try container.decodeIfPresent([AgentProvider].self, forKey: .providers) ?? [])
}
}
/// An agent provider on a host, such as Claude or Codex, with the models a new thread can use.
struct AgentProvider: Codable, Hashable, Sendable, Identifiable {
struct Model: Codable, Hashable, Sendable, Identifiable {
var slug: String
/// Such as "Claude Opus 5.5".
var name: String
var id: String { slug }
}
var instanceId: String
/// Such as "Claude" or "Codex".
var name: String
/// False when the provider cannot run in approval-required mode, so Ask before acting is hidden.
var canAskFirst: Bool
var models: [Model]
var id: String { instanceId }
}
/// A model picked for a new thread: a slug and the provider instance it runs on.
struct ModelRef: Codable, Hashable, Sendable {
var instanceId: String
var slug: String
}
/// How a route reaches its host, guessed by the gateway from the URL.
struct RouteKind: RawRepresentable, Codable, Hashable, Sendable {
let rawValue: String
init(rawValue: String) { self.rawValue = rawValue }
static let direct = RouteKind(rawValue: "direct")
static let vpn = RouteKind(rawValue: "vpn")
static let `public` = RouteKind(rawValue: "public")
/// "direct", "VPN" or "public", for sentences like "Laptop is on its VPN route".
var name: String { self == .vpn ? "VPN" : rawValue }
var symbol: String {
switch self {
case .direct: "house.fill"
case .vpn: "lock.shield.fill"
default: "cloud.fill"
}
}
}
/// One way to reach a host. `Route` in the contract, renamed here because `Route` is the app's
/// navigation.
struct HostRoute: Codable, Hashable, Sendable {
struct Result: RawRepresentable, Codable, Hashable, Sendable {
let rawValue: String
init(rawValue: String) { self.rawValue = rawValue }
static let ok = Result(rawValue: "ok")
static let failed = Result(rawValue: "failed")
static let untried = Result(rawValue: "untried")
}
var url: String
var kind: RouteKind
/// From the latest connect. `untried` when an earlier route answered first.
var result: Result
var error: String?
var checkedAt: Date?
/// The URL without its scheme, such as `laptop.gib:3773`.
var hostPort: String {
guard let components = URLComponents(string: url), let host = components.host else { return url }
return components.port.map { "\(host):\($0)" } ?? host
}
}
/// One host as the status screens show it.
struct HostStatusDetail: Codable, Hashable, Sendable, Identifiable {
var id: String
var label: String
var status: HostStatus
var error: String?
/// The route in use. Null unless online.
var activeUrl: String?
/// Preferred first, the order the gateway tries them.
var routes: [HostRoute]
var onlineSince: Date?
var lastOnlineAt: Date?
/// When the gateway tries again, while it waits to retry.
var nextRetryAt: Date?
var tokenExpiresAt: Date?
var serverVersion: String?
var activeRoute: HostRoute? { routes.first { $0.url == activeUrl } }
/// Online, but not on the first route.
var onFallback: Bool {
status == .online && activeUrl != nil && activeUrl != routes.first?.url
}
}
/// What Apple answered for one push.
struct PushResult: Codable, Hashable, Sendable {
var ok: Bool
var at: Date
/// Apple's HTTP status, or 0 when the push never reached Apple.
var status: Int
/// Apple's reason, such as `BadDeviceToken`, or why the gateway could not send.
var reason: String?
}
/// `GET /v1/status`: everything the settings screens show.
struct StatusResponse: Codable, Hashable, Sendable {
struct Gateway: Codable, Hashable, Sendable {
var startedAt: Date
/// False when the gateway has no APNs key, so nothing can be pushed.
var pushConfigured: Bool
var lastPush: PushResult?
}
/// The watch asking.
struct Device: Codable, Hashable, Sendable {
var name: String
var pairedAt: Date
/// True once the watch has sent its APNs token.
var pushRegistered: Bool
var apnsEnvironment: PushRegistration.Environment?
/// Which pushes this watch gets. Older gateways leave it out, and the screen hides the switches.
var notifications: NotificationPrefs? = nil
}
var hosts: [HostStatusDetail]
var gateway: Gateway
var device: Device
}
/// Which pushes a watch wants. `needsMe` covers approvals and questions on your own threads, and
/// `subagents` covers the same from subagents. Host problems and test pushes always go out.
struct NotificationPrefs: Codable, Hashable, Sendable {
/// One switch, named as `PUT /v1/device/notifications` names it.
enum Key: String, CaseIterable, Sendable {
case needsMe, finished, failed, subagents
var keyPath: WritableKeyPath<NotificationPrefs, Bool> {
switch self {
case .needsMe: \.needsMe
case .finished: \.finished
case .failed: \.failed
case .subagents: \.subagents
}
}
}
var needsMe: Bool
var finished: Bool
var failed: Bool
var subagents: Bool
subscript(key: Key) -> Bool {
get { self[keyPath: key.keyPath] }
set { self[keyPath: key.keyPath] = newValue }
}
}
struct PairRequest: Codable, Sendable { struct PairRequest: Codable, Sendable {
var code: String var code: String
var deviceName: String var deviceName: String
@@ -202,9 +780,23 @@ struct SendMessageRequest: Codable, Sendable {
var text: String var text: String
} }
enum RespondRequest: Encodable, Sendable { enum RespondRequest: Encodable, Equatable, Sendable {
/// One question's answer: an option value or typed text, or option values for a multi-select question.
enum Answer: Encodable, Hashable, Sendable {
case text(String)
case values([String])
func encode(to encoder: any Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case let .text(text): try container.encode(text)
case let .values(values): try container.encode(values)
}
}
}
case decision(requestId: String, decision: ApprovalDecision) case decision(requestId: String, decision: ApprovalDecision)
case answers(requestId: String, answers: [String: String]) case answers(requestId: String, answers: [String: Answer])
private enum CodingKeys: String, CodingKey { case requestId, decision, answers } private enum CodingKeys: String, CodingKey { case requestId, decision, answers }
@@ -225,6 +817,10 @@ struct NewThreadRequest: Codable, Sendable {
var hostId: String var hostId: String
var projectId: String var projectId: String
var text: String var text: String
/// A model from the host's `providers`. Nil uses the project's default.
var model: ModelRef? = nil
/// Starts the thread in approval-required mode. Nil or false uses the host's default mode.
var askFirst: Bool? = nil
} }
struct NewThreadResponse: Codable, Sendable { struct NewThreadResponse: Codable, Sendable {
@@ -236,9 +832,34 @@ struct ErrorResponse: Codable, Sendable {
var error: String var error: String
} }
/// `question` on an `input` push that asks one single-choice question: its options, so the
/// notification can offer each as a button. Answer with `[id: value]` on the respond route.
struct PushQuestion: Codable, Hashable, Sendable {
var id: String
/// At most 4.
var options: [PendingRequest.Question.Choice]
/// Adds a spoken or typed answer.
var allowCustomAnswer: Bool
/// The question in a push's `userInfo`, or nil when the push has none or it does not decode.
init?(userInfo: [AnyHashable: Any]) {
guard let object = userInfo["question"], JSONSerialization.isValidJSONObject(object),
let data = try? JSONSerialization.data(withJSONObject: object),
let question = try? JSON.decoder().decode(Self.self, from: data)
else { return nil }
self = question
}
init(id: String, options: [PendingRequest.Question.Choice], allowCustomAnswer: Bool) {
self.id = id
self.options = options
self.allowCustomAnswer = allowCustomAnswer
}
}
/// Notification categories the watch registers actions for. /// Notification categories the watch registers actions for.
enum PushCategory: String, CaseIterable, Sendable { enum PushCategory: String, CaseIterable, Sendable {
case approval, finished, failed, input, host case approval, finished, failed, input, host, test
} }
enum JSON { enum JSON {
+104
View File
@@ -0,0 +1,104 @@
// Screens the app can open, and the `wristcode://` URLs the complication uses to open them.
import Foundation
enum Route: Hashable, Sendable {
case thread(hostId: String, threadId: String)
case newThread
/// Every thread in Recent, past the feed's first 10.
case recent
/// Finished threads snoozed for later.
case snoozed
/// The status overview, and from it one host or the notifications page.
case status
case host(id: String)
case notifications
/// All threads: every host, then one host's projects, then one project's threads.
case allThreads
case hostProjects(hostId: String)
case projectThreads(hostId: String, projectId: String)
/// Threads on every host matching a search from All threads.
case search(query: String)
static let scheme = "wristcode"
/// The fewest characters the gateway searches for.
static let minimumSearch = 2
/// `wristcode://thread/<hostId>/<threadId>`, `wristcode://new`, `wristcode://recent`, `wristcode://snoozed`,
/// `wristcode://status`,
/// `wristcode://host/<hostId>`, `wristcode://notifications`, `wristcode://threads`,
/// `wristcode://projects/<hostId>`, `wristcode://project/<hostId>/<projectId>` or `wristcode://search?q=<query>`.
var url: URL {
var components = URLComponents()
components.scheme = Self.scheme
func path(_ parts: String...) -> String {
"/" + parts.map { $0.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? $0 }.joined(separator: "/")
}
switch self {
case let .thread(hostId, threadId):
components.host = "thread"
components.percentEncodedPath = path(hostId, threadId)
case .newThread:
components.host = "new"
case .recent:
components.host = "recent"
case .snoozed:
components.host = "snoozed"
case .status:
components.host = "status"
case let .host(id):
components.host = "host"
components.percentEncodedPath = path(id)
case .notifications:
components.host = "notifications"
case .allThreads:
components.host = "threads"
case let .hostProjects(hostId):
components.host = "projects"
components.percentEncodedPath = path(hostId)
case let .projectThreads(hostId, projectId):
components.host = "project"
components.percentEncodedPath = path(hostId, projectId)
case let .search(query):
components.host = "search"
components.queryItems = [URLQueryItem(name: "q", value: query)]
}
return components.url!
}
/// The route a `wristcode://` URL names, or nil when it names none.
init?(url: URL) {
guard url.scheme == Self.scheme,
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
else { return nil }
let parts = components.percentEncodedPath.split(separator: "/").map { $0.removingPercentEncoding ?? String($0) }
switch components.host {
case "thread" where parts.count == 2 && parts.allSatisfy { !$0.isEmpty }:
self = .thread(hostId: parts[0], threadId: parts[1])
case "new":
self = .newThread
case "recent":
self = .recent
case "snoozed":
self = .snoozed
case "status":
self = .status
case "host" where parts.count == 1 && !parts[0].isEmpty:
self = .host(id: parts[0])
case "notifications":
self = .notifications
case "threads":
self = .allThreads
case "projects" where parts.count == 1 && !parts[0].isEmpty:
self = .hostProjects(hostId: parts[0])
case "project" where parts.count == 2 && parts.allSatisfy { !$0.isEmpty }:
self = .projectThreads(hostId: parts[0], projectId: parts[1])
case "search":
guard let query = components.queryItems?.first(where: { $0.name == "q" })?.value, query.count >= Self.minimumSearch
else { return nil }
self = .search(query: query)
default:
return nil
}
}
}
+379 -37
View File
@@ -1,14 +1,14 @@
// App-wide state: whether the watch is paired, the navigation path, and the latest feed. // App-wide state: whether the watch is paired, the navigation path, the latest feed, the hosts
// and projects All threads browses, and the thread being snoozed. The last good feed is saved to
// disk, so launch shows it at once while the first refresh runs. It also
// keeps the complication current: it shares the mode and latest counts through the shared Keychain
// group and reloads the complication's timeline when they change.
import Foundation import Foundation
import Observation import Observation
import UserNotifications import UserNotifications
import WatchKit import WatchKit
import WidgetKit
enum Route: Hashable, Sendable {
case thread(hostId: String, threadId: String)
case newThread
}
@MainActor @Observable @MainActor @Observable
final class AppModel { final class AppModel {
@@ -22,33 +22,101 @@ final class AppModel {
/// The one instance the app delegate and notification handler share with the views. /// The one instance the app delegate and notification handler share with the views.
static let shared = AppModel(launchArguments: ProcessInfo.processInfo.arguments) static let shared = AppModel(launchArguments: ProcessInfo.processInfo.arguments)
var mode: Mode var mode: Mode {
didSet { if mode != oldValue { shareMode() } }
}
var path: [Route] = [] var path: [Route] = []
private(set) var feed: Feed? private(set) var feed: Feed?
private(set) var feedError: String? private(set) var feedError: String?
/// True while the feed on screen came from the cache and the first refresh has not answered.
private(set) var feedIsCached = false
/// Ids of feed items whose settle call is still out. The feed hides them meanwhile.
private(set) var settling: Set<String> = []
/// Why settling an item failed, by item id. Its row shows the message.
private(set) var settleErrors: [String: String] = [:]
/// Every thread in Recent, for the full Recent screen, which loads it when it appears.
private(set) var allRecent: [FeedItem]?
private(set) var allRecentError: String?
/// True while Settle all is out. Completed Recent threads are hidden meanwhile.
private(set) var settlingAll = false
/// Why Settle all failed, or how many threads it could not settle. The Recent header shows it.
private(set) var settleAllError: String?
/// Hosts and their projects for All threads, loaded when its screens appear.
private(set) var projects: ProjectsResponse?
private(set) var projectsError: String?
/// Snoozed threads, for the Snoozed screen, which loads them when it appears.
private(set) var snoozed: [FeedItem]?
private(set) var snoozedError: String?
/// The thread the Snooze until picker is open for.
var snoozing: FeedItem?
/// Where the feed and thread details are saved. Only used while paired, never in demo mode.
private let cache: FeedCache?
/// Answers every call in place of the mode's gateway, for tests.
private let gateway: (any GatewayAPI)?
private var apnsToken: String? private var apnsToken: String?
private var refreshing = false /// The feed refresh in flight. A refresh asked for meanwhile waits on it, and it runs once more.
private var refreshTask: Task<Void, Never>?
/// Bumped when a change to threads starts, when a refresh is asked for during another, and on
/// reset. A feed answer to a request sent before the bump is dropped, and the refresh runs again.
private var feedGeneration = 0
/// What was last written to disk, so an unchanged feed or thread is not written again.
private var savedFeed: Feed?
private var savedThread: ThreadDetail?
/// The latest disk write. Writes run off the main actor, one after another.
private(set) var cacheWrite: Task<Void, Never>?
/// The counts the complication was last told about.
private var sharedSummary: Summary?
init(mode: Mode, feed: Feed? = nil, path: [Route] = []) { /// With a `cache` and no `feed`, a paired model starts on the saved feed.
init(
mode: Mode, feed: Feed? = nil, allRecent: [FeedItem]? = nil, snoozed: [FeedItem]? = nil,
projects: ProjectsResponse? = nil, path: [Route] = [], cache: FeedCache? = nil,
gateway: (any GatewayAPI)? = nil
) {
self.mode = mode self.mode = mode
self.feed = feed self.cache = cache
self.gateway = gateway
self.allRecent = allRecent
self.snoozed = snoozed
self.projects = projects
self.path = path self.path = path
if feed == nil, case .paired = mode, let saved = cache?.feed() {
self.feed = saved
feedIsCached = true
} else {
self.feed = feed
}
} }
/// `-demo` starts in demo mode. `-screen new` or `-thread <hostId>/<threadId>` opens that /// `-demo` starts in demo mode. `-screen new|recent|status|notifications|threads`, `-host <hostId>` or
/// screen, which is how the screenshots in watch/screenshots are taken. /// `-thread <hostId>/<threadId>` opens that screen, which is how the screenshots in
/// watch/screenshots are taken.
convenience init(launchArguments args: [String]) { convenience init(launchArguments args: [String]) {
DeviceStore.migrateToSharedGroup()
let demo = args.contains("-demo") let demo = args.contains("-demo")
let mode: Mode = demo ? .demo : DeviceStore.load().map(Mode.paired) ?? .unpaired let mode: Mode = demo ? .demo : DeviceStore.load().map(Mode.paired) ?? .unpaired
var path: [Route] = [] func argument(_ flag: String) -> String? {
if demo, let i = args.firstIndex(of: "-screen"), i + 1 < args.count, args[i + 1] == "new" { guard demo, let i = args.firstIndex(of: flag), i + 1 < args.count else { return nil }
path = [.newThread] return args[i + 1]
} }
if demo, let i = args.firstIndex(of: "-thread"), i + 1 < args.count { var path: [Route] = []
let parts = args[i + 1].split(separator: "/", maxSplits: 1).map(String.init) switch argument("-screen") {
case "new": path = [.newThread]
case "recent": path = [.recent]
case "status": path = [.status]
case "notifications": path = [.status, .notifications]
case "threads": path = [.allThreads]
default: break
}
if let hostId = argument("-host") {
path = [.status, .host(id: hostId)]
}
if let thread = argument("-thread") {
let parts = thread.split(separator: "/", maxSplits: 1).map(String.init)
if parts.count == 2 { path = [.thread(hostId: parts[0], threadId: parts[1])] } if parts.count == 2 { path = [.thread(hostId: parts[0], threadId: parts[1])] }
} }
self.init(mode: mode, path: path) self.init(mode: mode, path: path, cache: .standard)
shareMode()
} }
var isPaired: Bool { var isPaired: Bool {
@@ -56,8 +124,12 @@ final class AppModel {
return false return false
} }
/// The cache while paired. Demo mode keeps off it.
private var pairedCache: FeedCache? { isPaired ? cache : nil }
var api: any GatewayAPI { var api: any GatewayAPI {
switch mode { if let gateway { return gateway }
return switch mode {
case let .paired(credentials): case let .paired(credentials):
GatewayClient(baseURL: credentials.gatewayURL, token: credentials.token) GatewayClient(baseURL: credentials.gatewayURL, token: credentials.token)
case .demo: case .demo:
@@ -75,31 +147,65 @@ final class AppModel {
let credentials = Credentials(gatewayURL: gatewayURL, token: response.deviceToken) let credentials = Credentials(gatewayURL: gatewayURL, token: response.deviceToken)
DeviceStore.save(credentials) DeviceStore.save(credentials)
mode = .paired(credentials) mode = .paired(credentials)
feed = nil resetContent()
feedError = nil
path = []
await enableNotifications() await enableNotifications()
} }
func startDemo() { func startDemo() {
mode = .demo mode = .demo
resetContent()
}
/// Back to the pairing screen. A real token and the saved feed and threads are forgotten; the
/// gateway URL is kept.
func signOut() {
if isPaired { DeviceStore.clearToken() }
// After any write still queued, so none puts a file back.
if let cache { write { cache.clear() } }
mode = .unpaired
resetContent()
}
/// Forgets what the screens loaded, for a new pairing, demo mode or signing out. A refresh still
/// in flight drops its answer.
private func resetContent() {
feedGeneration += 1
feed = nil feed = nil
feedError = nil feedError = nil
feedIsCached = false
settling = []
settleErrors = [:]
settlingAll = false
settleAllError = nil
allRecent = nil
allRecentError = nil
snoozed = nil
snoozedError = nil
snoozing = nil
projects = nil
projectsError = nil
savedFeed = nil
savedThread = nil
path = [] path = []
} }
/// Back to the pairing screen. A real token is forgotten; the gateway URL is kept. /// Revokes the device token on the gateway, then signs out. Signs out even when the gateway
func signOut() { /// cannot be reached, since the token is forgotten either way.
if isPaired { DeviceStore.clearToken() } func unpair() async {
mode = .unpaired if isPaired { try? await api.unpair() }
feed = nil signOut()
feedError = nil }
path = []
/// The gateway's host name, such as `wristcode.gbrown.org`. Nil in demo mode.
var gatewayHost: String? {
if case let .paired(credentials) = mode { return credentials.gatewayURL.host() }
return nil
} }
/// The message to show for a failed call. A 401 sends the app back to pairing and returns nil. /// The message to show for a failed call. A 401 sends the app back to pairing and returns nil.
/// A cancelled call, as when a screen's `.task` ends or a refresh restarts, returns nil too.
func message(for error: any Error) -> String? { func message(for error: any Error) -> String? {
if error is CancellationError { return nil } if error is CancellationError || (error as? URLError)?.code == .cancelled { return nil }
if let error = error as? GatewayError, error == .unauthorized { if let error = error as? GatewayError, error == .unauthorized {
signOut() signOut()
return nil return nil
@@ -109,15 +215,216 @@ final class AppModel {
// MARK: - Feed // MARK: - Feed
/// Loads the feed. Asked for while a refresh is in flight, it waits for that one, which then
/// runs again, since its answer may predate the caller's change.
func refreshFeed() async { func refreshFeed() async {
guard mode != .unpaired, !refreshing else { return } guard mode != .unpaired else { return }
refreshing = true if let refreshTask {
defer { refreshing = false } feedGeneration += 1
await refreshTask.value
return
}
let task = Task { await runRefresh() }
refreshTask = task
await task.value
}
/// Call as a change to threads goes out, such as a settle or an answer. A feed answer to a
/// request sent before it is dropped, so a poll cannot put back what the change removed.
func beginChange() {
feedGeneration += 1
}
/// Fetches the feed until an answer arrives with no change, reset or new request since it was
/// sent, then applies it. An answer from before signing out or switching mode is never applied.
private func runRefresh() async {
defer { refreshTask = nil }
while mode != .unpaired {
let generation = feedGeneration
let result: Result<Feed, any Error>
do { do {
feed = try await api.feed() result = .success(try await api.feed())
feedError = nil
} catch { } catch {
feedError = message(for: error) result = .failure(error)
}
guard generation == feedGeneration else { continue }
switch result {
case let .success(feed):
self.feed = feed
feedError = nil
feedIsCached = false
let listed = Set((feed.needsYou + feed.working + feed.recent + (allRecent ?? [])).map(\.id))
settleErrors = settleErrors.filter { listed.contains($0.key) }
share(Summary(feed))
saveFeed(feed)
case let .failure(error):
// A cancelled refresh changes nothing; the next one answers.
guard let message = message(for: error) else { return }
feedError = message
feedIsCached = false
}
return
}
}
/// The detail saved when the thread last loaded, to show while it loads again. Nil in demo mode.
func cachedThread(hostId: String, threadId: String) -> ThreadDetail? {
pairedCache?.thread(hostId: hostId, threadId: threadId)
}
/// Saves a thread's detail for the next time it opens, unless it is what was saved last.
func saveThread(_ detail: ThreadDetail) {
guard let cache = pairedCache, detail != savedThread else { return }
savedThread = detail
write { cache.save(detail) }
}
/// Saves the feed for the next launch, unless it is what was saved last.
private func saveFeed(_ feed: Feed) {
guard let cache = pairedCache, feed != savedFeed else { return }
savedFeed = feed
write { cache.save(feed) }
}
/// Runs a disk write off the main actor, after the writes before it.
private func write(_ body: @escaping @Sendable () -> Void) {
let previous = cacheWrite
cacheWrite = Task.detached(priority: .utility) {
await previous?.value
body()
}
}
/// Settles items concurrently. Each one leaves the feed at once and comes back with its error
/// if its call fails. Refreshes once every call has answered.
func settle(_ items: [FeedItem]) async {
let ids = Set(items.map(\.id))
beginChange()
settling.formUnion(ids)
for id in ids { settleErrors[id] = nil }
let api = api
let failures = await withTaskGroup(of: (String, (any Error)?).self) { group in
for item in items {
group.addTask {
do {
try await api.settle(hostId: item.hostId, threadId: item.threadId)
return (item.id, nil)
} catch {
return (item.id, error)
}
}
}
var failures: [String: any Error] = [:]
for await (id, error) in group {
if let error { failures[id] = error }
}
return failures
}
for (id, error) in failures {
settleErrors[id] = message(for: error)
}
let settled = ids.filter { failures[$0] == nil }
removeFromRecent(settled.count) { settled.contains($0.id) }
settling.subtract(ids)
await refreshFeed()
}
/// Recent threads to show: those not being settled.
func visibleRecent(_ items: [FeedItem]) -> [FeedItem] {
items.filter { !settling.contains($0.id) && !(settlingAll && $0.phase == .completed) }
}
/// `GET /v1/recent`, for the full Recent screen.
func loadAllRecent() async {
guard mode != .unpaired else { return }
do {
allRecent = try await api.recent().items
allRecentError = nil
} catch {
if let message = message(for: error) { allRecentError = message }
}
}
/// Settles every completed thread in Recent with one call, including those past the feed's first
/// 10. They leave at once. Then the feed, and the full list when it was loaded, refresh.
func settleAllRecent() async {
beginChange()
settlingAll = true
settleAllError = nil
do {
let result = try await api.settleRecent()
removeFromRecent(result.settled) { $0.phase == .completed }
if result.failed > 0 {
settleAllError = result.failed == 1 ? "1 thread didn't settle." : "\(result.failed) threads didn't settle."
}
} catch {
settleAllError = message(for: error)
}
settlingAll = false
await refreshFeed()
if allRecent != nil { await loadAllRecent() }
}
// MARK: - Snooze
/// Snoozes a finished thread until `until`. Once the gateway agrees, it leaves Recent, the picker
/// closes, and its screen does too if it is on top. Then the feed refreshes.
func snooze(_ item: FeedItem, until: Date) async throws {
beginChange()
try await api.snooze(hostId: item.hostId, threadId: item.threadId, until: until)
snoozing = nil
removeFromRecent(1) { $0.id == item.id }
feed?.snoozedTotal += 1
if path.last == item.route { path.removeLast() }
await refreshFeed()
}
/// `GET /v1/snoozed`, for the Snoozed screen.
func loadSnoozed() async {
guard mode != .unpaired else { return }
do {
snoozed = try await api.snoozed().items
snoozedError = nil
} catch {
if let message = message(for: error) { snoozedError = message }
}
}
/// Puts a snoozed thread back in Recent. It leaves the Snoozed list at once, then the feed and
/// the list refresh. On failure the list shows why, once the reload has put the thread back.
func unsnooze(_ item: FeedItem) async {
beginChange()
snoozed?.removeAll { $0.id == item.id }
var failure: String?
do {
try await api.unsnooze(hostId: item.hostId, threadId: item.threadId)
} catch {
failure = message(for: error)
}
await refreshFeed()
await loadSnoozed()
if let failure { snoozedError = failure }
}
/// Drops settled threads from the feed and the full list until the next refresh confirms it.
private func removeFromRecent(_ count: Int, where settled: (FeedItem) -> Bool) {
allRecent?.removeAll(where: settled)
guard var feed else { return }
feed.recent.removeAll(where: settled)
feed.recentTotal = max(feed.recent.count, feed.recentTotal - count)
self.feed = feed
}
// MARK: - All threads
/// `GET /v1/projects`, for the hosts and projects All threads lists.
func loadProjects() async {
guard mode != .unpaired else { return }
do {
projects = try await api.projects()
projectsError = nil
} catch {
if let message = message(for: error) { projectsError = message }
} }
} }
@@ -125,6 +432,38 @@ final class AppModel {
path = [route] path = [route]
} }
/// Opens a `wristcode://` link from the complication.
func open(url: URL) {
guard mode != .unpaired, let route = Route(url: url) else { return }
open(route)
}
// MARK: - Complication
/// Tells the complication about the mode. Counts from another mode, or from before signing
/// out, are forgotten.
private func shareMode() {
let demo = mode == .demo
if SharedState.demo != demo || mode == .unpaired { SharedState.lastSummary = nil }
SharedState.demo = demo
sharedSummary = nil
reloadComplication()
}
/// Hands the complication fresh counts. The feed refreshes every 10 s while on screen, so the
/// timeline only reloads when the counts or the next thread change.
private func share(_ summary: Summary) {
guard summary != sharedSummary else { return }
sharedSummary = summary
SharedState.lastSummary = summary
reloadComplication()
}
/// Asks the complication to fetch the summary again, as after a push or a notification action.
func reloadComplication() {
WidgetCenter.shared.reloadAllTimelines()
}
// MARK: - Notifications // MARK: - Notifications
func enableNotifications() async { func enableNotifications() async {
@@ -156,6 +495,7 @@ final class AppModel {
} }
func notificationArrived() { func notificationArrived() {
reloadComplication()
Task { await refreshFeed() } Task { await refreshFeed() }
} }
} }
@@ -163,6 +503,8 @@ final class AppModel {
extension AppModel { extension AppModel {
/// A demo model with the fixture feed already loaded, for previews. /// A demo model with the fixture feed already loaded, for previews.
static func preview(path: [Route] = []) -> AppModel { static func preview(path: [Route] = []) -> AppModel {
AppModel(mode: .demo, feed: DemoFixtures.previewFeed(), path: path) AppModel(
mode: .demo, feed: DemoFixtures.previewFeed(), allRecent: DemoFixtures.previewRecent(),
snoozed: DemoFixtures.previewSnoozed(), projects: ProjectsResponse(hosts: DemoFixtures(now: .now).projectHosts), path: path)
} }
} }
+70 -9
View File
@@ -1,5 +1,9 @@
// The device token and gateway URL, kept in the Keychain. They are readable after first unlock, so // The device token and gateway URL, kept in the Keychain. They are readable after first unlock, so
// a notification action handled in the background can still call the gateway. // a notification action handled in the background can still call the gateway.
//
// The items live in a Keychain access group the app and the complication share, so the complication
// can read them too. Builds before the complication kept them in the app's own access group;
// `migrateToSharedGroup()` moves them on launch, so a paired watch stays paired.
import Foundation import Foundation
import Security import Security
@@ -12,6 +16,10 @@ struct Credentials: Equatable, Sendable {
enum DeviceStore { enum DeviceStore {
static let defaultGatewayURL = "https://wristcode.gbrown.org" static let defaultGatewayURL = "https://wristcode.gbrown.org"
/// `<team>.org.gbrown.wristcode.shared`, from Info.plist, where the build fills in the team prefix.
static let accessGroup = Bundle.main.object(forInfoDictionaryKey: "WristCodeKeychainGroup") as? String
?? "GQJNM5YK2M.org.gbrown.wristcode.shared"
private static let service = "org.gbrown.wristcode" private static let service = "org.gbrown.wristcode"
private static let tokenAccount = "deviceToken" private static let tokenAccount = "deviceToken"
private static let urlAccount = "gatewayURL" private static let urlAccount = "gatewayURL"
@@ -39,6 +47,23 @@ enum DeviceStore {
read(urlAccount) ?? defaultGatewayURL read(urlAccount) ?? defaultGatewayURL
} }
/// Moves items an older build saved in the app's own access group into the shared one. Does
/// nothing once they have moved. If the move fails the old items stay, and `load()` still finds
/// them, so the app keeps working and only the complication goes without.
static func migrateToSharedGroup() {
for account in [tokenAccount, urlAccount] where readShared(account) == nil {
guard let legacy = readAnyGroup(account), legacy.group != accessGroup else { continue }
var item = query(account)
item[kSecAttrAccessGroup as String] = accessGroup
item[kSecValueData as String] = Data(legacy.value.utf8)
item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
guard SecItemAdd(item as CFDictionary, nil) == errSecSuccess else { continue }
var old = query(account)
old[kSecAttrAccessGroup as String] = legacy.group
SecItemDelete(old as CFDictionary)
}
}
/// Turns what someone typed into a gateway base URL: trims it, adds `https://` when there is /// Turns what someone typed into a gateway base URL: trims it, adds `https://` when there is
/// no scheme, and drops trailing slashes. Returns nil when it is not a usable URL. /// no scheme, and drops trailing slashes. Returns nil when it is not a usable URL.
static func normalizeGatewayURL(_ input: String) -> URL? { static func normalizeGatewayURL(_ input: String) -> URL? {
@@ -64,30 +89,66 @@ enum DeviceStore {
] ]
} }
private static func sharedQuery(_ account: String) -> [String: Any] {
query(account).merging([kSecAttrAccessGroup as String: accessGroup]) { _, new in new }
}
/// The shared item, or failing that one an older build left in the app's own group.
private static func read(_ account: String) -> String? { private static func read(_ account: String) -> String? {
var query = query(account) readShared(account) ?? readAnyGroup(account)?.value
}
private static func readShared(_ account: String) -> String? {
sharedData(account).flatMap { String(data: $0, encoding: .utf8) }
}
/// An item in the shared group only. `SharedState` keeps its values here too.
static func sharedData(_ account: String) -> Data? {
var query = sharedQuery(account)
query[kSecReturnData as String] = true query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef? var result: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess else { return nil }
let data = result as? Data return result as? Data
else { return nil }
return String(data: data, encoding: .utf8)
} }
private static func write(_ value: String, account: String) { /// Saves or, given nil, deletes an item in the shared group.
let data = Data(value.utf8) static func setSharedData(_ data: Data?, account: String) {
guard let data else {
SecItemDelete(sharedQuery(account) as CFDictionary)
return
}
let attributes: [String: Any] = [ let attributes: [String: Any] = [
kSecValueData as String: data, kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
] ]
let status = SecItemUpdate(query(account) as CFDictionary, attributes as CFDictionary) let status = SecItemUpdate(sharedQuery(account) as CFDictionary, attributes as CFDictionary)
if status == errSecItemNotFound { if status == errSecItemNotFound {
let item = query(account).merging(attributes) { _, new in new } let item = sharedQuery(account).merging(attributes) { _, new in new }
SecItemAdd(item as CFDictionary, nil) SecItemAdd(item as CFDictionary, nil)
} }
} }
/// An item in any access group this process can read, with the group it is in.
private static func readAnyGroup(_ account: String) -> (value: String, group: String)? {
var query = query(account)
query[kSecReturnData as String] = true
query[kSecReturnAttributes as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
let attributes = result as? [String: Any],
let data = attributes[kSecValueData as String] as? Data,
let value = String(data: data, encoding: .utf8)
else { return nil }
return (value, attributes[kSecAttrAccessGroup as String] as? String ?? "")
}
private static func write(_ value: String, account: String) {
setSharedData(Data(value.utf8), account: account)
}
/// Deletes the item from every access group, so a leftover from an older build goes too.
private static func delete(_ account: String) { private static func delete(_ account: String) {
SecItemDelete(query(account) as CFDictionary) SecItemDelete(query(account) as CFDictionary)
} }
+469 -24
View File
@@ -1,6 +1,10 @@
// Fixture data shaped like the docs/api.md examples, served by an in-memory gateway. Demo mode and // Fixture data shaped like the docs/api.md examples, served by an in-memory gateway. Demo mode and
// SwiftUI previews use it, so every screen renders without a live gateway. It reacts to writes: // SwiftUI previews use it, so every screen renders without a live gateway. It reacts to writes:
// answering an approval moves the thread to working, a reply restarts a finished thread, and so on. // answering an approval moves the thread to working, a reply restarts a finished thread, settling
// hides a thread from the feed, snoozing moves it from Recent to Snoozed, Ask before acting and the
// notification switches stick, and so on.
// Subagent threads open like any other but stay out of the feed, as the gateway's do. Search matches
// titles and messages on the online hosts.
import Foundation import Foundation
@@ -9,33 +13,65 @@ actor DemoGateway: GatewayAPI {
private var items: [FeedItem] private var items: [FeedItem]
private var messages: [String: [Message]] private var messages: [String: [Message]]
/// Pages before each thread's messages, newest page first.
private let earlierPages: [String: [[Message]]]
/// Files each thread's latest run changed, by thread id.
private let changedFiles: [String: [ChangedFile]]
/// Subagent ids by parent id. Subagents are in `items` but not the feed.
private let children: [String: [String]]
private let subagentIds: Set<String>
private let hosts: [HostSummary] private let hosts: [HostSummary]
private let projectHosts: [ProjectsResponse.Host] private let projectHosts: [ProjectsResponse.Host]
private var statusResponse: StatusResponse
/// Ids of settled threads, which the feed leaves out.
private var settled: Set<String>
/// Ids of threads in Ask before acting.
private var askingFirst: Set<String> = []
private var nextId = 1 private var nextId = 1
init(now: Date = .now) { init(now: Date = .now) {
let fixtures = DemoFixtures(now: now) let fixtures = DemoFixtures(now: now)
items = fixtures.items items = fixtures.items
messages = fixtures.messages messages = fixtures.messages
earlierPages = fixtures.history
changedFiles = fixtures.changedFiles
children = fixtures.children
subagentIds = Set(fixtures.children.values.joined())
settled = fixtures.settled
hosts = fixtures.hosts hosts = fixtures.hosts
projectHosts = fixtures.projectHosts projectHosts = fixtures.projectHosts
statusResponse = fixtures.status
} }
func feed() async throws -> Feed { func feed() async throws -> Feed {
let needsYou = items.filter(\.phase.needsYou).sorted { $0.updatedAt < $1.updatedAt } DemoFixtures.feed(unsettled, hosts: hosts, now: .now)
let working = items.filter(\.phase.isWorking).sorted { $0.updatedAt > $1.updatedAt } }
let recent = items.filter { $0.phase == .completed || $0.phase == .failed }
.sorted { $0.updatedAt > $1.updatedAt } func summary() async throws -> Summary {
return Feed(needsYou: needsYou, working: working, recent: Array(recent.prefix(10)), hosts: hosts) Summary(try await feed())
} }
func thread(hostId: String, threadId: String) async throws -> ThreadDetail { func thread(hostId: String, threadId: String) async throws -> ThreadDetail {
guard let item = item(hostId, threadId) else { throw GatewayError.http(status: 404, message: "Unknown thread.") } guard let item = item(hostId, threadId) else { throw GatewayError.http(status: 404, message: "Unknown thread.") }
return ThreadDetail(item: item, messages: messages[item.id] ?? [], canStop: item.phase.isWorking) return ThreadDetail(
item: item, messages: messages[item.id] ?? [], canStop: item.phase.isWorking,
askFirst: askingFirst.contains(item.id),
earlierCursor: earlierPages[item.id] == nil ? nil : "p0",
subagents: (children[item.id] ?? []).compactMap { id in items.first { $0.id == id }.map(row) },
changedFiles: changedFiles[item.id] ?? [])
}
/// Cursors are `p<n>`, the n-th page back from the thread's messages.
func history(hostId: String, threadId: String, cursor: String) async throws -> HistoryResponse {
guard let item = item(hostId, threadId), let pages = earlierPages[item.id],
let index = Int(cursor.dropFirst()), pages.indices.contains(index)
else { throw GatewayError.http(status: 404, message: "Unknown cursor.") }
return HistoryResponse(messages: pages[index], earlierCursor: index + 1 < pages.count ? "p\(index + 1)" : nil)
} }
func sendMessage(hostId: String, threadId: String, text: String) async throws { func sendMessage(hostId: String, threadId: String, text: String) async throws {
try update(hostId, threadId) { item in try update(hostId, threadId) { item in
settled.remove(item.id)
append(Message(id: newId(), role: .user, text: text, createdAt: .now), to: item.id) append(Message(id: newId(), role: .user, text: text, createdAt: .now), to: item.id)
if !item.phase.isWorking { if !item.phase.isWorking {
item.phase = .running item.phase = .running
@@ -44,10 +80,18 @@ actor DemoGateway: GatewayAPI {
} }
} }
/// Answers go to the thread that asked. For a card showing a subagent's request, that is the
/// subagent in `via`, and the parent itself has nothing to answer.
func respond(hostId: String, threadId: String, request: RespondRequest) async throws { func respond(hostId: String, threadId: String, request: RespondRequest) async throws {
try update(hostId, threadId) { item in let index = items.firstIndex { $0.requestThread == (hostId, threadId) }
guard item.request != nil else { throw GatewayError.http(status: 409, message: "Already answered.") } ?? items.firstIndex { $0.hostId == hostId && $0.threadId == threadId }
guard let index else { throw GatewayError.http(status: 404, message: "Unknown thread.") }
try update(items[index].hostId, items[index].threadId) { item in
guard item.request != nil, item.requestThread == (hostId, threadId) else {
throw GatewayError.http(status: 409, message: "Already answered.")
}
item.request = nil item.request = nil
item.via = nil
switch request { switch request {
case let .decision(_, decision) where decision == .decline || decision == .cancel: case let .decision(_, decision) where decision == .decline || decision == .cancel:
item.phase = .completed item.phase = .completed
@@ -60,6 +104,12 @@ actor DemoGateway: GatewayAPI {
append(Message(id: newId(), role: .assistant, text: line, createdAt: .now), to: item.id) append(Message(id: newId(), role: .assistant, text: line, createdAt: .now), to: item.id)
} }
} }
// A subagent's own thread stops waiting too.
if let child = items.firstIndex(where: { $0.hostId == hostId && $0.threadId == threadId }), child != index,
items[child].request != nil {
items[child].request = nil
items[child].phase = .running
}
} }
func stop(hostId: String, threadId: String) async throws { func stop(hostId: String, threadId: String) async throws {
@@ -69,14 +119,91 @@ actor DemoGateway: GatewayAPI {
} }
} }
func settle(hostId: String, threadId: String) async throws {
guard let item = item(hostId, threadId) else { throw GatewayError.http(status: 404, message: "Unknown thread.") }
settled.insert(item.id)
}
func setAskFirst(hostId: String, threadId: String, askFirst: Bool) async throws {
guard let item = item(hostId, threadId) else { throw GatewayError.http(status: 404, message: "Unknown thread.") }
if askFirst { askingFirst.insert(item.id) } else { askingFirst.remove(item.id) }
}
func recent() async throws -> RecentResponse {
RecentResponse(items: DemoFixtures.recent(unsettled, now: .now))
}
func settleRecent() async throws -> SettleRecentResponse {
let completed = DemoFixtures.recent(unsettled, now: .now).filter { $0.phase == .completed }
settled.formUnion(completed.map(\.id))
return SettleRecentResponse(settled: completed.count, failed: 0)
}
func snoozed() async throws -> RecentResponse {
RecentResponse(items: DemoFixtures.snoozed(unsettled, now: .now))
}
func snooze(hostId: String, threadId: String, until: Date) async throws {
try update(hostId, threadId) { item in
guard item.phase == .completed || item.phase == .failed else {
throw GatewayError.http(status: 409, message: "Only a finished thread can be snoozed.")
}
item.snoozedUntil = until
}
}
func unsnooze(hostId: String, threadId: String) async throws {
try update(hostId, threadId) { $0.snoozedUntil = nil }
}
func projects() async throws -> ProjectsResponse { func projects() async throws -> ProjectsResponse {
ProjectsResponse(hosts: projectHosts) ProjectsResponse(hosts: projectHosts)
} }
func projectThreads(hostId: String, projectId: String) async throws -> ThreadListResponse {
guard let host = projectHosts.first(where: { $0.id == hostId }) else {
throw GatewayError.http(status: 404, message: "Unknown host.")
}
guard host.status == .online else { throw GatewayError.http(status: 503, message: nil) }
let rows = items
.filter { $0.hostId == hostId && $0.projectId == projectId && !subagentIds.contains($0.id) }
.sorted { $0.updatedAt > $1.updatedAt }
.map(row)
return ThreadListResponse(items: rows)
}
/// Titles first, then messages, on online hosts only, newest first. A message match carries the
/// words around it.
func search(_ query: String) async throws -> SearchResponse {
let query = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard query.count >= 2 else { throw GatewayError.http(status: 400, message: "Search for at least two letters.") }
let online = Set(hosts.filter { $0.status == .online }.map(\.id))
let hits: [(SearchHit, Date)] = items.compactMap { item in
guard online.contains(item.hostId) else { return nil }
let hit = { (snippet: String?) in
SearchHit(thread: self.row(item), projectTitle: item.projectTitle, hostLabel: item.hostLabel, snippet: snippet)
}
if item.title.localizedCaseInsensitiveContains(query) { return (hit(nil), item.updatedAt) }
guard let message = messages[item.id]?.last(where: { $0.text.localizedCaseInsensitiveContains(query) }),
let range = message.text.range(of: query, options: .caseInsensitive)
else { return nil }
let start = message.text.index(range.lowerBound, offsetBy: -30, limitedBy: message.text.startIndex) ?? message.text.startIndex
let end = message.text.index(range.upperBound, offsetBy: 50, limitedBy: message.text.endIndex) ?? message.text.endIndex
let snippet = (start > message.text.startIndex ? "…" : "") + message.text[start ..< end] + (end < message.text.endIndex ? "…" : "")
return (hit(snippet), message.createdAt)
}
return SearchResponse(
hits: hits.sorted { $0.1 > $1.1 }.map(\.0),
unsearched: hosts.filter { !online.contains($0.id) }.map(\.label))
}
func newThread(_ request: NewThreadRequest) async throws -> NewThreadResponse { func newThread(_ request: NewThreadRequest) async throws -> NewThreadResponse {
guard let host = projectHosts.first(where: { $0.id == request.hostId }), guard let host = projectHosts.first(where: { $0.id == request.hostId }),
let project = host.projects.first(where: { $0.id == request.projectId }) let project = host.projects.first(where: { $0.id == request.projectId })
else { throw GatewayError.http(status: 404, message: "Unknown project.") } else { throw GatewayError.http(status: 404, message: "Unknown project.") }
if let model = request.model, !host.offers(model) {
throw GatewayError.http(status: 400, message: "\(host.label) does not offer \(model.slug).")
}
let threadId = "demo-\(newId())" let threadId = "demo-\(newId())"
let item = FeedItem( let item = FeedItem(
hostId: host.id, hostLabel: host.label, threadId: threadId, projectId: project.id, hostId: host.id, hostLabel: host.label, threadId: threadId, projectId: project.id,
@@ -84,13 +211,46 @@ actor DemoGateway: GatewayAPI {
updatedAt: .now, lastMessage: nil, request: nil) updatedAt: .now, lastMessage: nil, request: nil)
items.append(item) items.append(item)
messages[item.id] = [Message(id: newId(), role: .user, text: request.text, createdAt: .now)] messages[item.id] = [Message(id: newId(), role: .user, text: request.text, createdAt: .now)]
if request.askFirst == true { askingFirst.insert(item.id) }
return NewThreadResponse(hostId: host.id, threadId: threadId) return NewThreadResponse(hostId: host.id, threadId: threadId)
} }
func registerPush(_ registration: PushRegistration) async throws {} func registerPush(_ registration: PushRegistration) async throws {}
func status() async throws -> StatusResponse {
statusResponse
}
func reconnect(hostId: String) async throws {}
func testPush() async throws -> PushResult {
let result = PushResult(ok: true, at: .now, status: 200, reason: nil)
statusResponse.gateway.lastPush = result
return result
}
func setNotification(_ key: NotificationPrefs.Key, _ on: Bool) async throws -> NotificationPrefs {
var prefs = statusResponse.device.notifications ?? NotificationPrefs(needsMe: true, finished: true, failed: true, subagents: true)
prefs[key] = on
statusResponse.device.notifications = prefs
return prefs
}
func unpair() async throws {}
// MARK: - Helpers // MARK: - Helpers
/// Threads the feed lists: not settled, and not a subagent.
private var unsettled: [FeedItem] {
items.filter { !settled.contains($0.id) && !subagentIds.contains($0.id) }
}
private func row(_ item: FeedItem) -> ThreadRow {
ThreadRow(
hostId: item.hostId, threadId: item.threadId, title: item.title, phase: item.phase,
settled: settled.contains(item.id), updatedAt: item.updatedAt)
}
private func item(_ hostId: String, _ threadId: String) -> FeedItem? { private func item(_ hostId: String, _ threadId: String) -> FeedItem? {
items.first { $0.hostId == hostId && $0.threadId == threadId } items.first { $0.hostId == hostId && $0.threadId == threadId }
} }
@@ -116,8 +276,16 @@ actor DemoGateway: GatewayAPI {
struct DemoFixtures { struct DemoFixtures {
let items: [FeedItem] let items: [FeedItem]
let messages: [String: [Message]] let messages: [String: [Message]]
let history: [String: [[Message]]]
/// Files each thread's latest run changed, by thread id. Each thread's `changes` totals them.
let changedFiles: [String: [ChangedFile]]
/// Subagent ids by parent id.
let children: [String: [String]]
/// Threads settled from the start, which only a project's thread list shows.
let settled: Set<String>
let hosts: [HostSummary] let hosts: [HostSummary]
let projectHosts: [ProjectsResponse.Host] let projectHosts: [ProjectsResponse.Host]
let status: StatusResponse
init(now: Date) { init(now: Date) {
func ago(_ minutes: Double) -> Date { now.addingTimeInterval(-minutes * 60) } func ago(_ minutes: Double) -> Date { now.addingTimeInterval(-minutes * 60) }
@@ -125,6 +293,7 @@ struct DemoFixtures {
hosts = [ hosts = [
HostSummary(id: "mac-mini", label: "Mac mini", status: .online, error: nil), HostSummary(id: "mac-mini", label: "Mac mini", status: .online, error: nil),
HostSummary(id: "server-gib", label: "server-gib", status: .online, error: nil), HostSummary(id: "server-gib", label: "server-gib", status: .online, error: nil),
HostSummary(id: "laptop", label: "Laptop", status: .online, error: nil),
HostSummary(id: "littleman", label: "LittleMan", status: .offline, error: "Connection refused"), HostSummary(id: "littleman", label: "LittleMan", status: .offline, error: "Connection refused"),
] ]
@@ -141,6 +310,31 @@ struct DemoFixtures {
], ],
questions: [])) questions: []))
// File approvals: one with the diff the gateway rarely has before approval, one with the name
// only, which is what Claude sends.
func fileApproval(_ id: String, _ files: [PendingFile]) -> PendingRequest {
PendingRequest(
id: id, kind: "file-change", prompt: "Edit: \(files[0].path)", reason: nil,
options: [.init(decision: .accept, label: "Allow"), .init(decision: .decline, label: "Deny")],
questions: [], files: files)
}
let compose = FeedItem(
hostId: "server-gib", hostLabel: "server-gib", threadId: "t-compose", projectId: "p-wc",
projectTitle: "wristcode", title: "Gateway restart policy", phase: .waitingForApproval,
updatedAt: ago(2), lastMessage: "Switching the gateway to the Alpine image and restarting it on failure.",
request: fileApproval("r-compose", [
PendingFile(
path: "deploy/compose.yml", name: "compose.yml", additions: 9, deletions: 6,
preview: ["- image: oven/bun:1", "+ image: oven/bun:1-alpine", "+ restart: always"]),
]))
let healthRoute = FeedItem(
hostId: "laptop", hostLabel: "Laptop", threadId: "t-health-route", projectId: "p-wc-laptop",
projectTitle: "wristcode", title: "Health route", phase: .waitingForApproval,
updatedAt: ago(0.8), lastMessage: "Adding a /healthz route the container check can call.",
request: fileApproval("r-health", [
PendingFile(path: "gateway/src/health.ts", name: "health.ts", additions: nil, deletions: nil, preview: []),
]))
let question = FeedItem( let question = FeedItem(
hostId: "server-gib", hostLabel: "server-gib", threadId: "t-db", projectId: "p-wc", hostId: "server-gib", hostLabel: "server-gib", threadId: "t-db", projectId: "p-wc",
projectTitle: "wristcode", title: "Gateway storage", phase: .waitingForInput, projectTitle: "wristcode", title: "Gateway storage", phase: .waitingForInput,
@@ -154,15 +348,64 @@ struct DemoFixtures {
allowCustomAnswer: true), allowCustomAnswer: true),
])) ]))
let release = FeedItem(
hostId: "mac-mini", hostLabel: "Mac mini", threadId: "t-release", projectId: "p-wc-mac",
projectTitle: "wristcode", title: "Release checklist", phase: .waitingForInput,
updatedAt: ago(6), lastMessage: "The build is ready. Which checks should run first?",
request: PendingRequest(
id: "r-release", kind: "user_input", prompt: nil, reason: nil, options: [],
questions: [
.init(
id: "checks", header: "Checks", question: "Which checks should run before upload?",
options: [
.init(label: "Unit tests", value: "unit"),
.init(label: "UI tests", value: "ui"),
.init(label: "Lint", value: "lint"),
.init(label: "Archive", value: "archive"),
],
allowCustomAnswer: true, multiSelect: true),
]))
// A subagent's request, shown on the card of the thread that started it.
let homelab = FeedItem(
hostId: "mac-mini", hostLabel: "Mac mini", threadId: "t-homelab", projectId: "p-wristlab",
projectTitle: "WristLab", title: "HomeLab Stats Apple Watch App", phase: .waitingForApproval,
updatedAt: ago(4), lastMessage: "Delegated a PATH check to the Mac mini. Waiting on it before I wire up the build.",
request: PendingRequest(
id: "r-path", kind: "command", prompt: "brew install jq", reason: nil,
options: [.init(decision: .accept, label: "Allow"), .init(decision: .decline, label: "Deny")],
questions: []),
via: .init(hostId: "mac-mini", threadId: "t-path", title: "Check Panama PATH"))
// The subagent that asks on the homelab card. It has no card of its own.
let path = FeedItem(
hostId: "mac-mini", hostLabel: "Mac mini", threadId: "t-path", projectId: "p-wristlab",
projectTitle: "WristLab", title: "Check Panama PATH", phase: .waitingForApproval, updatedAt: ago(4),
lastMessage: "jq is missing from PATH. I want to install it.",
request: homelab.request)
let scaffold = FeedItem( let scaffold = FeedItem(
hostId: "mac-mini", hostLabel: "Mac mini", threadId: "t-scaffold", projectId: "p-wc-mac", hostId: "mac-mini", hostLabel: "Mac mini", threadId: "t-scaffold", projectId: "p-wc-mac",
projectTitle: "wristcode", title: "Watch app scaffold", phase: .running, updatedAt: ago(0.5), projectTitle: "wristcode", title: "Watch app scaffold", phase: .running, updatedAt: ago(0.5),
lastMessage: "Writing ThreadListView.swift", request: nil) lastMessage: "Writing ThreadListView.swift", request: nil,
activity: Activity(kind: .command, text: "xcodebuild test -scheme WristCode", startedAt: ago(0.7)))
let containerfile = FeedItem( let containerfile = FeedItem(
hostId: "server-gib", hostLabel: "server-gib", threadId: "t-container", projectId: "p-wc", hostId: "server-gib", hostLabel: "server-gib", threadId: "t-container", projectId: "p-wc",
projectTitle: "wristcode", title: "Gateway Containerfile", phase: .running, updatedAt: ago(2), projectTitle: "wristcode", title: "Gateway Containerfile", phase: .running, updatedAt: ago(2),
lastMessage: "Running podman build", request: nil) lastMessage: "Split the image build and the healthcheck into two subagents. Waiting on them.", request: nil,
activity: Activity(kind: .edit, text: "Containerfile", startedAt: ago(3)))
// The Containerfile thread's subagents, one delegated to the Mac mini.
let image = FeedItem(
hostId: "mac-mini", hostLabel: "Mac mini", threadId: "t-image", projectId: "p-wc-mac",
projectTitle: "wristcode", title: "Build the image", phase: .running, updatedAt: ago(1),
lastMessage: "Running podman build.", request: nil,
activity: Activity(kind: .command, text: "podman build -t wristcode-gateway .", startedAt: ago(1)))
let healthcheck = FeedItem(
hostId: "server-gib", hostLabel: "server-gib", threadId: "t-health", projectId: "p-wc",
projectTitle: "wristcode", title: "Read the healthcheck docs", phase: .completed, updatedAt: ago(2.5),
lastMessage: "Podman runs `HEALTHCHECK` on its own timer. Use `--health-interval`.", request: nil)
let nextjs = FeedItem( let nextjs = FeedItem(
hostId: "littleman", hostLabel: "LittleMan", threadId: "t-next", projectId: "p-site", hostId: "littleman", hostLabel: "LittleMan", threadId: "t-next", projectId: "p-site",
@@ -174,7 +417,78 @@ struct DemoFixtures {
projectTitle: "cdn", title: "Fix CDN headers", phase: .failed, updatedAt: ago(140), projectTitle: "cdn", title: "Fix CDN headers", phase: .failed, updatedAt: ago(140),
lastMessage: "cargo test failed: 2 tests in headers.rs", request: nil) lastMessage: "cargo test failed: 2 tests in headers.rs", request: nil)
items = [approval, question, scaffold, containerfile, nextjs, cdn] let statusScreens = FeedItem(
hostId: "laptop", hostLabel: "Laptop", threadId: "t-status", projectId: "p-wc-laptop",
projectTitle: "wristcode", title: "Status screens", phase: .completed, updatedAt: ago(35),
lastMessage: "Done. Status, environment and notification screens are in TestFlight build 202611011516.",
request: nil)
let hyprlockReply = "Fixed. hyprlock no longer crashes when a monitor sleeps. The fix is in `lock.qml`:\n"
+ "- a **bounded** retry when the monitor wakes\n- a *3 s* timeout on the surface"
let hyprlock = FeedItem(
hostId: "server-gib", hostLabel: "server-gib", threadId: "t-hyprlock", projectId: "p-panama",
projectTitle: "Panama", title: "Fix hyprlock crash", phase: .completed, updatedAt: ago(95),
lastMessage: hyprlockReply, request: nil)
// Older finished threads, past the feed's first 10 in Recent. The gateway has not read most of
// them, so they have no last message.
func older(_ host: HostSummary, _ id: String, _ project: String, _ title: String, hours: Double, failed: Bool = false) -> FeedItem {
FeedItem(
hostId: host.id, hostLabel: host.label, threadId: id, projectId: "p-\(project)", projectTitle: project,
title: title, phase: failed ? .failed : .completed, updatedAt: ago(hours * 60), lastMessage: nil, request: nil)
}
let (mac, server, laptop) = (hosts[0], hosts[1], hosts[2])
let olderRecent = [
older(laptop, "t-icons", "wristcode", "App icon variants", hours: 4),
older(mac, "t-receipts", "bill-tracker", "Receipt upload form", hours: 6),
older(server, "t-backup", "homelab", "Nightly restic backup", hours: 9, failed: true),
older(mac, "t-tailwind", "gbrown.org", "Tailwind 5 migration", hours: 20),
older(server, "t-caddy", "homelab", "Caddy wildcard certs", hours: 27),
older(laptop, "t-readme", "wristcode", "README pass", hours: 30),
older(mac, "t-csv", "bill-tracker", "CSV export", hours: 50),
older(server, "t-immich", "homelab", "Immich upgrade", hours: 72),
older(mac, "t-lint", "cdn", "Clippy warnings", hours: 96),
older(laptop, "t-pair", "wristcode", "Pairing code expiry", hours: 120),
]
// Finished threads snoozed for later: one back in a few hours, one tomorrow morning.
let calendar = Calendar.current
let later = calendar.nextDate(after: now.addingTimeInterval(2 * 3600), matching: DateComponents(minute: 0), matchingPolicy: .nextTime)
let tomorrow = calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: now))?.addingTimeInterval(9 * 3600)
var dependabot = older(mac, "t-deps", "gbrown.org", "Dependabot bumps", hours: 3)
dependabot.snoozedUntil = later
var plex = older(server, "t-plex", "homelab", "Plex transcoding", hours: 8)
plex.snoozedUntil = tomorrow
// Settled threads, which only the project's thread list shows.
let settledThreads = [
older(mac, "t-switches", "wristcode", "Phase 2 switches", hours: 26),
older(mac, "t-gear", "wristcode", "Gear icon fix", hours: 50),
older(mac, "t-window", "wristcode", "Recent window", hours: 53),
].map { item in
var item = item
item.projectId = "p-wc-mac"
return item
}
settled = Set(settledThreads.map(\.id))
func file(_ path: String, _ additions: Int, _ deletions: Int) -> ChangedFile {
ChangedFile(path: path, name: String(path.split(separator: "/").last ?? ""), additions: additions, deletions: deletions)
}
let changed = [
nextjs.id: [file("package.json", 31, 0), file("next.config.ts", 9, 6), file("app/layout.tsx", 2, 2)],
cdn.id: [file("src/headers.rs", 14, 6)],
]
changedFiles = changed
items = ([approval, compose, healthRoute, question, release, homelab, scaffold, containerfile, statusScreens, nextjs, hyprlock, cdn]
+ olderRecent + [dependabot, plex] + settledThreads + [path, image, healthcheck]).map { item in
guard let files = changed[item.id] else { return item }
var item = item
item.changes = ChangeTotals(
files: files.count, additions: files.map(\.additions).reduce(0, +), deletions: files.map(\.deletions).reduce(0, +))
return item
}
children = [homelab.id: [path.id], containerfile.id: [image.id, healthcheck.id]]
func m(_ id: String, _ role: Message.Role, _ minutes: Double, _ text: String) -> Message { func m(_ id: String, _ role: Message.Role, _ minutes: Double, _ text: String) -> Message {
Message(id: id, role: role, text: text, createdAt: ago(minutes)) Message(id: id, role: role, text: text, createdAt: ago(minutes))
@@ -190,50 +504,181 @@ struct DemoFixtures {
m("b1", .user, 10, "Add pairing codes to the gateway."), m("b1", .user, 10, "Add pairing codes to the gateway."),
m("b2", .assistant, 1, "Two ways to store pairing codes. Hashing is safer, plain is simpler to debug."), m("b2", .assistant, 1, "Two ways to store pairing codes. Hashing is safer, plain is simpler to debug."),
], ],
release.id: [
m("r1", .user, 25, "Get the watch app ready for TestFlight."),
m("r2", .assistant, 6, "The build is ready. Which checks should run first?"),
],
homelab.id: [
m("h1", .user, 30, "Build a HomeLab stats app for the watch."),
m("h2", .assistant, 4, "Delegated a PATH check to the Mac mini. Waiting on it before I wire up the build."),
],
scaffold.id: [ scaffold.id: [
m("c1", .user, 4, "Scaffold the watch app with XcodeGen."), m("c1", .user, 4, "Scaffold the watch app with XcodeGen."),
m("c2", .assistant, 0.5, "Writing ThreadListView.swift"), m("c2", .assistant, 0.5, "Writing ThreadListView.swift"),
], ],
containerfile.id: [ containerfile.id: [
m("d1", .user, 15, "Write a Containerfile for the gateway."), m("d1", .user, 15, "Write a Containerfile for the gateway."),
m("d2", .assistant, 2, "Running podman build"), m("d2", .assistant, 2, "Split the image build and the healthcheck into two subagents. Waiting on them."),
], ],
path.id: [m("p1", .assistant, 4, "jq is missing from PATH. I want to install it.")],
image.id: [m("i1", .assistant, 1, "Running podman build.")],
healthcheck.id: [m("g1", .assistant, 2.5, "Podman runs `HEALTHCHECK` on its own timer. Use `--health-interval`.")],
nextjs.id: [ nextjs.id: [
m("e1", .user, 90, "Bump Next.js to the latest release."), m("e1", .user, 90, "Bump Next.js to the latest release."),
m("e2", .assistant, 80, "Updating next and eslint-config-next, then running the build."), m("e2", .assistant, 80, "Updating next and eslint-config-next, then running the build."),
m("e3", .assistant, 62, "Done. Next.js is on 17.1 and the build passes. Want me to commit it?"), m("e3", .assistant, 62, "Done. Next.js is on 17.1 and the build passes. Want me to commit it?"),
], ],
statusScreens.id: [
m("s1", .user, 120, "Build the status, environment and notification screens."),
m("s2", .assistant, 35, "Done. Status, environment and notification screens are in TestFlight build 202611011516."),
],
hyprlock.id: [
m("k1", .user, 110, "hyprlock crashes when a monitor sleeps. Fix it."),
m("k2", .assistant, 95, hyprlockReply),
],
cdn.id: [ cdn.id: [
m("f1", .user, 150, "Fix the cache headers the CDN sends for images."), m("f1", .user, 150, "Fix the cache headers the CDN sends for images."),
m("f2", .assistant, 140, "cargo test failed: 2 tests in headers.rs"), m("f2", .assistant, 140, "cargo test failed: 2 tests in headers.rs"),
], ],
] ]
history = [
hyprlock.id: [
[
m("k-3", .user, 300, "Show the clock in 24 hours."),
m("k-4", .assistant, 290, "Switched the clock to `HH:mm`."),
],
[
m("k-1", .user, 420, "Theme hyprlock like the bar."),
m("k-2", .assistant, 400, "## Done\nhyprlock uses the **bar's** colors and font now."),
],
],
]
let claude = AgentProvider(
instanceId: "claudeAgent", name: "Claude", canAskFirst: true,
models: [
.init(slug: "claude-opus-5-5", name: "Claude Opus 5.5"),
.init(slug: "claude-sonnet-5-5", name: "Claude Sonnet 5.5"),
.init(slug: "claude-haiku-5-5", name: "Claude Haiku 5.5"),
])
let codex = AgentProvider(
instanceId: "codex", name: "Codex", canAskFirst: true,
models: [.init(slug: "gpt-5.5", name: "GPT-5.5"), .init(slug: "gpt-5-codex", name: "GPT-5 Codex")])
projectHosts = [ projectHosts = [
.init( .init(
id: "mac-mini", label: "Mac mini", status: .online, error: nil, id: "mac-mini", label: "Mac mini", status: .online, error: nil,
projects: [ projects: [
.init(id: "p-wc-mac", title: "wristcode", model: "claude-opus-5-5"), .init(id: "p-wc-mac", title: "wristcode", model: "claude-opus-5-5", instanceId: "claudeAgent"),
.init(id: "p-bill", title: "bill-tracker", model: "claude-sonnet-5-5"), .init(id: "p-bill", title: "bill-tracker", model: "claude-sonnet-5-5", instanceId: "claudeAgent"),
.init(id: "p-cdn", title: "cdn", model: "gpt-5-codex"), .init(id: "p-cdn", title: "cdn", model: "gpt-5-codex", instanceId: "codex"),
.init(id: "p-scratch", title: "scratch", model: nil), .init(id: "p-scratch", title: "scratch", model: nil),
]), ],
providers: [claude, codex]),
.init( .init(
id: "server-gib", label: "server-gib", status: .online, error: nil, id: "server-gib", label: "server-gib", status: .online, error: nil,
projects: [.init(id: "p-wc", title: "wristcode", model: "claude-opus-5-5")]), projects: [.init(id: "p-wc", title: "wristcode", model: "claude-opus-5-5", instanceId: "claudeAgent")],
providers: [claude]),
.init( .init(
id: "littleman", label: "LittleMan", status: .offline, error: "Connection refused", id: "littleman", label: "LittleMan", status: .offline, error: "Connection refused",
projects: [.init(id: "p-site", title: "gbrown.org", model: "claude-opus-5-5")]), projects: [.init(id: "p-site", title: "gbrown.org", model: "claude-opus-5-5", instanceId: "claudeAgent")]),
] ]
// Mac mini and server-gib on their first route, Laptop on its VPN fallback, LittleMan offline.
func route(_ url: String, _ kind: RouteKind, _ result: HostRoute.Result, _ error: String? = nil, _ minutes: Double? = nil) -> HostRoute {
HostRoute(url: url, kind: kind, result: result, error: error, checkedAt: minutes.map(ago))
}
func days(_ count: Double) -> Date { now.addingTimeInterval(count * 86_400) }
status = StatusResponse(
hosts: [
HostStatusDetail(
id: "mac-mini", label: "Mac mini", status: .online, error: nil, activeUrl: "http://mac.gib:3773",
routes: [route("http://mac.gib:3773", .direct, .ok, nil, 95), route("https://t3mac.gbrown.org", .public, .untried)],
onlineSince: ago(95), lastOnlineAt: nil, nextRetryAt: nil, tokenExpiresAt: days(29), serverVersion: "0.1.5"),
HostStatusDetail(
id: "server-gib", label: "server-gib", status: .online, error: nil, activeUrl: "http://server.gib:3773",
routes: [route("http://server.gib:3773", .direct, .ok, nil, 300), route("https://t3server.gbrown.org", .public, .untried)],
onlineSince: ago(300), lastOnlineAt: nil, nextRetryAt: nil, tokenExpiresAt: days(5.5), serverVersion: "0.1.5"),
HostStatusDetail(
id: "laptop", label: "Laptop", status: .online, error: nil, activeUrl: "http://laptopvpn.gib:3773",
routes: [
route("http://laptop.gib:3773", .direct, .failed, "no answer in 5 s", 29),
route("http://laptopvpn.gib:3773", .vpn, .ok, nil, 29),
route("https://t3laptop.gbrown.org", .public, .untried),
route("https://t3laptopvpn.gbrown.org", .public, .untried),
],
onlineSince: ago(29), lastOnlineAt: nil, nextRetryAt: nil, tokenExpiresAt: days(29.5), serverVersion: "0.1.5"),
HostStatusDetail(
id: "littleman", label: "LittleMan", status: .offline, error: "Connection refused", activeUrl: nil,
routes: [
route("http://littleman.gib:3773", .direct, .failed, "refused", 0.3),
route("https://t3littleman.gbrown.org", .public, .failed, "502", 0.3),
],
onlineSince: nil, lastOnlineAt: ago(4), nextRetryAt: now.addingTimeInterval(40),
tokenExpiresAt: days(29.5), serverVersion: "0.1.4"),
],
gateway: .init(startedAt: ago(600), pushConfigured: true, lastPush: PushResult(ok: true, at: ago(2), status: 200, reason: nil)),
device: .init(
name: "Apple Watch", pairedAt: days(-3), pushRegistered: true, apnsEnvironment: .production,
notifications: NotificationPrefs(needsMe: true, finished: true, failed: true, subagents: false)))
}
/// The feed the gateway builds from these threads: needs-you oldest first, working and Recent
/// newest first, only the first 10 of Recent, and a count of the snoozed.
static func feed(_ items: [FeedItem], hosts: [HostSummary], now: Date) -> Feed {
let recent = recent(items, now: now)
return Feed(
needsYou: items.filter(\.phase.needsYou).sorted { $0.updatedAt < $1.updatedAt },
working: items.filter(\.phase.isWorking).sorted { $0.updatedAt > $1.updatedAt },
recent: Array(recent.prefix(10)),
recentTotal: recent.count,
snoozedTotal: snoozed(items, now: now).count,
hosts: hosts)
}
/// Every finished or failed thread not snoozed, newest first, as `GET /v1/recent` lists them. A
/// snooze whose time has come no longer counts.
static func recent(_ items: [FeedItem], now: Date) -> [FeedItem] {
finished(items)
.filter { ($0.snoozedUntil ?? .distantPast) <= now }
.map { item in
var item = item
item.snoozedUntil = nil
return item
}
.sorted { $0.updatedAt > $1.updatedAt }
}
/// Finished threads still snoozed, the soonest to come back first, as `GET /v1/snoozed` lists them.
static func snoozed(_ items: [FeedItem], now: Date) -> [FeedItem] {
finished(items)
.filter { ($0.snoozedUntil ?? .distantPast) > now }
.sorted { $0.snoozedUntil! < $1.snoozedUntil! }
}
private static func finished(_ items: [FeedItem]) -> [FeedItem] {
items.filter { $0.phase == .completed || $0.phase == .failed }
}
/// Threads the feed lists: not settled, and not a subagent.
var listed: [FeedItem] {
let subagents = Set(children.values.joined())
return items.filter { !settled.contains($0.id) && !subagents.contains($0.id) }
} }
/// A feed for previews, without going through the actor. /// A feed for previews, without going through the actor.
static func previewFeed(now: Date = .now) -> Feed { static func previewFeed(now: Date = .now) -> Feed {
let fixtures = DemoFixtures(now: now) let fixtures = DemoFixtures(now: now)
return Feed( return feed(fixtures.listed, hosts: fixtures.hosts, now: now)
needsYou: fixtures.items.filter(\.phase.needsYou), }
working: fixtures.items.filter(\.phase.isWorking),
recent: fixtures.items.filter { !$0.phase.needsYou && !$0.phase.isWorking }, /// The full Recent list for previews.
hosts: fixtures.hosts) static func previewRecent(now: Date = .now) -> [FeedItem] {
recent(DemoFixtures(now: now).listed, now: now)
}
/// The Snoozed list for previews.
static func previewSnoozed(now: Date = .now) -> [FeedItem] {
snoozed(DemoFixtures(now: now).listed, now: now)
} }
} }
+65
View File
@@ -0,0 +1,65 @@
// The last good feed, and the last detail of the 30 threads saved most recently, saved as JSON in
// the app's caches folder. Launch shows them at once while the first refresh runs. Signing out clears
// them, and demo mode never reads or writes them.
import Foundation
struct FeedCache: Sendable {
let directory: URL
/// How many thread details are kept. Saving one more deletes the oldest.
var threadLimit = 30
static let standard = FeedCache(directory: .cachesDirectory.appending(path: "WristCode", directoryHint: .isDirectory))
func feed() -> Feed? {
read(Feed.self, "feed.json")
}
func save(_ feed: Feed) {
write(feed, "feed.json")
}
func thread(hostId: String, threadId: String) -> ThreadDetail? {
read(ThreadDetail.self, Self.threadFile(hostId, threadId))
}
func save(_ detail: ThreadDetail) {
write(detail, Self.threadFile(detail.item.hostId, detail.item.threadId))
pruneThreads()
}
/// Deletes the thread details saved longest ago, past `threadLimit`.
private func pruneThreads() {
let fileManager = FileManager.default
let key = URLResourceKey.contentModificationDateKey
guard let files = try? fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: [key]) else { return }
let threads = files.filter { $0.lastPathComponent.hasPrefix("thread-") }
guard threads.count > threadLimit else { return }
let modified = { (url: URL) in (try? url.resourceValues(forKeys: [key]).contentModificationDate) ?? .distantPast }
for url in threads.sorted(by: { modified($0) > modified($1) }).dropFirst(threadLimit) {
try? fileManager.removeItem(at: url)
}
}
/// Deletes everything saved, as when the watch unpairs.
func clear() {
try? FileManager.default.removeItem(at: directory)
}
/// `thread-<hostId>-<threadId>.json`, with the ids percent-encoded so any id is a safe file name.
private static func threadFile(_ hostId: String, _ threadId: String) -> String {
let safe = { (id: String) in id.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? id }
return "thread-\(safe(hostId))-\(safe(threadId)).json"
}
private func read<Value: Decodable>(_ type: Value.Type, _ name: String) -> Value? {
guard let data = try? Data(contentsOf: directory.appending(path: name)) else { return nil }
return try? JSON.decoder().decode(type, from: data)
}
private func write(_ value: some Encodable, _ name: String) {
guard let data = try? JSON.encoder().encode(value) else { return }
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try? data.write(to: directory.appending(path: name), options: .atomic)
}
}
+13
View File
@@ -18,6 +18,17 @@
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string> <string>$(MARKETING_VERSION)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>org.gbrown.wristcode</string>
<key>CFBundleURLSchemes</key>
<array>
<string>wristcode</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string> <string>$(CURRENT_PROJECT_VERSION)</string>
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>
@@ -26,5 +37,7 @@
<true/> <true/>
<key>WKWatchOnly</key> <key>WKWatchOnly</key>
<true/> <true/>
<key>WristCodeKeychainGroup</key>
<string>$(AppIdentifierPrefix)org.gbrown.wristcode.shared</string>
</dict> </dict>
</plist> </plist>
@@ -1,13 +1,24 @@
// Notification categories and actions from docs/api.md, and what each action does. Allow, Deny // Notification categories and actions from docs/api.md, and what each action does. Allow, Deny,
// and Reply run in the background against the gateway; tapping a notification opens its thread. // Reply, Settle and a question's answers run in the background against the gateway; tapping a
// notification opens its thread.
//
// An `input` push that carries `question` gets one button per option, plus "Say an answer" when the
// question takes its own answer. watchOS only lets a notification's actions change per push from
// its hosting controller, so `InputNotificationController` sets them when the push arrives. Any other
// `input` push keeps the category's Answer and Open, which open the thread's question screen.
import Foundation import Foundation
import SwiftUI
import UserNotifications import UserNotifications
import WatchKit
/// What a notification response asks for, parsed out of the payload. /// What a notification response asks for, parsed out of the payload.
enum NotificationCommand: Equatable, Sendable { enum NotificationCommand: Equatable, Sendable {
case respond(hostId: String, threadId: String, requestId: String, decision: ApprovalDecision) case respond(hostId: String, threadId: String, requestId: String, decision: ApprovalDecision)
/// One answer to a push's single question: an option's value, or what was said or typed.
case answer(hostId: String, threadId: String, requestId: String, questionId: String, value: String)
case reply(hostId: String, threadId: String, text: String) case reply(hostId: String, threadId: String, text: String)
case settle(hostId: String, threadId: String)
case open(Route?) case open(Route?)
case none case none
@@ -15,10 +26,18 @@ enum NotificationCommand: Equatable, Sendable {
static let allow = "allow" static let allow = "allow"
static let deny = "deny" static let deny = "deny"
static let reply = "reply" static let reply = "reply"
static let settle = "settle"
static let open = "open" static let open = "open"
/// Opens the thread, on a question the notification cannot answer itself.
static let answer = "answer"
/// A spoken or typed answer to a push's question.
static let say = "say"
/// `option.<n>` picks the push question's n-th option.
static let optionPrefix = "option."
} }
/// `userInfo` holds the custom keys `hostId`, `threadId` and, for approvals, `requestId`. /// `userInfo` holds the custom keys `hostId`, `threadId` and, for approvals and questions,
/// `requestId`, and for a lone single-choice question, `question`.
init(actionIdentifier: String, userInfo: [AnyHashable: Any], text: String?) { init(actionIdentifier: String, userInfo: [AnyHashable: Any], text: String?) {
// The gateway sends `threadId: ""` on host notifications, so empty means absent. // The gateway sends `threadId: ""` on host notifications, so empty means absent.
func value(_ key: String) -> String? { func value(_ key: String) -> String? {
@@ -29,6 +48,14 @@ enum NotificationCommand: Equatable, Sendable {
let requestId = value("requestId") let requestId = value("requestId")
let thread: Route? = if let hostId, let threadId { .thread(hostId: hostId, threadId: threadId) } else { nil } let thread: Route? = if let hostId, let threadId { .thread(hostId: hostId, threadId: threadId) } else { nil }
/// The question's answer, or the thread when the answer or the keys it needs are missing.
func answer(_ value: String?) -> Self {
let value = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard let hostId, let threadId, let requestId, let question = PushQuestion(userInfo: userInfo), !value.isEmpty
else { return .open(thread) }
return .answer(hostId: hostId, threadId: threadId, requestId: requestId, questionId: question.id, value: value)
}
switch actionIdentifier { switch actionIdentifier {
case Action.allow, Action.deny: case Action.allow, Action.deny:
guard let hostId, let threadId, let requestId else { self = .open(thread); return } guard let hostId, let threadId, let requestId else { self = .open(thread); return }
@@ -39,13 +66,34 @@ enum NotificationCommand: Equatable, Sendable {
let text = text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let text = text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard let hostId, let threadId, !text.isEmpty else { self = .open(thread); return } guard let hostId, let threadId, !text.isEmpty else { self = .open(thread); return }
self = .reply(hostId: hostId, threadId: threadId, text: text) self = .reply(hostId: hostId, threadId: threadId, text: text)
case Action.settle:
guard let hostId, let threadId else { self = .open(thread); return }
self = .settle(hostId: hostId, threadId: threadId)
case Action.say:
self = answer(text)
case let id where id.hasPrefix(Action.optionPrefix):
let index = Int(id.dropFirst(Action.optionPrefix.count))
let options = PushQuestion(userInfo: userInfo)?.options ?? []
self = answer(index.flatMap { options.indices.contains($0) ? options[$0].value : nil })
case UNNotificationDismissActionIdentifier: case UNNotificationDismissActionIdentifier:
self = .none self = .none
default: default:
// The Open action, or a tap on the notification itself. // Open, Answer, or a tap on the notification itself.
self = .open(thread) self = .open(thread)
} }
} }
/// The respond route's thread and body, for Allow, Deny and a question's answer.
var response: (hostId: String, threadId: String, request: RespondRequest)? {
switch self {
case let .respond(hostId, threadId, requestId, decision):
(hostId, threadId, .decision(requestId: requestId, decision: decision))
case let .answer(hostId, threadId, requestId, questionId, value):
(hostId, threadId, .answers(requestId: requestId, answers: [questionId: .text(value)]))
default:
nil
}
}
} }
@MainActor @MainActor
@@ -66,19 +114,41 @@ final class NotificationHandler: NSObject, UNUserNotificationCenterDelegate {
let reply = UNTextInputNotificationAction( let reply = UNTextInputNotificationAction(
identifier: Action.reply, title: "Reply", options: [], identifier: Action.reply, title: "Reply", options: [],
textInputButtonTitle: "Send", textInputPlaceholder: "Reply") textInputButtonTitle: "Send", textInputPlaceholder: "Reply")
let answer = UNNotificationAction(identifier: Action.answer, title: "Answer", options: [.foreground])
let settle = UNNotificationAction(identifier: Action.settle, title: "Settle", options: [])
func category(_ id: PushCategory, _ actions: [UNNotificationAction]) -> UNNotificationCategory { func category(_ id: PushCategory, _ actions: [UNNotificationAction]) -> UNNotificationCategory {
UNNotificationCategory(identifier: id.rawValue, actions: actions, intentIdentifiers: [], options: []) UNNotificationCategory(identifier: id.rawValue, actions: actions, intentIdentifiers: [], options: [])
} }
return [ return [
category(.approval, [allow, deny]), category(.approval, [allow, deny]),
category(.input, [open]), // A question push replaces these with its options; see `inputActions`.
category(.finished, [reply, open]), category(.input, [answer, open]),
category(.finished, [reply, settle, open]),
category(.failed, [open]), category(.failed, [open]),
category(.host, []), category(.host, []),
category(.test, []),
] ]
} }
/// An `input` push's actions: its question's options, "Say an answer" when the question takes
/// one, and Open. Without a question, Answer and Open, as the category registers them.
static func inputActions(_ question: PushQuestion?) -> [UNNotificationAction] {
typealias Action = NotificationCommand.Action
let open = UNNotificationAction(identifier: Action.open, title: "Open", options: [.foreground])
guard let question else {
return [UNNotificationAction(identifier: Action.answer, title: "Answer", options: [.foreground]), open]
}
let options = question.options.enumerated().map { index, option in
UNNotificationAction(identifier: Action.optionPrefix + String(index), title: option.label, options: [])
}
let say = UNTextInputNotificationAction(
identifier: Action.say, title: "Say an answer", options: [],
icon: UNNotificationActionIcon(systemImageName: "mic.fill"),
textInputButtonTitle: "Send", textInputPlaceholder: "Answer")
return options + (question.allowCustomAnswer ? [say] : []) + [open]
}
nonisolated func userNotificationCenter( nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter, willPresent notification: UNNotification _ center: UNUserNotificationCenter, willPresent notification: UNNotification
) async -> UNNotificationPresentationOptions { ) async -> UNNotificationPresentationOptions {
@@ -103,38 +173,87 @@ final class NotificationHandler: NSObject, UNUserNotificationCenterDelegate {
return return
case let .open(route): case let .open(route):
if let route { model.open(route) } else { model.path = [] } if let route { model.open(route) } else { model.path = [] }
model.reloadComplication()
await model.refreshFeed() await model.refreshFeed()
return return
case let .respond(hostId, threadId, requestId, decision): case .respond, .answer:
guard model.mode != .unpaired else { return } guard let response = command.response else { return }
await attempt("Couldn't answer") { await attempt("Couldn't answer") {
try await model.api.respond( try await model.api.respond(hostId: response.hostId, threadId: response.threadId, request: response.request)
hostId: hostId, threadId: threadId,
request: .decision(requestId: requestId, decision: decision))
} }
case let .reply(hostId, threadId, text): case let .reply(hostId, threadId, text):
guard model.mode != .unpaired else { return }
await attempt("Couldn't send your reply") { await attempt("Couldn't send your reply") {
try await model.api.sendMessage(hostId: hostId, threadId: threadId, text: text) try await model.api.sendMessage(hostId: hostId, threadId: threadId, text: text)
} }
case let .settle(hostId, threadId):
await attempt("Couldn't settle") {
try await model.api.settle(hostId: hostId, threadId: threadId)
} }
}
model.reloadComplication()
await model.refreshFeed() await model.refreshFeed()
} }
/// Runs a background action. If it fails, says so with a local notification, since nothing is /// Runs a background action. If it fails, says so with a local notification, since nothing is
/// on screen. /// on screen. Only a paired watch acts: after unpairing, or in demo mode, the action would go
/// nowhere or to the demo gateway, so the notification says the watch is not paired instead.
private func attempt(_ title: String, _ action: () async throws -> Void) async { private func attempt(_ title: String, _ action: () async throws -> Void) async {
guard AppModel.shared.isPaired else {
await notify(title, "This watch isn't paired. Open WristCode to pair it.")
return
}
AppModel.shared.beginChange()
do { do {
try await action() try await action()
} catch let error as GatewayError where error.isStale { } catch let error as GatewayError where error.isStale {
return return
} catch { } catch {
guard let message = AppModel.shared.message(for: error) else { return } guard let message = AppModel.shared.message(for: error) else { return }
await notify(title, message)
}
}
private func notify(_ title: String, _ body: String) async {
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
content.title = title content.title = title
content.body = message content.body = body
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
try? await UNUserNotificationCenter.current().add(request) try? await UNUserNotificationCenter.current().add(request)
} }
} }
/// The long look for `input` pushes. It shows the push's title and question, and sets the push's
/// own actions, which is the one place watchOS takes per-push action titles.
final class InputNotificationController: WKUserNotificationHostingController<InputNotificationView> {
private var threadTitle = ""
private var questionText = ""
override var body: InputNotificationView {
InputNotificationView(title: threadTitle, question: questionText)
}
override func didReceive(_ notification: UNNotification) {
let content = notification.request.content
threadTitle = content.title
questionText = content.body
notificationActions = NotificationHandler.inputActions(PushQuestion(userInfo: content.userInfo))
}
}
/// The thread's title and its question, above the actions the system draws.
struct InputNotificationView: View {
var title: String
var question: String
var body: some View {
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(.system(size: 15, weight: .semibold))
Text(question)
.font(.system(size: 13))
.foregroundStyle(Theme.secondaryText)
}
.frame(maxWidth: .infinity, alignment: .leading)
.fontDesign(.rounded)
}
} }
+29
View File
@@ -0,0 +1,29 @@
// What the app tells the complication besides the credentials: whether it is in demo mode, and the
// last summary it saw, which the complication shows when it cannot reach the gateway. Both live in
// the shared Keychain group next to the device token, the one store the two processes share. `api`
// is the gateway the complication and its Allow and Deny buttons call.
import Foundation
enum SharedState {
private static let demoAccount = "demo"
private static let summaryAccount = "lastSummary"
/// True while the app is in demo mode, so the complication shows the demo counts too.
static var demo: Bool {
get { DeviceStore.sharedData(demoAccount) == Data([1]) }
set { DeviceStore.setSharedData(newValue ? Data([1]) : nil, account: demoAccount) }
}
/// The demo gateway in demo mode, the paired gateway otherwise, and nil when not paired.
static var api: (any GatewayAPI)? {
if demo { return DemoGateway.shared }
return DeviceStore.load().map { GatewayClient(baseURL: $0.gatewayURL, token: $0.token) }
}
/// The last summary the app or the complication saw, or nil after signing out.
static var lastSummary: Summary? {
get { DeviceStore.sharedData(summaryAccount).flatMap { try? JSON.decoder().decode(Summary.self, from: $0) } }
set { DeviceStore.setSharedData(newValue.flatMap { try? JSON.encoder().encode($0) }, account: summaryAccount) }
}
}
+403
View File
@@ -0,0 +1,403 @@
// All threads, opened from the last row of the feed: a search field over the hosts, then a host's
// projects sorted by title, then a project's threads with settled ones included. Hosts and projects
// come from `GET /v1/projects`, threads from `GET /v1/projects/:hostId/:projectId/threads`, and
// search results from `GET /v1/search`. Each screen loads when it appears; nothing polls. A thread or
// a search hit opens the usual thread screen.
import SwiftUI
/// A search field, then every host, offline ones dimmed. Each host opens its projects.
struct HostsView: View {
@Environment(AppModel.self) private var model
/// Snapshots render the rows without their List, which ImageRenderer leaves blank.
var scrolls = true
var body: some View {
BrowseList(scrolls: scrolls, error: model.projectsError, loaded: model.projects != nil, reload: model.loadProjects) {
SearchField()
.feedRow()
} rows: {
if let hosts = model.projects?.hosts {
ForEach(hosts) { host in
BrowseRow(
title: host.label,
subtitle: host.status.problem ?? (host.projects.count == 1 ? "1 project" : "\(host.projects.count) projects"),
route: .hostProjects(hostId: host.id))
.opacity(host.status == .online ? 1 : 0.5)
.feedRow()
}
}
}
.navigationTitle("All threads")
}
}
/// One host's projects, sorted by title. Each opens its threads.
struct ProjectsView: View {
@Environment(AppModel.self) private var model
let hostId: String
var scrolls = true
var body: some View {
let host = model.projects?.hosts.first { $0.id == hostId }
BrowseList(scrolls: scrolls, error: model.projectsError, loaded: model.projects != nil, reload: model.loadProjects) {
if let host {
let projects = host.projects.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending }
ForEach(projects) { project in
BrowseRow(title: project.title, route: .projectThreads(hostId: host.id, projectId: project.id))
.feedRow()
}
if projects.isEmpty {
BrowseNote(text: "No projects.")
}
} else if model.projects != nil {
BrowseNote(text: "This host is gone.")
}
}
.navigationTitle(host?.label ?? "Projects")
}
}
/// One project's threads, newest first: a status dot, the title, dimmed once finished or settled,
/// and the age.
struct ProjectThreadsView: View {
@Environment(AppModel.self) private var model
let hostId: String
let projectId: String
@State private var rows: [ThreadRow]?
@State private var error: String?
private let scrolls: Bool
/// `rows` starts the screen loaded, for snapshots.
init(hostId: String, projectId: String, rows: [ThreadRow]? = nil, scrolls: Bool = true) {
self.hostId = hostId
self.projectId = projectId
self.scrolls = scrolls
_rows = State(initialValue: rows)
}
var body: some View {
let title = model.projects?.hosts.first { $0.id == hostId }?.projects.first { $0.id == projectId }?.title
BrowseList(scrolls: scrolls, error: error, loaded: rows != nil, reload: load) {
if let rows {
ForEach(rows) { row in
ThreadLine(row: row) {
Text(Format.elapsed(since: row.updatedAt))
.foregroundStyle(Theme.secondaryText)
}
.feedRow()
}
if rows.isEmpty {
BrowseNote(text: "No threads yet.")
}
}
}
.navigationTitle(title ?? "Threads")
.task {
// The title comes from the projects list, which a wristcode:// link may skip.
if model.projects == nil { await model.loadProjects() }
}
}
private func load() async {
do {
rows = try await model.api.projectThreads(hostId: hostId, projectId: projectId).items
error = nil
} catch {
if let message = model.message(for: error) { self.error = message }
}
}
}
/// Threads on every host matching `query`: each hit's host and project, its title, and the words
/// around the match when it was inside a message. Says which hosts were left out.
struct SearchView: View {
@Environment(AppModel.self) private var model
let query: String
@State private var response: SearchResponse?
@State private var error: String?
private let scrolls: Bool
/// `response` starts the screen loaded, for snapshots.
init(query: String, response: SearchResponse? = nil, scrolls: Bool = true) {
self.query = query
self.scrolls = scrolls
_response = State(initialValue: response)
}
var body: some View {
BrowseList(scrolls: scrolls, error: error, loaded: response != nil, reload: load) {
if let response {
ForEach(response.hits) { hit in
SearchHitCard(hit: hit, query: query)
.feedRow()
}
if response.hits.isEmpty {
Text("No threads match.")
.font(.system(size: 13))
.foregroundStyle(Theme.secondaryText)
.frame(maxWidth: .infinity)
.padding(.top, 16)
.feedRow()
}
if let note = SearchResponse.unsearchedNote(response.unsearched, offline: offlineHosts) {
Text(note)
.font(.system(size: 11))
.foregroundStyle(Theme.secondaryText)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
.padding(.horizontal, 8)
.feedRow()
}
}
}
.navigationTitle("\u{201C}\(query)\u{201D}")
}
/// Labels of hosts the feed or All threads last saw offline.
private var offlineHosts: Set<String> {
let feed = model.feed?.hosts.filter { $0.status != .online }.map(\.label) ?? []
let browsed = model.projects?.hosts.filter { $0.status != .online }.map(\.label) ?? []
return Set(feed + browsed)
}
private func load() async {
do {
response = try await model.api.search(query)
error = nil
} catch {
if let message = model.message(for: error) { self.error = message }
}
}
}
/// The field at the top of All threads. Tapping it opens dictation or the keyboard, as any watchOS
/// text field does, and submitting opens the results. A search under two letters stays here with a
/// hint, since the gateway needs two.
private struct SearchField: View {
@Environment(AppModel.self) private var model
@State private var tooShort = false
var body: some View {
VStack(alignment: .leading, spacing: 4) {
field
if tooShort {
Text("Search for at least two letters.")
.font(.system(size: 11))
.foregroundStyle(Theme.secondaryText)
.padding(.horizontal, 8)
}
}
}
private var field: some View {
TextFieldLink(prompt: Text("Search threads")) {
HStack(spacing: 7) {
Image(systemName: "magnifyingglass")
.foregroundStyle(Theme.secondaryText)
Text("Search")
.foregroundStyle(Theme.secondaryText)
Spacer(minLength: 4)
Image(systemName: "mic.fill")
.foregroundStyle(Theme.work)
}
.font(.system(size: 14, weight: .semibold))
.padding(.vertical, 9)
.padding(.horizontal, 12)
.background(Theme.control.opacity(0.7), in: RoundedRectangle(cornerRadius: 18, style: .continuous))
.contentShape(Rectangle())
} onSubmit: { text in
let query = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !query.isEmpty else { return }
tooShort = query.count < Route.minimumSearch
if !tooShort { model.path.append(.search(query: query)) }
}
.buttonStyle(.plain)
.accessibilityLabel("Search threads")
}
}
/// A search hit: "host · project", the title, dimmed once finished or settled, and the snippet with
/// the matched words picked out. Opens the thread.
private struct SearchHitCard: View {
@Environment(AppModel.self) private var model
var hit: SearchHit
var query: String
var body: some View {
Button { model.path.append(hit.thread.route) } label: {
Card(edge: Theme.card) {
Text("\(hit.hostLabel) · \(hit.projectTitle)")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Theme.secondaryText)
.lineLimit(1)
Text(hit.thread.title)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(hit.thread.isActive ? Color.white : Theme.secondaryText)
.lineLimit(2)
.padding(.top, 1)
if let snippet = hit.snippet {
Text(highlighted(snippet))
.font(.system(size: 12))
.foregroundStyle(Theme.secondaryText)
.lineLimit(3)
.padding(.top, 3)
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
/// The snippet with each match of the query bold and white.
private func highlighted(_ snippet: String) -> AttributedString {
var text = AttributedString(snippet)
var searchFrom = text.startIndex
while let range = text[searchFrom...].range(of: query, options: .caseInsensitive) {
text[range].inlinePresentationIntent = .stronglyEmphasized
text[range].foregroundColor = .white
searchFrom = range.upperBound
}
return text
}
}
/// A thread on one line: status dot, title, and `trailing` on the right. Opens the thread.
struct ThreadLine<Trailing: View>: View {
@Environment(AppModel.self) private var model
var row: ThreadRow
@ViewBuilder var trailing: Trailing
var body: some View {
Button { model.path.append(row.route) } label: {
Card(edge: Theme.card) {
HStack(spacing: 7) {
Circle()
.fill(row.color)
.frame(width: 7, height: 7)
Text(row.title)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(row.isActive ? Color.white : Theme.secondaryText)
.lineLimit(1)
Spacer(minLength: 4)
trailing
.font(.system(size: 11, weight: .semibold))
.lineLimit(1)
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
/// The List each browse screen scrolls, with Loading, an error and Try again. Loads on appear.
/// `header` shows above the rows even while they load. Once rows show, a failed reload only adds a
/// dim line under them.
private struct BrowseList<Header: View, Rows: View>: View {
var scrolls: Bool
var error: String?
var loaded: Bool
var reload: () async -> Void
@ViewBuilder var header: Header
@ViewBuilder var rows: Rows
var body: some View {
if scrolls {
List { content }
.listStyle(.plain)
.refreshable { await reload() }
.task { await reload() }
} else {
VStack(spacing: 6) { content }
.padding(.horizontal, 4)
}
}
@ViewBuilder private var content: some View {
header
if loaded {
rows
}
if !loaded || error != nil {
LoadStatus(loaded: loaded, error: error, retry: reload)
.feedRow()
}
}
}
extension BrowseList where Header == EmptyView {
init(scrolls: Bool, error: String?, loaded: Bool, reload: @escaping () async -> Void, @ViewBuilder rows: () -> Rows) {
self.init(scrolls: scrolls, error: error, loaded: loaded, reload: reload, header: { EmptyView() }, rows: rows)
}
}
/// A plain card with a title, an optional line under it, and a chevron.
private struct BrowseRow: View {
@Environment(AppModel.self) private var model
var title: String
var subtitle: String?
var route: Route
var body: some View {
Button { model.path.append(route) } label: {
Card(edge: Theme.done) {
HStack {
VStack(alignment: .leading, spacing: 1) {
Text(title)
.font(.system(size: 14, weight: .semibold))
.lineLimit(2)
if let subtitle {
Text(subtitle)
.font(.system(size: 11))
.foregroundStyle(Theme.secondaryText)
.lineLimit(1)
}
}
Spacer(minLength: 4)
Image(systemName: "chevron.right")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(Theme.secondaryText)
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
private struct BrowseNote: View {
var text: String
var body: some View {
Text(text)
.font(.system(size: 12))
.foregroundStyle(Theme.secondaryText)
.padding(.horizontal, 8)
.feedRow()
}
}
#Preview("Hosts") {
NavigationStack {
HostsView()
}
.environment(AppModel.preview())
.fontDesign(.rounded)
}
#Preview("Search") {
NavigationStack {
SearchView(query: "containerfile")
}
.environment(AppModel.preview())
.fontDesign(.rounded)
}
#Preview("Threads") {
NavigationStack {
ProjectThreadsView(hostId: "mac-mini", projectId: "p-wc-mac")
}
.environment(AppModel.preview())
.fontDesign(.rounded)
}
+224 -16
View File
@@ -1,5 +1,10 @@
// Mock B's attention feed: the count waiting on you, then cards for every host. Needs you first // Mock B's attention feed: the count waiting on you, then cards for every host. Needs you first
// (with Allow and Deny on the card), then working (with what the agent is doing), then recent. // (with Allow and Deny on the card, and the files a file approval would edit), then working (with
// the step the agent is on and how long it has run), then recent. A Recent row swipes left to settle
// or snooze, and the Recent header settles every completed thread. Past Recent's first 10, an "N
// more" row opens the full Recent list, and a violet row under it opens the snoozed threads. The last
// row opens All threads. At launch the feed saved last time shows at once, marked Updating until the
// first refresh answers.
import SwiftUI import SwiftUI
@@ -10,25 +15,37 @@ struct FeedView: View {
var body: some View { var body: some View {
List { List {
FeedHeader(feed: model.feed, error: model.feedError) FeedHeader(feed: model.feed, error: model.feedError, cached: model.feedIsCached)
.feedRow() .feedRow()
if let feed = model.feed { if let feed = model.feed {
let recent = model.visibleRecent(feed.recent)
let more = model.settlingAll ? 0 : feed.moreRecent
ForEach(feed.needsYou) { item in ForEach(feed.needsYou) { item in
NeedsYouCard(item: item).feedRow() NeedsYouCard(item: item).feedRow()
} }
ForEach(feed.working) { item in ForEach(feed.working) { item in
StatusCard(item: item).feedRow() StatusCard(item: item).feedRow()
} }
ForEach(feed.recent) { item in if !recent.isEmpty || more > 0 {
StatusCard(item: item).feedRow() RecentHeader(recent: recent, more: more).feedRow()
} }
if feed.needsYou.isEmpty && feed.working.isEmpty && feed.recent.isEmpty { ForEach(recent) { item in
Text("No threads in the last day.") RecentRow(item: item).feedRow()
}
if more > 0 {
MoreRecentRow(count: more).feedRow()
}
if feed.snoozedTotal > 0 {
SnoozedRow(count: feed.snoozedTotal).feedRow()
}
if feed.needsYou.isEmpty && feed.working.isEmpty && recent.isEmpty && more == 0 {
Text("No threads right now.")
.font(.system(size: 13)) .font(.system(size: 13))
.foregroundStyle(Theme.secondaryText) .foregroundStyle(Theme.secondaryText)
.feedRow() .feedRow()
} }
AllThreadsRow().feedRow()
} else if model.feedError != nil { } else if model.feedError != nil {
Button("Try again") { Task { await model.refreshFeed() } } Button("Try again") { Task { await model.refreshFeed() } }
.buttonStyle(PillButtonStyle(fill: Theme.control, foreground: .white)) .buttonStyle(PillButtonStyle(fill: Theme.control, foreground: .white))
@@ -38,6 +55,13 @@ struct FeedView: View {
.listStyle(.plain) .listStyle(.plain)
.refreshable { await model.refreshFeed() } .refreshable { await model.refreshFeed() }
.toolbar { .toolbar {
ToolbarItem(placement: .topBarLeading) {
NavigationLink(value: Route.status) {
Image(systemName: "gearshape.fill")
.foregroundStyle(.black)
}
.accessibilityLabel("Status and settings")
}
ToolbarItem(placement: .topBarTrailing) { ToolbarItem(placement: .topBarTrailing) {
NavigationLink(value: Route.newThread) { NavigationLink(value: Route.newThread) {
Image(systemName: "plus") Image(systemName: "plus")
@@ -60,25 +84,36 @@ struct FeedView: View {
} }
} }
private extension View { extension View {
/// A clear List row with the feed's card insets.
func feedRow() -> some View { func feedRow() -> some View {
listRowBackground(Color.clear) listRowBackground(Color.clear)
.listRowInsets(EdgeInsets(top: 3, leading: 4, bottom: 3, trailing: 4)) .listRowInsets(EdgeInsets(top: 3, leading: 4, bottom: 3, trailing: 4))
} }
} }
/// The big count of threads waiting on you, plus offline hosts and refresh errors. /// The big count of threads waiting on you, plus offline hosts and refresh errors. A feed from the
private struct FeedHeader: View { /// cache says Updating beside the count until the first refresh answers.
struct FeedHeader: View {
var feed: Feed? var feed: Feed?
var error: String? var error: String?
var cached = false
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
if let feed { if let feed {
let waiting = feed.needsYou.count let waiting = feed.needsYou.count
HStack(alignment: .firstTextBaseline) {
Text("\(waiting)") Text("\(waiting)")
.font(.system(size: 34, weight: .bold, design: .rounded)) .font(.system(size: 34, weight: .bold, design: .rounded))
.foregroundStyle(waiting > 0 ? Theme.you : Theme.done) .foregroundStyle(waiting > 0 ? Theme.you : Theme.done)
Spacer(minLength: 4)
if cached {
Text("Updating")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Theme.done)
}
}
Text(subtitle(feed)) Text(subtitle(feed))
.font(.system(size: 13)) .font(.system(size: 13))
.foregroundStyle(Theme.secondaryText) .foregroundStyle(Theme.secondaryText)
@@ -111,8 +146,116 @@ private struct FeedHeader: View {
} }
} }
/// A title over the finished and failed threads, with Settle all when any completed or when
/// threads past `recent` may have. Settle all is one call for every completed thread in Recent. It
/// leaves failed threads, which settle one at a time by swipe.
struct RecentHeader: View {
@Environment(AppModel.self) private var model
var title = "Recent"
var recent: [FeedItem]
/// Threads in Recent that `recent` leaves out.
var more = 0
var body: some View {
VStack(alignment: .leading, spacing: 2) {
HStack {
Text(title)
.foregroundStyle(Theme.secondaryText)
Spacer()
if more > 0 || recent.contains(where: { $0.phase == .completed }) {
Button("Settle all") { Task { await model.settleAllRecent() } }
.buttonStyle(.plain)
.foregroundStyle(Theme.work)
.disabled(model.settlingAll)
}
}
.font(.system(size: 12, weight: .semibold))
if let error = model.settleAllError {
Text(error)
.font(.system(size: 11))
.foregroundStyle(Theme.secondaryText)
}
}
.padding(.horizontal, 8)
.padding(.top, 4)
}
}
/// A Recent thread: its card, which opens the thread, and a left swipe to settle it or to snooze it,
/// which opens the Snooze until picker.
struct RecentRow: View {
@Environment(AppModel.self) private var model
var item: FeedItem
var body: some View {
StatusCard(item: item)
.swipeActions(edge: .trailing) {
// The first button sits at the edge, and a full swipe runs it.
Button { Task { await model.settle([item]) } } label: {
Label("Settle", systemImage: "checkmark")
}
.tint(Theme.work)
Button { model.snoozing = item } label: {
Label("Snooze", systemImage: "moon.fill")
}
.tint(Theme.snooze)
}
}
}
/// The last Recent row when the feed leaves threads out: "N more", dimmer than a card, opening the
/// full Recent list.
struct MoreRecentRow: View {
@Environment(AppModel.self) private var model
var count: Int
var body: some View {
Button { model.path.append(.recent) } label: {
Card(edge: Theme.done.opacity(0.5)) {
HStack {
Text("\(count) more")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Theme.done)
Spacer(minLength: 4)
Image(systemName: "chevron.right")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(Theme.done)
}
}
.opacity(0.8)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel("\(count) more recent threads")
}
}
/// The feed's last row, a plain card opening All threads.
struct AllThreadsRow: View {
@Environment(AppModel.self) private var model
var body: some View {
Button { model.path.append(.allThreads) } label: {
Card(edge: Theme.done) {
HStack {
Text("All threads")
.font(.system(size: 13, weight: .semibold))
Spacer(minLength: 4)
Image(systemName: "chevron.right")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(Theme.secondaryText)
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
/// An amber card for a thread waiting on an approval or an answer, with Allow and Deny inline. /// An amber card for a thread waiting on an approval or an answer, with Allow and Deny inline.
private struct NeedsYouCard: View { /// When a subagent asked, one line names it, and the answer goes to the subagent. A file approval
/// shows "Wants to edit" and the files in place of its prompt.
struct NeedsYouCard: View {
@Environment(AppModel.self) private var model @Environment(AppModel.self) private var model
var item: FeedItem var item: FeedItem
@State private var sending = false @State private var sending = false
@@ -129,7 +272,18 @@ private struct NeedsYouCard: View {
Text(item.title) Text(item.title)
.font(.system(size: 15, weight: .semibold)) .font(.system(size: 15, weight: .semibold))
.lineLimit(2) .lineLimit(2)
if let request = item.request { if let via = item.via, let request = item.request {
let name = Text(via.title).bold().foregroundStyle(.white)
Text(request.isQuestion ? "Subagent \(name) asks:" : request.fileEdit != nil ? "Subagent \(name):" : "Subagent \(name) wants to run:")
.font(.system(size: 12))
.foregroundStyle(Theme.secondaryText)
.lineLimit(2)
.padding(.top, 2)
}
if let edit = item.request?.fileEdit {
FileEditRows(edit: edit, limit: 3)
.padding(.top, 2)
} else if let request = item.request {
Text(request.summary) Text(request.summary)
.font(request.isQuestion ? .system(size: 12) : .system(size: 12, design: .monospaced)) .font(request.isQuestion ? .system(size: 12) : .system(size: 12, design: .monospaced))
.foregroundStyle(Theme.secondaryText) .foregroundStyle(Theme.secondaryText)
@@ -170,10 +324,12 @@ private struct NeedsYouCard: View {
private func respond(_ request: PendingRequest, _ decision: ApprovalDecision) { private func respond(_ request: PendingRequest, _ decision: ApprovalDecision) {
sending = true sending = true
error = nil error = nil
model.beginChange()
Task { Task {
do { do {
let target = item.requestThread
try await model.api.respond( try await model.api.respond(
hostId: item.hostId, threadId: item.threadId, hostId: target.hostId, threadId: target.threadId,
request: .decision(requestId: request.id, decision: decision)) request: .decision(requestId: request.id, decision: decision))
} catch let failure as GatewayError where failure.isStale { } catch let failure as GatewayError where failure.isStale {
// Someone answered it already; the refresh below drops the card. // Someone answered it already; the refresh below drops the card.
@@ -186,8 +342,10 @@ private struct NeedsYouCard: View {
} }
} }
/// A teal card for a working thread, or a grey one for a recent thread. /// A teal card for a working thread, or a grey one for a recent thread. A working thread shows the
private struct StatusCard: View { /// step it is on, or its latest message when the gateway names no step. A recent thread shows what
/// its latest run changed, such as "+42 −8 · 3 files", and why it failed to settle.
struct StatusCard: View {
@Environment(AppModel.self) private var model @Environment(AppModel.self) private var model
var item: FeedItem var item: FeedItem
@@ -203,12 +361,27 @@ private struct StatusCard: View {
.foregroundStyle(item.phase.isWorking ? Color.white : Theme.secondaryText) .foregroundStyle(item.phase.isWorking ? Color.white : Theme.secondaryText)
.lineLimit(2) .lineLimit(2)
.padding(.top, 1) .padding(.top, 1)
if item.phase.isWorking, let line = item.lastMessage { if item.phase.isWorking, let activity = item.activity {
Text(line) ActivityLine(activity: activity)
.padding(.top, 3)
} else if item.phase.isWorking, let line = item.lastMessage {
Text(Markdown.render(line))
.font(.system(size: 12)) .font(.system(size: 12))
.foregroundStyle(Theme.secondaryText) .foregroundStyle(Theme.secondaryText)
.lineLimit(2) .lineLimit(2)
.padding(.top, 3) .padding(.top, 3)
} else if !item.phase.isWorking, let changes = item.changes {
Text(changes: changes)
.font(.system(size: 11))
.foregroundStyle(Theme.secondaryText)
.lineLimit(1)
.padding(.top, 3)
}
if let error = model.settleErrors[item.id] {
Text(error)
.font(.system(size: 11))
.foregroundStyle(Theme.secondaryText)
.padding(.top, 4)
} }
} }
.contentShape(Rectangle()) .contentShape(Rectangle())
@@ -226,6 +399,41 @@ private struct StatusCard: View {
} }
} }
/// What a working thread is doing: a teal symbol for the kind of step, its text, and how long it has
/// run. The elapsed time is worked out when the card renders, so the feed's 10 s refresh moves it on.
struct ActivityLine: View {
var activity: Activity
var body: some View {
HStack(alignment: .firstTextBaseline, spacing: 4) {
Image(systemName: activity.kind.symbol)
.font(.system(size: 10, weight: .bold))
.foregroundStyle(Theme.work)
.frame(width: 14)
label
.font(.system(size: 12))
.foregroundStyle(Theme.secondaryText)
.lineLimit(2)
Spacer(minLength: 4)
if let startedAt = activity.startedAt {
Text(Format.elapsed(since: startedAt))
.font(.system(size: 11, weight: .semibold))
.monospacedDigit()
.foregroundStyle(Theme.work)
}
}
}
private var label: Text {
let mono = Text(code: activity.text).font(.system(size: 11))
return switch activity.kind {
case .command: mono
case .edit: Text("Editing \(mono)")
default: Text(activity.text)
}
}
}
#Preview("Feed") { #Preview("Feed") {
NavigationStack { NavigationStack {
FeedView() FeedView()
+72
View File
@@ -0,0 +1,72 @@
// What a file approval would edit, shown on the feed card and the thread screen in place of the raw
// prompt. Claude sends no diff before approval, so most show the file name alone.
import SwiftUI
/// "Wants to edit" over one file, or "Wants to edit 3 files" over a row for each: the name in
/// monospace and, when the gateway counted them, the lines added and removed on the right. On the
/// thread screen the title is amber and one file's first changed lines follow, removed in red and
/// added in green. Past `limit` files, one line counts the rest.
struct FileEditRows: View {
var edit: FileEdit
var limit = Int.max
var inThread = false
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(edit.title)
.font(.system(size: inThread ? 11 : 12, weight: inThread ? .semibold : .regular))
.foregroundStyle(inThread ? Theme.you : Theme.secondaryText)
ForEach(edit.files.prefix(limit)) { file in
HStack(spacing: 6) {
Text(code: file.name)
.foregroundStyle(.white)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 4)
if let counts = counts(file) {
counts
.lineLimit(1)
.layoutPriority(1)
}
}
.font(.system(size: 12))
}
if edit.files.count > limit {
Text("\(edit.files.count - limit) more")
.font(.system(size: 11))
.foregroundStyle(Theme.secondaryText)
}
if inThread, !edit.preview.isEmpty {
VStack(alignment: .leading, spacing: 1) {
ForEach(edit.preview) { line in
Text(code: line.text)
.foregroundStyle(color(line.change))
.lineLimit(1)
}
}
.font(.system(size: 11))
.padding(.top, 4)
}
}
.accessibilityElement(children: .combine)
}
/// "+9 −6" in monospace, green and red, leaving out a count the gateway did not send.
private func counts(_ file: PendingFile) -> Text? {
let parts = [
file.counts.additions.map { Text(code: $0).foregroundStyle(Theme.add) },
file.counts.deletions.map { Text(code: $0).foregroundStyle(Theme.bad) },
].compactMap(\.self)
guard let first = parts.first else { return nil }
return parts.dropFirst().reduce(first) { Text("\($0)\(Text(code: " "))\($1)") }
}
private func color(_ change: FileEdit.Line.Change) -> Color {
switch change {
case .added: Theme.add
case .removed: Theme.bad
case .context: Theme.secondaryText
}
}
}
+53
View File
@@ -0,0 +1,53 @@
// How every screen that loads data shows it: a static "Loading" before the first answer, the error
// and Try again when there is nothing to show, and once content is on screen, a small dim line under
// it when a refresh fails. Content is never replaced by an error.
import SwiftUI
/// The placeholder before a screen's first answer, the same as the feed's. Static, with no spinner.
struct LoadingText: View {
var body: some View {
Text("Loading")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Theme.secondaryText)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 8)
.padding(.top, 4)
}
}
/// What a screen shows under its content about loading. `loaded` is true once content is on screen.
/// Before then: Loading, or the error with Try again when `retry` is set. After: only a failed
/// refresh's error, small and dim.
struct LoadStatus: View {
var loaded: Bool
var error: String?
var retry: (() async -> Void)?
var body: some View {
if let error {
if loaded {
Text(error)
.font(.system(size: 11))
.foregroundStyle(Theme.done)
.lineLimit(3)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 8)
} else {
VStack(alignment: .leading, spacing: 8) {
Text(error)
.font(.system(size: 12))
.foregroundStyle(Theme.secondaryText)
.padding(.horizontal, 8)
if let retry {
Button("Try again") { Task { await retry() } }
.buttonStyle(PillButtonStyle(fill: Theme.control, foreground: .white))
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
} else if !loaded {
LoadingText()
}
}
}
+51
View File
@@ -0,0 +1,51 @@
// Message text as styled text. Inline markdown (bold, italics, inline code, links) goes through
// `AttributedString(markdown:)`. Block syntax is rewritten line by line first, since SwiftUI's Text
// runs full-parsed paragraphs together: headings turn bold, list markers turn into bullets, quote
// markers go, and fenced code keeps its lines in monospace.
import Foundation
enum Markdown {
static func render(_ source: String) -> AttributedString {
var result = AttributedString()
var fenced = false
var first = true
for line in source.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) {
if line.trimmingCharacters(in: .whitespaces).hasPrefix("```") {
fenced.toggle()
continue
}
if !first { result += AttributedString("\n") }
first = false
if fenced {
var code = AttributedString(line)
code.inlinePresentationIntent = .code
result += code
} else {
result += inline(block(line))
}
}
return result
}
/// One line with its block syntax rewritten as inline markdown or plain text.
static func block(_ line: String) -> String {
// A closing run of #s goes only after a space, so "C#" keeps its #.
if let heading = line.wholeMatch(of: /\s{0,3}#{1,6}\s+(.+?)(?:\s+#+)?\s*/) {
return "**\(heading.1)**"
}
if let item = line.wholeMatch(of: /(\s*)[-*+]\s+(.*)/) {
return "\(item.1)• \(item.2)"
}
if let quote = line.wholeMatch(of: /\s{0,3}>\s?(.*)/) {
return String(quote.1)
}
return line
}
/// Inline markdown, or the line as plain text when it does not parse.
private static func inline(_ line: String) -> AttributedString {
let options = AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace)
return (try? AttributedString(markdown: line, options: options)) ?? AttributedString(line)
}
}
+184 -34
View File
@@ -1,5 +1,6 @@
// Start a thread: pick where (host, then project), see the model it will use, dictate the task. // Start a thread: pick where (host, then project), pick the model from the host's providers, choose
// Defaults to the last host and project a thread was started in. // whether it asks before acting, and dictate the task. Defaults to the last host and project a
// thread was started in, the last model picked on that host, and the last Ask before acting.
import SwiftUI import SwiftUI
@@ -12,15 +13,59 @@ struct NewThreadView: View {
@Environment(AppModel.self) private var model @Environment(AppModel.self) private var model
@AppStorage("lastHostId") private var lastHostId = "" @AppStorage("lastHostId") private var lastHostId = ""
@AppStorage("lastProjectId") private var lastProjectId = "" @AppStorage("lastProjectId") private var lastProjectId = ""
/// The last model picked on each host, a JSON `[hostId: ModelRef]`.
@AppStorage("lastModels") private var lastModelsData = Data()
@AppStorage("askFirst") private var askFirst = false
@State private var projects: ProjectsResponse? @State private var projects: ProjectsResponse?
@State private var selection: ProjectRef? @State private var selection: ProjectRef?
@State private var picking = false @State private var picking = false
@State private var choosingAgent = false
@State private var sending = false @State private var sending = false
@State private var error: String? @State private var error: String?
/// Snapshots render the column without its ScrollView, which ImageRenderer leaves blank.
private let scrolls: Bool
/// `projects` starts the screen loaded, on the first project a thread can start in, for snapshots.
init(projects: ProjectsResponse? = nil, scrolls: Bool = true) {
self.scrolls = scrolls
_projects = State(initialValue: projects)
_selection = State(initialValue: projects.flatMap {
Self.defaultSelection(in: $0, last: ProjectRef(hostId: "", projectId: ""))
})
}
var body: some View { var body: some View {
ScrollView { if scrolls {
ScrollView { column }
.navigationTitle("New thread")
.toolbarForegroundStyle(Theme.you, for: .navigationBar)
.sheet(isPresented: $picking) {
if let projects {
ProjectPicker(projects: projects) { ref in
selection = ref
picking = false
}
}
}
.navigationDestination(isPresented: $choosingAgent) {
if let projects, let chosen = resolve(selection, in: projects) {
AgentList(
providers: chosen.host.providers, selected: agent(for: chosen),
defaultInstanceId: chosen.project.instanceId
) { pick in
lastModels[chosen.host.id] = pick
}
}
}
.task { await load() }
} else {
column
}
}
private var column: some View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
if let projects { if let projects {
let chosen = resolve(selection, in: projects) let chosen = resolve(selection, in: projects)
@@ -38,18 +83,18 @@ struct NewThreadView: View {
.buttonStyle(.plain) .buttonStyle(.plain)
if let chosen { if let chosen {
Card(edge: Theme.done) { let agent = agent(for: chosen)
Text("Agent") let provider = agent.flatMap { chosen.host.provider($0.instanceId) }
.font(.system(size: 11, weight: .semibold)) agentRow(chosen, agent: agent)
.foregroundStyle(Theme.secondaryText)
Text(chosen.project.model.map(Format.modelName) ?? "") if provider?.canAskFirst == true {
.font(.system(size: 15, weight: .semibold)) SwitchCard(title: "Ask before acting", isOn: $askFirst, tint: Theme.you, edge: Theme.done)
} }
TextFieldLink(prompt: Text("What should it do?")) { TextFieldLink(prompt: Text("What should it do?")) {
Label(sending ? "Starting" : "Say the task", systemImage: "mic.fill") Label(sending ? "Starting" : "Say the task", systemImage: "mic.fill")
} onSubmit: { text in } onSubmit: { text in
start(text, in: chosen) start(text, in: chosen, agent: agent, provider: provider)
} }
.buttonStyle(PillButtonStyle(fill: Theme.you)) .buttonStyle(PillButtonStyle(fill: Theme.you))
.disabled(sending || chosen.host.status != .online) .disabled(sending || chosen.host.status != .online)
@@ -61,30 +106,69 @@ struct NewThreadView: View {
.foregroundStyle(Theme.secondaryText) .foregroundStyle(Theme.secondaryText)
} }
} }
} else if error == nil {
Text("Loading")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Theme.secondaryText)
}
if let error {
Text(error)
.font(.system(size: 12))
.foregroundStyle(Theme.secondaryText)
} }
LoadStatus(loaded: projects != nil, error: error, retry: load)
} }
.padding(.horizontal, 4) .padding(.horizontal, 4)
} }
.navigationTitle("New thread")
.toolbarForegroundStyle(Theme.you, for: .navigationBar) /// The model the thread will use. It opens the model list when the host lists its providers.
.sheet(isPresented: $picking) { @ViewBuilder
if let projects { private func agentRow(_ chosen: Chosen, agent: ModelRef?) -> some View {
ProjectPicker(projects: projects) { ref in let card = Card(edge: Theme.done) {
selection = ref Text("Agent")
picking = false .font(.system(size: 11, weight: .semibold))
.foregroundStyle(Theme.secondaryText)
HStack {
Text(agent.flatMap { modelName($0, on: chosen.host) } ?? chosen.project.model.map(Format.modelName) ?? "")
.font(.system(size: 15, weight: .semibold))
if !chosen.host.providers.isEmpty {
Spacer(minLength: 4)
Image(systemName: "chevron.right")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(Theme.secondaryText)
} }
} }
} }
.task { await load() } if chosen.host.providers.isEmpty {
card
} else {
Button { choosingAgent = true } label: {
card.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
private func modelName(_ ref: ModelRef, on host: ProjectsResponse.Host) -> String? {
host.provider(ref.instanceId)?.models.first { $0.slug == ref.slug }?.name
}
private var lastModels: [String: ModelRef] {
get { (try? JSONDecoder().decode([String: ModelRef].self, from: lastModelsData)) ?? [:] }
nonmutating set { lastModelsData = (try? JSONEncoder().encode(newValue)) ?? Data() }
}
private func agent(for chosen: Chosen) -> ModelRef? {
Self.agent(on: chosen.host, project: chosen.project, last: lastModels[chosen.host.id])
}
/// The last model picked on the host while the host still offers it, else the project's default.
nonisolated static func agent(
on host: ProjectsResponse.Host, project: ProjectsResponse.Project, last: ModelRef?
) -> ModelRef? {
if let last, host.offers(last) { return last }
return project.defaultModel
}
/// What a new thread sends besides its text. The model goes only when it differs from the
/// project's default, so the default keeps its options, such as reasoning effort. Ask before
/// acting is false for a provider that cannot ask, and left out when the host lists no providers.
nonisolated static func options(
project: ProjectsResponse.Project, agent: ModelRef?, provider: AgentProvider?, askFirst: Bool
) -> (model: ModelRef?, askFirst: Bool?) {
let model = agent == project.defaultModel ? nil : agent
return (model, provider.map { $0.canAskFirst && askFirst })
} }
private func load() async { private func load() async {
@@ -97,13 +181,13 @@ struct NewThreadView: View {
} }
error = nil error = nil
} catch { } catch {
self.error = model.message(for: error) if let message = model.message(for: error) { self.error = message }
} }
} }
private func resolve(_ ref: ProjectRef?, in projects: ProjectsResponse) private typealias Chosen = (host: ProjectsResponse.Host, project: ProjectsResponse.Project)
-> (host: ProjectsResponse.Host, project: ProjectsResponse.Project)?
{ private func resolve(_ ref: ProjectRef?, in projects: ProjectsResponse) -> Chosen? {
guard let ref, guard let ref,
let host = projects.hosts.first(where: { $0.id == ref.hostId }), let host = projects.hosts.first(where: { $0.id == ref.hostId }),
let project = host.launchable.first(where: { $0.id == ref.projectId }) let project = host.launchable.first(where: { $0.id == ref.projectId })
@@ -128,15 +212,17 @@ struct NewThreadView: View {
return nil return nil
} }
private func start(_ text: String, in chosen: (host: ProjectsResponse.Host, project: ProjectsResponse.Project)) { private func start(_ text: String, in chosen: Chosen, agent: ModelRef?, provider: AgentProvider?) {
let text = text.trimmingCharacters(in: .whitespacesAndNewlines) let text = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return } guard !text.isEmpty else { return }
sending = true sending = true
error = nil error = nil
let options = Self.options(project: chosen.project, agent: agent, provider: provider, askFirst: askFirst)
Task { Task {
do { do {
let created = try await model.api.newThread( let created = try await model.api.newThread(NewThreadRequest(
NewThreadRequest(hostId: chosen.host.id, projectId: chosen.project.id, text: text)) hostId: chosen.host.id, projectId: chosen.project.id, text: text,
model: options.model, askFirst: options.askFirst))
lastHostId = chosen.host.id lastHostId = chosen.host.id
lastProjectId = chosen.project.id lastProjectId = chosen.project.id
await model.refreshFeed() await model.refreshFeed()
@@ -155,6 +241,70 @@ struct NewThreadView: View {
} }
} }
/// Every model the host offers, grouped by provider, with the project default's provider on top.
/// The current pick has a check. Tapping a model picks it and goes back.
struct AgentList: View {
@Environment(\.dismiss) private var dismiss
var providers: [AgentProvider]
var selected: ModelRef?
var defaultInstanceId: String?
var scrolls = true
var pick: (ModelRef) -> Void
var body: some View {
if scrolls {
ScrollView { column }
.navigationTitle("Agent")
} else {
column
}
}
private var column: some View {
let ordered = providers.filter { $0.instanceId == defaultInstanceId } + providers.filter { $0.instanceId != defaultInstanceId }
return VStack(alignment: .leading, spacing: 5) {
ForEach(ordered) { provider in
Text(provider.name)
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Theme.secondaryText)
.padding(.horizontal, 6)
.padding(.top, 4)
ForEach(provider.models) { model in
let ref = ModelRef(instanceId: provider.instanceId, slug: model.slug)
Button {
pick(ref)
dismiss()
} label: {
Card(edge: Theme.card) {
HStack {
Text(Self.shortName(model.name, provider: provider.name))
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.white)
Spacer(minLength: 4)
if ref == selected {
Image(systemName: "checkmark")
.font(.system(size: 13, weight: .bold))
.foregroundStyle(Theme.work)
}
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityAddTraits(ref == selected ? .isSelected : [])
}
}
}
.padding(.horizontal, 4)
}
/// "Claude Opus 5.5" under the Claude label reads "Opus 5.5".
static func shortName(_ name: String, provider: String) -> String {
let prefix = provider + " "
return name.hasPrefix(prefix) && name.count > prefix.count ? String(name.dropFirst(prefix.count)) : name
}
}
/// Two steps: the environment (host), then one of its projects. /// Two steps: the environment (host), then one of its projects.
private struct ProjectPicker: View { private struct ProjectPicker: View {
var projects: ProjectsResponse var projects: ProjectsResponse
+49
View File
@@ -0,0 +1,49 @@
// The full Recent list, opened from the feed's "N more" row. It loads `GET /v1/recent` when it
// appears; nothing polls. Rows look and swipe like the feed's, to settle or snooze, and the header
// has Settle all.
import SwiftUI
struct RecentView: View {
@Environment(AppModel.self) private var model
/// Snapshots render the rows without their List, which ImageRenderer leaves blank.
var scrolls = true
var body: some View {
Group {
if scrolls {
List { rows }
.listStyle(.plain)
.refreshable { await model.loadAllRecent() }
.task { await model.loadAllRecent() }
} else {
VStack(spacing: 6) { rows }
.padding(.horizontal, 4)
}
}
.navigationTitle("Recent")
}
@ViewBuilder private var rows: some View {
if let all = model.allRecent {
let items = model.visibleRecent(all)
RecentHeader(title: items.count == 1 ? "1 thread" : "\(items.count) threads", recent: items)
.feedRow()
ForEach(items) { item in
RecentRow(item: item).feedRow()
}
}
if model.allRecent == nil || model.allRecentError != nil {
LoadStatus(loaded: model.allRecent != nil, error: model.allRecentError, retry: model.loadAllRecent)
.feedRow()
}
}
}
#Preview("Recent") {
NavigationStack {
RecentView()
}
.environment(AppModel.preview())
.fontDesign(.rounded)
}
+236
View File
@@ -0,0 +1,236 @@
// Snoozing a finished thread hides it from Recent until a time picked from four fixed choices, and
// the gateway brings it back on its own. A Recent row's swipe and the thread screen's Snooze button
// open the same picker. When any thread is snoozed, a dim violet row under Recent counts them and
// opens the Snoozed list, where each card says when it wakes and swipes to Unsnooze.
import SwiftUI
/// One of the picker's times, worked out in the watch's time zone.
struct SnoozeChoice: Hashable, Identifiable {
var title: String
var until: Date
var id: String { title }
/// In 1 hour, This evening at 6 PM (left out from 5 PM on), Tomorrow at 9 AM, and Next week,
/// which is next Monday at 9 AM.
static func choices(now: Date = .now, calendar: Calendar = .current) -> [SnoozeChoice] {
let today = calendar.startOfDay(for: now)
func at(_ hour: Int, daysAfter days: Int) -> Date? {
calendar.date(byAdding: .day, value: days, to: today)
.flatMap { calendar.date(bySettingHour: hour, minute: 0, second: 0, of: $0) }
}
var choices = [SnoozeChoice(title: "In 1 hour", until: now.addingTimeInterval(3600))]
if calendar.component(.hour, from: now) < 17, let evening = at(18, daysAfter: 0) {
choices.append(SnoozeChoice(title: "This evening · 6 PM", until: evening))
}
if let tomorrow = at(9, daysAfter: 1) {
choices.append(SnoozeChoice(title: "Tomorrow · 9 AM", until: tomorrow))
}
// Days until the next Monday, a full week when today is Monday. Weekday 2 is Monday.
let days = (9 - calendar.component(.weekday, from: now)) % 7
if let monday = at(9, daysAfter: days == 0 ? 7 : days) {
choices.append(SnoozeChoice(title: "Next week", until: monday))
}
return choices
}
}
extension Format {
/// When a snoozed thread comes back: "Wakes at 6 PM" today, "Wakes tomorrow 9 AM", "Wakes Monday
/// 9 AM" within the week, and "Wakes Nov 30, 9 AM" past that.
static func wakes(at date: Date, now: Date = .now, calendar: Calendar = .current) -> String {
let style = Date.FormatStyle(locale: Locale(identifier: "en_US_POSIX"), calendar: calendar, timeZone: calendar.timeZone)
// "6:00 PM" becomes "6 PM". Some formatters put a narrow space before PM.
let time = date.formatted(style.hour(.defaultDigits(amPM: .abbreviated)).minute())
.replacingOccurrences(of: ":00", with: "")
.replacingOccurrences(of: "\u{202F}", with: " ")
let days = calendar.dateComponents([.day], from: calendar.startOfDay(for: now), to: calendar.startOfDay(for: date)).day ?? 0
switch days {
case ...0: return "Wakes at \(time)"
case 1: return "Wakes tomorrow \(time)"
case 2 ..< 7:
return "Wakes \(date.formatted(style.weekday(.wide))) \(time)"
default:
let day = date.formatted(style.month(.abbreviated).day())
return "Wakes \(day), \(time)"
}
}
}
/// "Snooze until" and the four choices. Picking one snoozes the thread; on success the picker closes,
/// and on failure it stays and says why.
struct SnoozePicker: View {
@Environment(AppModel.self) private var model
var item: FeedItem
/// Fixed for snapshots. Live, the choices are worked out when the picker opens.
var now: Date = .now
/// Snapshots render the choices without their ScrollView, which ImageRenderer leaves blank.
var scrolls = true
@State private var sending = false
@State private var error: String?
var body: some View {
if scrolls {
ScrollView { choices }
.navigationTitle("Snooze until")
} else {
choices
}
}
private var choices: some View {
VStack(spacing: 6) {
ForEach(SnoozeChoice.choices(now: now)) { choice in
Button(choice.title) { snooze(until: choice.until) }
.buttonStyle(PillButtonStyle(fill: Theme.card, foreground: .white))
}
if let error {
Text(error)
.font(.system(size: 11))
.foregroundStyle(Theme.secondaryText)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 8)
}
}
.disabled(sending)
.opacity(sending ? 0.5 : 1)
.padding(.horizontal, 4)
}
private func snooze(until: Date) {
sending = true
error = nil
Task {
do {
try await model.snooze(item, until: until)
} catch {
self.error = model.message(for: error)
}
sending = false
}
}
}
/// The dim violet row under Recent, "☾ 2 snoozed", opening the Snoozed list.
struct SnoozedRow: View {
@Environment(AppModel.self) private var model
var count: Int
var body: some View {
Button { model.path.append(.snoozed) } label: {
Card(edge: Theme.snooze) {
HStack(spacing: 5) {
Image(systemName: "moon.fill")
.font(.system(size: 11, weight: .bold))
Text("\(count) snoozed")
.font(.system(size: 13, weight: .semibold))
Spacer(minLength: 4)
Image(systemName: "chevron.right")
.font(.system(size: 11, weight: .bold))
}
.foregroundStyle(Theme.snooze)
}
.opacity(0.85)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel("\(count) snoozed threads")
}
}
/// Every snoozed thread, the soonest to wake first. It loads `GET /v1/snoozed` when it appears;
/// nothing polls.
struct SnoozedView: View {
@Environment(AppModel.self) private var model
/// Snapshots render the rows without their List, which ImageRenderer leaves blank.
var scrolls = true
var body: some View {
Group {
if scrolls {
List { rows }
.listStyle(.plain)
.refreshable { await model.loadSnoozed() }
.task { await model.loadSnoozed() }
} else {
VStack(spacing: 6) { rows }
.padding(.horizontal, 4)
}
}
.navigationTitle("Snoozed")
}
@ViewBuilder private var rows: some View {
if let items = model.snoozed {
ForEach(items) { item in
SnoozedCard(item: item).feedRow()
}
if items.isEmpty {
Text("Nothing snoozed.")
.font(.system(size: 13))
.foregroundStyle(Theme.secondaryText)
.padding(.horizontal, 8)
.feedRow()
}
}
if model.snoozed == nil || model.snoozedError != nil {
LoadStatus(loaded: model.snoozed != nil, error: model.snoozedError, retry: model.loadSnoozed)
.feedRow()
}
}
}
/// A snoozed thread: when it wakes in violet, its title, and where it runs. Opens the thread, and a
/// left swipe unsnoozes it.
private struct SnoozedCard: View {
@Environment(AppModel.self) private var model
var item: FeedItem
var body: some View {
Button { model.path.append(item.route) } label: {
Card(edge: Theme.snooze) {
if let until = item.snoozedUntil {
Text(Format.wakes(at: until))
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Theme.snooze)
.lineLimit(1)
}
Text(item.title)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Theme.secondaryText)
.lineLimit(2)
.padding(.top, 1)
Text("\(item.hostLabel) · \(item.projectTitle)")
.font(.system(size: 11))
.foregroundStyle(Theme.done)
.lineLimit(1)
.padding(.top, 2)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.swipeActions(edge: .trailing) {
Button { Task { await model.unsnooze(item) } } label: {
Label("Unsnooze", systemImage: "sun.max.fill")
}
.tint(Theme.snooze)
}
}
}
#Preview("Snoozed") {
NavigationStack {
SnoozedView()
}
.environment(AppModel.preview())
.fontDesign(.rounded)
}
#Preview("Snooze until") {
NavigationStack {
SnoozePicker(item: DemoFixtures.previewRecent()[0])
}
.environment(AppModel.preview())
.fontDesign(.rounded)
}
+185
View File
@@ -0,0 +1,185 @@
// What the status overview says about a `StatusResponse`: a headline, the issues worst first, and
// the healthy hosts folded into one row. Worked out at render, so relative times stay current.
import Foundation
struct StatusIssue: Hashable, Identifiable {
enum Severity: Int, Comparable {
case warning, problem
static func < (a: Self, b: Self) -> Bool { a.rawValue < b.rawValue }
}
enum Kind: Hashable {
case host, fallback, token, push
}
var id: String
var severity: Severity
var kind: Kind
var title: String
var detail: String?
/// The screen that explains it.
var route: Route
}
struct StatusReport {
let status: StatusResponse
let now: Date
init(_ status: StatusResponse, now: Date = .now) {
self.status = status
self.now = now
}
/// A host on its fallback route is still connected, so only that leaves the headline alone.
var allConnected: Bool { issues.allSatisfy { $0.kind == .fallback } }
var worst: StatusIssue.Severity? { issues.map(\.severity).max() }
/// "5 environments · pushes OK".
var subline: String {
let count = status.hosts.count
return "\(count) environment\(count == 1 ? "" : "s") · \(pushSummary)"
}
var pushesOK: Bool { pushIssues.isEmpty }
var pushSummary: String {
if !status.gateway.pushConfigured { return "pushes off" }
if !status.device.pushRegistered { return "watch not registered" }
if status.gateway.lastPush?.ok == false { return "last push failed" }
return "pushes OK"
}
var issues: [StatusIssue] {
var issues = status.hosts.compactMap(hostIssue)
if let token = tokenIssue { issues.append(token) }
issues += pushIssues
// Stable within a severity, so hosts keep the gateway's order.
return issues.enumerated()
.sorted { a, b in
a.element.severity == b.element.severity ? a.offset < b.offset : a.element.severity > b.element.severity
}
.map(\.element)
}
/// Hosts with nothing to say: online on their first route.
var healthy: [HostStatusDetail] {
status.hosts.filter { $0.status == .online && !$0.onFallback }
}
/// "4 more, all direct", or "all online" when they use different kinds of route.
var healthySummary: String? {
let hosts = healthy
guard !hosts.isEmpty else { return nil }
let kinds = Set(hosts.compactMap { $0.activeRoute?.kind })
let how = kinds.count == 1 ? "all \(kinds.first!.name)" : "all online"
let anyHostIssue = issues.contains { $0.kind == .host || $0.kind == .fallback }
if anyHostIssue { return "\(hosts.count) more, \(how)" }
return "\(hosts.count) environment\(hosts.count == 1 ? "" : "s"), \(how)"
}
// MARK: - Issues
private func hostIssue(_ host: HostStatusDetail) -> StatusIssue? {
let route = Route.host(id: host.id)
if host.onFallback, let active = host.activeRoute {
return StatusIssue(
id: "fallback-\(host.id)", severity: .warning, kind: .fallback,
title: "\(host.label) is on its \(active.kind.name) route",
detail: host.routes.first.map(Self.failure), route: route)
}
switch host.status {
case .online:
return nil
case .connecting:
return StatusIssue(
id: "host-\(host.id)", severity: .warning, kind: .host,
title: "\(host.label) connecting", detail: nil, route: route)
case .authInvalid:
return StatusIssue(
id: "host-\(host.id)", severity: .problem, kind: .host,
title: "\(host.label) needs a new token", detail: "Run renew-hosts.sh", route: route)
case .incompatible:
return StatusIssue(
id: "host-\(host.id)", severity: .problem, kind: .host,
title: "\(host.label) is incompatible", detail: host.error, route: route)
default:
return StatusIssue(
id: "host-\(host.id)", severity: .problem, kind: .host,
title: "\(host.label) offline", detail: offlineDetail(host), route: route)
}
}
/// "Both routes failed · 4 min", or the error when no route was tried.
private func offlineDetail(_ host: HostStatusDetail) -> String? {
let failed = host.routes.filter { $0.result == .failed }.count
let cause: String? = switch failed {
case 0: host.error
case host.routes.count: host.routes.count == 1 ? "Its route failed" : host.routes.count == 2 ? "Both routes failed" : "All \(failed) routes failed"
default: "\(failed) of \(host.routes.count) routes failed"
}
let since = host.lastOnlineAt.map { Format.age(of: $0, now: now) }
return [cause, since].compactMap(\.self).joined(separator: " · ").nilIfEmpty
}
/// Tokens that expire within 7 days, as one issue.
private var tokenIssue: StatusIssue? {
let soon = status.hosts.compactMap { host in
host.tokenExpiresAt.map { (host, $0) }
}
.filter { $0.1.timeIntervalSince(now) < 7 * 86_400 }
.sorted { $0.1 < $1.1 }
guard let (first, expiry) = soon.first else { return nil }
let when = Format.expiry(expiry, now: now)
let title = soon.count == 1 ? "\(first.label) token \(when)" : "\(soon.count) tokens \(when)"
return StatusIssue(
id: "token", severity: .warning, kind: .token, title: title,
detail: "Run renew-hosts.sh", route: soon.count == 1 ? .host(id: first.id) : .status)
}
private var pushIssues: [StatusIssue] {
let gateway = status.gateway
if !gateway.pushConfigured {
return [StatusIssue(
id: "push", severity: .problem, kind: .push, title: "Pushes are off",
detail: "The gateway has no APNs key", route: .notifications)]
}
if !status.device.pushRegistered {
return [StatusIssue(
id: "push", severity: .warning, kind: .push, title: "This watch gets no pushes",
detail: "It has not registered with the gateway", route: .notifications)]
}
if let push = gateway.lastPush, !push.ok {
return [StatusIssue(
id: "push", severity: .problem, kind: .push, title: "Last push failed",
detail: push.reason ?? "Status \(push.status)", route: .notifications)]
}
return []
}
/// "laptop.gib did not answer", or the host and its error.
static func failure(of route: HostRoute) -> String {
guard let error = route.error else { return "\(route.hostPort) did not connect" }
if error.hasPrefix("no answer") { return "\(route.hostPort) did not answer" }
return "\(route.hostPort): \(error)"
}
}
extension PushResult {
/// A short hint for a failed push, when the cause is a common one.
var hint: String? {
guard !ok else { return nil }
if status == 0 { return "The push never reached Apple." }
return switch reason {
case "BadDeviceToken": "Usually a sandbox/production mismatch. Re-pair this build."
case "Unregistered": "Apple no longer knows this watch. Reopen the app to register again."
case "DeviceTokenNotForTopic": "The gateway's APNs topic is not this app's bundle ID."
default: nil
}
}
}
private extension String {
var nilIfEmpty: String? { isEmpty ? nil : self }
}
+547
View File
@@ -0,0 +1,547 @@
// Settings mock C: an overview that leads with what is wrong, an environment page with mock B's
// route ladder, and a notifications page with a switch per kind of push, a test push and Unpair.
// Each screen loads
// `GET /v1/status` when it appears; nothing polls.
import SwiftUI
/// The overview: a headline, the issues worst first, then the healthy hosts folded into one row.
struct StatusView: View {
@Environment(AppModel.self) private var model
@State private var expanded = false
var status: StatusResponse?
var scrolls = true
var body: some View {
StatusLoader(status: status, scrolls: scrolls) { status in
let report = StatusReport(status)
Headline(report: report)
ForEach(report.issues) { issue in
Button { model.path.append(issue.route) } label: {
Card(edge: issue.severity == .problem ? Theme.bad : Theme.you) {
RowText(title: issue.title, detail: issue.detail)
}
}
.buttonStyle(.plain)
}
if let summary = report.healthySummary {
Button { expanded.toggle() } label: {
Card(edge: Theme.done) {
HStack {
RowText(title: summary)
Spacer(minLength: 4)
Image(systemName: expanded ? "chevron.up" : "chevron.down")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(Theme.secondaryText)
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if expanded {
ForEach(report.healthy) { host in
Button { model.path.append(.host(id: host.id)) } label: {
Card(edge: Theme.work) {
HStack(spacing: 6) {
if let kind = host.activeRoute?.kind {
Image(systemName: kind.symbol)
.font(.system(size: 11))
.foregroundStyle(Theme.work)
}
RowText(title: host.label)
}
}
}
.buttonStyle(.plain)
}
}
}
Button { model.path.append(.notifications) } label: {
Card(edge: Theme.done) {
HStack(spacing: 6) {
Image(systemName: "bell.fill")
.font(.system(size: 11))
.foregroundStyle(Theme.secondaryText)
RowText(title: "Notifications")
Spacer(minLength: 4)
Text(report.pushesOK ? "OK" : "Check")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(report.pushesOK ? Theme.work : Theme.you)
}
}
}
.buttonStyle(.plain)
.padding(.top, 4)
}
.navigationTitle("Status")
}
}
private struct Headline: View {
var report: StatusReport
var body: some View {
VStack(alignment: .leading, spacing: 2) {
Text(report.allConnected ? "All connected" : "Needs attention")
.font(.system(size: 20, weight: .heavy, design: .rounded))
.foregroundStyle(report.allConnected ? Theme.work : report.worst == .problem ? Theme.bad : Theme.you)
Text(report.subline)
.font(.system(size: 12))
.foregroundStyle(Theme.secondaryText)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 8)
.padding(.bottom, 4)
.accessibilityElement(children: .combine)
}
}
/// One environment: where it stands, its routes as a ladder, and Reconnect.
struct HostStatusView: View {
@Environment(AppModel.self) private var model
let hostId: String
var status: StatusResponse?
var scrolls = true
@State private var reload = 0
@State private var reconnecting = false
@State private var error: String?
var body: some View {
StatusLoader(reload: reload, status: status, scrolls: scrolls) { status in
if let host = status.hosts.first(where: { $0.id == hostId }) {
content(host, now: .now)
} else {
RowText(title: "This environment is gone.")
}
}
}
@ViewBuilder
private func content(_ host: HostStatusDetail, now: Date) -> some View {
let line = statusLine(host)
Text(line.text)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(line.color)
.padding(.horizontal, 4)
.navigationTitle(host.label)
RouteLadder(host: host)
.padding(.horizontal, 4)
.padding(.vertical, 4)
VStack(alignment: .leading, spacing: 2) {
if host.status != .online {
let parts = [
host.nextRetryAt.map { "Next try in \(Format.countdown(to: $0, now: now))" },
host.lastOnlineAt.map { "last online \($0.formatted(date: .omitted, time: .shortened))" },
].compactMap(\.self)
if !parts.isEmpty {
Text(parts.joined(separator: " · "))
.foregroundStyle(Theme.secondaryText)
}
}
if let expiry = host.tokenExpiresAt {
let days = expiry.timeIntervalSince(now) / 86_400
Text("Token \(Format.expiry(expiry, now: now))")
.foregroundStyle(days < 0 ? Theme.bad : days < 7 ? Theme.you : Theme.secondaryText)
}
if let version = host.serverVersion {
Text("T3 \(version)")
.foregroundStyle(Theme.secondaryText)
}
}
.font(.system(size: 11))
.padding(.horizontal, 4)
Button(reconnecting ? "Reconnecting" : "Reconnect") { reconnect() }
.buttonStyle(PillButtonStyle(fill: Theme.control, foreground: .white))
.disabled(reconnecting)
.padding(.top, 6)
if let error {
Text(error)
.font(.system(size: 11))
.foregroundStyle(Theme.secondaryText)
}
}
private func statusLine(_ host: HostStatusDetail) -> (text: String, color: Color) {
switch host.status {
case .online:
let kind = host.activeRoute?.kind.name ?? "direct"
return host.onFallback ? ("On its \(kind) route", Theme.you) : ("Online · \(kind)", Theme.work)
case .connecting:
return ("Connecting", Theme.secondaryText)
case .authInvalid:
return ("Needs a new token", Theme.bad)
case .incompatible:
return (host.error.map { "Incompatible: \($0)" } ?? "Incompatible", Theme.bad)
default:
return ("Offline", Theme.bad)
}
}
/// Asks the gateway to start again from the first route, then looks again once it has had a
/// moment to try.
private func reconnect() {
reconnecting = true
error = nil
Task {
do {
try await model.api.reconnect(hostId: hostId)
try await Task.sleep(for: .seconds(2))
reload += 1
} catch {
self.error = model.message(for: error)
}
reconnecting = false
}
}
}
/// The routes in the order the gateway walks them, each with its kind and what happened.
private struct RouteLadder: View {
var host: HostStatusDetail
var body: some View {
VStack(alignment: .leading, spacing: 9) {
ForEach(Array(host.routes.enumerated()), id: \.offset) { _, route in
HStack(alignment: .top, spacing: 7) {
Image(systemName: route.kind.symbol)
.font(.system(size: 10))
.foregroundStyle(color(route))
.frame(width: 20, height: 20)
.background(Circle().fill(.black))
VStack(alignment: .leading, spacing: 1) {
Text(route.hostPort)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(route.result == .untried ? Theme.done : route.result == .ok ? Theme.work : .white)
.lineLimit(1)
.minimumScaleFactor(0.8)
if let detail = detail(route) {
Text(detail)
.font(.system(size: 11))
.foregroundStyle(color(route))
}
}
.padding(.top, 2)
}
}
}
.background(alignment: .leading) {
Rectangle()
.fill(Color.white.opacity(0.18))
.frame(width: 2)
.padding(.leading, 9)
.padding(.vertical, 10)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private func color(_ route: HostRoute) -> Color {
switch route.result {
case .ok: Theme.work
case .failed: Theme.bad
default: Theme.done
}
}
private func detail(_ route: HostRoute) -> String? {
switch route.result {
case .failed:
return route.error ?? "failed"
case .ok:
let at = route.checkedAt ?? host.onlineSince
return at.map { "connected \($0.formatted(date: .omitted, time: .shortened))" } ?? "connected"
default:
return nil
}
}
}
/// Whether this watch gets pushes, the last push, which pushes it wants, a test push, the gateway,
/// and Unpair.
struct NotificationsView: View {
@Environment(AppModel.self) private var model
var status: StatusResponse?
var scrolls = true
@State private var reload = 0
/// The switches as last set here, ahead of the next status load.
@State private var prefs: NotificationPrefs?
@State private var prefsError: String?
@State private var sending = false
@State private var result: PushResult?
@State private var error: String?
@State private var confirmingUnpair = false
/// `status` and `result` start the screen loaded and after a test push, for snapshots.
init(status: StatusResponse? = nil, result: PushResult? = nil, scrolls: Bool = true) {
self.status = status
self.scrolls = scrolls
_result = State(initialValue: result)
}
var body: some View {
StatusLoader(reload: reload, status: status, scrolls: scrolls) { status in
content(status, now: .now)
}
.navigationTitle("Notifications")
.confirmationDialog("Unpair this watch?", isPresented: $confirmingUnpair) {
Button("Unpair", role: .destructive) { Task { await model.unpair() } }
} message: {
Text("It stops getting notifications until you pair it again.")
}
}
@ViewBuilder
private func content(_ status: StatusResponse, now: Date) -> some View {
let device = status.device
Card(edge: device.pushRegistered ? Theme.work : Theme.you) {
HStack {
RowText(title: "This watch", detail: device.apnsEnvironment?.rawValue)
Spacer(minLength: 4)
Text(device.pushRegistered ? "registered" : "not registered")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(device.pushRegistered ? Theme.work : Theme.you)
}
}
if !status.gateway.pushConfigured {
Card(edge: Theme.bad) {
RowText(title: "Pushes are off", detail: "The gateway has no APNs key")
}
}
let last = status.gateway.lastPush
Card(edge: last.map { $0.ok ? Theme.work : Theme.bad } ?? Theme.done) {
HStack {
RowText(title: "Last push")
Spacer(minLength: 4)
if let last {
let age = Format.age(of: last.at, now: now)
Text("\(age == "now" ? "just now" : "\(age) ago") \(last.ok ? "✓" : "✗")")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(last.ok ? Theme.work : Theme.bad)
} else {
Text("none yet")
.font(.system(size: 11))
.foregroundStyle(Theme.secondaryText)
}
}
if let last, !last.ok {
Text([last.reason ?? "Status \(last.status)", last.hint].compactMap(\.self).joined(separator: ". "))
.font(.system(size: 11))
.foregroundStyle(Theme.secondaryText)
.padding(.top, 2)
}
}
if let current = prefs ?? device.notifications {
Text("Notify me when")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Theme.secondaryText)
.padding(.horizontal, 6)
.padding(.top, 4)
ForEach(NotificationPrefs.Key.allCases, id: \.self) { key in
SwitchCard(title: Self.label(key), isOn: Binding(
get: { current[key] },
set: { set(key, $0, from: current) }))
}
Text("Host problems always notify.")
.font(.system(size: 11))
.foregroundStyle(Theme.secondaryText)
.padding(.horizontal, 6)
if let prefsError {
Text(prefsError)
.font(.system(size: 11))
.foregroundStyle(Theme.bad)
.padding(.horizontal, 6)
}
}
Button(sending ? "Sending" : "Send test") { sendTest() }
.buttonStyle(PillButtonStyle(fill: Theme.you))
.disabled(sending)
.padding(.top, 4)
if let result {
VStack(spacing: 2) {
if result.ok {
Text("Apple accepted it at \(result.at.formatted(.dateTime.hour().minute().second()))")
.foregroundStyle(Theme.work)
} else {
Text(result.reason ?? "Status \(result.status)")
.foregroundStyle(Theme.bad)
if let hint = result.hint {
Text(hint)
.foregroundStyle(Theme.secondaryText)
}
}
}
.font(.system(size: 11))
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
}
if let error {
Text(error)
.font(.system(size: 11))
.foregroundStyle(Theme.secondaryText)
}
HStack {
Text("Gateway")
.font(.system(size: 12, weight: .semibold))
Spacer(minLength: 4)
Text(model.gatewayHost ?? "demo")
.font(.system(size: 11))
.foregroundStyle(Theme.secondaryText)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.padding(.horizontal, 8)
.padding(.top, 8)
Button("Unpair") { confirmingUnpair = true }
.buttonStyle(PillButtonStyle(fill: Theme.control, foreground: Theme.bad))
}
private static func label(_ key: NotificationPrefs.Key) -> String {
switch key {
case .needsMe: "Needs me"
case .finished: "Finished"
case .failed: "Failed"
case .subagents: "Subagents ask"
}
}
/// Flips the switch at once and sends just that key. If the gateway refuses, it flips back and
/// a line under the switches shows why.
private func set(_ key: NotificationPrefs.Key, _ on: Bool, from current: NotificationPrefs) {
var next = current
next[key] = on
prefs = next
prefsError = nil
Task {
do {
prefs = try await model.api.setNotification(key, on)
} catch {
prefs?[key] = !on
prefsError = model.message(for: error)
}
}
}
private func sendTest() {
sending = true
result = nil
error = nil
Task {
do {
result = try await model.api.testPush()
reload += 1
} catch {
self.error = model.message(for: error)
}
sending = false
}
}
}
/// Loads the status when it appears, and again whenever `reload` changes, then shows `content`.
/// The last status stays on screen while a reload runs.
private struct StatusLoader<Content: View>: View {
@Environment(AppModel.self) private var model
var reload: Int
/// Snapshots render the column without its ScrollView, which ImageRenderer leaves blank.
var scrolls: Bool
var content: (StatusResponse) -> Content
@State private var status: StatusResponse?
@State private var error: String?
/// `status` starts the screen loaded, for snapshots and previews.
init(
reload: Int = 0, status: StatusResponse? = nil, scrolls: Bool = true,
@ViewBuilder content: @escaping (StatusResponse) -> Content
) {
self.reload = reload
self.scrolls = scrolls
self.content = content
_status = State(initialValue: status)
}
var body: some View {
if scrolls {
ScrollView { column }
.task(id: reload) { await load() }
} else {
column
}
}
private var column: some View {
VStack(alignment: .leading, spacing: 6) {
if let status {
content(status)
}
LoadStatus(loaded: status != nil, error: error, retry: load)
}
.padding(.horizontal, 2)
}
private func load() async {
do {
status = try await model.api.status()
error = nil
} catch {
if let message = model.message(for: error) { self.error = message }
}
}
}
/// A bold line and an optional grey one under it, as on mock C's issue cards.
private struct RowText: View {
var title: String
var detail: String?
var body: some View {
VStack(alignment: .leading, spacing: 1) {
Text(title)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.white)
if let detail {
Text(detail)
.font(.system(size: 11))
.foregroundStyle(Theme.secondaryText)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
#Preview("Status") {
NavigationStack {
StatusView()
}
.environment(AppModel.preview())
.fontDesign(.rounded)
}
#Preview("Environment") {
NavigationStack {
HostStatusView(hostId: "laptop")
}
.environment(AppModel.preview())
.fontDesign(.rounded)
}
#Preview("Notifications") {
NavigationStack {
NotificationsView()
}
.environment(AppModel.preview())
.fontDesign(.rounded)
}
+118 -1
View File
@@ -1,4 +1,5 @@
// Mock B colors and the shared card. Amber means it needs you, teal means working, grey means done. // Mock B colors and the shared card. Amber means it needs you, teal means working, grey means done,
// red means broken, and violet means snoozed. Green and red also count lines added and removed.
import SwiftUI import SwiftUI
@@ -10,6 +11,42 @@ enum Theme {
static let control = Color(red: 0.173, green: 0.173, blue: 0.180) // #2c2c2e static let control = Color(red: 0.173, green: 0.173, blue: 0.180) // #2c2c2e
static let secondaryText = Color(red: 0.682, green: 0.682, blue: 0.698) // #aeaeb2 static let secondaryText = Color(red: 0.682, green: 0.682, blue: 0.698) // #aeaeb2
static let bodyText = Color(red: 0.898, green: 0.898, blue: 0.918) // #e5e5ea static let bodyText = Color(red: 0.898, green: 0.898, blue: 0.918) // #e5e5ea
static let bad = Color(red: 1.0, green: 0.420, blue: 0.369) // #ff6b5e
static let add = Color(red: 0.353, green: 0.820, blue: 0.478) // #5ad17a
static let snooze = Color(red: 0.608, green: 0.549, blue: 1.0) // #9b8cff
}
extension Text {
/// Text marked as inline code, which Text sets in monospace. A monospaced Font loses to the app's
/// rounded design.
init(code: String) {
var attributed = AttributedString(code)
attributed.inlinePresentationIntent = .code
self.init(attributed)
}
/// "+42 −8 · 3 files" in monospace, the additions green, the deletions red and the rest in the
/// current style. Without `files`, just "+42 −8", as on a changed file's row.
init(changes: ChangeTotals, files: Bool = true) {
let parts = changes.parts
let counts = Text("\(Text(code: parts.additions).foregroundStyle(Theme.add))\(Text(code: " "))\(Text(code: parts.deletions).foregroundStyle(Theme.bad))")
self = files ? Text("\(counts)\(Text(code: " · \(parts.files)"))") : counts
}
}
extension Activity.Kind {
/// The symbol in front of an activity line.
var symbol: String {
switch self {
case .command: "terminal"
case .edit: "pencil"
case .search: "magnifyingglass"
case .web: "globe"
case .subagent: "arrow.triangle.branch"
case .thinking: "ellipsis"
default: "wrench.adjustable"
}
}
} }
extension Phase { extension Phase {
@@ -20,6 +57,11 @@ extension Phase {
} }
} }
extension ThreadRow {
/// The status dot: amber waiting, teal working, red failed, grey otherwise.
var color: Color { phase == .failed ? Theme.bad : phase?.color ?? Theme.done }
}
/// A dark rounded card with a 4 pt colored left edge. /// A dark rounded card with a 4 pt colored left edge.
struct Card<Content: View>: View { struct Card<Content: View>: View {
var edge: Color var edge: Color
@@ -43,6 +85,52 @@ struct Card<Content: View>: View {
} }
} }
/// A card holding one switch, like the mocks' setting rows. `tint` fills the switch when it is on.
/// The whole card toggles it.
struct SwitchCard: View {
var title: String
@Binding var isOn: Bool
var tint: Color = Theme.work
var edge: Color = Theme.card
var body: some View {
Toggle(isOn: $isOn) {
Text(title)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.white)
}
.toggleStyle(CardSwitchStyle(tint: tint, edge: edge))
}
}
/// The mocks' switch drawn by hand: watchOS's own renders as a placeholder in snapshots.
private struct CardSwitchStyle: ToggleStyle {
var tint: Color
var edge: Color
func makeBody(configuration: Configuration) -> some View {
Button { configuration.isOn.toggle() } label: {
Card(edge: edge) {
HStack {
configuration.label
Spacer(minLength: 4)
Capsule()
.fill(configuration.isOn ? tint : Color(white: 0.23))
.frame(width: 40, height: 24)
.overlay(alignment: configuration.isOn ? .trailing : .leading) {
Circle().fill(.white).padding(2.5)
}
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityRepresentation {
Toggle(isOn: configuration.$isOn) { configuration.label }
}
}
}
/// A full-width pill button, like mock B's Reply and Say the task. /// A full-width pill button, like mock B's Reply and Say the task.
struct PillButtonStyle: ButtonStyle { struct PillButtonStyle: ButtonStyle {
var fill: Color var fill: Color
@@ -87,6 +175,35 @@ enum Format {
return "\(hours / 24) d" return "\(hours / 24) d"
} }
/// A compact time since a date, such as "42s", "3m", "1h" or "2d", for an activity's elapsed time
/// and a thread row's age.
static func elapsed(since date: Date, now: Date = .now) -> String {
let seconds = max(0, Int(now.timeIntervalSince(date)))
if seconds < 60 { return "\(seconds)s" }
if seconds < 3600 { return "\(seconds / 60)m" }
if seconds < 86_400 { return "\(seconds / 3600)h" }
return "\(seconds / 86_400)d"
}
/// How long until a date, such as "40 s", "3 min" or "2 h".
static func countdown(to date: Date, now: Date = .now) -> String {
let seconds = max(0, Int(date.timeIntervalSince(now).rounded()))
if seconds < 60 { return "\(seconds) s" }
if seconds < 3600 { return "\(seconds / 60) min" }
return "\(seconds / 3600) h"
}
/// "expires in 29 days", "expires today" or "expired 2 days ago", for a token.
static func expiry(_ date: Date, now: Date = .now) -> String {
let days = Int((date.timeIntervalSince(now) / 86_400).rounded(.down))
if date < now {
let ago = Int(now.timeIntervalSince(date) / 86_400)
return ago < 1 ? "expired today" : "expired \(ago) day\(ago == 1 ? "" : "s") ago"
}
if days < 1 { return "expires today" }
return "expires in \(days) day\(days == 1 ? "" : "s")"
}
/// A model id made readable: `claude-opus-5-5` becomes "Claude Opus 5.5". /// A model id made readable: `claude-opus-5-5` becomes "Claude Opus 5.5".
static func modelName(_ id: String) -> String { static func modelName(_ id: String) -> String {
var words: [String] = [] var words: [String] = []
+441 -41
View File
@@ -1,40 +1,89 @@
// One thread: the agent's latest message large, older messages one scroll up, the pending approval // One thread: the agent's latest message large, older messages one scroll up with Load earlier above
// or question with its options, Reply by dictation or Scribble, and Stop while a run is active. // them, the pending approval or question with its options, the thread's subagents under a header
// that collapses them, the files its latest run changed with their line counts, Reply by
// dictation or Scribble, Settle and Snooze once it finished or failed, an Ask before acting switch,
// and Stop while a run is active. A failed action says why above Reply, and a reply that did not
// send shows its text with Send again. A subagent's request shows first, labeled with the subagent. A file
// approval names the files and shows the first changed lines when the gateway has them. Message
// text renders as markdown. The detail saved when the thread last loaded shows while it loads again.
import SwiftUI import SwiftUI
struct ThreadView: View { struct ThreadView: View {
@Environment(AppModel.self) private var model @Environment(AppModel.self) private var model
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
@Environment(\.dismiss) private var dismiss
let hostId: String let hostId: String
let threadId: String let threadId: String
@State private var detail: ThreadDetail? @State private var detail: ThreadDetail?
/// Why the last load failed. The next good load clears it.
@State private var error: String? @State private var error: String?
/// Why the last reply, answer, Settle, Stop or Load earlier failed. Loads leave it alone, so it
/// stays until the next action.
@State private var actionError: String?
/// A reply that failed to send, offered again with Send again.
@State private var unsent: String?
@State private var sending = false @State private var sending = false
@State private var visible = false @State private var visible = false
@State private var answers: [String: String] = [:] @State private var answers: [String: RespondRequest.Answer] = [:]
/// Options ticked so far on multi-select questions, by question id.
@State private var selections: [String: Set<String>] = [:]
@State private var scrolledToLatest = false @State private var scrolledToLatest = false
/// Why the last Ask before acting change failed. It shows under the switch.
@State private var modeError: String?
/// The Ask before acting value still being sent, which a poll landing meanwhile keeps.
@State private var pendingAskFirst: Bool?
/// Bumped when an Ask before acting change succeeds. A load sent before it keeps the switch as is.
@State private var askFirstGeneration = 0
/// Pages Load earlier fetched. The 10 s refresh replaces only the latest window.
@State private var history = MessageHistory()
@State private var loadingEarlier = false
/// Whether the Subagents section is open, once tapped. Nil follows the default: open while a
/// subagent waits on you, closed otherwise. Kept for as long as the screen is.
@State private var subagentsOpen: Bool?
/// Snapshots render the column without its ScrollView, which ImageRenderer leaves blank.
private let scrolls: Bool
/// `detail`, `selections` and `subagentsOpen` start the screen with a thread loaded, options
/// ticked and the Subagents section set, for snapshots and previews.
init(
hostId: String, threadId: String,
detail: ThreadDetail? = nil, selections: [String: Set<String>] = [:], subagentsOpen: Bool? = nil,
scrolls: Bool = true
) {
self.hostId = hostId
self.threadId = threadId
self.scrolls = scrolls
_detail = State(initialValue: detail)
_selections = State(initialValue: selections)
_subagentsOpen = State(initialValue: subagentsOpen)
}
var body: some View { var body: some View {
ScrollViewReader { proxy in if scrolls {
ScrollView { screen
} else {
column
}
}
private var column: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
if let detail { if let detail {
content(detail) content(detail)
} else if error == nil {
Text("Loading")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Theme.secondaryText)
}
if let error {
Text(error)
.font(.system(size: 12))
.foregroundStyle(Theme.secondaryText)
} }
LoadStatus(loaded: detail != nil, error: error)
} }
.padding(.horizontal, 4) .padding(.horizontal, 4)
} }
private var screen: some View {
ScrollViewReader { proxy in
ScrollView {
column
}
.onChange(of: detail?.messages.last?.id) { .onChange(of: detail?.messages.last?.id) {
guard !scrolledToLatest, detail != nil else { return } guard !scrolledToLatest, detail != nil else { return }
scrolledToLatest = true scrolledToLatest = true
@@ -48,7 +97,10 @@ struct ThreadView: View {
} }
} }
.navigationTitle(detail?.item.title ?? "Thread") .navigationTitle(detail?.item.title ?? "Thread")
.onAppear { visible = true } .onAppear {
if detail == nil { detail = model.cachedThread(hostId: hostId, threadId: threadId) }
visible = true
}
.onDisappear { visible = false } .onDisappear { visible = false }
.task(id: visible && scenePhase == .active) { .task(id: visible && scenePhase == .active) {
guard visible && scenePhase == .active else { return } guard visible && scenePhase == .active else { return }
@@ -61,20 +113,33 @@ struct ThreadView: View {
@ViewBuilder @ViewBuilder
private func content(_ detail: ThreadDetail) -> some View { private func content(_ detail: ThreadDetail) -> some View {
let split = MessageSplit(detail.messages) let split = MessageSplit(history.messages(before: detail.messages))
let item = detail.item let item = detail.item
if let cursor = history.cursor(detail) {
Button(loadingEarlier ? "Loading" : "Load earlier") { loadEarlier(cursor) }
.buttonStyle(ChoiceButtonStyle(fill: Theme.card, foreground: Theme.secondaryText))
.disabled(loadingEarlier)
}
ForEach(split.earlier) { message in ForEach(split.earlier) { message in
MessageRow(message: message) MessageRow(message: message)
} }
Text(split.latest?.text ?? item.lastMessage ?? "No reply yet.") // A subagent's request opens the screen, above the parent's latest message.
if let via = item.via, let request = item.request {
requestCards(request, from: via)
.id("latest")
.padding(.top, split.earlier.isEmpty ? 0 : 6)
}
Text(Markdown.render(split.latest?.text ?? item.lastMessage ?? "No reply yet."))
.font(.system(size: 16)) .font(.system(size: 16))
.foregroundStyle(Theme.bodyText) .foregroundStyle(Theme.bodyText)
.lineSpacing(1) .lineSpacing(1)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.id("latest") .id(item.via == nil ? "latest" : "message")
.padding(.top, split.earlier.isEmpty ? 0 : 6) .padding(.top, split.earlier.isEmpty || item.via != nil ? 0 : 6)
ForEach(split.after) { message in ForEach(split.after) { message in
MessageRow(message: message) MessageRow(message: message)
@@ -84,11 +149,27 @@ struct ThreadView: View {
.font(.system(size: 11, weight: .semibold)) .font(.system(size: 11, weight: .semibold))
.foregroundStyle(item.phase.color) .foregroundStyle(item.phase.color)
if let request = item.request { if item.via == nil, let request = item.request {
if request.isQuestion { requestCards(request, from: nil)
questions(request) }
} else {
approval(request) if !detail.subagents.isEmpty {
let open = subagentsOpen ?? detail.subagents.contains { $0.statusWord == "waiting" }
subagentsHeader(detail.subagents, open: open)
if open {
ForEach(detail.subagents) { row in
ThreadLine(row: row) {
Text(row.statusWord)
.foregroundStyle(row.isActive ? row.color : Theme.secondaryText)
}
}
}
}
if !detail.changedFiles.isEmpty {
sectionLabel("Changed")
ForEach(detail.changedFiles) { file in
ChangedFileRow(file: file)
} }
} }
@@ -97,14 +178,38 @@ struct ThreadView: View {
.disabled(sending) .disabled(sending)
} }
if let actionError {
actionFailure(actionError)
}
let settleable = (item.phase == .completed || item.phase == .failed) && item.request == nil
TextFieldLink(prompt: Text("Reply")) { TextFieldLink(prompt: Text("Reply")) {
Label("Reply", systemImage: "mic.fill") Label("Reply", systemImage: "mic.fill")
} onSubmit: { text in } onSubmit: { text in
send(text) send(text)
} }
.buttonStyle(PillButtonStyle(fill: Theme.work)) .buttonStyle(settleable ? PillButtonStyle(fill: Theme.card, foreground: .white) : PillButtonStyle(fill: Theme.work))
.disabled(sending) .disabled(sending)
if settleable {
HStack(spacing: 6) {
Button("Settle") { settle() }
.buttonStyle(PillButtonStyle(fill: Theme.work))
Button("Snooze") { model.snoozing = item }
.buttonStyle(PillButtonStyle(fill: Theme.control, foreground: Theme.snooze))
}
.disabled(sending)
}
if let askFirst = detail.askFirst {
SwitchCard(title: "Ask before acting", isOn: Binding(get: { askFirst }, set: { setAskFirst($0) }))
if let modeError {
Text(modeError)
.font(.system(size: 11))
.foregroundStyle(Theme.bad)
}
}
if detail.canStop { if detail.canStop {
Button(role: .destructive) { stop() } label: { Button(role: .destructive) { stop() } label: {
Label("Stop", systemImage: "stop.fill") Label("Stop", systemImage: "stop.fill")
@@ -114,9 +219,93 @@ struct ThreadView: View {
} }
} }
private func approval(_ request: PendingRequest) -> some View { /// Why an action failed, and for a reply that did not send, its text and Send again.
private func actionFailure(_ message: String) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(message)
.font(.system(size: 11))
.foregroundStyle(Theme.bad)
if let unsent {
Text("\u{201C}\(unsent)\u{201D}")
.font(.system(size: 12))
.foregroundStyle(Theme.secondaryText)
.lineLimit(3)
Button("Send again") { send(unsent) }
.buttonStyle(ChoiceButtonStyle())
.disabled(sending)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 6)
}
/// A small grey title over a list, such as Changed.
private func sectionLabel(_ title: String) -> some View {
Text(title)
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Theme.secondaryText)
.padding(.horizontal, 6)
.padding(.top, 4)
}
/// The Subagents title as a row that opens and closes the list, with the count, how many wait or
/// work, and a chevron on the right, as in "2 · 1 working".
private func subagentsHeader(_ rows: [ThreadRow], open: Bool) -> some View {
let counts = ["waiting", "working"].compactMap { word -> String? in
let count = rows.count { $0.statusWord == word }
return count > 0 ? "\(count) \(word)" : nil
}
let summary = (["\(rows.count)"] + counts).joined(separator: " · ")
return Button { subagentsOpen = !open } label: {
HStack(spacing: 4) {
Text("Subagents")
Spacer(minLength: 4)
Text(summary)
.lineLimit(1)
Image(systemName: open ? "chevron.down" : "chevron.right")
.font(.system(size: 10, weight: .semibold))
.frame(width: 12)
}
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Theme.secondaryText)
.padding(.horizontal, 6)
.padding(.vertical, 6)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel("Subagents, \(summary)")
.accessibilityValue(open ? "Expanded" : "Collapsed")
}
@ViewBuilder
private func requestCards(_ request: PendingRequest, from via: FeedItem.Via?) -> some View {
if request.isQuestion {
questions(request, from: via)
} else {
approval(request, from: via)
}
}
/// "From subagent" and its title, at the top of a request card a subagent sent.
@ViewBuilder
private func subagentLabel(_ via: FeedItem.Via?) -> some View {
if let via {
Text("From subagent")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Theme.you)
Text(via.title)
.font(.system(size: 13, weight: .semibold))
.padding(.top, 1)
.padding(.bottom, 3)
}
}
private func approval(_ request: PendingRequest, from via: FeedItem.Via?) -> some View {
Card(edge: Theme.you) { Card(edge: Theme.you) {
if let prompt = request.prompt { subagentLabel(via)
if let edit = request.fileEdit {
FileEditRows(edit: edit, inThread: true)
} else if let prompt = request.prompt {
Text(prompt) Text(prompt)
.font(.system(size: 13, design: .monospaced)) .font(.system(size: 13, design: .monospaced))
.foregroundStyle(.white) .foregroundStyle(.white)
@@ -143,28 +332,39 @@ struct ThreadView: View {
} }
} }
private func questions(_ request: PendingRequest) -> some View { private func questions(_ request: PendingRequest, from via: FeedItem.Via?) -> some View {
ForEach(request.questions) { question in ForEach(request.questions) { question in
Card(edge: Theme.you) { Card(edge: Theme.you) {
subagentLabel(via)
Text(question.header) Text(question.header)
.font(.system(size: 11, weight: .semibold)) .font(.system(size: 11, weight: .semibold))
.foregroundStyle(Theme.you) .foregroundStyle(Theme.you)
Text(question.question) Text(question.question)
.font(.system(size: 14, weight: .semibold)) .font(.system(size: 14, weight: .semibold))
.padding(.top, 1) .padding(.top, 1)
if question.multiSelect {
Text("Choose any")
.font(.system(size: 11))
.foregroundStyle(Theme.secondaryText)
.padding(.top, 1)
}
VStack(spacing: 6) { VStack(spacing: 6) {
if question.multiSelect {
multiSelectOptions(request, question)
} else {
ForEach(question.options, id: \.value) { choice in ForEach(question.options, id: \.value) { choice in
let chosen = answers[question.id] == choice.value let chosen = answers[question.id] == .text(choice.value)
Button(choice.label) { answer(request, question.id, choice.value) } Button(choice.label) { answer(request, question.id, .text(choice.value)) }
.buttonStyle(ChoiceButtonStyle( .buttonStyle(ChoiceButtonStyle(
fill: chosen ? Theme.you : Theme.control, fill: chosen ? Theme.you : Theme.control,
foreground: chosen ? .black : .white)) foreground: chosen ? .black : .white))
} }
}
if question.allowCustomAnswer { if question.allowCustomAnswer {
TextFieldLink(prompt: Text(question.question)) { TextFieldLink(prompt: Text(question.question)) {
Text("Other…") Text("Other…")
} onSubmit: { text in } onSubmit: { text in
answer(request, question.id, text) answer(request, question.id, .text(text))
} }
.buttonStyle(ChoiceButtonStyle()) .buttonStyle(ChoiceButtonStyle())
} }
@@ -175,6 +375,37 @@ struct ThreadView: View {
} }
} }
/// Checkmark toggles, then Send. With more questions still open, the button says Done and the
/// answers go out together once the last one is in.
@ViewBuilder
private func multiSelectOptions(_ request: PendingRequest, _ question: PendingRequest.Question) -> some View {
let picked = selections[question.id, default: []]
ForEach(question.options, id: \.value) { choice in
let checked = picked.contains(choice.value)
Button { toggle(question.id, choice.value) } label: {
HStack(spacing: 6) {
Image(systemName: checked ? "checkmark.circle.fill" : "circle")
Text(choice.label)
.multilineTextAlignment(.leading)
Spacer(minLength: 0)
}
}
.buttonStyle(ChoiceButtonStyle(
fill: checked ? Theme.you : Theme.control,
foreground: checked ? .black : .white))
.accessibilityAddTraits(checked ? .isSelected : [])
}
let last = request.questions.allSatisfy { $0.id == question.id || answers[$0.id] != nil }
Button(last ? "Send" : "Done") {
let values = question.options.map(\.value).filter(picked.contains)
answer(request, question.id, .values(values))
}
.buttonStyle(PillButtonStyle(
fill: picked.isEmpty ? Theme.control : Theme.you,
foreground: picked.isEmpty ? Theme.done : .black))
.disabled(picked.isEmpty)
}
private func statusLine(_ item: FeedItem) -> String { private func statusLine(_ item: FeedItem) -> String {
let place = "\(item.hostLabel) · \(item.projectTitle)" let place = "\(item.hostLabel) · \(item.projectTitle)"
return switch item.phase { return switch item.phase {
@@ -189,24 +420,57 @@ struct ThreadView: View {
// MARK: - Actions // MARK: - Actions
private func load() async { private func load() async {
let askGeneration = askFirstGeneration
do { do {
detail = try await model.api.thread(hostId: hostId, threadId: threadId) var fresh = try await model.api.thread(hostId: hostId, threadId: threadId)
if let pendingAskFirst {
fresh.askFirst = pendingAskFirst
} else if askGeneration != askFirstGeneration, let current = detail?.askFirst {
// Sent before the switch last changed, so its value may be the old one.
fresh.askFirst = current
}
if let detail { history.keep(detail.messages) }
detail = fresh
error = nil error = nil
model.saveThread(fresh)
} catch { } catch {
self.error = model.message(for: error) if let message = model.message(for: error) { self.error = message }
} }
} }
private func perform(_ action: @escaping @MainActor () async throws -> Void) { /// Fetches the page before `cursor` and puts it above the messages already shown. The window
/// on screen is kept from now on, so a refresh landing before the page loses nothing.
private func loadEarlier(_ cursor: String) {
loadingEarlier = true
actionError = nil
if let detail { history.startKeeping(detail.messages) }
Task {
do {
history.prepend(try await model.api.history(hostId: hostId, threadId: threadId, cursor: cursor))
} catch {
actionError = model.message(for: error)
}
loadingEarlier = false
}
}
/// Runs an action, then reloads the thread and the feed. A failure shows in `actionError`, which
/// the reload leaves alone. `text` is the reply being sent, offered again if it fails.
private func perform(sending text: String? = nil, _ action: @escaping @MainActor () async throws -> Void) {
sending = true sending = true
error = nil actionError = nil
unsent = nil
model.beginChange()
Task { Task {
do { do {
try await action() try await action()
} catch let failure as GatewayError where failure.isStale { } catch let failure as GatewayError where failure.isStale {
self.error = "Already answered." actionError = "Already answered."
} catch { } catch {
self.error = model.message(for: error) if let message = model.message(for: error) {
actionError = message
unsent = text
}
} }
await load() await load()
await model.refreshFeed() await model.refreshFeed()
@@ -217,26 +481,71 @@ struct ThreadView: View {
private func send(_ text: String) { private func send(_ text: String) {
let text = text.trimmingCharacters(in: .whitespacesAndNewlines) let text = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return } guard !text.isEmpty else { return }
perform { try await model.api.sendMessage(hostId: hostId, threadId: threadId, text: text) } perform(sending: text) { try await model.api.sendMessage(hostId: hostId, threadId: threadId, text: text) }
} }
/// Answers the pending request on the thread that asked, which is the subagent when `via` is set.
private func respond(_ request: RespondRequest) { private func respond(_ request: RespondRequest) {
perform { try await model.api.respond(hostId: hostId, threadId: threadId, request: request) } let target: (hostId: String, threadId: String) = detail?.item.requestThread ?? (hostId, threadId)
perform { try await model.api.respond(hostId: target.hostId, threadId: target.threadId, request: request) }
}
private func toggle(_ questionId: String, _ value: String) {
if selections[questionId, default: []].remove(value) == nil {
selections[questionId, default: []].insert(value)
}
} }
/// Records one answer. Once every question has one, sends them together. /// Records one answer. Once every question has one, sends them together.
private func answer(_ request: PendingRequest, _ questionId: String, _ value: String) { private func answer(_ request: PendingRequest, _ questionId: String, _ value: RespondRequest.Answer) {
answers[questionId] = value answers[questionId] = value
let all = request.questions.allSatisfy { answers[$0.id] != nil } let all = request.questions.allSatisfy { answers[$0.id] != nil }
guard all else { return } guard all else { return }
let payload = answers.filter { key, _ in request.questions.contains { $0.id == key } } let payload = answers.filter { key, _ in request.questions.contains { $0.id == key } }
answers = [:] answers = [:]
selections = [:]
respond(.answers(requestId: request.id, answers: payload)) respond(.answers(requestId: request.id, answers: payload))
} }
private func stop() { private func stop() {
perform { try await model.api.stop(hostId: hostId, threadId: threadId) } perform { try await model.api.stop(hostId: hostId, threadId: threadId) }
} }
/// Flips the switch at once and tells the gateway. If it refuses, the switch flips back and the
/// line under it shows why.
private func setAskFirst(_ on: Bool) {
detail?.askFirst = on
pendingAskFirst = on
modeError = nil
Task {
do {
try await model.api.setAskFirst(hostId: hostId, threadId: threadId, askFirst: on)
askFirstGeneration += 1
} catch {
detail?.askFirst = !on
modeError = model.message(for: error)
}
pendingAskFirst = nil
}
}
/// Settles the thread and goes back to the feed. On failure the screen stays and shows why.
private func settle() {
sending = true
actionError = nil
unsent = nil
model.beginChange()
Task {
do {
try await model.api.settle(hostId: hostId, threadId: threadId)
dismiss()
await model.refreshFeed()
} catch {
actionError = model.message(for: error)
}
sending = false
}
}
} }
/// Splits a thread's messages around the agent's latest message, which the screen shows large. /// Splits a thread's messages around the agent's latest message, which the screen shows large.
@@ -258,12 +567,66 @@ struct MessageSplit {
} }
} }
/// Older pages fetched by Load earlier, oldest first, kept in front of the latest window the thread
/// route returns. Once Load earlier is asked for, a refresh's outgoing window is kept too, so no gap
/// opens when new messages push the window forward. A message shows once, by id, and the copy
/// received last replaces an earlier one, so a message caught mid-stream gets its full text.
struct MessageHistory {
private(set) var older: [Message] = []
/// The cursor for the page before `older`, once a page has loaded.
private var nextCursor: String?
private var loaded = false
/// True once Load earlier has been asked for. From then on, every outgoing window is kept.
private var keeping = false
/// Where Load earlier fetches from next, or nil when the thread starts in view.
func cursor(_ detail: ThreadDetail) -> String? {
loaded ? nextCursor : detail.earlierCursor
}
/// The older pages, then `latest`.
func messages(before latest: [Message]) -> [Message] {
let ids = Set(latest.map(\.id))
return older.filter { !ids.contains($0.id) } + latest
}
mutating func prepend(_ page: HistoryResponse) {
older = Self.merge(page.messages, into: older, atFront: true)
nextCursor = page.earlierCursor
loaded = true
keeping = true
}
/// Starts keeping outgoing windows, with `window`, as a Load earlier request goes out.
mutating func startKeeping(_ window: [Message]) {
keeping = true
keep(window)
}
/// Keeps the window a refresh replaces, once Load earlier has been asked for.
mutating func keep(_ window: [Message]) {
guard keeping else { return }
older = Self.merge(window, into: older, atFront: false)
}
/// `base` with `incoming` merged in. A message already in `base` takes the incoming copy in
/// place; the rest go in front or behind, in their own order.
private static func merge(_ incoming: [Message], into base: [Message], atFront: Bool) -> [Message] {
let copies = Dictionary(incoming.map { ($0.id, $0) }, uniquingKeysWith: { _, last in last })
let baseIds = Set(base.map(\.id))
var added = Set<String>()
let new = incoming.filter { !baseIds.contains($0.id) && added.insert($0.id).inserted }
let updated = base.map { copies[$0.id] ?? $0 }
return atFront ? new + updated : updated + new
}
}
private struct MessageRow: View { private struct MessageRow: View {
var message: Message var message: Message
var body: some View { var body: some View {
let mine = message.role == .user let mine = message.role == .user
Text(message.text) Text(Markdown.render(message.text))
.font(.system(size: 13)) .font(.system(size: 13))
.foregroundStyle(mine ? Color.white : Theme.secondaryText) .foregroundStyle(mine ? Color.white : Theme.secondaryText)
.padding(.horizontal, mine ? 9 : 0) .padding(.horizontal, mine ? 9 : 0)
@@ -273,6 +636,29 @@ private struct MessageRow: View {
} }
} }
/// A file the latest run changed: its name in monospace, and its line counts on the right. Read only.
private struct ChangedFileRow: View {
var file: ChangedFile
var body: some View {
Card(edge: Theme.card) {
HStack(spacing: 6) {
Text(code: file.name)
.foregroundStyle(.white)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 4)
Text(changes: ChangeTotals(files: 1, additions: file.additions, deletions: file.deletions), files: false)
.lineLimit(1)
.layoutPriority(1)
}
.font(.system(size: 12))
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(file.name), \(file.additions) added, \(file.deletions) removed")
}
}
/// One-tap replies for a finished turn, like mock B's chips. /// One-tap replies for a finished turn, like mock B's chips.
private struct QuickReplies: View { private struct QuickReplies: View {
var send: (String) -> Void var send: (String) -> Void
@@ -301,9 +687,23 @@ private struct QuickReplies: View {
.environment(AppModel.preview()) .environment(AppModel.preview())
} }
#Preview("Subagent") {
NavigationStack {
ThreadView(hostId: "mac-mini", threadId: "t-homelab")
}
.environment(AppModel.preview())
}
#Preview("Question") { #Preview("Question") {
NavigationStack { NavigationStack {
ThreadView(hostId: "server-gib", threadId: "t-db") ThreadView(hostId: "server-gib", threadId: "t-db")
} }
.environment(AppModel.preview()) .environment(AppModel.preview())
} }
#Preview("Multi-select") {
NavigationStack {
ThreadView(hostId: "mac-mini", threadId: "t-release", selections: ["checks": ["unit", "lint"]])
}
.environment(AppModel.preview())
}
+4
View File
@@ -4,5 +4,9 @@
<dict> <dict>
<key>aps-environment</key> <key>aps-environment</key>
<string>development</string> <string>development</string>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)org.gbrown.wristcode.shared</string>
</array>
</dict> </dict>
</plist> </plist>
+33 -1
View File
@@ -1,4 +1,6 @@
// App entry. Notification setup and APNs registration go through the WatchKit app delegate. // App entry. Notification setup and APNs registration go through the WatchKit app delegate, and
// `input` pushes get their own long look, which sets a question's options as its actions. The Snooze
// until picker opens over every screen, from a Recent row or the thread screen.
import SwiftUI import SwiftUI
import WatchKit import WatchKit
@@ -13,6 +15,7 @@ struct WristCodeApp: App {
RootView() RootView()
.environment(model) .environment(model)
} }
WKNotificationScene(controller: InputNotificationController.self, category: PushCategory.input.rawValue)
} }
} }
@@ -28,18 +31,47 @@ struct RootView: View {
NavigationStack(path: $model.path) { NavigationStack(path: $model.path) {
FeedView() FeedView()
.navigationDestination(for: Route.self) { route in .navigationDestination(for: Route.self) { route in
// A new identity per route, so swapping one thread for another at the
// same place in the stack starts the screen's state and polling afresh.
Group {
switch route { switch route {
case let .thread(hostId, threadId): case let .thread(hostId, threadId):
ThreadView(hostId: hostId, threadId: threadId) ThreadView(hostId: hostId, threadId: threadId)
case .newThread: case .newThread:
NewThreadView() NewThreadView()
case .recent:
RecentView()
case .snoozed:
SnoozedView()
case .status:
StatusView()
case let .host(id):
HostStatusView(hostId: id)
case .notifications:
NotificationsView()
case .allThreads:
HostsView()
case let .hostProjects(hostId):
ProjectsView(hostId: hostId)
case let .projectThreads(hostId, projectId):
ProjectThreadsView(hostId: hostId, projectId: projectId)
case let .search(query):
SearchView(query: query)
} }
} }
.id(route)
}
}
.sheet(item: $model.snoozing) { item in
NavigationStack {
SnoozePicker(item: item)
}
} }
} }
} }
.fontDesign(.rounded) .fontDesign(.rounded)
.tint(Theme.work) .tint(Theme.work)
.onOpenURL { model.open(url: $0) }
} }
} }
+229
View File
@@ -0,0 +1,229 @@
// How AppModel keeps the feed honest around changes: a refresh asked for during another runs again,
// an answer sent before a change or a reset is dropped, action errors outlive the reloads after
// them, and the disk cache skips unchanged writes. These write the complication's shared state, so
// they run in the serialized SharedStateTests suite and put that state back after.
import Foundation
import Testing
@testable import WristCode
/// A gateway whose feed answers wait for the test to hand them over, unless `holdFeed` is off, and
/// whose other calls fail with `failure` when it is set.
private actor FakeGateway: GatewayAPI {
struct Unused: Error {}
var holdFeed = true
var feedAnswer = Feed(needsYou: [], working: [], recent: [], hosts: [])
var failure: GatewayError?
var snoozedItems: [FeedItem] = []
private(set) var feedCalls = 0
private var waiting: [CheckedContinuation<Feed, any Error>] = []
var waitingFeeds: Int { waiting.count }
func set(holdFeed: Bool? = nil, feed: Feed? = nil, failure: GatewayError?? = nil, snoozed: [FeedItem]? = nil) {
if let holdFeed { self.holdFeed = holdFeed }
if let feed { feedAnswer = feed }
if let failure { self.failure = failure }
if let snoozed { snoozedItems = snoozed }
}
/// Answers the oldest feed request still waiting.
func answerFeed(_ feed: Feed) {
waiting.removeFirst().resume(returning: feed)
}
private func check() throws {
if let failure { throw failure }
}
func feed() async throws -> Feed {
feedCalls += 1
guard holdFeed else { return feedAnswer }
return try await withCheckedThrowingContinuation { waiting.append($0) }
}
func snoozed() async throws -> RecentResponse {
RecentResponse(items: snoozedItems)
}
func settle(hostId: String, threadId: String) async throws { try check() }
func unsnooze(hostId: String, threadId: String) async throws { try check() }
func settleRecent() async throws -> SettleRecentResponse { try check(); throw Unused() }
func recent() async throws -> RecentResponse { try check(); throw Unused() }
func projects() async throws -> ProjectsResponse { try check(); throw Unused() }
func summary() async throws -> Summary { throw Unused() }
func thread(hostId: String, threadId: String) async throws -> ThreadDetail { throw Unused() }
func history(hostId: String, threadId: String, cursor: String) async throws -> HistoryResponse { throw Unused() }
func sendMessage(hostId: String, threadId: String, text: String) async throws { throw Unused() }
func respond(hostId: String, threadId: String, request: RespondRequest) async throws { throw Unused() }
func stop(hostId: String, threadId: String) async throws { throw Unused() }
func setAskFirst(hostId: String, threadId: String, askFirst: Bool) async throws { throw Unused() }
func snooze(hostId: String, threadId: String, until: Date) async throws { throw Unused() }
func projectThreads(hostId: String, projectId: String) async throws -> ThreadListResponse { throw Unused() }
func search(_ query: String) async throws -> SearchResponse { throw Unused() }
func newThread(_ request: NewThreadRequest) async throws -> NewThreadResponse { throw Unused() }
func registerPush(_ registration: PushRegistration) async throws { throw Unused() }
func status() async throws -> StatusResponse { throw Unused() }
func reconnect(hostId: String) async throws { throw Unused() }
func testPush() async throws -> PushResult { throw Unused() }
func setNotification(_ key: NotificationPrefs.Key, _ on: Bool) async throws -> NotificationPrefs { throw Unused() }
func unpair() async throws { throw Unused() }
}
/// Waits, a turn at a time, until `condition` holds.
@MainActor private func until(_ condition: () async -> Bool) async throws {
for _ in 0 ..< 1000 {
if await condition() { return }
await Task.yield()
}
Issue.record("Timed out")
throw CancellationError()
}
/// Lets tasks already queued on the main actor run up to their first wait.
@MainActor private func settleMainActor() async {
for _ in 0 ..< 5 { await Task.yield() }
}
/// Puts the complication's shared state back after `body`.
@MainActor private func keepingSharedState(_ body: () async throws -> Void) async rethrows {
let demo = SharedState.demo
let summary = SharedState.lastSummary
defer {
SharedState.demo = demo
SharedState.lastSummary = summary
}
try await body()
}
private let fixtures = DemoFixtures.previewFeed(now: Date(timeIntervalSince1970: 1_800_000_000))
/// A feed with one recent thread, and the same feed after it settled.
private let withThread = Feed(needsYou: [], working: [], recent: [fixtures.recent[0]], hosts: fixtures.hosts)
private let settled = Feed(needsYou: [], working: [], recent: [], hosts: fixtures.hosts)
extension SharedStateTests {
@MainActor @Test func refreshAskedForDuringAnotherRunsAgain() async throws {
try await keepingSharedState {
let gateway = FakeGateway()
let model = AppModel(mode: .demo, gateway: gateway)
let poll = Task { await model.refreshFeed() }
try await until { await gateway.waitingFeeds == 1 }
// A change lands while the poll is out. Its refresh waits instead of being dropped.
let afterChange = Task { await model.refreshFeed() }
await settleMainActor()
await gateway.answerFeed(withThread)
try await until { await gateway.waitingFeeds == 1 }
#expect(model.feed == nil)
await gateway.answerFeed(settled)
await poll.value
await afterChange.value
#expect(model.feed == settled)
#expect(await gateway.feedCalls == 2)
}
}
@MainActor @Test func feedAnswersFromBeforeAChangeOrResetAreDropped() async throws {
try await keepingSharedState {
let gateway = FakeGateway()
let model = AppModel(mode: .demo, gateway: gateway)
let poll = Task { await model.refreshFeed() }
try await until { await gateway.waitingFeeds == 1 }
model.beginChange()
await gateway.answerFeed(withThread)
try await until { await gateway.waitingFeeds == 1 }
await gateway.answerFeed(settled)
await poll.value
#expect(model.feed == settled)
// Signing out while a refresh is out: its answer never lands.
let late = Task { await model.refreshFeed() }
try await until { await gateway.waitingFeeds == 1 }
model.signOut()
await gateway.answerFeed(withThread)
await late.value
#expect(model.feed == nil)
#expect(SharedState.lastSummary == nil)
#expect(await gateway.feedCalls == 3)
}
}
@MainActor @Test func failedUnsnoozeShowsItsErrorAfterTheReload() async throws {
try await keepingSharedState {
let item = fixtures.recent[0]
let gateway = FakeGateway()
await gateway.set(holdFeed: false, failure: .transport("Gateway down"), snoozed: [item])
let model = AppModel(mode: .demo, snoozed: [item], gateway: gateway)
await model.unsnooze(item)
#expect(model.snoozedError == "Gateway down")
#expect(model.snoozed == [item])
}
}
@MainActor @Test func settleErrorsLeaveWithTheirThreadsAndOnSignOut() async throws {
try await keepingSharedState {
let item = fixtures.recent[0]
let gateway = FakeGateway()
await gateway.set(holdFeed: false, feed: withThread, failure: .transport("Gateway down"))
let model = AppModel(mode: .demo, gateway: gateway)
await model.settle([item])
#expect(model.settleErrors[item.id] == "Gateway down")
// The thread left the feed elsewhere, so its error goes too.
await gateway.set(feed: settled)
await model.refreshFeed()
#expect(model.settleErrors.isEmpty)
await gateway.set(feed: withThread)
await model.settle([item])
await model.settleAllRecent()
await model.loadAllRecent()
await model.loadProjects()
await model.unsnooze(item)
#expect(!model.settleErrors.isEmpty)
#expect(model.settleAllError != nil)
#expect(model.allRecentError != nil)
#expect(model.projectsError != nil)
#expect(model.snoozedError != nil)
model.signOut()
#expect(model.settleErrors.isEmpty)
#expect(model.settling.isEmpty)
#expect(!model.settlingAll)
#expect(model.settleAllError == nil)
#expect(model.allRecentError == nil)
#expect(model.projectsError == nil)
#expect(model.snoozedError == nil)
}
}
@MainActor @Test func unchangedFeedIsNotWrittenAgain() async throws {
try await keepingSharedState {
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
let cache = FeedCache(directory: directory)
let file = directory.appending(path: "feed.json")
let gateway = FakeGateway()
await gateway.set(holdFeed: false, feed: withThread)
let credentials = Credentials(gatewayURL: URL(string: "https://gateway.test")!, token: "t")
let model = AppModel(mode: .paired(credentials), cache: cache, gateway: gateway)
await model.refreshFeed()
await model.cacheWrite?.value
#expect(cache.feed()?.recent.map(\.id) == withThread.recent.map(\.id))
try FileManager.default.removeItem(at: file)
await model.refreshFeed()
await model.cacheWrite?.value
#expect(!FileManager.default.fileExists(atPath: file.path()))
await gateway.set(feed: settled)
await model.refreshFeed()
await model.cacheWrite?.value
#expect(cache.feed()?.recent.map(\.id) == [])
}
}
}
+847 -2
View File
@@ -2,7 +2,9 @@
// the screens rely on. // the screens rely on.
import Foundation import Foundation
import Security
import Testing import Testing
import UserNotifications
@testable import WristCode @testable import WristCode
private func decode<T: Decodable>(_ type: T.Type, _ json: String) throws -> T { private func decode<T: Decodable>(_ type: T.Type, _ json: String) throws -> T {
@@ -57,6 +59,43 @@ private let feedItemJSON = """
#expect(request.quickOptions?.allow.label == "Allow") #expect(request.quickOptions?.allow.label == "Allow")
#expect(request.quickOptions?.deny.decision == .decline) #expect(request.quickOptions?.deny.decision == .decline)
#expect(request.summary == "bun test --filter ocr") #expect(request.summary == "bun test --filter ocr")
// An older gateway without `recentTotal` has nothing past `recent`.
#expect(feed.recentTotal == 0)
#expect(feed.moreRecent == 0)
}
@Test func feedWithRecentTotal() throws {
let item = feedItemJSON.replacingOccurrences(of: "waiting_for_approval", with: "completed")
let feed = try decode(Feed.self, """
{ "needsYou": [], "working": [], "recent": [\(item)], "recentTotal": 12, "hosts": [] }
""")
#expect(feed.recentTotal == 12)
#expect(feed.moreRecent == 11)
#expect(Feed(needsYou: [], working: [], recent: feed.recent, hosts: []).moreRecent == 0)
}
@Test func recentResponses() throws {
let item = feedItemJSON.replacingOccurrences(
of: "\"Parser and tests are written. I want to run the suite.\"", with: "null")
let recent = try decode(RecentResponse.self, "{ \"items\": [\(item)] }")
#expect(recent.items.count == 1)
#expect(recent.items.first?.lastMessage == nil)
let settled = try decode(SettleRecentResponse.self, #"{ "settled": 11, "failed": 1 }"#)
#expect(settled == SettleRecentResponse(settled: 11, failed: 1))
}
@Test func subagentRequest() throws {
let item = try decode(FeedItem.self, feedItemJSON.replacingOccurrences(
of: "\"request\": {",
with: #""via": { "hostId": "mac", "threadId": "sub", "title": "Check Panama PATH" }, "request": {"#))
#expect(item.via == FeedItem.Via(hostId: "mac", threadId: "sub", title: "Check Panama PATH"))
#expect(item.requestThread == ("mac", "sub"))
#expect(item.route == .thread(hostId: "...", threadId: "..."))
// A gateway older than `via` leaves it out, and the request belongs to the thread itself.
let plain = try decode(FeedItem.self, feedItemJSON)
#expect(plain.via == nil)
#expect(plain.requestThread == ("...", "..."))
} }
@Test func questionRequest() throws { @Test func questionRequest() throws {
@@ -69,6 +108,33 @@ private let feedItemJSON = """
#expect(request.quickOptions == nil) #expect(request.quickOptions == nil)
#expect(request.summary == "Hash the codes?") #expect(request.summary == "Hash the codes?")
#expect(request.questions.first?.allowCustomAnswer == true) #expect(request.questions.first?.allowCustomAnswer == true)
// A gateway older than multi-select leaves the flag out, which means single-select.
#expect(request.questions.first?.multiSelect == false)
}
@Test func multiSelectQuestion() throws {
let question = try decode(PendingRequest.Question.self, """
{ "id": "q1", "header": "Checks", "question": "Which checks?",
"options": [{ "label": "Unit", "value": "unit" }, { "label": "Lint", "value": "lint" }],
"allowCustomAnswer": false, "multiSelect": true }
""")
#expect(question.multiSelect)
#expect(question.options.map(\.value) == ["unit", "lint"])
}
@Test func summaryFromDocs() throws {
let summary = try decode(Summary.self, """
{ "needsYou": 1, "working": 3, "hostsOffline": 0,
"next": { "hostId": "h", "threadId": "t", "title": "Receipt OCR", "hostLabel": "Mac mini" } }
""")
#expect(summary.needsYou == 1)
#expect(summary.working == 3)
#expect(summary.next?.title == "Receipt OCR")
#expect(summary.next?.route == .thread(hostId: "h", threadId: "t"))
let idle = try decode(Summary.self, #"{ "needsYou": 0, "working": 0, "hostsOffline": 2, "next": null }"#)
#expect(idle.next == nil)
#expect(idle.hostsOffline == 2)
} }
@Test func unknownEnumValuesStillDecode() throws { @Test func unknownEnumValuesStillDecode() throws {
@@ -90,6 +156,11 @@ private let feedItemJSON = """
"canStop": true } "canStop": true }
""") """)
#expect(detail.canStop) #expect(detail.canStop)
// An older gateway leaves out askFirst, so the switch stays hidden, and the phase 3 fields.
#expect(detail.askFirst == nil)
#expect(detail.earlierCursor == nil)
#expect(detail.subagents.isEmpty)
#expect(detail.item.activity == nil)
#expect(detail.messages.first?.role == .assistant) #expect(detail.messages.first?.role == .assistant)
#expect(detail.messages.first?.createdAt == Date(timeIntervalSince1970: 1_793_526_000)) #expect(detail.messages.first?.createdAt == Date(timeIntervalSince1970: 1_793_526_000))
} }
@@ -103,6 +174,177 @@ private let feedItemJSON = """
let host = try #require(response.hosts.first) let host = try #require(response.hosts.first)
#expect(host.projects.count == 2) #expect(host.projects.count == 2)
#expect(host.launchable.map(\.id) == ["a"]) #expect(host.launchable.map(\.id) == ["a"])
#expect(host.providers.isEmpty)
#expect(host.projects.first?.defaultModel == nil)
}
@Test func phase2FromDocs() throws {
let projects = try decode(ProjectsResponse.self, """
{ "hosts": [{ "id": "...", "label": "Mac mini", "status": "online", "error": null,
"projects": [{ "id": "a", "title": "wristcode", "model": "claude-opus-5-5", "instanceId": "claudeAgent" }],
"providers": [{ "instanceId": "claudeAgent", "name": "Claude", "canAskFirst": true,
"models": [{ "slug": "claude-opus-5-5", "name": "Claude Opus 5.5" }] }] }] }
""")
let host = try #require(projects.hosts.first)
#expect(host.projects.first?.defaultModel == ModelRef(instanceId: "claudeAgent", slug: "claude-opus-5-5"))
#expect(host.offers(ModelRef(instanceId: "claudeAgent", slug: "claude-opus-5-5")))
#expect(!host.offers(ModelRef(instanceId: "codex", slug: "claude-opus-5-5")))
let detail = try decode(ThreadDetail.self, #"{ "item": \#(feedItemJSON), "messages": [], "canStop": true, "askFirst": false }"#)
#expect(detail.askFirst == false)
let prefs = try decode(NotificationPrefs.self, #"{ "needsMe": true, "finished": true, "failed": true, "subagents": false }"#)
#expect(NotificationPrefs.Key.allCases.map { prefs[$0] } == [true, true, true, false])
let request = try encodeObject(NewThreadRequest(
hostId: "h", projectId: "p", text: "t",
model: ModelRef(instanceId: "claudeAgent", slug: "claude-sonnet-5-5"), askFirst: true))
#expect((request["model"] as? [String: String]) == ["instanceId": "claudeAgent", "slug": "claude-sonnet-5-5"])
#expect(request["askFirst"] as? Bool == true)
let plain = try encodeObject(NewThreadRequest(hostId: "h", projectId: "p", text: "t"))
#expect(plain["model"] == nil && plain["askFirst"] == nil)
}
@Test func phase3FromDocs() throws {
let working = feedItemJSON
.replacingOccurrences(of: "waiting_for_approval", with: "running")
.replacingOccurrences(
of: #""request": {"#,
with: #""activity": { "kind": "command", "text": "bun test", "startedAt": "2026-11-01T09:40:00.000Z" }, "request": {"#)
let detail = try decode(ThreadDetail.self, """
{ "item": \(working), "messages": [], "canStop": true, "askFirst": false, "earlierCursor": "w12",
"subagents": [
{ "hostId": "h", "threadId": "s1", "title": "Check PATH", "phase": "running", "settled": false, "updatedAt": "2026-11-01T09:41:00.000Z" },
{ "hostId": "h", "threadId": "s2", "title": "Stopped", "phase": null, "settled": true, "updatedAt": "2026-11-01T09:30:00.000Z" }
] }
""")
let activity = try #require(detail.item.activity)
#expect(activity.kind == .command)
#expect(activity.text == "bun test")
#expect(activity.startedAt == Date(timeIntervalSince1970: 1_793_526_000))
#expect(detail.earlierCursor == "w12")
#expect(detail.subagents.map(\.statusWord) == ["working", "settled"])
#expect(detail.subagents.map(\.isActive) == [true, false])
#expect(detail.subagents.last?.phase == nil)
let thinking = try decode(Activity.self, #"{ "kind": "thinking", "text": "Thinking", "startedAt": null }"#)
#expect(thinking.startedAt == nil)
let history = try decode(HistoryResponse.self, """
{ "messages": [{ "id": "m1", "role": "user", "text": "Hi", "createdAt": "2026-11-01T09:00:00Z" }], "earlierCursor": null }
""")
#expect(history.messages.map(\.id) == ["m1"])
#expect(history.earlierCursor == nil)
let list = try decode(ThreadListResponse.self, """
{ "items": [{ "hostId": "h", "threadId": "t", "title": "Phase 3", "phase": "failed", "settled": false,
"updatedAt": "2026-11-01T09:41:00.000Z" }] }
""")
#expect(list.items.first?.statusWord == "failed")
#expect(list.items.first?.route == .thread(hostId: "h", threadId: "t"))
}
@Test func phase4FromDocs() throws {
let item = try decode(FeedItem.self, feedItemJSON.replacingOccurrences(
of: #""request": {"#, with: #""changes": { "files": 3, "additions": 42, "deletions": 8 }, "request": {"#))
#expect(item.changes == ChangeTotals(files: 3, additions: 42, deletions: 8))
#expect(try decode(FeedItem.self, feedItemJSON).changes == nil)
let detail = try decode(ThreadDetail.self, """
{ "item": \(feedItemJSON), "messages": [], "canStop": false, "subagents": [],
"changedFiles": [{ "path": "gateway/Containerfile", "name": "Containerfile", "additions": 31, "deletions": 0 }] }
""")
#expect(detail.changedFiles == [ChangedFile(path: "gateway/Containerfile", name: "Containerfile", additions: 31, deletions: 0)])
#expect(try decode(ThreadDetail.self, #"{ "item": \#(feedItemJSON), "messages": [], "canStop": true }"#).changedFiles.isEmpty)
let summary = try decode(Summary.self, """
{ "needsYou": 1, "working": 3, "hostsOffline": 0,
"next": { "hostId": "h", "threadId": "t", "title": "Receipt OCR", "hostLabel": "Mac mini", "request": "bun test --filter ocr" },
"current": { "hostId": "h", "threadId": "w", "title": "Phase 3 screens",
"activity": { "kind": "command", "text": "bun test", "startedAt": null } } }
""")
#expect(summary.next?.request == "bun test --filter ocr")
#expect(summary.current?.title == "Phase 3 screens")
#expect(summary.current?.activity?.text == "bun test")
#expect(summary.current?.route == .thread(hostId: "h", threadId: "w"))
let unread = try decode(Summary.self, """
{ "needsYou": 1, "working": 0, "hostsOffline": 0, "current": null,
"next": { "hostId": "h", "threadId": "t", "title": "Receipt OCR", "hostLabel": "Mac mini", "request": null } }
""")
#expect(unread.next?.request == nil)
#expect(unread.current == nil)
let search = try decode(SearchResponse.self, """
{ "hits": [{ "thread": { "hostId": "h", "threadId": "t", "title": "Jellyfin image", "phase": null, "settled": true,
"updatedAt": "2026-11-01T09:41:00.000Z" },
"projectTitle": "homelab", "hostLabel": "Mac mini", "snippet": "…rebuild from the Containerfile and…" },
{ "thread": { "hostId": "h", "threadId": "u", "title": "Gateway Containerfile", "phase": "running", "settled": false,
"updatedAt": "2026-11-01T09:42:00.000Z" },
"projectTitle": "wristcode", "hostLabel": "Mac mini", "snippet": null }],
"unsearched": ["Laptop"] }
""")
#expect(search.hits.map(\.id) == ["h/t", "h/u"])
#expect(search.hits.first?.snippet == "…rebuild from the Containerfile and…")
#expect(search.hits.last?.snippet == nil)
#expect(search.unsearched == ["Laptop"])
}
@Test func phase5FromDocs() throws {
let feed = try decode(Feed.self, """
{
"needsYou": [], "working": [], "recent": [], "recentTotal": 0, "snoozedTotal": 2, "hosts": []
}
""")
#expect(feed.snoozedTotal == 2)
let snoozed = try decode(RecentResponse.self, """
{ "items": [{
"hostId": "h", "hostLabel": "Mac mini", "threadId": "t", "projectId": "p", "projectTitle": "wristcode",
"title": "Gateway Containerfile", "phase": "completed", "updatedAt": "2026-11-02T14:00:00.000Z",
"lastMessage": null, "activity": null, "changes": null, "snoozedUntil": "2026-11-03T14:00:00.000Z",
"request": null, "via": null
}] }
""")
#expect(snoozed.items.first?.snoozedUntil == JSON.parseDate("2026-11-03T14:00:00.000Z"))
let request = try decode(PendingRequest.self, """
{
"id": "r", "kind": "file-change", "prompt": "Edit: deploy/compose.yml", "reason": null,
"options": [{ "decision": "accept", "label": "Allow" }, { "decision": "decline", "label": "Deny" }],
"files": [
{ "path": "deploy/compose.yml", "name": "compose.yml", "additions": 9, "deletions": 6,
"preview": ["- image: bun:1", "+ image: bun:1-alpine"] },
{ "path": "gateway/src/health.ts", "name": "health.ts", "additions": null, "deletions": null, "preview": [] }
],
"questions": []
}
""")
#expect(request.files.map(\.name) == ["compose.yml", "health.ts"])
#expect(request.files[0].additions == 9)
#expect(request.files[1].deletions == nil)
#expect(request.files[0].preview.count == 2)
// Older gateways send neither, which decodes as nothing snoozed and no files.
let old = try decode(Feed.self, """
{ "needsYou": [\(feedItemJSON)], "working": [], "recent": [], "hosts": [] }
""")
#expect(old.snoozedTotal == 0)
#expect(old.needsYou.first?.snoozedUntil == nil)
#expect(old.needsYou.first?.request?.files == [])
}
@Test func pushQuestion() throws {
let info: [AnyHashable: Any] = [
"hostId": "h", "threadId": "t", "requestId": "r",
"question": ["id": "q1", "options": [["label": "bun:1-alpine", "value": "alpine"]], "allowCustomAnswer": true],
]
let question = try #require(PushQuestion(userInfo: info))
#expect(question.id == "q1")
#expect(question.options.map(\.value) == ["alpine"])
#expect(question.allowCustomAnswer)
// Pushes without one, as from an older gateway, and malformed ones have none.
#expect(PushQuestion(userInfo: ["hostId": "h"]) == nil)
#expect(PushQuestion(userInfo: ["question": ["id": "q1"]]) == nil)
} }
@Test func smallResponses() throws { @Test func smallResponses() throws {
@@ -111,6 +353,33 @@ private let feedItemJSON = """
#expect(try decode(ErrorResponse.self, #"{ "error": "That host is offline" }"#).error == "That host is offline") #expect(try decode(ErrorResponse.self, #"{ "error": "That host is offline" }"#).error == "That host is offline")
} }
@Test func statusFromDocs() throws {
let status = try decode(StatusResponse.self, """
{
"hosts": [{
"id": "laptop", "label": "Laptop", "status": "online", "error": null,
"activeUrl": "http://laptopvpn.gib:3773",
"routes": [
{ "url": "http://laptop.gib:3773", "kind": "direct", "result": "failed", "error": "no answer in 5 s", "checkedAt": "2026-11-01T09:12:00.000Z" },
{ "url": "http://laptopvpn.gib:3773", "kind": "vpn", "result": "ok", "error": null, "checkedAt": "2026-11-01T09:12:00.000Z" },
{ "url": "https://t3laptop.gbrown.org", "kind": "public", "result": "untried", "error": null, "checkedAt": null }
],
"onlineSince": "2026-11-01T09:12:00.000Z", "lastOnlineAt": null, "nextRetryAt": null,
"tokenExpiresAt": "2026-12-01T00:00:00.000Z", "serverVersion": "0.1.5"
}],
"gateway": { "startedAt": "2026-11-01T08:00:00.000Z", "pushConfigured": true,
"lastPush": { "ok": false, "at": "2026-11-01T09:40:00.000Z", "status": 400, "reason": "BadDeviceToken" } },
"device": { "name": "Apple Watch", "pairedAt": "2026-10-30T08:00:00.000Z", "pushRegistered": true, "apnsEnvironment": null }
}
""")
let host = try #require(status.hosts.first)
#expect(host.onFallback)
#expect(host.activeRoute?.kind == .vpn)
#expect(host.routes.map(\.hostPort) == ["laptop.gib:3773", "laptopvpn.gib:3773", "t3laptop.gbrown.org"])
#expect(status.device.apnsEnvironment == nil)
#expect(status.gateway.lastPush?.hint?.contains("sandbox/production") == true)
}
@Test func badDateFails() { @Test func badDateFails() {
#expect(throws: DecodingError.self) { #expect(throws: DecodingError.self) {
try decode(Message.self, #"{ "id": "m", "role": "user", "text": "x", "createdAt": "yesterday" }"#) try decode(Message.self, #"{ "id": "m", "role": "user", "text": "x", "createdAt": "yesterday" }"#)
@@ -127,11 +396,19 @@ private let feedItemJSON = """
} }
@Test func respondWithAnswers() throws { @Test func respondWithAnswers() throws {
let object = try encodeObject(RespondRequest.answers(requestId: "r1", answers: ["q1": "yes"])) let object = try encodeObject(RespondRequest.answers(requestId: "r1", answers: ["q1": .text("yes")]))
#expect(object["answers"] as? [String: String] == ["q1": "yes"]) #expect(object["answers"] as? [String: String] == ["q1": "yes"])
#expect(object["decision"] == nil) #expect(object["decision"] == nil)
} }
@Test func respondWithMultiSelectAnswers() throws {
let object = try encodeObject(RespondRequest.answers(
requestId: "r1", answers: ["checks": .values(["unit", "lint"]), "note": .text("after lunch")]))
let answers = try #require(object["answers"] as? [String: Any])
#expect(answers["checks"] as? [String] == ["unit", "lint"])
#expect(answers["note"] as? String == "after lunch")
}
@Test func pushRegistration() throws { @Test func pushRegistration() throws {
let object = try encodeObject(PushRegistration(apnsToken: "ab", apnsEnvironment: .sandbox)) let object = try encodeObject(PushRegistration(apnsToken: "ab", apnsEnvironment: .sandbox))
#expect(object["apnsEnvironment"] as? String == "sandbox") #expect(object["apnsEnvironment"] as? String == "sandbox")
@@ -160,6 +437,94 @@ private let feedItemJSON = """
#expect(Format.age(of: now.addingTimeInterval(-50 * 3600), now: now) == "2 d") #expect(Format.age(of: now.addingTimeInterval(-50 * 3600), now: now) == "2 d")
} }
@Test func elapsedTimes() {
let now = Date(timeIntervalSince1970: 100_000)
#expect(Format.elapsed(since: now.addingTimeInterval(5), now: now) == "0s")
#expect(Format.elapsed(since: now.addingTimeInterval(-42), now: now) == "42s")
#expect(Format.elapsed(since: now.addingTimeInterval(-60), now: now) == "1m")
#expect(Format.elapsed(since: now.addingTimeInterval(-199), now: now) == "3m")
#expect(Format.elapsed(since: now.addingTimeInterval(-3600), now: now) == "1h")
#expect(Format.elapsed(since: now.addingTimeInterval(-50 * 3600), now: now) == "2d")
}
@Test func olderMessagesMerge() throws {
func m(_ id: String) -> Message { Message(id: id, role: .assistant, text: id, createdAt: .now) }
let detail = try decode(ThreadDetail.self, #"{ "item": \#(feedItemJSON), "messages": [], "canStop": false, "earlierCursor": "w3" }"#)
var history = MessageHistory()
#expect(history.cursor(detail) == "w3")
#expect(history.messages(before: [m("4"), m("5")]).map(\.id) == ["4", "5"])
// A page that overlaps the window shows each message once.
history.prepend(HistoryResponse(messages: [m("2"), m("3"), m("4")], earlierCursor: "w1"))
#expect(history.cursor(detail) == "w1")
#expect(history.messages(before: [m("4"), m("5")]).map(\.id) == ["2", "3", "4", "5"])
// A refresh moves the window on. The outgoing window stays, so no gap opens.
history.keep([m("4"), m("5")])
#expect(history.messages(before: [m("6"), m("7")]).map(\.id) == ["2", "3", "4", "5", "6", "7"])
history.keep([m("6"), m("7")])
history.prepend(HistoryResponse(messages: [m("1"), m("2")], earlierCursor: nil))
#expect(history.cursor(detail) == nil)
#expect(history.messages(before: [m("7")]).map(\.id) == ["1", "2", "3", "4", "5", "6", "7"])
// Nothing is kept before Load earlier is used.
var fresh = MessageHistory()
fresh.keep([m("4")])
#expect(fresh.messages(before: [m("5")]).map(\.id) == ["5"])
}
@Test func olderMessagesTakeTheNewerCopy() {
func m(_ id: String, _ text: String) -> Message { Message(id: id, role: .assistant, text: text, createdAt: .now) }
var history = MessageHistory()
// Load earlier goes out with 3 caught mid-stream. A refresh lands before the page does.
history.startKeeping([m("3", "Runn"), m("4", "Done")])
history.keep([m("3", "Running the suite"), m("4", "Done"), m("5", "Next")])
#expect(history.messages(before: [m("6", "Later")]).map(\.text) == ["Running the suite", "Done", "Next", "Later"])
// The page repeats 3 and its copy, fetched last, wins in place.
history.prepend(HistoryResponse(messages: [m("2", "Two"), m("3", "Running the suite, all 48")], earlierCursor: nil))
#expect(history.messages(before: [m("6", "Later")]).map(\.text)
== ["Two", "Running the suite, all 48", "Done", "Next", "Later"])
}
@Test func queryValuesAreEncodedStrictly() {
let url = GatewayClient.url(
URL(string: "https://gateway.test")!, ["v1", "threads", "h/1", "t"],
query: [URLQueryItem(name: "cursor", value: "habc+/d=&e f")])
#expect(url.absoluteString == "https://gateway.test/v1/threads/h%2F1/t?cursor=habc%2B%2Fd%3D%26e%20f")
#expect(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?.first?.value == "habc+/d=&e f")
}
@Test func threadCacheKeepsTheNewest() async throws {
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
defer { try? FileManager.default.removeItem(at: directory) }
var cache = FeedCache(directory: directory)
cache.threadLimit = 3
var detail = try decode(ThreadDetail.self, #"{ "item": \#(feedItemJSON), "messages": [], "canStop": false }"#)
for id in ["a", "b", "c", "d", "e"] {
detail.item.threadId = id
cache.save(detail)
try await Task.sleep(for: .milliseconds(20))
}
cache.save(Feed(needsYou: [], working: [], recent: [], hosts: []))
let kept = ["a", "b", "c", "d", "e"].filter { cache.thread(hostId: detail.item.hostId, threadId: $0) != nil }
#expect(kept == ["c", "d", "e"])
#expect(cache.feed() != nil)
}
@Test func markdownReadsCleanly() {
#expect(Markdown.block("## Done") == "**Done**")
#expect(Markdown.block("## Porting to C#") == "**Porting to C#**")
#expect(Markdown.block("## Done ##") == "**Done**")
#expect(Markdown.block("# Notes # ") == "**Notes**")
#expect(Markdown.block(" - item") == " • item")
#expect(Markdown.block("> quoted") == "quoted")
#expect(Markdown.block("**bold** stays") == "**bold** stays")
let text = String(Markdown.render("All **48** pass in `host.ts`:\n- bounded retry\n```\n# not a heading\n```").characters)
#expect(text == "All 48 pass in host.ts:\n• bounded retry\n# not a heading")
}
@Test func messageSplitShowsLatestAgentMessage() { @Test func messageSplitShowsLatestAgentMessage() {
func m(_ id: String, _ role: Message.Role) -> Message { Message(id: id, role: role, text: id, createdAt: .now) } func m(_ id: String, _ role: Message.Role) -> Message { Message(id: id, role: role, text: id, createdAt: .now) }
let split = MessageSplit([m("1", .user), m("2", .assistant), m("3", .assistant), m("4", .user)]) let split = MessageSplit([m("1", .user), m("2", .assistant), m("3", .assistant), m("4", .user)])
@@ -185,6 +550,103 @@ private let feedItemJSON = """
== ProjectRef(hostId: "on", projectId: "a")) == ProjectRef(hostId: "on", projectId: "a"))
} }
@Test func newThreadAgentAndOptions() {
let claude = AgentProvider(
instanceId: "claudeAgent", name: "Claude", canAskFirst: true,
models: [.init(slug: "opus", name: "Claude Opus"), .init(slug: "sonnet", name: "Claude Sonnet")])
let other = AgentProvider(instanceId: "other", name: "Other", canAskFirst: false, models: [.init(slug: "o1", name: "O1")])
let project = ProjectsResponse.Project(id: "p", title: "p", model: "opus", instanceId: "claudeAgent")
let host = ProjectsResponse.Host(id: "h", label: "H", status: .online, error: nil, projects: [project], providers: [claude, other])
let sonnet = ModelRef(instanceId: "claudeAgent", slug: "sonnet")
// The last pick wins while the host still offers it, else the project's default.
#expect(NewThreadView.agent(on: host, project: project, last: sonnet) == sonnet)
#expect(NewThreadView.agent(on: host, project: project, last: ModelRef(instanceId: "claudeAgent", slug: "gone"))
== project.defaultModel)
#expect(NewThreadView.agent(on: host, project: project, last: nil) == project.defaultModel)
// The default model is left out so it keeps its options. A provider that cannot ask sends false.
let byDefault = NewThreadView.options(project: project, agent: project.defaultModel, provider: claude, askFirst: true)
#expect(byDefault.model == nil && byDefault.askFirst == true)
let picked = NewThreadView.options(project: project, agent: sonnet, provider: claude, askFirst: false)
#expect(picked.model == sonnet && picked.askFirst == false)
let o1 = ModelRef(instanceId: "other", slug: "o1")
#expect(NewThreadView.options(project: project, agent: o1, provider: other, askFirst: true).askFirst == false)
// An older gateway lists no providers, so neither is sent.
let old = NewThreadView.options(project: project, agent: nil, provider: nil, askFirst: true)
#expect(old.model == nil && old.askFirst == nil)
#expect(AgentList.shortName("Claude Opus 5.5", provider: "Claude") == "Opus 5.5")
#expect(AgentList.shortName("GPT-5.5", provider: "Codex") == "GPT-5.5")
}
@Test func summaryFromFeed() throws {
let feed = DemoFixtures.previewFeed()
let summary = Summary(feed)
#expect(summary.needsYou == feed.needsYou.count)
#expect(summary.working == feed.working.count)
#expect(summary.hostsOffline == 1)
#expect(summary.next?.threadId == feed.needsYou.first?.threadId)
#expect(Summary(Feed(needsYou: [], working: [], recent: [], hosts: [])).next == nil)
}
@Test func summaryFromFeedNamesRequestAndCurrent() throws {
let feed = DemoFixtures.previewFeed()
let summary = Summary(feed)
#expect(summary.next?.request == feed.needsYou.first?.request?.summary)
#expect(summary.current?.threadId == feed.working.first?.threadId)
#expect(summary.current?.activity == feed.working.first?.activity)
}
@Test func changesLine() {
let parts = ChangeTotals(files: 3, additions: 42, deletions: 8).parts
#expect(parts.additions == "+42")
#expect(parts.deletions == "\u{2212}8")
#expect(parts.files == "3 files")
#expect(ChangeTotals(files: 1, additions: 0, deletions: 2).parts == ("+0", "\u{2212}2", "1 file"))
}
@Test func unsearchedNote() {
#expect(SearchResponse.unsearchedNote([], offline: []) == nil)
#expect(SearchResponse.unsearchedNote(["Laptop"], offline: ["Laptop"])
== "Laptop is offline, so its threads were not searched.")
#expect(SearchResponse.unsearchedNote(["Laptop", "LittleMan"], offline: ["Laptop", "LittleMan"])
== "Laptop and LittleMan are offline, so their threads were not searched.")
// A host the watch saw online failed or was slow, which the gateway does not tell apart.
#expect(SearchResponse.unsearchedNote(["Laptop"], offline: []) == "Laptop could not be searched.")
#expect(SearchResponse.unsearchedNote(["Laptop", "LittleMan"], offline: ["Laptop"])
== "Laptop and LittleMan could not be searched.")
#expect(SearchResponse.unsearchedNote(["A", "B", "C"], offline: []) == "A, B and C could not be searched.")
}
@Test func complicationMode() {
let next = Summary.Next(hostId: "h", threadId: "t", title: "Receipt OCR", hostLabel: "Mac mini", request: "bun test")
let current = Summary.Current(hostId: "h", threadId: "w", title: "Phase 3", activity: nil)
#expect(ComplicationMode(Summary(needsYou: 2, working: 1, next: next, current: current, hostsOffline: 0)) == .waiting(next))
#expect(ComplicationMode(Summary(needsYou: 0, working: 1, next: nil, current: current, hostsOffline: 0)) == .working(current))
#expect(ComplicationMode(Summary(needsYou: 0, working: 0, next: nil, hostsOffline: 0)) == .idle)
// An older gateway names no working thread.
#expect(ComplicationMode(Summary(needsYou: 0, working: 3, next: nil, hostsOffline: 0)) == .idle)
}
@Test func routeURLs() {
let thread = Route.thread(hostId: "mac mini", threadId: "t/1")
#expect(thread.url.absoluteString == "wristcode://thread/mac%20mini/t%2F1")
#expect(Route(url: thread.url) == thread)
#expect(Route(url: Route.newThread.url) == .newThread)
for route in [
Route.recent, .snoozed, .status, .host(id: "mac mini"), .notifications, .allThreads, .hostProjects(hostId: "mac mini"),
.projectThreads(hostId: "mac mini", projectId: "p/1"), .search(query: "bun & test?"),
] {
#expect(Route(url: route.url) == route)
}
#expect(Route(url: URL(string: "wristcode://thread/only-host")!) == nil)
// The gateway searches two characters or more.
#expect(Route(url: URL(string: "wristcode://search?q=a")!) == nil)
#expect(Route(url: URL(string: "wristcode://search?q=ab")!) == .search(query: "ab"))
#expect(Route(url: URL(string: "https://thread/h/t")!) == nil)
}
@Test func notificationCommands() { @Test func notificationCommands() {
let info: [AnyHashable: Any] = ["hostId": "h", "threadId": "t", "requestId": "r"] let info: [AnyHashable: Any] = ["hostId": "h", "threadId": "t", "requestId": "r"]
#expect(NotificationCommand(actionIdentifier: "allow", userInfo: info, text: nil) #expect(NotificationCommand(actionIdentifier: "allow", userInfo: info, text: nil)
@@ -203,6 +665,191 @@ private let feedItemJSON = """
// Allow without a requestId cannot answer, so it opens the thread instead. // Allow without a requestId cannot answer, so it opens the thread instead.
#expect(NotificationCommand(actionIdentifier: "allow", userInfo: ["hostId": "h", "threadId": "t"], text: nil) #expect(NotificationCommand(actionIdentifier: "allow", userInfo: ["hostId": "h", "threadId": "t"], text: nil)
== .open(.thread(hostId: "h", threadId: "t"))) == .open(.thread(hostId: "h", threadId: "t")))
#expect(NotificationCommand(actionIdentifier: "settle", userInfo: info, text: nil) == .settle(hostId: "h", threadId: "t"))
#expect(NotificationCommand(actionIdentifier: "settle", userInfo: ["hostId": "h"], text: nil) == .open(nil))
// Answer opens the thread's question screen.
#expect(NotificationCommand(actionIdentifier: "answer", userInfo: info, text: nil) == .open(.thread(hostId: "h", threadId: "t")))
}
@Test func questionActionsBuildAnswers() throws {
let info: [AnyHashable: Any] = [
"hostId": "h", "threadId": "t", "requestId": "r",
"question": [
"id": "base", "allowCustomAnswer": true,
"options": [["label": "bun:1-alpine", "value": "alpine"], ["label": "bun:1-debian", "value": "debian"]],
],
]
func body(_ action: String, _ text: String? = nil) throws -> [String: Any] {
let response = try #require(NotificationCommand(actionIdentifier: action, userInfo: info, text: text).response)
#expect(response.hostId == "h" && response.threadId == "t")
return try encodeObject(response.request)
}
let picked = try body("option.1")
#expect(picked["requestId"] as? String == "r")
#expect(picked["answers"] as? [String: String] == ["base": "debian"])
#expect(try body("say", " the slim one ")["answers"] as? [String: String] == ["base": "the slim one"])
// An option the push does not have, an empty answer, or no question opens the thread instead.
let thread = NotificationCommand.open(.thread(hostId: "h", threadId: "t"))
#expect(NotificationCommand(actionIdentifier: "option.4", userInfo: info, text: nil) == thread)
#expect(NotificationCommand(actionIdentifier: "say", userInfo: info, text: " ") == thread)
#expect(NotificationCommand(actionIdentifier: "option.0", userInfo: ["hostId": "h", "threadId": "t", "requestId": "r"], text: nil) == thread)
}
@MainActor @Test func finishedNotificationActions() throws {
let finished = try #require(NotificationHandler.categories.first { $0.identifier == PushCategory.finished.rawValue })
#expect(finished.actions.map(\.title) == ["Reply", "Settle", "Open"])
// Settle runs in the background, without opening the app.
#expect(!finished.actions[1].options.contains(.foreground))
}
@MainActor @Test func cancelledCallsShowNoError() {
let model = AppModel(mode: .demo)
#expect(model.message(for: CancellationError()) == nil)
#expect(model.message(for: URLError(.cancelled)) == nil)
#expect(GatewayClient.transportError(URLError(.cancelled)) is CancellationError)
#expect(model.message(for: GatewayClient.transportError(URLError(.cancelled))) == nil)
// Other transport failures still show.
let offline = GatewayClient.transportError(URLError(.notConnectedToInternet))
#expect(offline as? GatewayError == .transport(URLError(.notConnectedToInternet).localizedDescription))
#expect(model.message(for: offline) != nil)
}
@Test func fileEditLines() throws {
func request(_ files: [PendingFile], kind: String = "file-change") -> PendingRequest {
PendingRequest(id: "r", kind: kind, prompt: "Edit: a", reason: nil, options: [], questions: [], files: files)
}
let compose = PendingFile(
path: "deploy/compose.yml", name: "compose.yml", additions: 9, deletions: 6,
preview: ["- image: bun:1", "+ image: bun:1-alpine", " context"])
let health = PendingFile(path: "src/health.ts", name: "health.ts", additions: nil, deletions: nil, preview: ["+x"])
let one = try #require(request([compose]).fileEdit)
#expect(one.title == "Wants to edit")
#expect(one.sentence == "Wants to edit compose.yml")
#expect(one.preview.map(\.change) == [.removed, .added, .context])
#expect(compose.counts.additions == "+9" && compose.counts.deletions == "\u{2212}6")
#expect(health.counts.additions == nil && health.counts.deletions == nil)
let two = try #require(request([compose, health]).fileEdit)
#expect(two.title == "Wants to edit 2 files")
#expect(two.sentence == "Wants to edit 2 files: compose.yml, health.ts")
// Several files show no preview.
#expect(two.preview.isEmpty)
// No files, or another kind, keeps the prompt.
#expect(request([]).fileEdit == nil)
#expect(request([compose], kind: "command").fileEdit == nil)
}
@MainActor @Test func questionNotificationActions() throws {
let question = PushQuestion(
id: "base", options: [.init(label: "bun:1-alpine", value: "alpine"), .init(label: "bun:1-debian", value: "debian")],
allowCustomAnswer: true)
let actions = NotificationHandler.inputActions(question)
#expect(actions.map(\.title) == ["bun:1-alpine", "bun:1-debian", "Say an answer", "Open"])
#expect(actions.map(\.identifier) == ["option.0", "option.1", "say", "open"])
#expect(actions[2] is UNTextInputNotificationAction)
var closed = question
closed.allowCustomAnswer = false
#expect(NotificationHandler.inputActions(closed).map(\.title) == ["bun:1-alpine", "bun:1-debian", "Open"])
#expect(NotificationHandler.inputActions(nil).map(\.title) == ["Answer", "Open"])
}
}
/// Reads and writes the real shared Keychain group, so the entitlement and Info.plist group agree.
@Suite(.serialized) struct SharedStateTests {
@Test func accessGroupHasTeamPrefix() {
#expect(DeviceStore.accessGroup.hasSuffix(".org.gbrown.wristcode.shared"))
#expect(!DeviceStore.accessGroup.hasPrefix("$"))
}
@Test func roundTrip() {
let demo = SharedState.demo
let summary = SharedState.lastSummary
defer {
SharedState.demo = demo
SharedState.lastSummary = summary
}
let saved = Summary(needsYou: 2, working: 1, next: nil, hostsOffline: 0)
SharedState.lastSummary = saved
#expect(SharedState.lastSummary == saved)
SharedState.lastSummary = nil
#expect(SharedState.lastSummary == nil)
SharedState.demo = true
#expect(SharedState.demo)
SharedState.demo = false
#expect(!SharedState.demo)
}
/// A token saved by a build before the complication sits in the app's own group. Launch moves it.
@Test func migratesLegacyItems() throws {
let before = DeviceStore.load()
defer {
if let before { DeviceStore.save(before) } else { DeviceStore.clearToken() }
}
let team = try #require(DeviceStore.accessGroup.split(separator: ".").first)
let appGroup = "\(team).org.gbrown.wristcode.watchkitapp"
func item(_ account: String) -> [String: Any] {
[kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "org.gbrown.wristcode",
kSecAttrAccount as String: account]
}
for (account, value) in [("deviceToken", "wc_legacy"), ("gatewayURL", "https://legacy.example")] {
SecItemDelete(item(account) as CFDictionary)
var legacy = item(account)
legacy[kSecAttrAccessGroup as String] = appGroup
legacy[kSecValueData as String] = Data(value.utf8)
#expect(SecItemAdd(legacy as CFDictionary, nil) == errSecSuccess)
}
#expect(DeviceStore.sharedData("deviceToken") == nil)
// Before the move the app still finds the old items.
#expect(DeviceStore.load()?.token == "wc_legacy")
DeviceStore.migrateToSharedGroup()
#expect(DeviceStore.sharedData("deviceToken") == Data("wc_legacy".utf8))
#expect(DeviceStore.load() == Credentials(gatewayURL: URL(string: "https://legacy.example")!, token: "wc_legacy"))
var old = item("deviceToken")
old[kSecAttrAccessGroup as String] = appGroup
#expect(SecItemCopyMatching(old as CFDictionary, nil) == errSecItemNotFound)
}
}
@Suite struct StatusReportTests {
private let now = Date(timeIntervalSince1970: 1_800_000_000)
@Test func demoListsIssuesWorstFirst() {
let report = StatusReport(DemoFixtures(now: now).status, now: now)
#expect(report.issues.map(\.title) == [
"LittleMan offline", "Laptop is on its VPN route", "server-gib token expires in 5 days",
])
#expect(report.issues.map(\.detail) == ["Both routes failed · 4 min", "laptop.gib:3773 did not answer", "Run renew-hosts.sh"])
#expect(!report.allConnected)
#expect(report.worst == .problem)
#expect(report.healthySummary == "2 more, all direct")
#expect(report.subline == "4 environments · pushes OK")
}
@Test func fallbackAloneIsStillAllConnected() {
var status = DemoFixtures(now: now).status
status.hosts.removeAll { $0.id == "littleman" }
for i in status.hosts.indices { status.hosts[i].tokenExpiresAt = now.addingTimeInterval(30 * 86_400) }
let report = StatusReport(status, now: now)
#expect(report.allConnected)
#expect(report.issues.map(\.kind) == [.fallback])
}
@Test func pushProblems() {
var status = DemoFixtures(now: now).status
status.device.pushRegistered = false
#expect(StatusReport(status, now: now).pushSummary == "watch not registered")
status.gateway.pushConfigured = false
let report = StatusReport(status, now: now)
#expect(report.pushSummary == "pushes off")
#expect(report.issues.first { $0.kind == .push }?.route == .notifications)
} }
} }
@@ -210,7 +857,7 @@ private let feedItemJSON = """
@Test func allowMovesThreadToWorking() async throws { @Test func allowMovesThreadToWorking() async throws {
let gateway = DemoGateway() let gateway = DemoGateway()
let before = try await gateway.feed() let before = try await gateway.feed()
let item = try #require(before.needsYou.first { $0.request?.quickOptions != nil }) let item = try #require(before.needsYou.first { $0.request?.quickOptions != nil && $0.via == nil })
try await gateway.respond( try await gateway.respond(
hostId: item.hostId, threadId: item.threadId, hostId: item.hostId, threadId: item.threadId,
request: .decision(requestId: item.request!.id, decision: .accept)) request: .decision(requestId: item.request!.id, decision: .accept))
@@ -223,4 +870,202 @@ private let feedItemJSON = """
request: .decision(requestId: item.request!.id, decision: .accept)) request: .decision(requestId: item.request!.id, decision: .accept))
} }
} }
@Test func subagentApprovalGoesToTheSubagent() async throws {
let gateway = DemoGateway()
let item = try #require(try await gateway.feed().needsYou.first { $0.via != nil })
let via = try #require(item.via)
let decision = RespondRequest.decision(requestId: item.request!.id, decision: .accept)
// The parent has no request of its own to answer.
await #expect(throws: GatewayError.http(status: 409, message: "Already answered.")) {
try await gateway.respond(hostId: item.hostId, threadId: item.threadId, request: decision)
}
try await gateway.respond(hostId: via.hostId, threadId: via.threadId, request: decision)
let after = try await gateway.feed()
#expect(!after.needsYou.contains { $0.id == item.id })
#expect(after.working.first { $0.id == item.id }?.via == nil)
}
@Test func settleLeavesTheFeed() async throws {
let gateway = DemoGateway()
let item = try #require(try await gateway.feed().recent.first)
try await gateway.settle(hostId: item.hostId, threadId: item.threadId)
#expect(!(try await gateway.feed().recent.contains { $0.id == item.id }))
// A reply brings it back.
try await gateway.sendMessage(hostId: item.hostId, threadId: item.threadId, text: "One more thing")
#expect(try await gateway.feed().working.contains { $0.id == item.id })
}
@Test func feedShowsTenRecentAndCountsTheRest() async throws {
let gateway = DemoGateway()
let feed = try await gateway.feed()
let all = try await gateway.recent().items
#expect(feed.recent.count == 10)
#expect(feed.recentTotal == all.count)
#expect(feed.moreRecent == all.count - 10)
#expect(feed.recent == Array(all.prefix(10)))
}
@Test func settleRecentLeavesFailedThreads() async throws {
let gateway = DemoGateway()
let all = try await gateway.recent().items
let result = try await gateway.settleRecent()
#expect(result == SettleRecentResponse(settled: all.filter { $0.phase == .completed }.count, failed: 0))
let left = try await gateway.recent().items
#expect(!left.isEmpty && left.allSatisfy { $0.phase == .failed })
#expect(try await gateway.feed().moreRecent == 0)
}
@Test func subagentsOpenButStayOutOfTheFeed() async throws {
let gateway = DemoGateway()
let feed = try await gateway.feed()
let parent = try #require(feed.working.first { $0.threadId == "t-container" })
let detail = try await gateway.thread(hostId: parent.hostId, threadId: parent.threadId)
#expect(!detail.subagents.isEmpty)
let listed = feed.needsYou + feed.working + feed.recent
for row in detail.subagents {
#expect(!listed.contains { $0.threadId == row.threadId })
#expect(try await gateway.thread(hostId: row.hostId, threadId: row.threadId).item.title == row.title)
}
}
@Test func searchSkipsOfflineHosts() async throws {
let gateway = DemoGateway()
let response = try await gateway.search("healthcheck")
#expect(response.hits.map(\.thread.threadId).sorted() == ["t-container", "t-health"])
#expect(response.hits.first { $0.thread.threadId == "t-container" }?.snippet?.contains("healthcheck") == true)
#expect(response.hits.first { $0.thread.threadId == "t-health" }?.snippet == nil)
#expect(response.unsearched == ["LittleMan"])
// Bump Next.js is on LittleMan, which is offline.
#expect(try await gateway.search("Next.js").hits.isEmpty)
}
@Test func approvalForTheSmartStack() async throws {
let gateway = DemoGateway()
// The demo's longest wait is a question, which gets no buttons.
#expect(await WaitingEntry.Approval.load(for: try await gateway.summary(), api: gateway) == nil)
let summary = Summary(
needsYou: 1, working: 0, next: .init(hostId: "mac-mini", threadId: "t-ocr", title: "Receipt OCR", hostLabel: "Mac mini"),
hostsOffline: 0)
let approval = try #require(await WaitingEntry.Approval.load(for: summary, api: gateway))
#expect(approval.requestId == "r-ocr")
#expect(approval.allow.decision == .accept && approval.deny.decision == .decline)
}
@Test func multiSelectAnswerMovesThreadToWorking() async throws {
let gateway = DemoGateway()
let item = try #require(try await gateway.feed().needsYou.first { $0.request?.questions.first?.multiSelect == true })
let request = try #require(item.request)
let question = try #require(request.questions.first)
try await gateway.respond(
hostId: item.hostId, threadId: item.threadId,
request: .answers(requestId: request.id, answers: [question.id: .values(["unit", "lint"])]))
#expect(try await gateway.summary().working == 3)
#expect(try await gateway.feed().working.contains { $0.id == item.id })
}
}
@Suite struct SnoozeTests {
private var calendar: Calendar {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(identifier: "America/Chicago")!
return calendar
}
private func date(_ day: Int, _ hour: Int, _ minute: Int = 0) -> Date {
calendar.date(from: DateComponents(year: 2026, month: 11, day: day, hour: hour, minute: minute))!
}
@Test func choicesOnAMondayAfternoon() {
// Monday, November 2, 2026 at 2 PM.
let choices = SnoozeChoice.choices(now: date(2, 14), calendar: calendar)
#expect(choices.map(\.title) == ["In 1 hour", "This evening · 6 PM", "Tomorrow · 9 AM", "Next week"])
#expect(choices.map(\.until) == [date(2, 15), date(2, 18), date(3, 9), date(9, 9)])
}
@Test func eveningLeavesFromFivePM() {
#expect(SnoozeChoice.choices(now: date(2, 16, 59), calendar: calendar).count == 4)
let late = SnoozeChoice.choices(now: date(2, 17, 30), calendar: calendar)
#expect(late.map(\.title) == ["In 1 hour", "Tomorrow · 9 AM", "Next week"])
}
@Test func nextWeekIsNextMonday() {
// Sunday the 8th: tomorrow is Monday. Saturday the 7th: two days on.
#expect(SnoozeChoice.choices(now: date(8, 10), calendar: calendar).last?.until == date(9, 9))
#expect(SnoozeChoice.choices(now: date(7, 10), calendar: calendar).last?.until == date(9, 9))
#expect(SnoozeChoice.choices(now: date(4, 10), calendar: calendar).last?.until == date(9, 9))
}
@Test func wakesWording() {
let now = date(2, 14)
#expect(Format.wakes(at: date(2, 18), now: now, calendar: calendar) == "Wakes at 6 PM")
#expect(Format.wakes(at: date(2, 15, 30), now: now, calendar: calendar) == "Wakes at 3:30 PM")
#expect(Format.wakes(at: date(3, 9), now: now, calendar: calendar) == "Wakes tomorrow 9 AM")
#expect(Format.wakes(at: date(6, 9), now: now, calendar: calendar) == "Wakes Friday 9 AM")
#expect(Format.wakes(at: date(30, 9), now: now, calendar: calendar) == "Wakes Nov 30, 9 AM")
}
@Test func snoozeMovesAThreadFromRecentToSnoozed() async throws {
let gateway = DemoGateway()
let before = try await gateway.feed()
let item = try #require(before.recent.first)
try await gateway.snooze(hostId: item.hostId, threadId: item.threadId, until: .now.addingTimeInterval(3600))
let after = try await gateway.feed()
#expect(!after.recent.contains { $0.id == item.id })
#expect(after.snoozedTotal == before.snoozedTotal + 1)
#expect(try await gateway.snoozed().items.contains { $0.id == item.id && $0.snoozedUntil != nil })
try await gateway.unsnooze(hostId: item.hostId, threadId: item.threadId)
#expect(try await gateway.feed().recent.contains { $0.id == item.id })
}
}
@MainActor @Suite struct FeedCacheTests {
private func temporaryCache() -> FeedCache {
FeedCache(directory: FileManager.default.temporaryDirectory.appending(path: "FeedCacheTests-\(UUID().uuidString)"))
}
/// Fixtures from a whole second, so their dates survive JSON, which keeps milliseconds.
private let gateway = DemoGateway(now: Date(timeIntervalSince1970: 1_793_646_000))
@Test func roundTrip() async throws {
let cache = temporaryCache()
defer { cache.clear() }
#expect(cache.feed() == nil)
let feed = try await gateway.feed()
cache.save(feed)
#expect(cache.feed() == feed)
let detail = try await gateway.thread(hostId: "server-gib", threadId: "t-compose")
cache.save(detail)
#expect(cache.thread(hostId: "server-gib", threadId: "t-compose") == detail)
#expect(cache.thread(hostId: "server-gib", threadId: "t-other") == nil)
}
@Test func pairedModelStartsOnTheCacheAndSignOutClearsIt() async throws {
let cache = temporaryCache()
defer { cache.clear() }
let feed = try await gateway.feed()
cache.save(feed)
let credentials = Credentials(gatewayURL: URL(string: "https://gateway.invalid")!, token: "token")
let model = AppModel(mode: .paired(credentials), cache: cache)
#expect(model.feed == feed)
#expect(model.feedIsCached)
model.signOut()
// The clear runs off the main actor, after any write still queued.
await model.cacheWrite?.value
#expect(cache.feed() == nil)
#expect(model.feed == nil)
}
@Test func demoModeKeepsOffTheCache() async throws {
let cache = temporaryCache()
defer { cache.clear() }
cache.save(try await DemoGateway().feed())
let model = AppModel(mode: .demo, cache: cache)
#expect(model.feed == nil)
#expect(!model.feedIsCached)
#expect(model.cachedThread(hostId: "server-gib", threadId: "t-compose") == nil)
}
} }
+343
View File
@@ -0,0 +1,343 @@
// Renders screens with demo data to PNGs in watch/screenshots. Skipped unless WC_SCREENSHOT_DIR is
// set, which xcodebuild passes through from TEST_RUNNER_WC_SCREENSHOT_DIR:
//
// TEST_RUNNER_WC_SCREENSHOT_DIR=$PWD/screenshots xcodebuild ... test -only-testing:WristCodeTests/SnapshotTests
import ImageIO
import SwiftUI
import Testing
import UniformTypeIdentifiers
import WidgetKit
@testable import WristCode
private let outputDirectory = ProcessInfo.processInfo.environment["WC_SCREENSHOT_DIR"]
/// The Series 12 (46mm) screen in points.
private let screen = CGSize(width: 208, height: 248)
@MainActor @Suite(.enabled(if: outputDirectory != nil))
struct SnapshotTests {
@Test func complication() async throws {
let gateway = DemoGateway()
let waiting = WaitingEntry(date: .now, content: .counts(try await gateway.summary(), stale: false))
let feed = try await gateway.feed()
for item in feed.needsYou {
let target = item.requestThread
try await gateway.respond(
hostId: target.hostId, threadId: target.threadId,
request: .decision(requestId: item.request!.id, decision: .decline))
}
let working = WaitingEntry(date: .now, content: .counts(try await gateway.summary(), stale: false))
try write(ComplicationGallery(sections: [("\(feed.needsYou.count) waiting", waiting), ("Nothing waiting", working)]), "complication.png")
try write(ComplicationGallery(sections: [("\(feed.needsYou.count) waiting", waiting)]), "complication-waiting.png")
try write(ComplicationGallery(sections: [("Nothing waiting", working)]), "complication-working.png")
}
@Test func smartStack() async throws {
// Answering the release question leaves the homelab card's subagent approval waiting longest.
let gateway = DemoGateway()
let release = try await gateway.thread(hostId: "mac-mini", threadId: "t-release")
try await gateway.respond(
hostId: "mac-mini", threadId: "t-release",
request: .answers(requestId: release.item.request!.id, answers: ["checks": .values(["unit"])]))
let summary = try await gateway.summary()
let approval = try #require(await WaitingEntry.Approval.load(for: summary, api: gateway))
let waiting = WaitingEntry(date: .now, content: .counts(summary, stale: false), approval: approval)
// Denying everything on a fresh gateway leaves the scaffold thread working most recently.
let idle = DemoGateway()
for item in try await idle.feed().needsYou {
let target = item.requestThread
try await idle.respond(
hostId: target.hostId, threadId: target.threadId,
request: .decision(requestId: item.request!.id, decision: .decline))
}
let working = WaitingEntry(date: .now, content: .counts(try await idle.summary(), stale: false))
try write(SmartStackGallery(entries: [waiting, working]), "smart-stack.png")
}
@Test func changes() async throws {
let gateway = DemoGateway()
let feed = try await gateway.feed()
try write(feedColumn {
RecentHeader(recent: feed.recent, more: feed.moreRecent)
ForEach(feed.recent.prefix(4)) { StatusCard(item: $0) }
}, "feed-changes.png")
let detail = try await gateway.thread(hostId: "littleman", threadId: "t-next")
try write(titled("Thread", ThreadView(hostId: "littleman", threadId: "t-next", detail: detail, scrolls: false)), "thread-changes.png")
}
@Test func search() async throws {
let gateway = DemoGateway()
let hits = try await gateway.search("healthcheck")
try write(titled("\u{201C}healthcheck\u{201D}", SearchView(query: "healthcheck", response: hits, scrolls: false)), "search.png")
let none = try await gateway.search("terraform")
try write(titled("\u{201C}terraform\u{201D}", SearchView(query: "terraform", response: none, scrolls: false)), "search-empty.png")
}
@Test func questionNotification() throws {
let question = PushQuestion(
id: "base", options: [.init(label: "bun:1-alpine", value: "alpine"), .init(label: "bun:1-debian", value: "debian")],
allowCustomAnswer: true)
try write(NotificationPreview(
view: InputNotificationView(title: "Gateway Containerfile", question: "Which base image?"),
actions: NotificationHandler.inputActions(question).map(\.title)), "notification-question.png")
}
@Test func multiSelectQuestion() async throws {
let detail = try await DemoGateway().thread(hostId: "mac-mini", threadId: "t-release")
let view = ThreadView(
hostId: "mac-mini", threadId: "t-release", detail: detail,
selections: ["checks": ["unit", "lint"]], scrolls: false)
.environment(AppModel.preview())
.fontDesign(.rounded)
.tint(Theme.work)
try write(view, "thread-multiselect.png")
}
@Test func subagentCard() async throws {
let feed = try await DemoGateway().feed()
let item = try #require(feed.needsYou.first { $0.via != nil })
try write(feedColumn { NeedsYouCard(item: item) }, "feed-subagent.png")
}
@Test func recentWithSettleAll() async throws {
let feed = try await DemoGateway().feed()
try write(feedColumn {
RecentHeader(recent: feed.recent, more: feed.moreRecent)
ForEach(feed.recent) { StatusCard(item: $0) }
MoreRecentRow(count: feed.moreRecent)
AllThreadsRow()
}, "feed-recent.png")
}
@Test func workingCardsWithActivity() async throws {
let feed = try await DemoGateway().feed()
try write(feedColumn { ForEach(feed.working) { StatusCard(item: $0) } }, "feed-activity.png")
}
@Test func browseHostsAndThreads() async throws {
// The preview model has the demo hosts and projects loaded.
try write(titled("All threads", HostsView(scrolls: false)), "browse-hosts.png")
let rows = try await DemoGateway().projectThreads(hostId: "mac-mini", projectId: "p-wc-mac").items
try write(titled("wristcode", ProjectThreadsView(hostId: "mac-mini", projectId: "p-wc-mac", rows: rows, scrolls: false)), "browse-threads.png")
}
@Test func threadWithSubagents() async throws {
let detail = try await DemoGateway().thread(hostId: "server-gib", threadId: "t-container")
try write(titled("Thread", ThreadView(hostId: "server-gib", threadId: "t-container", detail: detail, scrolls: false)), "thread-subagents.png")
try write(titled("Thread", ThreadView(hostId: "server-gib", threadId: "t-container", detail: detail, subagentsOpen: true, scrolls: false)), "thread-subagents-open.png")
}
@Test func threadWithEarlierAndMarkdown() async throws {
let detail = try await DemoGateway().thread(hostId: "server-gib", threadId: "t-hyprlock")
try write(titled("Thread", ThreadView(hostId: "server-gib", threadId: "t-hyprlock", detail: detail, scrolls: false)), "thread-earlier.png")
}
@Test func fullRecentList() async throws {
// The preview model has every demo Recent thread loaded.
try write(titled("Recent", RecentView(scrolls: false)), "recent.png")
}
@Test func subagentThread() async throws {
let detail = try await DemoGateway().thread(hostId: "mac-mini", threadId: "t-homelab")
try write(titled("Thread", ThreadView(hostId: "mac-mini", threadId: "t-homelab", detail: detail, scrolls: false)), "thread-subagent.png")
}
@Test func finishedThreadWithSettle() async throws {
let detail = try await DemoGateway().thread(hostId: "laptop", threadId: "t-status")
try write(titled("Thread", ThreadView(hostId: "laptop", threadId: "t-status", detail: detail, scrolls: false)), "thread-settle.png")
}
@Test func runningThreadWithAskFirst() async throws {
let detail = try await DemoGateway().thread(hostId: "mac-mini", threadId: "t-scaffold")
try write(titled("Thread", ThreadView(hostId: "mac-mini", threadId: "t-scaffold", detail: detail, scrolls: false)), "thread.png")
}
@Test func newThreadAndAgentList() async throws {
let projects = try await DemoGateway().projects()
// The demo opens on Mac mini › wristcode, with Ask before acting on as the mock shows it.
UserDefaults.standard.removeObject(forKey: "lastModels")
UserDefaults.standard.set(true, forKey: "askFirst")
defer { UserDefaults.standard.removeObject(forKey: "askFirst") }
try write(titled("New thread", NewThreadView(projects: projects, scrolls: false)), "new-thread.png")
let host = try #require(projects.hosts.first)
let project = try #require(host.projects.first)
let list = AgentList(
providers: host.providers, selected: project.defaultModel, defaultInstanceId: project.instanceId,
scrolls: false) { _ in }
try write(titled("Agent", list), "agent-list.png")
}
@Test func statusScreens() async throws {
let status = try await DemoGateway().status()
try write(titled("Status", StatusView(status: status, scrolls: false)), "status.png")
try write(titled("Laptop", HostStatusView(hostId: "laptop", status: status, scrolls: false)), "status-environment.png")
let result = PushResult(ok: true, at: .now, status: 200, reason: nil)
try write(titled("Notifications", NotificationsView(status: status, result: result, scrolls: false)), "status-notifications.png")
}
@Test func snooze() async throws {
let feed = try await DemoGateway().feed()
let item = try #require(feed.recent.first)
// 2 PM today, before This evening drops out, so all four choices show.
let afternoon = try #require(Calendar.current.date(bySettingHour: 14, minute: 0, second: 0, of: .now))
try write(titled("Snooze until", SnoozePicker(item: item, now: afternoon, scrolls: false)), "snooze-picker.png")
// The preview model has the demo's snoozed threads loaded.
try write(titled("Snoozed", SnoozedView(scrolls: false)), "snoozed.png")
try write(feedColumn {
RecentHeader(recent: feed.recent, more: feed.moreRecent)
ForEach(feed.recent.suffix(2)) { StatusCard(item: $0) }
MoreRecentRow(count: feed.moreRecent)
SnoozedRow(count: feed.snoozedTotal)
}, "feed-snoozed.png")
}
@Test func fileApprovals() async throws {
let gateway = DemoGateway()
let feed = try await gateway.feed()
let files = feed.needsYou.filter { $0.request?.fileEdit != nil }
#expect(files.count == 2)
try write(feedColumn { ForEach(files) { NeedsYouCard(item: $0) } }, "approval-file.png")
let detail = try await gateway.thread(hostId: "server-gib", threadId: "t-compose")
try write(titled("Thread", ThreadView(hostId: "server-gib", threadId: "t-compose", detail: detail, scrolls: false)), "thread-approval-file.png")
}
@Test func cachedFeed() async throws {
let feed = try await DemoGateway().feed()
try write(feedColumn {
FeedHeader(feed: feed, cached: true)
ForEach(feed.needsYou.prefix(2)) { NeedsYouCard(item: $0) }
}, "feed-cached.png")
}
/// Feed rows stacked as the feed's List lays them out, which ImageRenderer leaves blank.
private func feedColumn(@ViewBuilder _ rows: () -> some View) -> some View {
VStack(spacing: 6) {
rows()
}
.padding(.horizontal, 4)
.padding(.top, 10)
.environment(AppModel.preview())
.fontDesign(.rounded)
.tint(Theme.work)
}
/// The screen under a title like the navigation bar's, which ImageRenderer leaves out.
private func titled(_ title: String, _ view: some View) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.system(size: 17, weight: .semibold))
.foregroundStyle(.white)
.padding(.horizontal, 10)
.padding(.top, 10)
view
}
.environment(AppModel.preview())
.fontDesign(.rounded)
.tint(Theme.work)
}
/// Renders at the screen's width and as tall as the content, as if scrolled through.
private func write(_ view: some View, _ name: String) throws {
let renderer = ImageRenderer(content: view
.frame(width: screen.width)
.fixedSize(horizontal: false, vertical: true)
.padding(.bottom, 8)
.background(.black)
.environment(\.colorScheme, .dark))
renderer.scale = 2
let image = try #require(renderer.cgImage)
let url = URL(fileURLWithPath: try #require(outputDirectory)).appending(path: name)
let destination = try #require(CGImageDestinationCreateWithURL(url as CFURL, UTType.png.identifier as CFString, 1, nil))
CGImageDestinationAddImage(destination, image, nil)
#expect(CGImageDestinationFinalize(destination))
}
}
/// Every family at its 46mm size, once per titled entry.
private struct ComplicationGallery: View {
var sections: [(title: String, entry: WaitingEntry)]
var body: some View {
VStack(alignment: .leading, spacing: 10) {
ForEach(sections.indices, id: \.self) { index in
section(sections[index].title, sections[index].entry)
}
}
.padding(.horizontal, 10)
.padding(.top, 8)
.foregroundStyle(.white)
}
private func section(_ title: String, _ entry: WaitingEntry) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(title.uppercased())
.font(.system(size: 10, weight: .semibold, design: .rounded))
.foregroundStyle(Theme.done)
WaitingView(entry: entry, familyOverride: .accessoryInline)
.font(.system(size: 13, weight: .semibold, design: .rounded))
.frame(height: 18)
WaitingView(entry: entry, familyOverride: .accessoryRectangular, inStackOverride: false)
.frame(width: 184, height: 72)
HStack(spacing: 16) {
// Outside a face AccessoryWidgetBackground fills a square; a face clips it round.
WaitingView(entry: entry, familyOverride: .accessoryCircular)
.frame(width: 50, height: 50)
.clipShape(Circle())
WaitingView(entry: entry, familyOverride: .accessoryCorner)
.frame(width: 50, height: 50)
}
}
}
}
/// The rectangular widget as the Smart Stack shows it, on its card and with Allow and Deny when the
/// waiting thread wants an approval.
private struct SmartStackGallery: View {
var entries: [WaitingEntry]
var body: some View {
VStack(spacing: 10) {
ForEach(entries.indices, id: \.self) { index in
WaitingView(entry: entries[index], familyOverride: .accessoryRectangular, inStackOverride: true)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.frame(width: 196, height: 96, alignment: .topLeading)
.background(Color(white: 0.11), in: RoundedRectangle(cornerRadius: 18, style: .continuous))
}
}
.padding(.top, 8)
.foregroundStyle(.white)
.fontDesign(.rounded)
}
}
/// The question notification's long look over its actions. The system draws the actions; these
/// stand in for them with the same titles.
private struct NotificationPreview: View {
var view: InputNotificationView
var actions: [String]
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text("WRISTCODE")
.font(.system(size: 11, weight: .semibold, design: .rounded))
.foregroundStyle(Theme.secondaryText)
view
.padding(.bottom, 4)
ForEach(actions, id: \.self) { title in
Text(title)
.font(.system(size: 15, weight: .semibold, design: .rounded))
.frame(maxWidth: .infinity)
.padding(.vertical, 9)
.background(Theme.control, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
}
}
.padding(.horizontal, 8)
.padding(.top, 10)
.foregroundStyle(.white)
}
}
+31
View File
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>WristCode</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
<key>WristCodeKeychainGroup</key>
<string>$(AppIdentifierPrefix)org.gbrown.wristcode.shared</string>
</dict>
</plist>
+37
View File
@@ -0,0 +1,37 @@
// Allow and Deny on the Smart Stack widget. WidgetKit runs the intent without opening the app, then
// reloads the widget's timeline. A request someone already answered (409) counts as done.
//
// The app target compiles this file too, because WaitingView builds the buttons.
import AppIntents
struct RespondIntent: AppIntent {
static let title: LocalizedStringResource = "Answer an approval"
static let isDiscoverable = false
@Parameter(title: "Host") var hostId: String
@Parameter(title: "Thread") var threadId: String
@Parameter(title: "Request") var requestId: String
@Parameter(title: "Decision") var decision: String
init() {}
init(_ approval: WaitingEntry.Approval, decision: ApprovalDecision) {
hostId = approval.hostId
threadId = approval.threadId
requestId = approval.requestId
self.decision = decision.rawValue
}
func perform() async throws -> some IntentResult {
guard let api = SharedState.api else { return .result() }
do {
try await api.respond(
hostId: hostId, threadId: threadId,
request: .decision(requestId: requestId, decision: ApprovalDecision(rawValue: decision)))
} catch let error as GatewayError where error.isStale {
// Answered already, on the watch or elsewhere. The reload shows what is next.
}
return .result()
}
}
+382
View File
@@ -0,0 +1,382 @@
// One view per complication family, in mock B's colors: amber when something needs you, teal for
// work in progress, and nothing that animates. Tinted faces use the accented parts only.
//
// The rectangular slot switches, as mock C does: while anything waits on you it shows the counts,
// the thread that has waited longest and what it wants; otherwise the working thread changed most
// recently, how long its step has run and what it is doing. In the Smart Stack, where the widget
// has its container background, an approval also gets Allow and Deny, which answer through
// `RespondIntent` without opening the app.
//
// The app target compiles this file too, so the snapshot tests can render the complication.
import SwiftUI
import WidgetKit
struct WaitingEntry: TimelineEntry {
enum Content: Equatable {
/// Counts from the gateway. `stale` when they are the last ones seen because the gateway
/// could not be reached.
case counts(Summary, stale: Bool)
case notPaired
/// The gateway could not be reached and there is nothing saved to show.
case unavailable
}
/// The Allow and Deny pair for the summary's `next` thread, when it waits on an approval that
/// offers both. The thread named is the one that answers it, a subagent when one asked.
struct Approval: Equatable, Sendable {
var hostId: String
var threadId: String
var requestId: String
var allow: PendingRequest.Option
var deny: PendingRequest.Option
}
var date: Date
var content: Content
/// Only looked up for the rectangular family, which the Smart Stack uses.
var approval: Approval? = nil
/// Ranks the complication higher in the Smart Stack while something waits.
var relevance: TimelineEntryRelevance? {
guard case let .counts(summary, _) = content, summary.needsYou > 0 else { return nil }
return TimelineEntryRelevance(score: Float(summary.needsYou))
}
static let sample = WaitingEntry(date: .now, content: .counts(Summary(
needsYou: 2, working: 3,
next: .init(hostId: "mac-mini", threadId: "t-ocr", title: "Receipt OCR", hostLabel: "Mac mini", request: "bun test --filter ocr"),
hostsOffline: 0), stale: false))
}
extension WaitingEntry.Approval {
/// Reads `next`'s pending request. Nil unless it is an approval with Allow and Deny, or when the
/// thread cannot be read, in which case the widget opens the thread instead.
static func load(for summary: Summary, api: any GatewayAPI) async -> Self? {
guard summary.needsYou > 0, let next = summary.next,
let detail = try? await api.thread(hostId: next.hostId, threadId: next.threadId),
let request = detail.item.request, !request.isQuestion, let quick = request.quickOptions
else { return nil }
let target = detail.item.requestThread
return Self(hostId: target.hostId, threadId: target.threadId, requestId: request.id, allow: quick.allow, deny: quick.deny)
}
}
/// What the rectangular slot shows.
enum ComplicationMode: Equatable {
/// Something waits on you: the counts, then the thread that has waited longest and what it wants.
case waiting(Summary.Next)
/// Nothing waits: the working thread changed most recently and what it is doing.
case working(Summary.Current)
/// Nothing waits on you and nothing works, or an older gateway sent no thread to name.
case idle
init(_ summary: Summary) {
if summary.needsYou > 0, let next = summary.next {
self = .waiting(next)
} else if let current = summary.current {
self = .working(current)
} else {
self = .idle
}
}
}
struct WaitingView: View {
@Environment(\.widgetFamily) private var widgetFamily
@Environment(\.showsWidgetContainerBackground) private var showsBackground
var entry: WaitingEntry
/// Overrides the environment's family, for rendering outside a widget.
var familyOverride: WidgetFamily?
/// Overrides whether this is the Smart Stack, for rendering outside a widget.
var inStackOverride: Bool?
private var family: WidgetFamily { familyOverride ?? widgetFamily }
/// Watch faces drop the container background, and the Smart Stack keeps it.
private var inStack: Bool { inStackOverride ?? showsBackground }
var body: some View {
content
.widgetURL(route?.url)
.containerBackground(for: .widget) {
if waiting > 0 {
LinearGradient(colors: [Theme.you.opacity(0.3), Theme.card], startPoint: .top, endPoint: .bottom)
} else {
Theme.card
}
}
}
@ViewBuilder
private var content: some View {
switch family {
case .accessoryCircular: circular
case .accessoryRectangular: rectangular
case .accessoryInline: inline
case .accessoryCorner: corner
default: circular
}
}
// MARK: - Families
private var circular: some View {
ZStack {
AccessoryWidgetBackground()
if let summary, summary.needsYou > 0 || summary.working > 0 {
let waits = summary.needsYou > 0
VStack(spacing: -3) {
Text("\(waits ? summary.needsYou : summary.working)")
.font(.system(size: 26, weight: .bold, design: .rounded))
.foregroundStyle(waits ? Theme.you : Theme.work)
.minimumScaleFactor(0.6)
.widgetAccentable()
Text(waits ? "waiting" : "working")
.font(.system(size: 9, weight: .semibold, design: .rounded))
.foregroundStyle(.secondary)
}
} else if summary != nil {
Image(systemName: "checkmark")
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(.secondary)
} else {
Image(systemName: unpairedSymbol)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(.secondary)
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(spoken)
}
private var rectangular: some View {
VStack(alignment: .leading, spacing: 0) {
if let summary {
switch ComplicationMode(summary) {
case let .waiting(next): waitingRows(summary, next)
case let .working(current): workingRows(summary, current)
case .idle: idleRows(summary)
}
} else {
Text("WristCode")
.font(.system(size: 15, weight: .bold, design: .rounded))
.foregroundStyle(Theme.work)
.widgetAccentable()
Text(entry.content == .notPaired ? "Open the app to pair" : "Can't reach the gateway")
.font(.system(size: 13, design: .rounded))
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityElement(children: .contain)
}
/// The counts, the thread that has waited longest, and the command it wants to run or its
/// question. In the Smart Stack an approval adds Allow and Deny.
@ViewBuilder
private func waitingRows(_ summary: Summary, _ next: Summary.Next) -> some View {
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text("● \(summary.needsYou) waiting")
.foregroundStyle(Theme.you)
.widgetAccentable()
Spacer(minLength: 0)
if summary.working > 0 {
Text("\(summary.working) working")
.foregroundStyle(Theme.work)
}
staleMark
}
.font(.system(size: 12, weight: .semibold, design: .rounded))
.lineLimit(1)
Text(next.title)
.font(.system(size: 15, weight: .semibold, design: .rounded))
.lineLimit(1)
.padding(.top, 1)
if let request = next.request {
Text(code: request)
.font(.system(size: 12))
.foregroundStyle(.secondary)
.lineLimit(inStack && entry.approval == nil ? 2 : 1)
} else {
Text(next.hostLabel)
.font(.system(size: 13, design: .rounded))
.foregroundStyle(.secondary)
.lineLimit(1)
}
if inStack, let approval = entry.approval {
HStack(spacing: 5) {
Button(intent: RespondIntent(approval, decision: approval.allow.decision)) {
stackButton(approval.allow.label, fill: Theme.you, foreground: .black)
}
Button(intent: RespondIntent(approval, decision: approval.deny.decision)) {
stackButton(approval.deny.label, fill: Theme.control, foreground: .white)
}
}
.buttonStyle(.plain)
.padding(.top, 4)
}
}
/// The working thread changed most recently with how long its step has run, the step itself, and
/// the waiting count under it. The elapsed time is as of the entry, since nothing here ticks.
@ViewBuilder
private func workingRows(_ summary: Summary, _ current: Summary.Current) -> some View {
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text(current.title)
.lineLimit(1)
if let startedAt = current.activity?.startedAt {
Text("· \(Format.elapsed(since: startedAt, now: entry.date))")
.monospacedDigit()
.fixedSize()
}
Spacer(minLength: 0)
staleMark
}
.font(.system(size: 13, weight: .semibold, design: .rounded))
.foregroundStyle(Theme.work)
.widgetAccentable()
if let activity = current.activity {
HStack(alignment: .firstTextBaseline, spacing: 4) {
Image(systemName: activity.kind.symbol)
.font(.system(size: 10, weight: .bold))
.foregroundStyle(Theme.work)
Text(code: activity.text)
.font(.system(size: 12))
.lineLimit(inStack ? 2 : 1)
}
.padding(.top, 2)
} else {
Text(summary.working == 1 ? "1 working" : "\(summary.working) working")
.font(.system(size: 13, design: .rounded))
.foregroundStyle(.secondary)
.padding(.top, 2)
}
Text(summary.needsYou > 0 ? "● \(summary.needsYou) waiting" : "Nothing waiting")
.font(.system(size: 12, weight: .semibold, design: .rounded))
.foregroundStyle(summary.needsYou > 0 ? Theme.you : .secondary)
.padding(.top, 3)
}
private func idleRows(_ summary: Summary) -> some View {
Group {
HStack(spacing: 4) {
if summary.needsYou > 0 {
Text("● \(summary.needsYou) waiting")
.foregroundStyle(Theme.you)
} else {
Image(systemName: "checkmark")
Text("Nothing waiting")
}
Spacer(minLength: 0)
staleMark
}
.font(.system(size: 14, weight: .semibold, design: .rounded))
.foregroundStyle(.secondary)
Text("\(summary.working) working")
.font(.system(size: 17, weight: .bold, design: .rounded))
.foregroundStyle(summary.working > 0 ? Theme.work : .secondary)
.widgetAccentable()
if summary.hostsOffline > 0 {
Text(summary.hostsOffline == 1 ? "1 host offline" : "\(summary.hostsOffline) hosts offline")
.font(.system(size: 13, design: .rounded))
.foregroundStyle(.secondary)
}
}
}
/// The count with the thread's title, then the counts alone, then the waiting count.
private var inline: some View {
ViewThatFits {
Text(titled ?? spoken)
Text(spoken)
if let summary { Text("\(summary.needsYou) waiting") }
}
}
/// "2 waiting · Receipt OCR" or "3 working · Phase 3 screens", for the inline slot.
private var titled: String? {
guard let summary else { return nil }
return switch ComplicationMode(summary) {
case let .waiting(next): "\(summary.needsYou) waiting · \(next.title)"
case let .working(current): "\(summary.working) working · \(current.title)"
case .idle: nil
}
}
private var corner: some View {
Group {
if let summary {
let waits = summary.needsYou > 0
Text("\(waits ? summary.needsYou : summary.working)")
.font(.system(size: 24, weight: .bold, design: .rounded))
.foregroundStyle(waits ? Theme.you : summary.working > 0 ? Theme.work : Color.secondary)
.widgetAccentable()
} else {
Image(systemName: unpairedSymbol)
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(.secondary)
}
}
.widgetLabel {
if let summary {
Text(summary.needsYou > 0 ? "waiting" : "working")
} else {
Text("WristCode")
}
}
.accessibilityLabel(spoken)
}
// MARK: - Pieces
private var summary: Summary? {
if case let .counts(summary, _) = entry.content { return summary }
return nil
}
private var waiting: Int { summary?.needsYou ?? 0 }
/// Where a tap goes: the thread waiting longest, else the working thread, else the feed.
private var route: Route? {
guard let summary else { return nil }
return switch ComplicationMode(summary) {
case let .waiting(next): next.route
case let .working(current): current.route
case .idle: nil
}
}
private var unpairedSymbol: String {
entry.content == .notPaired ? "applewatch.slash" : "wifi.slash"
}
/// A Smart Stack button's label, drawn here because widgets show a button's label as is.
private func stackButton(_ title: String, fill: Color, foreground: Color) -> some View {
Text(title)
.font(.system(size: 12, weight: .bold, design: .rounded))
.foregroundStyle(foreground)
.lineLimit(1)
.frame(maxWidth: .infinity)
.padding(.vertical, 4)
.background(fill, in: Capsule())
}
/// Shown beside the count when it is the last one seen, not a fresh one.
@ViewBuilder
private var staleMark: some View {
if case .counts(_, stale: true) = entry.content {
Image(systemName: "wifi.slash")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(.secondary)
}
}
/// "2 waiting · 3 working", the inline text and the VoiceOver label.
private var spoken: String {
switch entry.content {
case let .counts(summary, _): "\(summary.needsYou) waiting · \(summary.working) working"
case .notPaired: "WristCode not paired"
case .unavailable: "WristCode offline"
}
}
}
+106
View File
@@ -0,0 +1,106 @@
// The watch face complication and Smart Stack widget: what waits on you or what is working, from
// `GET /v1/summary`. For the rectangular family it also reads the waiting thread, so the Smart Stack
// can offer Allow and Deny. It reads the device token and the demo flag from the shared Keychain
// group, and the app reloads it whenever its own feed or a notification changes the counts.
import SwiftUI
import WidgetKit
@main
struct WristCodeWidgets: WidgetBundle {
var body: some Widget {
WaitingWidget()
}
}
struct WaitingWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "WaitingCount", provider: WaitingProvider()) { entry in
WaitingView(entry: entry)
}
.configurationDisplayName("Waiting on you")
.description("Threads waiting for your approval or answer, or what is working now.")
.supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline, .accessoryCorner])
}
}
struct WaitingProvider: TimelineProvider {
/// How long until WidgetKit asks again. The app reloads sooner when it learns something.
static let refreshInterval: TimeInterval = 15 * 60
func placeholder(in context: Context) -> WaitingEntry {
.sample
}
func getSnapshot(in context: Context, completion: @escaping @Sendable (WaitingEntry) -> Void) {
if context.isPreview {
completion(.sample)
return
}
let family = context.family
Task { completion(await Self.load(family)) }
}
func getTimeline(in context: Context, completion: @escaping @Sendable (Timeline<WaitingEntry>) -> Void) {
let family = context.family
Task {
let entry = await Self.load(family)
completion(Timeline(entries: [entry], policy: .after(entry.date.addingTimeInterval(Self.refreshInterval))))
}
}
/// The summary, and for the rectangular family the waiting approval's Allow and Deny.
static func load(_ family: WidgetFamily) async -> WaitingEntry {
guard let api = SharedState.api else { return WaitingEntry(date: .now, content: .notPaired) }
do {
let summary = try await api.summary()
SharedState.lastSummary = summary
let approval = family == .accessoryRectangular ? await WaitingEntry.Approval.load(for: summary, api: api) : nil
return WaitingEntry(date: .now, content: .counts(summary, stale: false), approval: approval)
} catch GatewayError.unauthorized {
return WaitingEntry(date: .now, content: .notPaired)
} catch {
guard let last = SharedState.lastSummary else { return WaitingEntry(date: .now, content: .unavailable) }
return WaitingEntry(date: .now, content: .counts(last, stale: true))
}
}
}
private func entry(_ needsYou: Int, _ working: Int, next: Bool = true) -> WaitingEntry {
let thread = Summary.Next(hostId: "mac-mini", threadId: "t-ocr", title: "Receipt OCR", hostLabel: "Mac mini", request: "bun test --filter ocr")
let current = Summary.Current(
hostId: "mac-mini", threadId: "t-scaffold", title: "Watch app scaffold",
activity: Activity(kind: .command, text: "bun test", startedAt: .now.addingTimeInterval(-42)))
return WaitingEntry(date: .now, content: .counts(
Summary(needsYou: needsYou, working: working, next: needsYou > 0 && next ? thread : nil, current: working > 0 ? current : nil, hostsOffline: 1),
stale: false))
}
#Preview("Circular", as: .accessoryCircular) {
WaitingWidget()
} timeline: {
entry(2, 3)
entry(0, 3)
WaitingEntry(date: .now, content: .notPaired)
}
#Preview("Rectangular", as: .accessoryRectangular) {
WaitingWidget()
} timeline: {
entry(2, 3)
entry(0, 3)
WaitingEntry(date: .now, content: .notPaired)
}
#Preview("Inline", as: .accessoryInline) {
WaitingWidget()
} timeline: {
entry(2, 3)
}
#Preview("Corner", as: .accessoryCorner) {
WaitingWidget()
} timeline: {
entry(2, 3)
entry(0, 3)
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)org.gbrown.wristcode.shared</string>
</array>
</dict>
</plist>
+44
View File
@@ -19,6 +19,12 @@ targets:
platform: watchOS platform: watchOS
sources: sources:
- WristCode - WristCode
# The complication's views and the intent its buttons run, so the snapshot tests can render them.
- WristCodeWidget/WaitingView.swift
- WristCodeWidget/RespondIntent.swift
dependencies:
# Embedded in the watch app's PlugIns folder.
- target: WristCodeWidget
info: info:
path: WristCode/Info.plist path: WristCode/Info.plist
properties: properties:
@@ -32,18 +38,56 @@ targets:
WKWatchOnly: true WKWatchOnly: true
# HTTPS only, so no export compliance documentation is needed. # HTTPS only, so no export compliance documentation is needed.
ITSAppUsesNonExemptEncryption: false ITSAppUsesNonExemptEncryption: false
# wristcode://thread/<hostId>/<threadId>, which the complication opens.
CFBundleURLTypes:
- CFBundleURLName: org.gbrown.wristcode
CFBundleURLSchemes: [wristcode]
WristCodeKeychainGroup: $(AppIdentifierPrefix)org.gbrown.wristcode.shared
entitlements: entitlements:
path: WristCode/WristCode.entitlements path: WristCode/WristCode.entitlements
properties: properties:
# Archives are signed for development; exporting for App Store Connect re-signs with the # Archives are signed for development; exporting for App Store Connect re-signs with the
# distribution profile, which turns this into production. # distribution profile, which turns this into production.
aps-environment: development aps-environment: development
# Shared with the complication: the device token, gateway URL, demo flag and last summary.
# A Keychain group rather than an App Group, because automatic signing with an App Store
# Connect API key can register IDs and profiles but not App Groups.
keychain-access-groups: [$(AppIdentifierPrefix)org.gbrown.wristcode.shared]
settings: settings:
base: base:
# The container's bundle ID plus .watchkitapp, as App Store validation requires. This is also # The container's bundle ID plus .watchkitapp, as App Store validation requires. This is also
# the APNs topic. # the APNs topic.
PRODUCT_BUNDLE_IDENTIFIER: org.gbrown.wristcode.watchkitapp PRODUCT_BUNDLE_IDENTIFIER: org.gbrown.wristcode.watchkitapp
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
# The complication. It shares the API client, models, Keychain store and demo fixtures with the
# app, and reads the device token through the shared Keychain group.
WristCodeWidget:
type: app-extension
platform: watchOS
sources:
- WristCodeWidget
- WristCode/API
- WristCode/Auth
- WristCode/Demo
- WristCode/Shared
- WristCode/Views/Theme.swift
info:
path: WristCodeWidget/Info.plist
properties:
CFBundleDisplayName: WristCode
CFBundleShortVersionString: $(MARKETING_VERSION)
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
NSExtension:
NSExtensionPointIdentifier: com.apple.widgetkit-extension
WristCodeKeychainGroup: $(AppIdentifierPrefix)org.gbrown.wristcode.shared
entitlements:
path: WristCodeWidget/WristCodeWidget.entitlements
properties:
keychain-access-groups: [$(AppIdentifierPrefix)org.gbrown.wristcode.shared]
settings:
base:
# Extensions take the watch app's bundle ID as a prefix.
PRODUCT_BUNDLE_IDENTIFIER: org.gbrown.wristcode.watchkitapp.widget
# The App Store only takes a watch-only app inside an empty iOS stub, the same pair Xcode's # The App Store only takes a watch-only app inside an empty iOS stub, the same pair Xcode's
# "Watch-only App" template creates. The stub owns the App Store Connect record # "Watch-only App" template creates. The stub owns the App Store Connect record
# (org.gbrown.wristcode) and embeds the watch app; it never installs on an iPhone. # (org.gbrown.wristcode) and embeds the watch app; it never installs on an iPhone.
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff. Show more