Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25be69fa30 | ||
|
|
99b300855e | ||
|
|
edc4b88990 | ||
|
|
4dc4e57dac | ||
|
|
d10df56cc9 | ||
|
|
480db90e98 | ||
|
|
c34b046aae | ||
|
|
bfcbb69550 | ||
|
|
f8241e42bb | ||
|
|
b08fd9bdb1 | ||
|
|
45251b1a43 | ||
|
|
d916bfbab5 | ||
|
|
abd2fd0e13 | ||
|
|
52210d0223 | ||
|
|
6bc9cac6ca | ||
|
|
3cc7e40852 | ||
|
|
a5222e14e8 | ||
|
|
2a127327bf | ||
|
|
cf8995e63f | ||
|
|
a4754b65b0 | ||
|
|
a8b6375afe | ||
|
|
442036b22f | ||
|
|
221622b574 | ||
|
|
7ca0362745 | ||
|
|
9707daf1fe | ||
|
|
e6fd2b8f51 | ||
|
|
277683d5ee | ||
|
|
42b6a4920c | ||
|
|
f46c803ad4 | ||
|
|
27469dbc77 | ||
|
|
67992dbf43 | ||
|
|
0994fd7d5a | ||
|
|
d4d67295c6 | ||
|
|
830bf4f139 | ||
|
|
69858d4719 | ||
|
|
f772f4e4c4 | ||
|
|
16bae4410d | ||
|
|
21215210e3 | ||
|
|
7ad1045be4 | ||
|
|
9731fd621f | ||
|
|
bdfb79b891 |
No files matched your search
@@ -1,11 +1,12 @@
|
|||||||
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, a key that server-gib only accepts for ~/Server/ci-deploy.sh.
|
# DEPLOY_SSH_KEY_VPS_B64, a base64 encoded key that junior.gib only accepts for
|
||||||
|
# ~/Server/ci-deploy.sh.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -78,17 +79,18 @@ jobs:
|
|||||||
docker push "$IMAGE:latest"
|
docker push "$IMAGE:latest"
|
||||||
|
|
||||||
- name: Deploy
|
- name: Deploy
|
||||||
# The key comes in through env, not ${{ secrets.* }} in the script: the runner prints the
|
# 192.168.2.2 is junior's WireGuard address, reachable from every runner. The secret is the
|
||||||
# script, and Gitea does not mask a multi-line secret split across lines.
|
# 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:
|
env:
|
||||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY_HOME }}
|
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\n' "$DEPLOY_SSH_KEY" > "$HOME/.deploy_key"
|
printf '%s' "$DEPLOY_SSH_KEY_B64" | base64 -d > "$HOME/.deploy_key"
|
||||||
ssh -i "$HOME/.deploy_key" \
|
ssh -i "$HOME/.deploy_key" \
|
||||||
-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"
|
||||||
@@ -12,3 +12,5 @@ DerivedData/
|
|||||||
build/
|
build/
|
||||||
*.p8
|
*.p8
|
||||||
gateway/data/
|
gateway/data/
|
||||||
|
# Your machines for scripts/renew-hosts.sh
|
||||||
|
scripts/hosts.conf
|
||||||
@@ -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.
|
||||||
@@ -1,57 +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, and running `host add` again with a fresh
|
|
||||||
credential renews it.
|
|
||||||
|
|
||||||
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).
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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,23 +113,48 @@ 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 questions (`kind: "user_input"`), `options` is empty and `questions` lists
|
For `kind: "file-change"`, `files` lists the files the agent wants to change as
|
||||||
`{ id, header, question, options: [{ label, value }], allowCustomAnswer }`.
|
`{ path, name, additions, deletions, preview }`. `name` is the last part of `path`. Most providers,
|
||||||
|
Claude included, send the diff only after the edit runs, so `additions` and `deletions` are usually
|
||||||
|
null and `preview` is empty. When a diff comes with the request, `preview` holds its first 4
|
||||||
|
changed lines, each starting with `+` or `-`.
|
||||||
|
|
||||||
|
An option may also carry `warning`, a short note about a risky choice such as "Applies to every
|
||||||
|
thread". For questions (`kind: "user_input"`), `options` is empty and `questions` lists
|
||||||
|
`{ id, header, question, options: [{ label, value }], allowCustomAnswer, multiSelect }`.
|
||||||
|
|
||||||
## Thread
|
## Thread
|
||||||
|
|
||||||
```
|
```
|
||||||
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
|
||||||
@@ -117,44 +172,233 @@ or
|
|||||||
202
|
202
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Each answer is an option `value` or a typed answer. For a question with `multiSelect: true`, send an
|
||||||
|
array of option values instead, for example `{ "<questionId>": ["sqlite", "postgres"] }`.
|
||||||
|
|
||||||
```
|
```
|
||||||
POST /v1/threads/:hostId/:threadId/stop
|
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
|
||||||
|
|
||||||
|
What the watch face complication and Smart Stack widget show. It reads at most two threads, so it
|
||||||
|
is cheap to call.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /v1/summary
|
||||||
|
|
||||||
|
200 { "needsYou": 1, "working": 3, "hostsOffline": 0,
|
||||||
|
"next": { "hostId": "...", "threadId": "...", "title": "Receipt OCR", "hostLabel": "Mac mini",
|
||||||
|
"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. 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
|
||||||
|
|
||||||
```
|
```
|
||||||
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`, and, for approvals and questions,
|
`aps.category` and the custom keys `hostId`, `threadId` (absent on `host` pushes), and, for
|
||||||
`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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -1,203 +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 bundle ID is `org.gbrown.wristcode`.
|
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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, 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,21 +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": {
|
||||||
|
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": {
|
||||||
@@ -155,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",
|
||||||
@@ -164,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: [] },
|
||||||
@@ -176,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));
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -211,7 +376,7 @@ export function createRoutes({ db, hub, cache, log = () => {} }: ApiDeps) {
|
|||||||
if ("decision" in body) {
|
if ("decision" in body) {
|
||||||
if (!DECISIONS.has(body.decision)) throw new HttpError(400, "Unknown decision.");
|
if (!DECISIONS.has(body.decision)) throw new HttpError(400, "Unknown decision.");
|
||||||
await host.dispatch({ ...base, decision: body.decision });
|
await host.dispatch({ ...base, decision: body.decision });
|
||||||
} else if ("answers" in body && body.answers && typeof body.answers === "object") {
|
} else if ("answers" in body && isAnswers(body.answers)) {
|
||||||
await host.dispatch({ ...base, answers: body.answers });
|
await host.dispatch({ ...base, answers: body.answers });
|
||||||
} else {
|
} else {
|
||||||
throw new HttpError(400, "Send a decision or answers.");
|
throw new HttpError(400, "Send a decision or answers.");
|
||||||
@@ -221,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);
|
||||||
@@ -242,12 +455,23 @@ export function createRoutes({ db, hub, cache, log = () => {} }: ApiDeps) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isAnswers = (value: unknown): value is Record<string, string | string[]> =>
|
||||||
|
typeof value === "object" &&
|
||||||
|
value !== null &&
|
||||||
|
Object.values(value).every(
|
||||||
|
(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. */
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export interface ApnsConfig {
|
|||||||
teamId: string;
|
teamId: string;
|
||||||
/** The .p8 key's PEM text. */
|
/** The .p8 key's PEM text. */
|
||||||
key: string;
|
key: string;
|
||||||
/** The app's bundle ID. */
|
/** The watch app's bundle ID, which is its APNs topic. */
|
||||||
topic: string;
|
topic: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export function loadConfig(env = process.env): Config {
|
|||||||
dbPath: env.WRISTCODE_DB ?? "data/wristcode.sqlite",
|
dbPath: env.WRISTCODE_DB ?? "data/wristcode.sqlite",
|
||||||
apns:
|
apns:
|
||||||
keyId && teamId && key
|
keyId && teamId && key
|
||||||
? { keyId, teamId, key: key.replace(/\\n/g, "\n"), topic: env.APNS_TOPIC ?? "org.gbrown.wristcode" }
|
? { keyId, teamId, key: key.replace(/\\n/g, "\n"), topic: env.APNS_TOPIC ?? "org.gbrown.wristcode.watchkitapp" }
|
||||||
: null,
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
@@ -21,8 +91,13 @@ export interface PendingRequest {
|
|||||||
/** What the agent wants to do, such as the command line. Null when the server gave none. */
|
/** What the agent wants to do, such as the command line. Null when the server gave none. */
|
||||||
prompt: string | null;
|
prompt: string | null;
|
||||||
reason: string | null;
|
reason: string | null;
|
||||||
/** Approval choices, in the server's order. Empty for questions. */
|
/** Approval choices, in the server's order. Empty for questions. `warning` flags a risky choice. */
|
||||||
options: Array<{ decision: ApprovalDecision; label: 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;
|
||||||
@@ -30,9 +105,20 @@ export interface PendingRequest {
|
|||||||
question: string;
|
question: string;
|
||||||
options: Array<{ label: string; value: string }>;
|
options: Array<{ label: string; value: string }>;
|
||||||
allowCustomAnswer: boolean;
|
allowCustomAnswer: boolean;
|
||||||
|
/** When true, answer with an array of option values. */
|
||||||
|
multiSelect: boolean;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -44,7 +130,96 @@ 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. */
|
||||||
|
export interface Summary {
|
||||||
|
needsYou: number;
|
||||||
|
working: number;
|
||||||
|
/** The thread that has waited longest for you, if any. */
|
||||||
|
next: {
|
||||||
|
hostId: string;
|
||||||
|
threadId: string;
|
||||||
|
title: string;
|
||||||
|
hostLabel: string;
|
||||||
|
/** 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Feed {
|
export interface Feed {
|
||||||
@@ -52,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";
|
||||||
@@ -70,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 {
|
||||||
@@ -80,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;
|
||||||
@@ -106,12 +326,17 @@ export interface SendMessageRequest {
|
|||||||
|
|
||||||
export type RespondRequest =
|
export type RespondRequest =
|
||||||
| { requestId: string; decision: ApprovalDecision }
|
| { requestId: string; decision: ApprovalDecision }
|
||||||
| { requestId: string; answers: Record<string, string> };
|
/** Per question id: an option value, a typed answer, or for multi-select questions an array of option values. */
|
||||||
|
| { requestId: string; answers: Record<string, string | string[]> };
|
||||||
|
|
||||||
export interface NewThreadRequest {
|
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 {
|
||||||
@@ -125,10 +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. */
|
||||||
threadId: string;
|
hostId?: string;
|
||||||
|
/** Absent on `host` pushes, which are about a host rather than a thread. */
|
||||||
|
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";
|
||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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") : [])];
|
||||||
|
}
|
||||||
@@ -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)`);
|
||||||
|
|||||||
@@ -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)}`),
|
||||||
);
|
);
|
||||||
@@ -79,7 +86,7 @@ export class Notifier {
|
|||||||
title: label,
|
title: label,
|
||||||
body,
|
body,
|
||||||
category: "host",
|
category: "host",
|
||||||
data: { hostId, threadId: "" },
|
data: { hostId },
|
||||||
collapseId: `host:${hostId}`,
|
collapseId: `host:${hostId}`,
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -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 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An approval push's body: the files a file-change approval would edit, as "Wants to edit
|
||||||
|
* compose.yml +9 −6" or "Wants to edit 3 files: a.ts, b.ts, c.ts", or else the command it wants to
|
||||||
|
* 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.";
|
||||||
|
}
|
||||||
|
|
||||||
function body(phase: Phase, lastError: string | null, summary: ThreadSummary | undefined): string {
|
function body(phase: Phase, lastError: string | null, summary: ThreadSummary | undefined): string {
|
||||||
switch (phase) {
|
switch (phase) {
|
||||||
case "waiting_for_approval": {
|
case "waiting_for_approval":
|
||||||
const prompt = summary?.request?.prompt;
|
return approvalBody(summary?.request ?? null);
|
||||||
return prompt ? `Wants to run: ${trimText(prompt, BODY_CHARS)}` : "Needs your approval.";
|
|
||||||
}
|
|
||||||
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.";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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, 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,37 +67,146 @@ 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),
|
||||||
id: message.id,
|
|
||||||
role: message.role as Message["role"],
|
|
||||||
text: message.text.length > MESSAGE_CHARS ? `${message.text.slice(0, MESSAGE_CHARS)}…` : message.text,
|
|
||||||
createdAt: message.createdAt,
|
|
||||||
})),
|
|
||||||
lastAssistantMessage: lastAssistant ? trimText(lastAssistant.text, LAST_MESSAGE_CHARS) : null,
|
lastAssistantMessage: lastAssistant ? trimText(lastAssistant.text, LAST_MESSAGE_CHARS) : null,
|
||||||
request: pendingRequest(thread, turnItems),
|
request: pendingRequest(thread, turnItems),
|
||||||
|
activity: currentActivity(turnItems),
|
||||||
|
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,
|
||||||
|
role: message.role as Message["role"],
|
||||||
|
text: message.text.length > MESSAGE_CHARS ? `${message.text.slice(0, MESSAGE_CHARS)}…` : message.text,
|
||||||
|
createdAt: message.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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,
|
||||||
);
|
);
|
||||||
if (item && isApprovalRequest(item)) {
|
if (item && isApprovalRequest(item)) {
|
||||||
const options = item.options?.length
|
const options = item.options?.length
|
||||||
? item.options.map(({ decision, label }) => ({ decision, label }))
|
? item.options.map(({ decision, label, warning }) => ({ decision, label, ...(warning ? { warning } : {}) }))
|
||||||
: [
|
: [
|
||||||
{ 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 {
|
||||||
@@ -80,6 +217,7 @@ function pendingRequest(thread: ThreadShell, turnItems: BoundedThread["projectio
|
|||||||
question: question.question,
|
question: question.question,
|
||||||
options: question.options.map((option) => ({ label: option.label, value: option.value ?? option.label })),
|
options: question.options.map((option) => ({ label: option.label, value: option.value ?? option.label })),
|
||||||
allowCustomAnswer: question.allowCustomAnswer ?? false,
|
allowCustomAnswer: question.allowCustomAnswer ?? false,
|
||||||
|
multiSelect: question.multiSelect ?? false,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -95,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;
|
||||||
@@ -118,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));
|
||||||
@@ -140,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,
|
||||||
@@ -151,58 +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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Builds the feed across every host, waiting briefly for summaries it has not read yet. */
|
/** The longest waiting subagent that `parent` delegated to, if any. */
|
||||||
export async function buildFeed(hosts: T3Host[], cache: SummaryCache, now = Date.now()): Promise<Feed> {
|
function waitingDelegate(hosts: T3Host[], parentHost: T3Host, parent: ThreadShell): Candidate | undefined {
|
||||||
const candidates: Array<{ host: T3Host; thread: ThreadShell; phase: Phase }> = [];
|
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, plus the longest waiting request and the busiest working thread.
|
||||||
|
* Reads at most those two threads, waiting briefly as the feed does.
|
||||||
|
*/
|
||||||
|
export async function buildSummary(hosts: T3Host[], cache: SummaryCache): Promise<Summary> {
|
||||||
|
let needsYou = 0;
|
||||||
|
let working = 0;
|
||||||
|
let next: { host: T3Host; thread: ThreadShell } | null = null;
|
||||||
|
let current: { host: T3Host; thread: ThreadShell } | null = null;
|
||||||
|
for (const host of hosts) {
|
||||||
|
for (const thread of host.threads.values()) {
|
||||||
|
if (thread.archivedAt || thread.deletedAt) continue;
|
||||||
|
const phase = threadPhase(thread);
|
||||||
|
if (phase === "starting" || phase === "running") {
|
||||||
|
working++;
|
||||||
|
if (!current || thread.updatedAt > current.thread.updatedAt) current = { host, thread };
|
||||||
|
}
|
||||||
|
if (phase !== "waiting_for_approval" && phase !== "waiting_for_input") continue;
|
||||||
|
needsYou++;
|
||||||
|
const waitingSince = thread.pendingRuntimeRequest?.createdAt ?? "";
|
||||||
|
if (!next || waitingSince < (next.thread.pendingRuntimeRequest?.createdAt ?? "")) next = { host, thread };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const read = [next, current].filter((pick) => pick !== null);
|
||||||
|
const missing = read.filter(({ host, thread }) => !cache.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 {
|
||||||
|
needsYou,
|
||||||
|
working,
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every thread the feed shows, in feed order, with subagent requests on their parents' cards. */
|
||||||
|
export function feedCandidates(hosts: T3Host[], now = Date.now()) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { describe, expect, it } from "bun:test";
|
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 { summarize, trimText } from "../src/views.ts";
|
import type { T3Host } from "../src/t3/host.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",
|
||||||
@@ -69,9 +70,10 @@ describe("summarize", () => {
|
|||||||
reason: null,
|
reason: null,
|
||||||
options: [
|
options: [
|
||||||
{ decision: "accept", label: "Allow once" },
|
{ decision: "accept", label: "Allow once" },
|
||||||
{ decision: "acceptAlways", label: "Always allow" },
|
{ decision: "acceptAlways", label: "Always allow", warning: "Applies to every thread" },
|
||||||
{ decision: "decline", label: "Deny" },
|
{ decision: "decline", label: "Deny" },
|
||||||
],
|
],
|
||||||
|
files: [],
|
||||||
questions: [],
|
questions: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -98,7 +100,300 @@ describe("summarize", () => {
|
|||||||
thread({ pendingRuntimeRequest: { id: "q1", kind: "user_input", createdAt: "2026-11-01T09:00:00.000Z" } }),
|
thread({ pendingRuntimeRequest: { id: "q1", kind: "user_input", createdAt: "2026-11-01T09:00:00.000Z" } }),
|
||||||
);
|
);
|
||||||
expect(summary.request?.questions).toEqual([
|
expect(summary.request?.questions).toEqual([
|
||||||
{ id: "db", header: "Database", question: "Which database?", options: [{ label: "SQLite", value: "SQLite" }], allowCustomAnswer: false },
|
{ id: "db", header: "Database", question: "Which database?", options: [{ label: "SQLite", value: "SQLite" }], allowCustomAnswer: false, multiSelect: false },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("buildSummary", () => {
|
||||||
|
const at = (minute: number) => `2026-11-01T09:${String(minute).padStart(2, "0")}:00.000Z`;
|
||||||
|
const host = (label: string, status: string, threads: ThreadShell[]) =>
|
||||||
|
({
|
||||||
|
id: label,
|
||||||
|
label,
|
||||||
|
status,
|
||||||
|
threads: new Map(threads.map((t) => [t.id, t])),
|
||||||
|
bounded: () => Promise.reject(new Error("not read")),
|
||||||
|
}) as unknown as T3Host;
|
||||||
|
|
||||||
|
it("counts waiting and working threads and names the one waiting longest", async () => {
|
||||||
|
const summary = await buildSummary([
|
||||||
|
host("Mac mini", "online", [
|
||||||
|
thread({ id: "a", title: "Newer ask", pendingRuntimeRequest: { id: "r1", kind: "command", createdAt: at(30) } }),
|
||||||
|
thread({ id: "b", status: "running" }),
|
||||||
|
thread({ id: "c", status: "completed" }),
|
||||||
|
thread({ id: "d", status: "running", lineage: { relationshipToParent: "subagent" } }),
|
||||||
|
]),
|
||||||
|
host("VPS", "offline", [
|
||||||
|
thread({ id: "e", title: "Older ask", pendingRuntimeRequest: { id: "q1", kind: "user_input", createdAt: at(5) } }),
|
||||||
|
]),
|
||||||
|
], new SummaryCache());
|
||||||
|
expect(summary).toEqual({
|
||||||
|
needsYou: 2,
|
||||||
|
working: 1,
|
||||||
|
next: { hostId: "VPS", threadId: "e", title: "Older ask", hostLabel: "VPS", request: null },
|
||||||
|
current: { hostId: "Mac mini", threadId: "b", title: "Receipt OCR", activity: null },
|
||||||
|
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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<!doctype html><html><head><meta charset="utf-8"><title>WristCode Phase 4: What changed</title><style>
|
||||||
|
:root { --you:#ffb02e; --work:#4fd1c5; --done:#6b6b70; --card:#17171a; --bad:#ff6b5e; --dim:#aeaeb2; --add:#5ad17a; }
|
||||||
|
body { margin:0; font-family:"SF Pro Rounded", ui-rounded, system-ui, sans-serif; }
|
||||||
|
h3 { font-family:system-ui,sans-serif; font-size:14px; margin:6px 0 2px; color:var(--foreground,#ddd); text-align:center; grid-column:1/-1; }
|
||||||
|
.grid { display:grid; grid-template-columns:repeat(3,1fr); gap:22px 10px; padding:8px 0 16px; justify-items:center; }
|
||||||
|
figure { margin:0; display:flex; flex-direction:column; align-items:center; gap:8px; }
|
||||||
|
figcaption { font-size:12px; color:var(--muted-foreground,#999); text-align:center; max-width:220px; line-height:1.35; font-family:system-ui,sans-serif; }
|
||||||
|
.bezel { background:#0b0b0c; padding:9px; border-radius:48px; box-shadow:0 0 0 1px #2a2a2c; }
|
||||||
|
.screen { width:184px; height:225px; background:#000; border-radius:40px; overflow:hidden; color:#fff; padding:0 8px; box-sizing:border-box; font-size:13px; position:relative; }
|
||||||
|
.top { display:flex; justify-content:space-between; align-items:baseline; padding:11px 8px 5px; font-weight:600; font-size:14px; }
|
||||||
|
.card { background:var(--card); border-radius:15px; padding:7px 9px; margin-bottom:5px; border-left:4px solid var(--c, var(--card)); }
|
||||||
|
.row { display:flex; align-items:center; gap:6px; }
|
||||||
|
.name { font-weight:600; font-size:13px; line-height:1.2; }
|
||||||
|
.meta { font-size:10.5px; color:var(--c, var(--dim)); font-weight:600; }
|
||||||
|
.line { font-size:11.5px; color:var(--dim); margin-top:3px; line-height:1.25; }
|
||||||
|
.mono { font-family:"SF Mono",ui-monospace,monospace; font-size:10.5px; }
|
||||||
|
.label { font-size:10.5px; color:var(--dim); padding:4px 6px 3px; font-weight:600; }
|
||||||
|
.big { display:block; text-align:center; border-radius:20px; padding:7px; font-size:13px; font-weight:700; margin:4px 0; }
|
||||||
|
.r { margin-left:auto; white-space:nowrap; }
|
||||||
|
.add { color:var(--add); } .del { color:var(--bad); }
|
||||||
|
.dot { width:7px; height:7px; border-radius:50%; flex:none; }
|
||||||
|
.face { background:radial-gradient(circle at 50% 40%, #1a1a1d, #000 70%); }
|
||||||
|
.time { text-align:right; font-size:30px; font-weight:600; padding:10px 10px 0; letter-spacing:-.5px; }
|
||||||
|
</style></head><body><div class="grid">
|
||||||
|
<h3>Option 1: a line on the card, the files on the thread screen</h3>
|
||||||
|
<figure><div class="bezel"><div class="screen"><div class="top"><span>Recent</span><span>9:41</span></div>
|
||||||
|
<div class="card" style="--c:var(--done)"><div class="meta">Mac mini · 4m ago</div><div class="name" style="color:var(--dim)">Gateway Containerfile</div><div class="line mono"><span class="add">+42</span> <span class="del">−8</span> · 3 files</div></div>
|
||||||
|
<div class="card" style="--c:var(--done)"><div class="meta">Desktop · 1h ago</div><div class="name" style="color:var(--dim)">Explain the hub</div></div>
|
||||||
|
</div></div><figcaption><b>Recent card.</b> One line of totals under the title. A thread that changed nothing, like a question, shows no line.</figcaption></figure>
|
||||||
|
<figure><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--work)">‹ Thread</span><span>9:41</span></div>
|
||||||
|
<div class="line" style="padding:0 6px 4px;color:#ddd">Done. The image builds and the healthcheck passes.</div>
|
||||||
|
<div class="label">Changed</div>
|
||||||
|
<div class="card row"><span class="mono">Containerfile</span><span class="r mono"><span class="add">+31</span> <span class="del">−0</span></span></div>
|
||||||
|
<div class="card row"><span class="mono">compose.yml</span><span class="r mono"><span class="add">+9</span> <span class="del">−6</span></span></div>
|
||||||
|
<div class="card row"><span class="mono">health.ts</span><span class="r mono"><span class="add">+2</span> <span class="del">−2</span></span></div>
|
||||||
|
</div></div><figcaption><b>Thread screen.</b> A Changed section under the messages lists each file of the latest run with its counts. Read only.</figcaption></figure>
|
||||||
|
<figure><div class="bezel"><div class="screen"><div class="top"><span>Recent</span><span>9:41</span></div>
|
||||||
|
<div class="card" style="--c:var(--done)"><div class="meta">Mac mini · 4m ago</div><div class="name" style="color:var(--dim)">Gateway Containerfile</div><div class="line"><span class="add">●</span> 3 files changed</div></div>
|
||||||
|
</div></div><figcaption><b>Lighter card line.</b> Only the file count, no line counts. Same thread screen as the middle.</figcaption></figure>
|
||||||
|
<h3>Option 2: thread screen only</h3>
|
||||||
|
<figure style="grid-column:2"><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--work)">‹ Thread</span><span>9:41</span></div>
|
||||||
|
<div class="line" style="padding:0 6px 4px;color:#ddd">Done. The image builds and the healthcheck passes.</div>
|
||||||
|
<div class="card row"><span class="name" style="font-size:12px">3 files changed</span><span class="r mono"><span class="add">+42</span> <span class="del">−8</span> ›</span></div>
|
||||||
|
<span class="big" style="background:var(--card)">Reply</span>
|
||||||
|
</div></div><figcaption><b>One row on the thread screen.</b> Cards stay as they are. The row opens the file list.</figcaption></figure>
|
||||||
|
</div></body></html>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<!doctype html><html><head><meta charset="utf-8"><title>WristCode Phase 4: Search</title><style>
|
||||||
|
:root { --you:#ffb02e; --work:#4fd1c5; --done:#6b6b70; --card:#17171a; --bad:#ff6b5e; --dim:#aeaeb2; --add:#5ad17a; }
|
||||||
|
body { margin:0; font-family:"SF Pro Rounded", ui-rounded, system-ui, sans-serif; }
|
||||||
|
h3 { font-family:system-ui,sans-serif; font-size:14px; margin:6px 0 2px; color:var(--foreground,#ddd); text-align:center; grid-column:1/-1; }
|
||||||
|
.grid { display:grid; grid-template-columns:repeat(3,1fr); gap:22px 10px; padding:8px 0 16px; justify-items:center; }
|
||||||
|
figure { margin:0; display:flex; flex-direction:column; align-items:center; gap:8px; }
|
||||||
|
figcaption { font-size:12px; color:var(--muted-foreground,#999); text-align:center; max-width:220px; line-height:1.35; font-family:system-ui,sans-serif; }
|
||||||
|
.bezel { background:#0b0b0c; padding:9px; border-radius:48px; box-shadow:0 0 0 1px #2a2a2c; }
|
||||||
|
.screen { width:184px; height:225px; background:#000; border-radius:40px; overflow:hidden; color:#fff; padding:0 8px; box-sizing:border-box; font-size:13px; position:relative; }
|
||||||
|
.top { display:flex; justify-content:space-between; align-items:baseline; padding:11px 8px 5px; font-weight:600; font-size:14px; }
|
||||||
|
.card { background:var(--card); border-radius:15px; padding:7px 9px; margin-bottom:5px; border-left:4px solid var(--c, var(--card)); }
|
||||||
|
.row { display:flex; align-items:center; gap:6px; }
|
||||||
|
.name { font-weight:600; font-size:13px; line-height:1.2; }
|
||||||
|
.meta { font-size:10.5px; color:var(--c, var(--dim)); font-weight:600; }
|
||||||
|
.line { font-size:11.5px; color:var(--dim); margin-top:3px; line-height:1.25; }
|
||||||
|
.mono { font-family:"SF Mono",ui-monospace,monospace; font-size:10.5px; }
|
||||||
|
.label { font-size:10.5px; color:var(--dim); padding:4px 6px 3px; font-weight:600; }
|
||||||
|
.big { display:block; text-align:center; border-radius:20px; padding:7px; font-size:13px; font-weight:700; margin:4px 0; }
|
||||||
|
.r { margin-left:auto; white-space:nowrap; }
|
||||||
|
.add { color:var(--add); } .del { color:var(--bad); }
|
||||||
|
.dot { width:7px; height:7px; border-radius:50%; flex:none; }
|
||||||
|
.face { background:radial-gradient(circle at 50% 40%, #1a1a1d, #000 70%); }
|
||||||
|
.time { text-align:right; font-size:30px; font-weight:600; padding:10px 10px 0; letter-spacing:-.5px; }
|
||||||
|
</style></head><body><div class="grid">
|
||||||
|
<h3>Option 1: a Search row at the top of All threads</h3>
|
||||||
|
<figure><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--work)">‹ All threads</span><span>9:41</span></div>
|
||||||
|
<div class="card row" style="background:#1f1f22"><span style="color:var(--dim)">⌕</span><span style="color:var(--dim)">Search</span><span class="r" style="color:var(--work)">🎙</span></div>
|
||||||
|
<div class="card row"><span class="dot" style="background:var(--work)"></span><span class="name">Desktop</span></div>
|
||||||
|
<div class="card row"><span class="dot" style="background:var(--work)"></span><span class="name">Mac mini</span></div>
|
||||||
|
</div></div><figcaption><b>All threads.</b> A search field above the hosts. Tapping it opens dictation or the keyboard, as watchOS does for any text field.</figcaption></figure>
|
||||||
|
<figure><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--work)">‹ "containerfile"</span><span>9:41</span></div>
|
||||||
|
<div class="card"><div class="meta">Mac mini · wristcode</div><div class="name">Gateway Containerfile</div></div>
|
||||||
|
<div class="card"><div class="meta">server-gib · homelab</div><div class="name" style="color:var(--dim)">Jellyfin image update</div><div class="line">…rebuild from the <b style="color:#fff">Containerfile</b> and…</div></div>
|
||||||
|
</div></div><figcaption><b>Results.</b> Every host searched at once, best first. A match inside a message shows a snippet. Tapping opens the thread.</figcaption></figure>
|
||||||
|
<figure><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--work)">‹ "containerfile"</span><span>9:41</span></div>
|
||||||
|
<div class="line" style="padding:20px 10px;text-align:center">No threads match.<br><br><span style="font-size:10.5px">Laptop is offline, so its threads were not searched.</span></div>
|
||||||
|
</div></div><figcaption><b>No results.</b> Says which hosts it could not reach.</figcaption></figure>
|
||||||
|
<h3>Option 2: a search button on the feed</h3>
|
||||||
|
<figure style="grid-column:2"><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--dim)">⚙</span><span style="display:flex;gap:10px"><span>⌕</span><span>+</span></span></div>
|
||||||
|
<div style="font-size:30px;font-weight:700;color:var(--you);padding:0 8px">2</div><div class="line" style="padding:0 8px">waiting on you</div>
|
||||||
|
</div></div><figcaption><b>Feed toolbar.</b> Search sits beside + and opens dictation straight away. One tap less, but the top right gets crowded.</figcaption></figure>
|
||||||
|
</div></body></html>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<!doctype html><html><head><meta charset="utf-8"><title>WristCode Phase 4: Complication</title><style>
|
||||||
|
:root { --you:#ffb02e; --work:#4fd1c5; --done:#6b6b70; --card:#17171a; --bad:#ff6b5e; --dim:#aeaeb2; --add:#5ad17a; }
|
||||||
|
body { margin:0; font-family:"SF Pro Rounded", ui-rounded, system-ui, sans-serif; }
|
||||||
|
h3 { font-family:system-ui,sans-serif; font-size:14px; margin:6px 0 2px; color:var(--foreground,#ddd); text-align:center; grid-column:1/-1; }
|
||||||
|
.grid { display:grid; grid-template-columns:repeat(3,1fr); gap:22px 10px; padding:8px 0 16px; justify-items:center; }
|
||||||
|
figure { margin:0; display:flex; flex-direction:column; align-items:center; gap:8px; }
|
||||||
|
figcaption { font-size:12px; color:var(--muted-foreground,#999); text-align:center; max-width:220px; line-height:1.35; font-family:system-ui,sans-serif; }
|
||||||
|
.bezel { background:#0b0b0c; padding:9px; border-radius:48px; box-shadow:0 0 0 1px #2a2a2c; }
|
||||||
|
.screen { width:184px; height:225px; background:#000; border-radius:40px; overflow:hidden; color:#fff; padding:0 8px; box-sizing:border-box; font-size:13px; position:relative; }
|
||||||
|
.top { display:flex; justify-content:space-between; align-items:baseline; padding:11px 8px 5px; font-weight:600; font-size:14px; }
|
||||||
|
.card { background:var(--card); border-radius:15px; padding:7px 9px; margin-bottom:5px; border-left:4px solid var(--c, var(--card)); }
|
||||||
|
.row { display:flex; align-items:center; gap:6px; }
|
||||||
|
.name { font-weight:600; font-size:13px; line-height:1.2; }
|
||||||
|
.meta { font-size:10.5px; color:var(--c, var(--dim)); font-weight:600; }
|
||||||
|
.line { font-size:11.5px; color:var(--dim); margin-top:3px; line-height:1.25; }
|
||||||
|
.mono { font-family:"SF Mono",ui-monospace,monospace; font-size:10.5px; }
|
||||||
|
.label { font-size:10.5px; color:var(--dim); padding:4px 6px 3px; font-weight:600; }
|
||||||
|
.big { display:block; text-align:center; border-radius:20px; padding:7px; font-size:13px; font-weight:700; margin:4px 0; }
|
||||||
|
.r { margin-left:auto; white-space:nowrap; }
|
||||||
|
.add { color:var(--add); } .del { color:var(--bad); }
|
||||||
|
.dot { width:7px; height:7px; border-radius:50%; flex:none; }
|
||||||
|
.face { background:radial-gradient(circle at 50% 40%, #1a1a1d, #000 70%); }
|
||||||
|
.time { text-align:right; font-size:30px; font-weight:600; padding:10px 10px 0; letter-spacing:-.5px; }
|
||||||
|
</style></head><body><div class="grid">
|
||||||
|
<h3>Watch face (rectangular slot)</h3>
|
||||||
|
<figure><div class="bezel"><div class="screen face"><div class="time">9:41</div>
|
||||||
|
<div style="margin:30px 4px 0;background:#141416;border-radius:12px;padding:7px 9px"><div class="row"><span class="meta" style="--c:var(--you)">● 2 waiting</span><span class="r meta">1 working</span></div><div class="name" style="font-size:12px;margin-top:2px">HomeLab Stats</div><div class="line mono" style="margin-top:1px">brew install jq</div></div>
|
||||||
|
</div></div><figcaption><b>A. Next request.</b> The counts, then the thread that has waited longest and what it wants. Tap opens that thread.</figcaption></figure>
|
||||||
|
<figure><div class="bezel"><div class="screen face"><div class="time">9:41</div>
|
||||||
|
<div style="margin:30px 4px 0;background:#141416;border-radius:12px;padding:7px 9px"><div class="meta" style="--c:var(--work)">Phase 3 screens · 42s</div><div class="line mono" style="color:#fff;margin-top:2px">›_ bun test</div><div class="meta" style="margin-top:3px;--c:var(--you)">● 2 waiting</div></div>
|
||||||
|
</div></div><figcaption><b>B. Current activity.</b> What the busiest working thread is doing now, with the waiting count under it.</figcaption></figure>
|
||||||
|
<figure><div class="bezel"><div class="screen face"><div class="time">9:41</div>
|
||||||
|
<div style="margin:30px 4px 0;background:#141416;border-radius:12px;padding:7px 9px"><div class="row"><span class="meta" style="--c:var(--you)">● 2</span><span class="meta" style="--c:var(--work)">● 1</span><span class="meta" style="--c:var(--done)">● 4</span></div><div class="name" style="font-size:12px;margin-top:3px">HomeLab Stats</div><div class="line">wants to run a command</div></div>
|
||||||
|
</div></div><figcaption><b>C. Switching.</b> Shows A while anything waits on you, B otherwise. Recent count added as a third dot.</figcaption></figure>
|
||||||
|
<h3>Smart Stack</h3>
|
||||||
|
<figure style="grid-column:2"><div class="bezel"><div class="screen"><div class="time" style="font-size:22px;padding-top:8px">9:41</div>
|
||||||
|
<div style="margin:8px 0 0;background:#1c1c1f;border-radius:16px;padding:8px 10px"><div class="row"><span class="meta" style="--c:var(--you)">2 waiting</span><span class="r meta" style="--c:var(--work)">1 working</span></div><div class="name" style="font-size:12px;margin-top:3px">HomeLab Stats</div><div class="line mono">brew install jq</div><div class="row" style="gap:5px;margin-top:6px"><span class="big" style="flex:1;margin:0;padding:4px;background:var(--you);color:#000;font-size:11px">Allow</span><span class="big" style="flex:1;margin:0;padding:4px;background:#2c2c2e;font-size:11px">Deny</span></div></div>
|
||||||
|
</div></div><figcaption><b>Smart Stack widget.</b> The same content as the chosen face option, larger. With A, Allow and Deny work right in the stack.</figcaption></figure>
|
||||||
|
</div></body></html>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<!doctype html><html><head><meta charset="utf-8"><title>WristCode Phase 4: Answering from the notification</title><style>
|
||||||
|
:root { --you:#ffb02e; --work:#4fd1c5; --done:#6b6b70; --card:#17171a; --bad:#ff6b5e; --dim:#aeaeb2; --add:#5ad17a; }
|
||||||
|
body { margin:0; font-family:"SF Pro Rounded", ui-rounded, system-ui, sans-serif; }
|
||||||
|
h3 { font-family:system-ui,sans-serif; font-size:14px; margin:6px 0 2px; color:var(--foreground,#ddd); text-align:center; grid-column:1/-1; }
|
||||||
|
.grid { display:grid; grid-template-columns:repeat(3,1fr); gap:22px 10px; padding:8px 0 16px; justify-items:center; }
|
||||||
|
figure { margin:0; display:flex; flex-direction:column; align-items:center; gap:8px; }
|
||||||
|
figcaption { font-size:12px; color:var(--muted-foreground,#999); text-align:center; max-width:220px; line-height:1.35; font-family:system-ui,sans-serif; }
|
||||||
|
.bezel { background:#0b0b0c; padding:9px; border-radius:48px; box-shadow:0 0 0 1px #2a2a2c; }
|
||||||
|
.screen { width:184px; height:225px; background:#000; border-radius:40px; overflow:hidden; color:#fff; padding:0 8px; box-sizing:border-box; font-size:13px; position:relative; }
|
||||||
|
.top { display:flex; justify-content:space-between; align-items:baseline; padding:11px 8px 5px; font-weight:600; font-size:14px; }
|
||||||
|
.card { background:var(--card); border-radius:15px; padding:7px 9px; margin-bottom:5px; border-left:4px solid var(--c, var(--card)); }
|
||||||
|
.row { display:flex; align-items:center; gap:6px; }
|
||||||
|
.name { font-weight:600; font-size:13px; line-height:1.2; }
|
||||||
|
.meta { font-size:10.5px; color:var(--c, var(--dim)); font-weight:600; }
|
||||||
|
.line { font-size:11.5px; color:var(--dim); margin-top:3px; line-height:1.25; }
|
||||||
|
.mono { font-family:"SF Mono",ui-monospace,monospace; font-size:10.5px; }
|
||||||
|
.label { font-size:10.5px; color:var(--dim); padding:4px 6px 3px; font-weight:600; }
|
||||||
|
.big { display:block; text-align:center; border-radius:20px; padding:7px; font-size:13px; font-weight:700; margin:4px 0; }
|
||||||
|
.r { margin-left:auto; white-space:nowrap; }
|
||||||
|
.add { color:var(--add); } .del { color:var(--bad); }
|
||||||
|
.dot { width:7px; height:7px; border-radius:50%; flex:none; }
|
||||||
|
.face { background:radial-gradient(circle at 50% 40%, #1a1a1d, #000 70%); }
|
||||||
|
.time { text-align:right; font-size:30px; font-weight:600; padding:10px 10px 0; letter-spacing:-.5px; }
|
||||||
|
</style></head><body><div class="grid">
|
||||||
|
<h3>A question arrives</h3>
|
||||||
|
<figure><div class="bezel"><div class="screen"><div class="top"><span style="font-size:11px;color:var(--dim)">WRISTCODE</span><span></span></div>
|
||||||
|
<div class="name" style="padding:0 4px">Gateway Containerfile</div><div class="line" style="padding:0 4px">Which base image?</div>
|
||||||
|
<span class="big" style="background:#2c2c2e;margin-top:8px">bun:1-alpine</span><span class="big" style="background:#2c2c2e">bun:1-debian</span><span class="big" style="background:#2c2c2e">Open</span>
|
||||||
|
</div></div><figcaption><b>A. Options as buttons.</b> Each option is a button on the notification itself, then Open. A tap sends that answer.</figcaption></figure>
|
||||||
|
<figure><div class="bezel"><div class="screen"><div class="top"><span style="font-size:11px;color:var(--dim)">WRISTCODE</span><span></span></div>
|
||||||
|
<div class="name" style="padding:0 4px">Gateway Containerfile</div><div class="line" style="padding:0 4px">Which base image?</div>
|
||||||
|
<span class="big" style="background:#2c2c2e;margin-top:8px">bun:1-alpine</span><span class="big" style="background:#2c2c2e">bun:1-debian</span><span class="big" style="background:#2c2c2e">🎙 Say an answer</span>
|
||||||
|
</div></div><figcaption><b>B. Options and a spoken answer.</b> Like A, plus a dictated answer for questions that allow one. Open stays below.</figcaption></figure>
|
||||||
|
<figure><div class="bezel"><div class="screen"><div class="top"><span style="font-size:11px;color:var(--dim)">WRISTCODE</span><span></span></div>
|
||||||
|
<div class="name" style="padding:0 4px">Gateway Containerfile</div><div class="line" style="padding:0 4px">2 questions</div>
|
||||||
|
<span class="big" style="background:var(--you);color:#000;margin-top:8px">Answer</span><span class="big" style="background:#2c2c2e">Open</span>
|
||||||
|
</div></div><figcaption><b>Several questions.</b> In A and B, a request with more than one question shows Answer, which opens the app on the question screen.</figcaption></figure>
|
||||||
|
</div></body></html>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<!doctype html><html><head><meta charset="utf-8"><title>WristCode Phase 5: Snooze</title><style>
|
||||||
|
:root { --you:#ffb02e; --work:#4fd1c5; --done:#6b6b70; --card:#17171a; --bad:#ff6b5e; --dim:#aeaeb2; --add:#5ad17a; --snz:#9b8cff; }
|
||||||
|
body { margin:0; font-family:"SF Pro Rounded", ui-rounded, system-ui, sans-serif; }
|
||||||
|
h3 { font-family:system-ui,sans-serif; font-size:14px; margin:6px 0 2px; color:var(--foreground,#ddd); text-align:center; grid-column:1/-1; }
|
||||||
|
.grid { display:grid; grid-template-columns:repeat(3,1fr); gap:22px 10px; padding:8px 0 16px; justify-items:center; }
|
||||||
|
figure { margin:0; display:flex; flex-direction:column; align-items:center; gap:8px; }
|
||||||
|
figcaption { font-size:12px; color:var(--muted-foreground,#999); text-align:center; max-width:220px; line-height:1.35; font-family:system-ui,sans-serif; }
|
||||||
|
.bezel { background:#0b0b0c; padding:9px; border-radius:48px; box-shadow:0 0 0 1px #2a2a2c; }
|
||||||
|
.screen { width:184px; height:225px; background:#000; border-radius:40px; overflow:hidden; color:#fff; padding:0 8px; box-sizing:border-box; font-size:13px; }
|
||||||
|
.top { display:flex; justify-content:space-between; align-items:baseline; padding:11px 8px 5px; font-weight:600; font-size:14px; }
|
||||||
|
.card { background:var(--card); border-radius:15px; padding:7px 9px; margin-bottom:5px; border-left:4px solid var(--c, var(--card)); }
|
||||||
|
.row { display:flex; align-items:center; gap:6px; }
|
||||||
|
.name { font-weight:600; font-size:13px; line-height:1.2; }
|
||||||
|
.meta { font-size:10.5px; color:var(--c, var(--dim)); font-weight:600; }
|
||||||
|
.line { font-size:11.5px; color:var(--dim); margin-top:3px; line-height:1.25; }
|
||||||
|
.mono { font-family:"SF Mono",ui-monospace,monospace; font-size:10.5px; }
|
||||||
|
.big { display:block; text-align:center; border-radius:20px; padding:7px; font-size:13px; font-weight:700; margin:4px 0; }
|
||||||
|
.r { margin-left:auto; white-space:nowrap; }
|
||||||
|
.add { color:var(--add); } .del { color:var(--bad); }
|
||||||
|
.acts { display:flex; gap:5px; margin-top:6px; } .acts span { flex:1; text-align:center; border-radius:13px; padding:5px 0; font-size:12px; font-weight:700; }
|
||||||
|
.swipe { display:flex; margin-bottom:5px; } .swipe .card { flex:1; margin:0; border-radius:15px 0 0 15px; }
|
||||||
|
.act { width:44px; display:flex; flex-direction:column; align-items:center; justify-content:center; font-size:9.5px; font-weight:700; color:#000; }
|
||||||
|
</style></head><body><div class="grid">
|
||||||
|
<h3>Option 1: a second swipe action beside Settle</h3>
|
||||||
|
<figure><div class="bezel"><div class="screen"><div class="top"><span>Recent</span><span>9:41</span></div>
|
||||||
|
<div class="swipe"><div class="card" style="--c:var(--done)"><div class="meta">Mac mini · 4m ago</div><div class="name" style="color:var(--dim)">Gateway Containerfile</div></div><div class="act" style="background:var(--snz)">☾<br>Snooze</div><div class="act" style="background:var(--work);border-radius:0 15px 15px 0">✓<br>Settle</div></div>
|
||||||
|
<div class="card" style="--c:var(--done)"><div class="meta">Desktop · 1h ago</div><div class="name" style="color:var(--dim)">Explain the hub</div></div>
|
||||||
|
</div></div><figcaption><b>Swipe left.</b> Snooze sits beside Settle, in violet.</figcaption></figure>
|
||||||
|
<figure><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--work)">Snooze until</span><span>9:41</span></div>
|
||||||
|
<span class="big" style="background:var(--card)">In 1 hour</span><span class="big" style="background:var(--card)">This evening · 6 PM</span><span class="big" style="background:var(--card)">Tomorrow · 9 AM</span><span class="big" style="background:var(--card)">Next week</span>
|
||||||
|
</div></div><figcaption><b>Pick a time.</b> Four fixed choices. The card leaves Recent and comes back on its own when the time is up.</figcaption></figure>
|
||||||
|
<figure><div class="bezel"><div class="screen"><div class="top"><span></span><span>9:41</span></div>
|
||||||
|
<div class="card" style="--c:var(--done)"><div class="meta">Desktop · 1h ago</div><div class="name" style="color:var(--dim)">Explain the hub</div></div>
|
||||||
|
<div class="card" style="--c:var(--snz);opacity:.85"><div class="row"><span class="name" style="font-size:12px;color:var(--snz)">☾ 2 snoozed</span><span class="r" style="color:var(--snz)">›</span></div></div>
|
||||||
|
</div></div><figcaption><b>Snoozed row.</b> Under Recent, a dim row counts snoozed threads and opens their list, each with when it wakes and Unsnooze.</figcaption></figure>
|
||||||
|
<h3>Option 2: snooze from the thread screen only</h3>
|
||||||
|
<figure style="grid-column:2"><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--work)">‹ Thread</span><span>9:41</span></div>
|
||||||
|
<div class="line" style="padding:0 6px 4px;color:#ddd">Done. The image builds and the healthcheck passes.</div>
|
||||||
|
<span class="big" style="background:var(--card)">Reply</span>
|
||||||
|
<div class="row" style="gap:5px"><span class="big" style="flex:1;background:var(--work);color:#000;margin:0">Settle</span><span class="big" style="flex:1;background:#2c2c2e;color:var(--snz);margin:0">Snooze</span></div>
|
||||||
|
</div></div><figcaption><b>Beside Settle.</b> Cards keep their one swipe action. Snooze is a button next to Settle on the thread screen, with the same time picker.</figcaption></figure>
|
||||||
|
</div></body></html>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<!doctype html><html><head><meta charset="utf-8"><title>WristCode Phase 5: File approvals</title><style>
|
||||||
|
:root { --you:#ffb02e; --work:#4fd1c5; --done:#6b6b70; --card:#17171a; --bad:#ff6b5e; --dim:#aeaeb2; --add:#5ad17a; --snz:#9b8cff; }
|
||||||
|
body { margin:0; font-family:"SF Pro Rounded", ui-rounded, system-ui, sans-serif; }
|
||||||
|
h3 { font-family:system-ui,sans-serif; font-size:14px; margin:6px 0 2px; color:var(--foreground,#ddd); text-align:center; grid-column:1/-1; }
|
||||||
|
.grid { display:grid; grid-template-columns:repeat(3,1fr); gap:22px 10px; padding:8px 0 16px; justify-items:center; }
|
||||||
|
figure { margin:0; display:flex; flex-direction:column; align-items:center; gap:8px; }
|
||||||
|
figcaption { font-size:12px; color:var(--muted-foreground,#999); text-align:center; max-width:220px; line-height:1.35; font-family:system-ui,sans-serif; }
|
||||||
|
.bezel { background:#0b0b0c; padding:9px; border-radius:48px; box-shadow:0 0 0 1px #2a2a2c; }
|
||||||
|
.screen { width:184px; height:225px; background:#000; border-radius:40px; overflow:hidden; color:#fff; padding:0 8px; box-sizing:border-box; font-size:13px; }
|
||||||
|
.top { display:flex; justify-content:space-between; align-items:baseline; padding:11px 8px 5px; font-weight:600; font-size:14px; }
|
||||||
|
.card { background:var(--card); border-radius:15px; padding:7px 9px; margin-bottom:5px; border-left:4px solid var(--c, var(--card)); }
|
||||||
|
.row { display:flex; align-items:center; gap:6px; }
|
||||||
|
.name { font-weight:600; font-size:13px; line-height:1.2; }
|
||||||
|
.meta { font-size:10.5px; color:var(--c, var(--dim)); font-weight:600; }
|
||||||
|
.line { font-size:11.5px; color:var(--dim); margin-top:3px; line-height:1.25; }
|
||||||
|
.mono { font-family:"SF Mono",ui-monospace,monospace; font-size:10.5px; }
|
||||||
|
.big { display:block; text-align:center; border-radius:20px; padding:7px; font-size:13px; font-weight:700; margin:4px 0; }
|
||||||
|
.r { margin-left:auto; white-space:nowrap; }
|
||||||
|
.add { color:var(--add); } .del { color:var(--bad); }
|
||||||
|
.acts { display:flex; gap:5px; margin-top:6px; } .acts span { flex:1; text-align:center; border-radius:13px; padding:5px 0; font-size:12px; font-weight:700; }
|
||||||
|
.swipe { display:flex; margin-bottom:5px; } .swipe .card { flex:1; margin:0; border-radius:15px 0 0 15px; }
|
||||||
|
.act { width:44px; display:flex; flex-direction:column; align-items:center; justify-content:center; font-size:9.5px; font-weight:700; color:#000; }
|
||||||
|
</style></head><body><div class="grid">
|
||||||
|
<h3>Option 1: file and line counts on the card</h3>
|
||||||
|
<figure><div class="bezel"><div class="screen"><div class="top"><span>2</span><span>9:41</span></div>
|
||||||
|
<div class="card" style="--c:var(--you)"><div class="meta" style="--c:var(--you)">Mac mini · wristcode</div><div class="name">Gateway Containerfile</div>
|
||||||
|
<div class="line">Wants to edit</div><div class="row mono" style="margin-top:2px"><span style="color:#fff">compose.yml</span><span class="r"><span class="add">+9</span> <span class="del">−6</span></span></div>
|
||||||
|
<div class="acts"><span style="background:var(--you);color:#000">Allow</span><span style="background:#2c2c2e">Deny</span></div></div>
|
||||||
|
</div></div><figcaption><b>Feed card.</b> "Wants to edit", then the file name and how many lines it adds and removes. Same on the thread screen and the notification.</figcaption></figure>
|
||||||
|
<figure><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--work)">‹ Thread</span><span>9:41</span></div>
|
||||||
|
<div class="card" style="--c:var(--you)"><div class="meta" style="--c:var(--you)">Wants to edit</div><div class="row mono"><span style="color:#fff">compose.yml</span><span class="r"><span class="add">+9</span> <span class="del">−6</span></span></div>
|
||||||
|
<div class="mono" style="margin-top:5px;line-height:1.35"><div class="del">− image: bun:1</div><div class="add">+ image: bun:1-alpine</div><div class="add">+ restart: always</div></div></div>
|
||||||
|
<div class="acts" style="margin:0 2px"><span style="background:var(--you);color:#000">Allow</span><span style="background:#2c2c2e">Deny</span></div>
|
||||||
|
</div></div><figcaption><b>Thread screen.</b> The same, plus the first few changed lines, red and green. Enough to catch a wrong file or a wild edit.</figcaption></figure>
|
||||||
|
<figure><div class="bezel"><div class="screen"><div class="top"><span style="color:var(--work)">‹ Thread</span><span>9:41</span></div>
|
||||||
|
<div class="card" style="--c:var(--you)"><div class="meta" style="--c:var(--you)">Wants to edit 3 files</div>
|
||||||
|
<div class="row mono"><span style="color:#fff">compose.yml</span><span class="r"><span class="add">+9</span> <span class="del">−6</span></span></div>
|
||||||
|
<div class="row mono"><span style="color:#fff">Containerfile</span><span class="r"><span class="add">+31</span> <span class="del">−0</span></span></div>
|
||||||
|
<div class="row mono"><span style="color:#fff">health.ts</span><span class="r"><span class="add">+2</span> <span class="del">−2</span></span></div></div>
|
||||||
|
</div></div><figcaption><b>Several files.</b> One row each, with no line preview.</figcaption></figure>
|
||||||
|
<h3>Option 2: file name only</h3>
|
||||||
|
<figure style="grid-column:2"><div class="bezel"><div class="screen"><div class="top"><span>2</span><span>9:41</span></div>
|
||||||
|
<div class="card" style="--c:var(--you)"><div class="meta" style="--c:var(--you)">Mac mini · wristcode</div><div class="name">Gateway Containerfile</div>
|
||||||
|
<div class="line">Wants to edit <span class="mono" style="color:#fff">compose.yml</span></div>
|
||||||
|
<div class="acts"><span style="background:var(--you);color:#000">Allow</span><span style="background:#2c2c2e">Deny</span></div></div>
|
||||||
|
</div></div><figcaption><b>Just the name.</b> No counts and no preview. Simplest, and it works even when T3 sends no diff.</figcaption></figure>
|
||||||
|
</div></body></html>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Renews the gateway's T3 sessions, which last 30 days and cannot be refreshed. For each host it
|
||||||
|
# makes a one-time pairing credential on that host and hands it straight to `wristcode host add`
|
||||||
|
# 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.
|
||||||
|
#
|
||||||
|
# scripts/renew-hosts.sh renew every host in hosts.conf
|
||||||
|
# scripts/renew-hosts.sh "Mac mini" renew one host by label
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# 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}"
|
||||||
|
|
||||||
|
# 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.
|
||||||
|
MINT='
|
||||||
|
set -e
|
||||||
|
export T3CODE_HOME="${T3CODE_HOME:-$HOME/.t3}"
|
||||||
|
for candidate in "$(command -v t3 || true)" "$HOME/.t3/bin/t3" "$HOME/.local/bin/t3" $(ls -d "$HOME"/.t3/runtime/versions/*/t3 2>/dev/null | sort -V -r); do
|
||||||
|
if [ -n "$candidate" ] && [ -x "$candidate" ]; then t3="$candidate"; break; fi
|
||||||
|
done
|
||||||
|
[ -n "${t3:-}" ] || { echo "no t3 CLI found" >&2; exit 1; }
|
||||||
|
"$t3" auth pairing create --scope orchestration:read --scope orchestration:operate \
|
||||||
|
--label "WristCode gateway" --ttl 10m --json |
|
||||||
|
sed -n "s/.*\"credential\": *\"\\([^\"]*\\)\".*/\\1/p"
|
||||||
|
'
|
||||||
|
|
||||||
|
renew() {
|
||||||
|
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
|
||||||
|
credential="$(bash -c "$MINT")"
|
||||||
|
else
|
||||||
|
credential="$(ssh -o BatchMode=yes "$ssh_host" "bash -s" <<<"$MINT")"
|
||||||
|
fi
|
||||||
|
if [ -z "$credential" ]; then
|
||||||
|
echo "$label: could not make a pairing credential" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
# The credential travels on stdin, not on a command line other processes could read.
|
||||||
|
ssh -o BatchMode=yes "$GATEWAY_SSH" \
|
||||||
|
"read -r credential && $GATEWAY_RUNTIME exec $GATEWAY_CONTAINER wristcode host add '$url' \"\$credential\" --label '$label'$alt_args" \
|
||||||
|
<<<"$credential"
|
||||||
|
}
|
||||||
|
|
||||||
|
failed=0
|
||||||
|
for entry in "${HOSTS[@]}"; do
|
||||||
|
IFS='|' read -r label ssh_host url <<<"$entry"
|
||||||
|
if [ $# -gt 0 ] && [ "$1" != "$label" ]; then continue; fi
|
||||||
|
renew "$label" "$ssh_host" "$url" || failed=1
|
||||||
|
done
|
||||||
|
|
||||||
|
ssh -o BatchMode=yes "$GATEWAY_SSH" "$GATEWAY_RUNTIME exec $GATEWAY_CONTAINER wristcode host list" | cut -f1,4
|
||||||
|
exit "$failed"
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
# WristCode watch app
|
||||||
|
|
||||||
|
SwiftUI, watch only, Swift 6 with strict concurrency, and no dependencies. It talks to the gateway
|
||||||
|
over HTTPS + JSON as described in [docs/api.md](../docs/api.md); `WristCode/API/Models.swift`
|
||||||
|
mirrors [gateway/src/contract.ts](../gateway/src/contract.ts).
|
||||||
|
|
||||||
|
## Build and test
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd watch
|
||||||
|
xcodegen generate # the .xcodeproj is generated and gitignored
|
||||||
|
xcodebuild -scheme WristCode -destination 'platform=watchOS Simulator,name=Apple Watch Series 12 (46mm)' build
|
||||||
|
xcodebuild -scheme WristCode -destination 'platform=watchOS Simulator,name=Apple Watch Series 12 (46mm)' test
|
||||||
|
```
|
||||||
|
|
||||||
|
## TestFlight
|
||||||
|
|
||||||
|
The App Store only takes a watch-only app inside an empty iOS stub, so `project.yml` has two app
|
||||||
|
targets, the same pair Xcode's "Watch-only App" template makes:
|
||||||
|
|
||||||
|
| Target | Bundle ID | Job |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `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 |
|
||||||
|
| `WristCodeWidget` | `org.gbrown.wristcode.watchkitapp.widget` | The complication, embedded in the watch app |
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scripts/testflight.sh # archive, export and upload to App Store Connect
|
||||||
|
scripts/testflight.sh --no-upload # archive and export an .ipa to build/export-<n> only
|
||||||
|
```
|
||||||
|
|
||||||
|
The build number defaults to a `YYYYMMDDHHMM` timestamp (override it with `BUILD_NUMBER`). Signing is
|
||||||
|
automatic through the App Store Connect API key in `ASC_KEY_PATH`, `ASC_KEY_ID` and `ASC_ISSUER_ID`,
|
||||||
|
which default to the key in `~/.appstoreconnect/private_keys`. The entitlements file says
|
||||||
|
`aps-environment: development`; the App Store export re-signs it as `production`. Release builds
|
||||||
|
register their token with the gateway as `production`, Debug builds as `sandbox`.
|
||||||
|
|
||||||
|
## Demo mode
|
||||||
|
|
||||||
|
`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, 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:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
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 -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. 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
|
||||||
|
|
||||||
|
| Path | Job |
|
||||||
|
| --- | --- |
|
||||||
|
| `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 |
|
||||||
|
| `FeedCache.swift` | The last feed and thread details on disk, for an instant launch |
|
||||||
|
| `API/GatewayClient.swift` | `GatewayAPI` protocol and the URLSession client |
|
||||||
|
| `API/Models.swift` | Codable mirror of the contract |
|
||||||
|
| `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 |
|
||||||
|
| `Views/` | Pair, Feed, full Recent list, Thread, New thread, Status, and the mock B theme |
|
||||||
|
| `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 |
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
// HTTPS + JSON calls to the WristCode gateway. docs/api.md lists every route.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum GatewayError: LocalizedError, Equatable {
|
||||||
|
/// 401: the device token is missing or revoked. The app shows the pairing screen.
|
||||||
|
case unauthorized
|
||||||
|
/// Any other non-2xx status, with the gateway's `{ "error": ... }` message when it sent one.
|
||||||
|
case http(status: Int, message: String?)
|
||||||
|
case transport(String)
|
||||||
|
case decoding(String)
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .unauthorized:
|
||||||
|
"This watch is no longer paired."
|
||||||
|
case let .http(status, message):
|
||||||
|
message ?? Self.defaultMessage(status)
|
||||||
|
case let .transport(message):
|
||||||
|
message
|
||||||
|
case let .decoding(message):
|
||||||
|
"The gateway sent something unexpected. \(message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True for 409, when the request was stale, such as an approval someone already answered.
|
||||||
|
var isStale: Bool {
|
||||||
|
if case .http(409, _) = self { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func defaultMessage(_ status: Int) -> String {
|
||||||
|
switch status {
|
||||||
|
case 404: "Not found."
|
||||||
|
case 409: "Already handled."
|
||||||
|
case 429: "Too many attempts. Wait a minute."
|
||||||
|
case 503: "That host is offline."
|
||||||
|
default: "The gateway returned \(status)."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything the watch asks of a gateway. `GatewayClient` talks to the real one and `DemoGateway`
|
||||||
|
/// serves fixtures, so every screen works without a live gateway.
|
||||||
|
protocol GatewayAPI: Sendable {
|
||||||
|
func feed() async throws -> Feed
|
||||||
|
func summary() async throws -> Summary
|
||||||
|
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 respond(hostId: String, threadId: String, request: RespondRequest) 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
|
||||||
|
/// 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 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 {
|
||||||
|
let baseURL: URL
|
||||||
|
let token: String?
|
||||||
|
var session: URLSession = .shared
|
||||||
|
|
||||||
|
/// `POST /v1/pair`. The only call made without a device token.
|
||||||
|
static func pair(baseURL: URL, code: String, deviceName: String) async throws -> PairResponse {
|
||||||
|
let client = GatewayClient(baseURL: baseURL, token: nil)
|
||||||
|
return try await client.call("POST", ["v1", "pair"], body: PairRequest(code: code, deviceName: deviceName))
|
||||||
|
}
|
||||||
|
|
||||||
|
func feed() async throws -> 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 {
|
||||||
|
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 {
|
||||||
|
try await callIgnoringBody("POST", ["v1", "threads", hostId, threadId, "messages"], body: SendMessageRequest(text: text))
|
||||||
|
}
|
||||||
|
|
||||||
|
func respond(hostId: String, threadId: String, request: RespondRequest) async throws {
|
||||||
|
try await callIgnoringBody("POST", ["v1", "threads", hostId, threadId, "respond"], body: request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop(hostId: String, threadId: String) async throws {
|
||||||
|
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 {
|
||||||
|
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 {
|
||||||
|
try await call("POST", ["v1", "threads"], body: request)
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerPush(_ registration: PushRegistration) async throws {
|
||||||
|
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
|
||||||
|
|
||||||
|
/// A failed request as the app sees it. A request cancelled because its task was, as when a
|
||||||
|
/// 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>(
|
||||||
|
_ method: String, _ path: [String], query: [URLQueryItem] = [], body: Body?
|
||||||
|
) async throws -> Response {
|
||||||
|
let data = try await perform(method, path, query: query, body: body)
|
||||||
|
do {
|
||||||
|
return try JSON.decoder().decode(Response.self, from: data)
|
||||||
|
} catch {
|
||||||
|
throw GatewayError.decoding(String(describing: error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func callIgnoringBody<Body: Encodable>(_ method: String, _ path: [String], body: Body?) async throws {
|
||||||
|
_ = try await perform(method, path, body: body)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func perform<Body: Encodable>(
|
||||||
|
_ method: String, _ path: [String], query: [URLQueryItem] = [], body: Body?
|
||||||
|
) async throws -> Data {
|
||||||
|
var request = URLRequest(url: Self.url(baseURL, path, query: query), timeoutInterval: 20)
|
||||||
|
request.httpMethod = method
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
if let token {
|
||||||
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
}
|
||||||
|
if let body {
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.httpBody = try JSON.encoder().encode(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: Data
|
||||||
|
let response: URLResponse
|
||||||
|
do {
|
||||||
|
(data, response) = try await session.data(for: request)
|
||||||
|
} catch let error as URLError {
|
||||||
|
throw Self.transportError(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let http = response as? HTTPURLResponse else {
|
||||||
|
throw GatewayError.transport("No HTTP response.")
|
||||||
|
}
|
||||||
|
guard (200 ..< 300).contains(http.statusCode) else {
|
||||||
|
if http.statusCode == 401 { throw GatewayError.unauthorized }
|
||||||
|
let message = try? JSON.decoder().decode(ErrorResponse.self, from: data).error
|
||||||
|
throw GatewayError.http(status: http.statusCode, message: message)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,895 @@
|
|||||||
|
// Codable mirror of gateway/src/contract.ts. docs/api.md describes the same JSON for humans.
|
||||||
|
// Change both sides together.
|
||||||
|
//
|
||||||
|
// String enums from the gateway (phase, host status, approval decision) are modeled as
|
||||||
|
// RawRepresentable structs rather than Swift enums, so a value the watch does not know yet still
|
||||||
|
// decodes instead of failing the whole feed.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct Phase: RawRepresentable, Codable, Hashable, Sendable {
|
||||||
|
let rawValue: String
|
||||||
|
init(rawValue: String) { self.rawValue = rawValue }
|
||||||
|
|
||||||
|
static let starting = Phase(rawValue: "starting")
|
||||||
|
static let running = Phase(rawValue: "running")
|
||||||
|
static let waitingForApproval = Phase(rawValue: "waiting_for_approval")
|
||||||
|
static let waitingForInput = Phase(rawValue: "waiting_for_input")
|
||||||
|
static let completed = Phase(rawValue: "completed")
|
||||||
|
static let failed = Phase(rawValue: "failed")
|
||||||
|
|
||||||
|
var needsYou: Bool { self == .waitingForApproval || self == .waitingForInput }
|
||||||
|
var isWorking: Bool { self == .starting || self == .running }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HostStatus: RawRepresentable, Codable, Hashable, Sendable {
|
||||||
|
let rawValue: String
|
||||||
|
init(rawValue: String) { self.rawValue = rawValue }
|
||||||
|
|
||||||
|
static let connecting = HostStatus(rawValue: "connecting")
|
||||||
|
static let online = HostStatus(rawValue: "online")
|
||||||
|
static let offline = HostStatus(rawValue: "offline")
|
||||||
|
static let authInvalid = HostStatus(rawValue: "auth_invalid")
|
||||||
|
static let incompatible = HostStatus(rawValue: "incompatible")
|
||||||
|
|
||||||
|
/// A short label for a host that is not online, such as "offline" or "needs a new token".
|
||||||
|
var problem: String? {
|
||||||
|
switch self {
|
||||||
|
case .online: nil
|
||||||
|
case .connecting: "connecting"
|
||||||
|
case .authInvalid: "needs a new token"
|
||||||
|
case .incompatible: "incompatible"
|
||||||
|
default: "offline"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ApprovalDecision: RawRepresentable, Codable, Hashable, Sendable {
|
||||||
|
let rawValue: String
|
||||||
|
init(rawValue: String) { self.rawValue = rawValue }
|
||||||
|
|
||||||
|
static let accept = ApprovalDecision(rawValue: "accept")
|
||||||
|
static let acceptForSession = ApprovalDecision(rawValue: "acceptForSession")
|
||||||
|
static let acceptAlways = ApprovalDecision(rawValue: "acceptAlways")
|
||||||
|
static let decline = ApprovalDecision(rawValue: "decline")
|
||||||
|
static let cancel = ApprovalDecision(rawValue: "cancel")
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HostSummary: Codable, Hashable, Sendable, Identifiable {
|
||||||
|
var id: String
|
||||||
|
var label: String
|
||||||
|
var status: HostStatus
|
||||||
|
var error: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Something a thread is waiting on: an approval, or questions for the user.
|
||||||
|
struct PendingRequest: Codable, Hashable, Sendable, Identifiable {
|
||||||
|
struct Option: Codable, Hashable, Sendable {
|
||||||
|
var decision: ApprovalDecision
|
||||||
|
var label: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Question: Codable, Hashable, Sendable, Identifiable {
|
||||||
|
struct Choice: Codable, Hashable, Sendable {
|
||||||
|
var label: String
|
||||||
|
var value: String
|
||||||
|
}
|
||||||
|
|
||||||
|
var id: String
|
||||||
|
var header: String
|
||||||
|
var question: String
|
||||||
|
var options: [Choice]
|
||||||
|
var allowCustomAnswer: Bool
|
||||||
|
/// When true, the answer is an array of option values.
|
||||||
|
var multiSelect = false
|
||||||
|
}
|
||||||
|
|
||||||
|
var id: String
|
||||||
|
/// `command`, `file-change`, `permission`, `user_input`, ...
|
||||||
|
var kind: String
|
||||||
|
var prompt: String?
|
||||||
|
var reason: String?
|
||||||
|
var options: [Option]
|
||||||
|
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 }
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
var quickOptions: (allow: Option, deny: Option)? {
|
||||||
|
guard let allow = options.first(where: { $0.decision == .accept }),
|
||||||
|
let deny = options.first(where: { $0.decision == .decline })
|
||||||
|
else { return nil }
|
||||||
|
return (allow, deny)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One line saying what the agent wants, for a feed card.
|
||||||
|
var summary: String {
|
||||||
|
if let question = questions.first { return question.question }
|
||||||
|
if let prompt, !prompt.isEmpty { return prompt }
|
||||||
|
if let reason, !reason.isEmpty { return reason }
|
||||||
|
return switch kind {
|
||||||
|
case "command": "Run a command"
|
||||||
|
case "file-change", "file_change": "Change files"
|
||||||
|
default: "Approve a request"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
/// 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 hostLabel: String
|
||||||
|
var threadId: String
|
||||||
|
var projectId: String
|
||||||
|
var projectTitle: String
|
||||||
|
var title: String
|
||||||
|
var phase: Phase
|
||||||
|
var updatedAt: Date
|
||||||
|
var lastMessage: String?
|
||||||
|
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 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 {
|
||||||
|
var needsYou: [FeedItem]
|
||||||
|
var working: [FeedItem]
|
||||||
|
/// Finished or failed threads not settled yet, newest first and at most 10.
|
||||||
|
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]
|
||||||
|
|
||||||
|
/// `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 Role: RawRepresentable, Codable, Hashable, Sendable {
|
||||||
|
let rawValue: String
|
||||||
|
init(rawValue: String) { self.rawValue = rawValue }
|
||||||
|
static let user = Role(rawValue: "user")
|
||||||
|
static let assistant = Role(rawValue: "assistant")
|
||||||
|
static let system = Role(rawValue: "system")
|
||||||
|
}
|
||||||
|
|
||||||
|
var id: String
|
||||||
|
var role: Role
|
||||||
|
var text: String
|
||||||
|
var createdAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ThreadDetail: Codable, Hashable, Sendable {
|
||||||
|
var item: FeedItem
|
||||||
|
/// Oldest first, at most 20.
|
||||||
|
var messages: [Message]
|
||||||
|
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 Host: Codable, Hashable, Sendable, Identifiable {
|
||||||
|
var id: String
|
||||||
|
var label: String
|
||||||
|
var status: HostStatus
|
||||||
|
var error: String?
|
||||||
|
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.
|
||||||
|
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 {
|
||||||
|
var id: String
|
||||||
|
var title: String
|
||||||
|
/// The model a new thread uses by default, such as `claude-opus-5-5`.
|
||||||
|
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]
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
var code: String
|
||||||
|
var deviceName: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PairResponse: Codable, Sendable {
|
||||||
|
var deviceId: String
|
||||||
|
var deviceToken: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PushRegistration: Codable, Sendable {
|
||||||
|
enum Environment: String, Codable, Sendable {
|
||||||
|
case sandbox, production
|
||||||
|
}
|
||||||
|
|
||||||
|
var apnsToken: String
|
||||||
|
var apnsEnvironment: Environment
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SendMessageRequest: Codable, Sendable {
|
||||||
|
var text: String
|
||||||
|
}
|
||||||
|
|
||||||
|
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 answers(requestId: String, answers: [String: Answer])
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey { case requestId, decision, answers }
|
||||||
|
|
||||||
|
func encode(to encoder: any Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
switch self {
|
||||||
|
case let .decision(requestId, decision):
|
||||||
|
try container.encode(requestId, forKey: .requestId)
|
||||||
|
try container.encode(decision, forKey: .decision)
|
||||||
|
case let .answers(requestId, answers):
|
||||||
|
try container.encode(requestId, forKey: .requestId)
|
||||||
|
try container.encode(answers, forKey: .answers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NewThreadRequest: Codable, Sendable {
|
||||||
|
var hostId: String
|
||||||
|
var projectId: 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 {
|
||||||
|
var hostId: String
|
||||||
|
var threadId: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ErrorResponse: Codable, Sendable {
|
||||||
|
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.
|
||||||
|
enum PushCategory: String, CaseIterable, Sendable {
|
||||||
|
case approval, finished, failed, input, host, test
|
||||||
|
}
|
||||||
|
|
||||||
|
enum JSON {
|
||||||
|
static func decoder() -> JSONDecoder {
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
decoder.dateDecodingStrategy = .custom { decoder in
|
||||||
|
let container = try decoder.singleValueContainer()
|
||||||
|
let string = try container.decode(String.self)
|
||||||
|
guard let date = parseDate(string) else {
|
||||||
|
throw DecodingError.dataCorruptedError(
|
||||||
|
in: container, debugDescription: "Not an ISO 8601 date: \(string)")
|
||||||
|
}
|
||||||
|
return date
|
||||||
|
}
|
||||||
|
return decoder
|
||||||
|
}
|
||||||
|
|
||||||
|
static func encoder() -> JSONEncoder {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.dateEncodingStrategy = .custom { date, encoder in
|
||||||
|
var container = encoder.singleValueContainer()
|
||||||
|
try container.encode(Date.ISO8601FormatStyle(includingFractionalSeconds: true).format(date))
|
||||||
|
}
|
||||||
|
return encoder
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses ISO 8601 timestamps with or without fractional seconds.
|
||||||
|
static func parseDate(_ string: String) -> Date? {
|
||||||
|
let withFraction = Date.ISO8601FormatStyle(includingFractionalSeconds: true)
|
||||||
|
if let date = try? withFraction.parse(string) { return date }
|
||||||
|
return try? Date.ISO8601FormatStyle().parse(string)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,510 @@
|
|||||||
|
// 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 Observation
|
||||||
|
import UserNotifications
|
||||||
|
import WatchKit
|
||||||
|
import WidgetKit
|
||||||
|
|
||||||
|
@MainActor @Observable
|
||||||
|
final class AppModel {
|
||||||
|
enum Mode: Equatable {
|
||||||
|
case unpaired
|
||||||
|
case paired(Credentials)
|
||||||
|
/// Serves fixtures from `DemoGateway`. Nothing leaves the watch.
|
||||||
|
case demo
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The one instance the app delegate and notification handler share with the views.
|
||||||
|
static let shared = AppModel(launchArguments: ProcessInfo.processInfo.arguments)
|
||||||
|
|
||||||
|
var mode: Mode {
|
||||||
|
didSet { if mode != oldValue { shareMode() } }
|
||||||
|
}
|
||||||
|
var path: [Route] = []
|
||||||
|
private(set) var feed: Feed?
|
||||||
|
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?
|
||||||
|
/// 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?
|
||||||
|
|
||||||
|
/// 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.cache = cache
|
||||||
|
self.gateway = gateway
|
||||||
|
self.allRecent = allRecent
|
||||||
|
self.snoozed = snoozed
|
||||||
|
self.projects = projects
|
||||||
|
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|recent|status|notifications|threads`, `-host <hostId>` or
|
||||||
|
/// `-thread <hostId>/<threadId>` opens that screen, which is how the screenshots in
|
||||||
|
/// watch/screenshots are taken.
|
||||||
|
convenience init(launchArguments args: [String]) {
|
||||||
|
DeviceStore.migrateToSharedGroup()
|
||||||
|
let demo = args.contains("-demo")
|
||||||
|
let mode: Mode = demo ? .demo : DeviceStore.load().map(Mode.paired) ?? .unpaired
|
||||||
|
func argument(_ flag: String) -> String? {
|
||||||
|
guard demo, let i = args.firstIndex(of: flag), i + 1 < args.count else { return nil }
|
||||||
|
return args[i + 1]
|
||||||
|
}
|
||||||
|
var path: [Route] = []
|
||||||
|
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])] }
|
||||||
|
}
|
||||||
|
self.init(mode: mode, path: path, cache: .standard)
|
||||||
|
shareMode()
|
||||||
|
}
|
||||||
|
|
||||||
|
var isPaired: Bool {
|
||||||
|
if case .paired = mode { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cache while paired. Demo mode keeps off it.
|
||||||
|
private var pairedCache: FeedCache? { isPaired ? cache : nil }
|
||||||
|
|
||||||
|
var api: any GatewayAPI {
|
||||||
|
if let gateway { return gateway }
|
||||||
|
return switch mode {
|
||||||
|
case let .paired(credentials):
|
||||||
|
GatewayClient(baseURL: credentials.gatewayURL, token: credentials.token)
|
||||||
|
case .demo:
|
||||||
|
DemoGateway.shared
|
||||||
|
case .unpaired:
|
||||||
|
GatewayClient(baseURL: URL(string: DeviceStore.defaultGatewayURL)!, token: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Pairing
|
||||||
|
|
||||||
|
func pair(gatewayURL: URL, code: String) async throws {
|
||||||
|
let deviceName = WKInterfaceDevice.current().name
|
||||||
|
let response = try await GatewayClient.pair(baseURL: gatewayURL, code: code, deviceName: deviceName)
|
||||||
|
let credentials = Credentials(gatewayURL: gatewayURL, token: response.deviceToken)
|
||||||
|
DeviceStore.save(credentials)
|
||||||
|
mode = .paired(credentials)
|
||||||
|
resetContent()
|
||||||
|
await enableNotifications()
|
||||||
|
}
|
||||||
|
|
||||||
|
func startDemo() {
|
||||||
|
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
|
||||||
|
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 = []
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Revokes the device token on the gateway, then signs out. Signs out even when the gateway
|
||||||
|
/// cannot be reached, since the token is forgotten either way.
|
||||||
|
func unpair() async {
|
||||||
|
if isPaired { try? await api.unpair() }
|
||||||
|
signOut()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
/// A cancelled call, as when a screen's `.task` ends or a refresh restarts, returns nil too.
|
||||||
|
func message(for error: any Error) -> String? {
|
||||||
|
if error is CancellationError || (error as? URLError)?.code == .cancelled { return nil }
|
||||||
|
if let error = error as? GatewayError, error == .unauthorized {
|
||||||
|
signOut()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return error.localizedDescription
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
guard mode != .unpaired else { return }
|
||||||
|
if let refreshTask {
|
||||||
|
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 {
|
||||||
|
result = .success(try await api.feed())
|
||||||
|
} catch {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func open(_ route: 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
|
||||||
|
|
||||||
|
func enableNotifications() async {
|
||||||
|
guard isPaired else { return }
|
||||||
|
let center = UNUserNotificationCenter.current()
|
||||||
|
let granted = (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false
|
||||||
|
if granted {
|
||||||
|
WKApplication.shared().registerForRemoteNotifications()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func didRegister(apnsToken data: Data) {
|
||||||
|
apnsToken = data.map { String(format: "%02x", $0) }.joined()
|
||||||
|
Task { await sendPushToken() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sendPushToken() async {
|
||||||
|
guard isPaired, let apnsToken else { return }
|
||||||
|
#if DEBUG
|
||||||
|
let environment = PushRegistration.Environment.sandbox
|
||||||
|
#else
|
||||||
|
let environment = PushRegistration.Environment.production
|
||||||
|
#endif
|
||||||
|
do {
|
||||||
|
try await api.registerPush(PushRegistration(apnsToken: apnsToken, apnsEnvironment: environment))
|
||||||
|
} catch {
|
||||||
|
_ = message(for: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func notificationArrived() {
|
||||||
|
reloadComplication()
|
||||||
|
Task { await refreshFeed() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AppModel {
|
||||||
|
/// A demo model with the fixture feed already loaded, for previews.
|
||||||
|
static func preview(path: [Route] = []) -> AppModel {
|
||||||
|
AppModel(
|
||||||
|
mode: .demo, feed: DemoFixtures.previewFeed(), allRecent: DemoFixtures.previewRecent(),
|
||||||
|
snoozed: DemoFixtures.previewSnoozed(), projects: ProjectsResponse(hosts: DemoFixtures(now: .now).projectHosts), path: path)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{ "filename" : "icon.png", "idiom" : "universal", "platform" : "watchos", "size" : "1024x1024" }
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 }
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 }
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// 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 Security
|
||||||
|
|
||||||
|
struct Credentials: Equatable, Sendable {
|
||||||
|
var gatewayURL: URL
|
||||||
|
var token: String
|
||||||
|
}
|
||||||
|
|
||||||
|
enum DeviceStore {
|
||||||
|
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 tokenAccount = "deviceToken"
|
||||||
|
private static let urlAccount = "gatewayURL"
|
||||||
|
|
||||||
|
static func load() -> Credentials? {
|
||||||
|
guard let token = read(tokenAccount),
|
||||||
|
let urlString = read(urlAccount),
|
||||||
|
let url = URL(string: urlString)
|
||||||
|
else { return nil }
|
||||||
|
return Credentials(gatewayURL: url, token: token)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func save(_ credentials: Credentials) {
|
||||||
|
write(credentials.gatewayURL.absoluteString, account: urlAccount)
|
||||||
|
write(credentials.token, account: tokenAccount)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Forgets the token but keeps the URL, so the pairing screen is prefilled.
|
||||||
|
static func clearToken() {
|
||||||
|
delete(tokenAccount)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last gateway URL used, for prefilling the pairing screen.
|
||||||
|
static var lastGatewayURL: String {
|
||||||
|
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
|
||||||
|
/// no scheme, and drops trailing slashes. Returns nil when it is not a usable URL.
|
||||||
|
static func normalizeGatewayURL(_ input: String) -> URL? {
|
||||||
|
var text = input.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !text.isEmpty else { return nil }
|
||||||
|
if !text.contains("://") { text = "https://" + text }
|
||||||
|
while text.hasSuffix("/") { text.removeLast() }
|
||||||
|
guard let components = URLComponents(string: text),
|
||||||
|
let scheme = components.scheme?.lowercased(),
|
||||||
|
scheme == "https" || scheme == "http",
|
||||||
|
let host = components.host, !host.isEmpty
|
||||||
|
else { return nil }
|
||||||
|
return components.url
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Keychain
|
||||||
|
|
||||||
|
private static func query(_ account: String) -> [String: Any] {
|
||||||
|
[
|
||||||
|
kSecClass as String: kSecClassGenericPassword,
|
||||||
|
kSecAttrService as String: service,
|
||||||
|
kSecAttrAccount as String: account,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
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? {
|
||||||
|
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[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||||
|
var result: CFTypeRef?
|
||||||
|
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess else { return nil }
|
||||||
|
return result as? Data
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves or, given nil, deletes an item in the shared group.
|
||||||
|
static func setSharedData(_ data: Data?, account: String) {
|
||||||
|
guard let data else {
|
||||||
|
SecItemDelete(sharedQuery(account) as CFDictionary)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let attributes: [String: Any] = [
|
||||||
|
kSecValueData as String: data,
|
||||||
|
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
||||||
|
]
|
||||||
|
let status = SecItemUpdate(sharedQuery(account) as CFDictionary, attributes as CFDictionary)
|
||||||
|
if status == errSecItemNotFound {
|
||||||
|
let item = sharedQuery(account).merging(attributes) { _, new in new }
|
||||||
|
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) {
|
||||||
|
SecItemDelete(query(account) as CFDictionary)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,684 @@
|
|||||||
|
// 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:
|
||||||
|
// 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
|
||||||
|
|
||||||
|
actor DemoGateway: GatewayAPI {
|
||||||
|
static let shared = DemoGateway()
|
||||||
|
|
||||||
|
private var items: [FeedItem]
|
||||||
|
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 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
|
||||||
|
|
||||||
|
init(now: Date = .now) {
|
||||||
|
let fixtures = DemoFixtures(now: now)
|
||||||
|
items = fixtures.items
|
||||||
|
messages = fixtures.messages
|
||||||
|
earlierPages = fixtures.history
|
||||||
|
changedFiles = fixtures.changedFiles
|
||||||
|
children = fixtures.children
|
||||||
|
subagentIds = Set(fixtures.children.values.joined())
|
||||||
|
settled = fixtures.settled
|
||||||
|
hosts = fixtures.hosts
|
||||||
|
projectHosts = fixtures.projectHosts
|
||||||
|
statusResponse = fixtures.status
|
||||||
|
}
|
||||||
|
|
||||||
|
func feed() async throws -> Feed {
|
||||||
|
DemoFixtures.feed(unsettled, hosts: hosts, now: .now)
|
||||||
|
}
|
||||||
|
|
||||||
|
func summary() async throws -> Summary {
|
||||||
|
Summary(try await feed())
|
||||||
|
}
|
||||||
|
|
||||||
|
func thread(hostId: String, threadId: String) async throws -> ThreadDetail {
|
||||||
|
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,
|
||||||
|
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 {
|
||||||
|
try update(hostId, threadId) { item in
|
||||||
|
settled.remove(item.id)
|
||||||
|
append(Message(id: newId(), role: .user, text: text, createdAt: .now), to: item.id)
|
||||||
|
if !item.phase.isWorking {
|
||||||
|
item.phase = .running
|
||||||
|
item.lastMessage = "Working on it."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
let index = items.firstIndex { $0.requestThread == (hostId, threadId) }
|
||||||
|
?? 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.via = nil
|
||||||
|
switch request {
|
||||||
|
case let .decision(_, decision) where decision == .decline || decision == .cancel:
|
||||||
|
item.phase = .completed
|
||||||
|
item.lastMessage = "OK, I won't run it. Tell me what to do instead."
|
||||||
|
default:
|
||||||
|
item.phase = .running
|
||||||
|
item.lastMessage = "Thanks. Continuing."
|
||||||
|
}
|
||||||
|
if let line = item.lastMessage {
|
||||||
|
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 {
|
||||||
|
try update(hostId, threadId) { item in
|
||||||
|
item.phase = .completed
|
||||||
|
item.lastMessage = "Stopped."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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 {
|
||||||
|
guard let host = projectHosts.first(where: { $0.id == request.hostId }),
|
||||||
|
let project = host.projects.first(where: { $0.id == request.projectId })
|
||||||
|
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 item = FeedItem(
|
||||||
|
hostId: host.id, hostLabel: host.label, threadId: threadId, projectId: project.id,
|
||||||
|
projectTitle: project.title, title: String(request.text.prefix(40)), phase: .starting,
|
||||||
|
updatedAt: .now, lastMessage: nil, request: nil)
|
||||||
|
items.append(item)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
/// 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? {
|
||||||
|
items.first { $0.hostId == hostId && $0.threadId == threadId }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func update(_ hostId: String, _ threadId: String, _ change: (inout FeedItem) throws -> Void) throws {
|
||||||
|
guard let index = items.firstIndex(where: { $0.hostId == hostId && $0.threadId == threadId }) else {
|
||||||
|
throw GatewayError.http(status: 404, message: "Unknown thread.")
|
||||||
|
}
|
||||||
|
try change(&items[index])
|
||||||
|
items[index].updatedAt = .now
|
||||||
|
}
|
||||||
|
|
||||||
|
private func append(_ message: Message, to key: String) {
|
||||||
|
messages[key, default: []].append(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func newId() -> String {
|
||||||
|
nextId += 1
|
||||||
|
return "m\(nextId)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DemoFixtures {
|
||||||
|
let items: [FeedItem]
|
||||||
|
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 projectHosts: [ProjectsResponse.Host]
|
||||||
|
let status: StatusResponse
|
||||||
|
|
||||||
|
init(now: Date) {
|
||||||
|
func ago(_ minutes: Double) -> Date { now.addingTimeInterval(-minutes * 60) }
|
||||||
|
|
||||||
|
hosts = [
|
||||||
|
HostSummary(id: "mac-mini", label: "Mac mini", 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"),
|
||||||
|
]
|
||||||
|
|
||||||
|
let approval = FeedItem(
|
||||||
|
hostId: "mac-mini", hostLabel: "Mac mini", threadId: "t-ocr", projectId: "p-bill",
|
||||||
|
projectTitle: "bill-tracker", title: "Receipt OCR", phase: .waitingForApproval,
|
||||||
|
updatedAt: ago(3), lastMessage: "Parser and tests are written. I want to run the suite.",
|
||||||
|
request: PendingRequest(
|
||||||
|
id: "r-ocr", kind: "command", prompt: "bun test --filter ocr", reason: nil,
|
||||||
|
options: [
|
||||||
|
.init(decision: .accept, label: "Allow"),
|
||||||
|
.init(decision: .acceptForSession, label: "Allow this session"),
|
||||||
|
.init(decision: .decline, label: "Deny"),
|
||||||
|
],
|
||||||
|
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(
|
||||||
|
hostId: "server-gib", hostLabel: "server-gib", threadId: "t-db", projectId: "p-wc",
|
||||||
|
projectTitle: "wristcode", title: "Gateway storage", phase: .waitingForInput,
|
||||||
|
updatedAt: ago(1), lastMessage: "Two ways to store pairing codes.",
|
||||||
|
request: PendingRequest(
|
||||||
|
id: "r-db", kind: "user_input", prompt: nil, reason: nil, options: [],
|
||||||
|
questions: [
|
||||||
|
.init(
|
||||||
|
id: "q1", header: "Storage", question: "Hash pairing codes or store them plain?",
|
||||||
|
options: [.init(label: "Hash them", value: "hash"), .init(label: "Plain", value: "plain")],
|
||||||
|
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(
|
||||||
|
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),
|
||||||
|
lastMessage: "Writing ThreadListView.swift", request: nil,
|
||||||
|
activity: Activity(kind: .command, text: "xcodebuild test -scheme WristCode", startedAt: ago(0.7)))
|
||||||
|
|
||||||
|
let containerfile = FeedItem(
|
||||||
|
hostId: "server-gib", hostLabel: "server-gib", threadId: "t-container", projectId: "p-wc",
|
||||||
|
projectTitle: "wristcode", title: "Gateway Containerfile", phase: .running, updatedAt: ago(2),
|
||||||
|
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(
|
||||||
|
hostId: "littleman", hostLabel: "LittleMan", threadId: "t-next", projectId: "p-site",
|
||||||
|
projectTitle: "gbrown.org", title: "Bump Next.js", phase: .completed, updatedAt: ago(62),
|
||||||
|
lastMessage: "Done. Next.js is on 17.1 and the build passes. Want me to commit it?", request: nil)
|
||||||
|
|
||||||
|
let cdn = FeedItem(
|
||||||
|
hostId: "mac-mini", hostLabel: "Mac mini", threadId: "t-cdn", projectId: "p-cdn",
|
||||||
|
projectTitle: "cdn", title: "Fix CDN headers", phase: .failed, updatedAt: ago(140),
|
||||||
|
lastMessage: "cargo test failed: 2 tests in headers.rs", request: nil)
|
||||||
|
|
||||||
|
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 {
|
||||||
|
Message(id: id, role: role, text: text, createdAt: ago(minutes))
|
||||||
|
}
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
approval.id: [
|
||||||
|
m("a1", .user, 20, "Add OCR for receipts. Use the existing parser module."),
|
||||||
|
m("a2", .assistant, 12, "I'll add a receipt parser in src/ocr and tests beside it."),
|
||||||
|
m("a3", .assistant, 3, "Parser and tests are written. I want to run the suite."),
|
||||||
|
],
|
||||||
|
question.id: [
|
||||||
|
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."),
|
||||||
|
],
|
||||||
|
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: [
|
||||||
|
m("c1", .user, 4, "Scaffold the watch app with XcodeGen."),
|
||||||
|
m("c2", .assistant, 0.5, "Writing ThreadListView.swift"),
|
||||||
|
],
|
||||||
|
containerfile.id: [
|
||||||
|
m("d1", .user, 15, "Write a Containerfile for the gateway."),
|
||||||
|
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: [
|
||||||
|
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("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: [
|
||||||
|
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"),
|
||||||
|
],
|
||||||
|
]
|
||||||
|
|
||||||
|
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 = [
|
||||||
|
.init(
|
||||||
|
id: "mac-mini", label: "Mac mini", status: .online, error: nil,
|
||||||
|
projects: [
|
||||||
|
.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", instanceId: "claudeAgent"),
|
||||||
|
.init(id: "p-cdn", title: "cdn", model: "gpt-5-codex", instanceId: "codex"),
|
||||||
|
.init(id: "p-scratch", title: "scratch", model: nil),
|
||||||
|
],
|
||||||
|
providers: [claude, codex]),
|
||||||
|
.init(
|
||||||
|
id: "server-gib", label: "server-gib", status: .online, error: nil,
|
||||||
|
projects: [.init(id: "p-wc", title: "wristcode", model: "claude-opus-5-5", instanceId: "claudeAgent")],
|
||||||
|
providers: [claude]),
|
||||||
|
.init(
|
||||||
|
id: "littleman", label: "LittleMan", status: .offline, error: "Connection refused",
|
||||||
|
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.
|
||||||
|
static func previewFeed(now: Date = .now) -> Feed {
|
||||||
|
let fixtures = DemoFixtures(now: now)
|
||||||
|
return feed(fixtures.listed, hosts: fixtures.hosts, now: now)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The full Recent list for previews.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?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>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<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>
|
||||||
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
<false/>
|
||||||
|
<key>WKApplication</key>
|
||||||
|
<true/>
|
||||||
|
<key>WKWatchOnly</key>
|
||||||
|
<true/>
|
||||||
|
<key>WristCodeKeychainGroup</key>
|
||||||
|
<string>$(AppIdentifierPrefix)org.gbrown.wristcode.shared</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
// Notification categories and actions from docs/api.md, and what each action does. Allow, Deny,
|
||||||
|
// 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 SwiftUI
|
||||||
|
import UserNotifications
|
||||||
|
import WatchKit
|
||||||
|
|
||||||
|
/// What a notification response asks for, parsed out of the payload.
|
||||||
|
enum NotificationCommand: Equatable, Sendable {
|
||||||
|
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 settle(hostId: String, threadId: String)
|
||||||
|
case open(Route?)
|
||||||
|
case none
|
||||||
|
|
||||||
|
enum Action {
|
||||||
|
static let allow = "allow"
|
||||||
|
static let deny = "deny"
|
||||||
|
static let reply = "reply"
|
||||||
|
static let settle = "settle"
|
||||||
|
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 and questions,
|
||||||
|
/// `requestId`, and for a lone single-choice question, `question`.
|
||||||
|
init(actionIdentifier: String, userInfo: [AnyHashable: Any], text: String?) {
|
||||||
|
// The gateway sends `threadId: ""` on host notifications, so empty means absent.
|
||||||
|
func value(_ key: String) -> String? {
|
||||||
|
(userInfo[key] as? String).flatMap { $0.isEmpty ? nil : $0 }
|
||||||
|
}
|
||||||
|
let hostId = value("hostId")
|
||||||
|
let threadId = value("threadId")
|
||||||
|
let requestId = value("requestId")
|
||||||
|
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 {
|
||||||
|
case Action.allow, Action.deny:
|
||||||
|
guard let hostId, let threadId, let requestId else { self = .open(thread); return }
|
||||||
|
self = .respond(
|
||||||
|
hostId: hostId, threadId: threadId, requestId: requestId,
|
||||||
|
decision: actionIdentifier == Action.allow ? .accept : .decline)
|
||||||
|
case Action.reply:
|
||||||
|
let text = text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
guard let hostId, let threadId, !text.isEmpty else { self = .open(thread); return }
|
||||||
|
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:
|
||||||
|
self = .none
|
||||||
|
default:
|
||||||
|
// Open, Answer, or a tap on the notification itself.
|
||||||
|
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
|
||||||
|
final class NotificationHandler: NSObject, UNUserNotificationCenterDelegate {
|
||||||
|
static let shared = NotificationHandler()
|
||||||
|
|
||||||
|
func install() {
|
||||||
|
let center = UNUserNotificationCenter.current()
|
||||||
|
center.delegate = self
|
||||||
|
center.setNotificationCategories(Self.categories)
|
||||||
|
}
|
||||||
|
|
||||||
|
static var categories: Set<UNNotificationCategory> {
|
||||||
|
typealias Action = NotificationCommand.Action
|
||||||
|
let allow = UNNotificationAction(identifier: Action.allow, title: "Allow", options: [])
|
||||||
|
let deny = UNNotificationAction(identifier: Action.deny, title: "Deny", options: [.destructive])
|
||||||
|
let open = UNNotificationAction(identifier: Action.open, title: "Open", options: [.foreground])
|
||||||
|
let reply = UNTextInputNotificationAction(
|
||||||
|
identifier: Action.reply, title: "Reply", options: [],
|
||||||
|
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 {
|
||||||
|
UNNotificationCategory(identifier: id.rawValue, actions: actions, intentIdentifiers: [], options: [])
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
category(.approval, [allow, deny]),
|
||||||
|
// A question push replaces these with its options; see `inputActions`.
|
||||||
|
category(.input, [answer, open]),
|
||||||
|
category(.finished, [reply, settle, open]),
|
||||||
|
category(.failed, [open]),
|
||||||
|
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(
|
||||||
|
_ center: UNUserNotificationCenter, willPresent notification: UNNotification
|
||||||
|
) async -> UNNotificationPresentationOptions {
|
||||||
|
await AppModel.shared.notificationArrived()
|
||||||
|
return [.banner, .list, .sound]
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated func userNotificationCenter(
|
||||||
|
_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse
|
||||||
|
) async {
|
||||||
|
let command = NotificationCommand(
|
||||||
|
actionIdentifier: response.actionIdentifier,
|
||||||
|
userInfo: response.notification.request.content.userInfo,
|
||||||
|
text: (response as? UNTextInputNotificationResponse)?.userText)
|
||||||
|
await run(command)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func run(_ command: NotificationCommand) async {
|
||||||
|
let model = AppModel.shared
|
||||||
|
switch command {
|
||||||
|
case .none:
|
||||||
|
return
|
||||||
|
case let .open(route):
|
||||||
|
if let route { model.open(route) } else { model.path = [] }
|
||||||
|
model.reloadComplication()
|
||||||
|
await model.refreshFeed()
|
||||||
|
return
|
||||||
|
case .respond, .answer:
|
||||||
|
guard let response = command.response else { return }
|
||||||
|
await attempt("Couldn't answer") {
|
||||||
|
try await model.api.respond(hostId: response.hostId, threadId: response.threadId, request: response.request)
|
||||||
|
}
|
||||||
|
case let .reply(hostId, threadId, text):
|
||||||
|
await attempt("Couldn't send your reply") {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs a background action. If it fails, says so with a local notification, since nothing is
|
||||||
|
/// 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 {
|
||||||
|
guard AppModel.shared.isPaired else {
|
||||||
|
await notify(title, "This watch isn't paired. Open WristCode to pair it.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
AppModel.shared.beginChange()
|
||||||
|
do {
|
||||||
|
try await action()
|
||||||
|
} catch let error as GatewayError where error.isStale {
|
||||||
|
return
|
||||||
|
} catch {
|
||||||
|
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()
|
||||||
|
content.title = title
|
||||||
|
content.body = body
|
||||||
|
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,443 @@
|
|||||||
|
// 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, 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
|
||||||
|
|
||||||
|
struct FeedView: View {
|
||||||
|
@Environment(AppModel.self) private var model
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
@State private var visible = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
FeedHeader(feed: model.feed, error: model.feedError, cached: model.feedIsCached)
|
||||||
|
.feedRow()
|
||||||
|
|
||||||
|
if let feed = model.feed {
|
||||||
|
let recent = model.visibleRecent(feed.recent)
|
||||||
|
let more = model.settlingAll ? 0 : feed.moreRecent
|
||||||
|
ForEach(feed.needsYou) { item in
|
||||||
|
NeedsYouCard(item: item).feedRow()
|
||||||
|
}
|
||||||
|
ForEach(feed.working) { item in
|
||||||
|
StatusCard(item: item).feedRow()
|
||||||
|
}
|
||||||
|
if !recent.isEmpty || more > 0 {
|
||||||
|
RecentHeader(recent: recent, more: more).feedRow()
|
||||||
|
}
|
||||||
|
ForEach(recent) { item in
|
||||||
|
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))
|
||||||
|
.foregroundStyle(Theme.secondaryText)
|
||||||
|
.feedRow()
|
||||||
|
}
|
||||||
|
AllThreadsRow().feedRow()
|
||||||
|
} else if model.feedError != nil {
|
||||||
|
Button("Try again") { Task { await model.refreshFeed() } }
|
||||||
|
.buttonStyle(PillButtonStyle(fill: Theme.control, foreground: .white))
|
||||||
|
.feedRow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.refreshable { await model.refreshFeed() }
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
NavigationLink(value: Route.status) {
|
||||||
|
Image(systemName: "gearshape.fill")
|
||||||
|
.foregroundStyle(.black)
|
||||||
|
}
|
||||||
|
.accessibilityLabel("Status and settings")
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
NavigationLink(value: Route.newThread) {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
.fontWeight(.bold)
|
||||||
|
.foregroundStyle(.black)
|
||||||
|
}
|
||||||
|
.accessibilityLabel("New thread")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear { visible = true }
|
||||||
|
.onDisappear { visible = false }
|
||||||
|
// Refresh on appear, when the app comes to the foreground, and every 10 s while on screen.
|
||||||
|
.task(id: visible && scenePhase == .active) {
|
||||||
|
guard visible && scenePhase == .active else { return }
|
||||||
|
while !Task.isCancelled {
|
||||||
|
await model.refreshFeed()
|
||||||
|
try? await Task.sleep(for: .seconds(10))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
/// A clear List row with the feed's card insets.
|
||||||
|
func feedRow() -> some View {
|
||||||
|
listRowBackground(Color.clear)
|
||||||
|
.listRowInsets(EdgeInsets(top: 3, leading: 4, bottom: 3, trailing: 4))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The big count of threads waiting on you, plus offline hosts and refresh errors. A feed from the
|
||||||
|
/// cache says Updating beside the count until the first refresh answers.
|
||||||
|
struct FeedHeader: View {
|
||||||
|
var feed: Feed?
|
||||||
|
var error: String?
|
||||||
|
var cached = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
if let feed {
|
||||||
|
let waiting = feed.needsYou.count
|
||||||
|
HStack(alignment: .firstTextBaseline) {
|
||||||
|
Text("\(waiting)")
|
||||||
|
.font(.system(size: 34, weight: .bold, design: .rounded))
|
||||||
|
.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))
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(Theme.secondaryText)
|
||||||
|
ForEach(feed.hosts.filter { $0.status != .online }) { host in
|
||||||
|
Label("\(host.label) \(host.status.problem ?? "")", systemImage: "wifi.slash")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(Theme.done)
|
||||||
|
}
|
||||||
|
} else if error == nil {
|
||||||
|
Text("Loading")
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.foregroundStyle(Theme.secondaryText)
|
||||||
|
}
|
||||||
|
if let error {
|
||||||
|
Text(error)
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(Theme.done)
|
||||||
|
.lineLimit(3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.bottom, 4)
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func subtitle(_ feed: Feed) -> String {
|
||||||
|
let working = feed.working.count
|
||||||
|
let lead = feed.needsYou.isEmpty ? "nothing waiting" : "waiting on you"
|
||||||
|
return working > 0 ? "\(lead) · \(working) working" : lead
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
/// 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
|
||||||
|
var item: FeedItem
|
||||||
|
@State private var sending = false
|
||||||
|
@State private var error: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Card(edge: Theme.you) {
|
||||||
|
Button { model.path.append(item.route) } label: {
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text("\(item.hostLabel) · \(item.projectTitle)")
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.foregroundStyle(Theme.you)
|
||||||
|
.lineLimit(1)
|
||||||
|
Text(item.title)
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.lineLimit(2)
|
||||||
|
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)
|
||||||
|
.font(request.isQuestion ? .system(size: 12) : .system(size: 12, design: .monospaced))
|
||||||
|
.foregroundStyle(Theme.secondaryText)
|
||||||
|
.lineLimit(3)
|
||||||
|
.padding(.top, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
if let request = item.request, let quick = request.quickOptions {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Button(quick.allow.label) { respond(request, quick.allow.decision) }
|
||||||
|
.buttonStyle(ChoiceButtonStyle(fill: Theme.you, foreground: .black))
|
||||||
|
Button(quick.deny.label) { respond(request, quick.deny.decision) }
|
||||||
|
.buttonStyle(ChoiceButtonStyle())
|
||||||
|
}
|
||||||
|
.disabled(sending)
|
||||||
|
.opacity(sending ? 0.5 : 1)
|
||||||
|
.padding(.top, 7)
|
||||||
|
} else if item.request?.isQuestion == true {
|
||||||
|
Button("Answer") { model.path.append(item.route) }
|
||||||
|
.buttonStyle(ChoiceButtonStyle(fill: Theme.you, foreground: .black))
|
||||||
|
.padding(.top, 7)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error {
|
||||||
|
Text(error)
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(Theme.secondaryText)
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func respond(_ request: PendingRequest, _ decision: ApprovalDecision) {
|
||||||
|
sending = true
|
||||||
|
error = nil
|
||||||
|
model.beginChange()
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let target = item.requestThread
|
||||||
|
try await model.api.respond(
|
||||||
|
hostId: target.hostId, threadId: target.threadId,
|
||||||
|
request: .decision(requestId: request.id, decision: decision))
|
||||||
|
} catch let failure as GatewayError where failure.isStale {
|
||||||
|
// Someone answered it already; the refresh below drops the card.
|
||||||
|
} catch {
|
||||||
|
self.error = model.message(for: error)
|
||||||
|
}
|
||||||
|
await model.refreshFeed()
|
||||||
|
sending = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A teal card for a working thread, or a grey one for a recent thread. A working thread shows the
|
||||||
|
/// 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
|
||||||
|
var item: FeedItem
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button { model.path.append(item.route) } label: {
|
||||||
|
Card(edge: item.phase.color) {
|
||||||
|
Text(meta)
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.foregroundStyle(item.phase == .failed ? Theme.secondaryText : item.phase.color)
|
||||||
|
.lineLimit(1)
|
||||||
|
Text(item.title)
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.foregroundStyle(item.phase.isWorking ? Color.white : Theme.secondaryText)
|
||||||
|
.lineLimit(2)
|
||||||
|
.padding(.top, 1)
|
||||||
|
if item.phase.isWorking, let activity = item.activity {
|
||||||
|
ActivityLine(activity: activity)
|
||||||
|
.padding(.top, 3)
|
||||||
|
} else if item.phase.isWorking, let line = item.lastMessage {
|
||||||
|
Text(Markdown.render(line))
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Theme.secondaryText)
|
||||||
|
.lineLimit(2)
|
||||||
|
.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())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var meta: String {
|
||||||
|
if item.phase.isWorking {
|
||||||
|
return "\(item.hostLabel) · \(item.projectTitle)"
|
||||||
|
}
|
||||||
|
let age = Format.age(of: item.updatedAt)
|
||||||
|
let state = item.phase == .failed ? "Failed" : age == "now" ? "just now" : "\(age) ago"
|
||||||
|
return "\(item.hostLabel) · \(state)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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") {
|
||||||
|
NavigationStack {
|
||||||
|
FeedView()
|
||||||
|
}
|
||||||
|
.environment(AppModel.preview())
|
||||||
|
.fontDesign(.rounded)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
// Start a thread: pick where (host, then project), pick the model from the host's providers, choose
|
||||||
|
// 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
|
||||||
|
|
||||||
|
struct ProjectRef: Hashable {
|
||||||
|
var hostId: String
|
||||||
|
var projectId: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct NewThreadView: View {
|
||||||
|
@Environment(AppModel.self) private var model
|
||||||
|
@AppStorage("lastHostId") private var lastHostId = ""
|
||||||
|
@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 selection: ProjectRef?
|
||||||
|
@State private var picking = false
|
||||||
|
@State private var choosingAgent = false
|
||||||
|
@State private var sending = false
|
||||||
|
@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 {
|
||||||
|
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) {
|
||||||
|
if let projects {
|
||||||
|
let chosen = resolve(selection, in: projects)
|
||||||
|
Button { picking = true } label: {
|
||||||
|
Card(edge: Theme.done) {
|
||||||
|
Text("Where")
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.foregroundStyle(Theme.secondaryText)
|
||||||
|
Text(chosen.map { "\($0.host.label) › \($0.project.title)" } ?? "Choose a project")
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
if let chosen {
|
||||||
|
let agent = agent(for: chosen)
|
||||||
|
let provider = agent.flatMap { chosen.host.provider($0.instanceId) }
|
||||||
|
agentRow(chosen, agent: agent)
|
||||||
|
|
||||||
|
if provider?.canAskFirst == true {
|
||||||
|
SwitchCard(title: "Ask before acting", isOn: $askFirst, tint: Theme.you, edge: Theme.done)
|
||||||
|
}
|
||||||
|
|
||||||
|
TextFieldLink(prompt: Text("What should it do?")) {
|
||||||
|
Label(sending ? "Starting" : "Say the task", systemImage: "mic.fill")
|
||||||
|
} onSubmit: { text in
|
||||||
|
start(text, in: chosen, agent: agent, provider: provider)
|
||||||
|
}
|
||||||
|
.buttonStyle(PillButtonStyle(fill: Theme.you))
|
||||||
|
.disabled(sending || chosen.host.status != .online)
|
||||||
|
.padding(.top, 4)
|
||||||
|
|
||||||
|
if chosen.host.status != .online {
|
||||||
|
Text("\(chosen.host.label) is \(chosen.host.status.problem ?? "offline").")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Theme.secondaryText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LoadStatus(loaded: projects != nil, error: error, retry: load)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The model the thread will use. It opens the model list when the host lists its providers.
|
||||||
|
@ViewBuilder
|
||||||
|
private func agentRow(_ chosen: Chosen, agent: ModelRef?) -> some View {
|
||||||
|
let card = Card(edge: Theme.done) {
|
||||||
|
Text("Agent")
|
||||||
|
.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
do {
|
||||||
|
let response = try await model.api.projects()
|
||||||
|
projects = response
|
||||||
|
if selection == nil {
|
||||||
|
selection = Self.defaultSelection(
|
||||||
|
in: response, last: ProjectRef(hostId: lastHostId, projectId: lastProjectId))
|
||||||
|
}
|
||||||
|
error = nil
|
||||||
|
} catch {
|
||||||
|
if let message = model.message(for: error) { self.error = message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private typealias Chosen = (host: ProjectsResponse.Host, project: ProjectsResponse.Project)
|
||||||
|
|
||||||
|
private func resolve(_ ref: ProjectRef?, in projects: ProjectsResponse) -> Chosen? {
|
||||||
|
guard let ref,
|
||||||
|
let host = projects.hosts.first(where: { $0.id == ref.hostId }),
|
||||||
|
let project = host.launchable.first(where: { $0.id == ref.projectId })
|
||||||
|
else { return nil }
|
||||||
|
return (host, project)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last used project when it can still start threads, else the first project on the
|
||||||
|
/// first online host.
|
||||||
|
nonisolated static func defaultSelection(in projects: ProjectsResponse, last: ProjectRef) -> ProjectRef? {
|
||||||
|
if let host = projects.hosts.first(where: { $0.id == last.hostId }),
|
||||||
|
host.launchable.contains(where: { $0.id == last.projectId })
|
||||||
|
{
|
||||||
|
return last
|
||||||
|
}
|
||||||
|
let hosts = projects.hosts.filter { $0.status == .online } + projects.hosts.filter { $0.status != .online }
|
||||||
|
for host in hosts {
|
||||||
|
if let project = host.launchable.first {
|
||||||
|
return ProjectRef(hostId: host.id, projectId: project.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func start(_ text: String, in chosen: Chosen, agent: ModelRef?, provider: AgentProvider?) {
|
||||||
|
let text = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !text.isEmpty else { return }
|
||||||
|
sending = true
|
||||||
|
error = nil
|
||||||
|
let options = Self.options(project: chosen.project, agent: agent, provider: provider, askFirst: askFirst)
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
let created = try await model.api.newThread(NewThreadRequest(
|
||||||
|
hostId: chosen.host.id, projectId: chosen.project.id, text: text,
|
||||||
|
model: options.model, askFirst: options.askFirst))
|
||||||
|
lastHostId = chosen.host.id
|
||||||
|
lastProjectId = chosen.project.id
|
||||||
|
await model.refreshFeed()
|
||||||
|
// Replace this screen with the new thread, so Back returns to the feed.
|
||||||
|
let route = Route.thread(hostId: created.hostId, threadId: created.threadId)
|
||||||
|
if model.path.last == .newThread {
|
||||||
|
model.path[model.path.count - 1] = route
|
||||||
|
} else {
|
||||||
|
model.path.append(route)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
self.error = model.message(for: error)
|
||||||
|
}
|
||||||
|
sending = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
private struct ProjectPicker: View {
|
||||||
|
var projects: ProjectsResponse
|
||||||
|
var pick: (ProjectRef) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List(projects.hosts) { host in
|
||||||
|
NavigationLink {
|
||||||
|
List(host.launchable) { project in
|
||||||
|
Button {
|
||||||
|
pick(ProjectRef(hostId: host.id, projectId: project.id))
|
||||||
|
} label: {
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text(project.title)
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
Text(project.model.map(Format.modelName) ?? "")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(Theme.secondaryText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(host.label)
|
||||||
|
.overlay {
|
||||||
|
if host.launchable.isEmpty {
|
||||||
|
Text("No projects with a default model.")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(Theme.secondaryText)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
VStack(alignment: .leading, spacing: 1) {
|
||||||
|
Text(host.label)
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
Text(host.status.problem ?? "\(host.launchable.count) projects")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(host.status == .online ? Theme.secondaryText : Theme.done)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Where")
|
||||||
|
}
|
||||||
|
.fontDesign(.rounded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("New thread") {
|
||||||
|
NavigationStack {
|
||||||
|
NewThreadView()
|
||||||
|
}
|
||||||
|
.environment(AppModel.preview())
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// Pairing: the gateway URL and the 6 digit code that `wristcode pair` prints on the gateway.
|
||||||
|
// Shown whenever there is no device token, or the gateway answers 401.
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct PairView: View {
|
||||||
|
@Environment(AppModel.self) private var model
|
||||||
|
@State private var url = DeviceStore.lastGatewayURL
|
||||||
|
@State private var code = ""
|
||||||
|
@State private var sending = false
|
||||||
|
@State private var error: String?
|
||||||
|
|
||||||
|
private var digits: String { code.filter(\.isNumber) }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("WristCode")
|
||||||
|
.font(.system(size: 22, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(Theme.you)
|
||||||
|
Text("Run wristcode pair on the gateway, then enter the code.")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(Theme.secondaryText)
|
||||||
|
|
||||||
|
field("Gateway") {
|
||||||
|
TextField("Gateway URL", text: $url)
|
||||||
|
.textContentType(.URL)
|
||||||
|
}
|
||||||
|
field("Code") {
|
||||||
|
TextField("6 digits", text: $code)
|
||||||
|
.textContentType(.oneTimeCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(sending ? "Pairing" : "Pair") { pair() }
|
||||||
|
.buttonStyle(PillButtonStyle(fill: Theme.you))
|
||||||
|
.disabled(sending || digits.count != 6)
|
||||||
|
.opacity(digits.count == 6 ? 1 : 0.5)
|
||||||
|
.padding(.top, 4)
|
||||||
|
|
||||||
|
if let error {
|
||||||
|
Text(error)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Theme.secondaryText)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button("Try the demo") { model.startDemo() }
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundStyle(Theme.work)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.top, 6)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
}
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func field(_ label: String, @ViewBuilder input: () -> some View) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.foregroundStyle(Theme.secondaryText)
|
||||||
|
input()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pair() {
|
||||||
|
guard let gatewayURL = DeviceStore.normalizeGatewayURL(url) else {
|
||||||
|
error = "That gateway URL doesn't look right."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sending = true
|
||||||
|
error = nil
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await model.pair(gatewayURL: gatewayURL, code: digits)
|
||||||
|
} catch let failure as GatewayError where failure == .unauthorized {
|
||||||
|
error = "That code is wrong or expired."
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
sending = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Pair") {
|
||||||
|
PairView()
|
||||||
|
.environment(AppModel(mode: .unpaired))
|
||||||
|
.fontDesign(.rounded)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
// 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
|
||||||
|
|
||||||
|
enum Theme {
|
||||||
|
static let you = Color(red: 1.0, green: 0.690, blue: 0.180) // #ffb02e
|
||||||
|
static let work = Color(red: 0.310, green: 0.820, blue: 0.773) // #4fd1c5
|
||||||
|
static let done = Color(red: 0.420, green: 0.420, blue: 0.439) // #6b6b70
|
||||||
|
static let card = Color(red: 0.090, green: 0.090, blue: 0.102) // #17171a
|
||||||
|
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 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 {
|
||||||
|
var color: Color {
|
||||||
|
if needsYou { return Theme.you }
|
||||||
|
if isWorking { return Theme.work }
|
||||||
|
return Theme.done
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
struct Card<Content: View>: View {
|
||||||
|
var edge: Color
|
||||||
|
@ViewBuilder var content: Content
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
content
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.vertical, 9)
|
||||||
|
.padding(.leading, 15)
|
||||||
|
.padding(.trailing, 11)
|
||||||
|
.background(alignment: .leading) {
|
||||||
|
ZStack(alignment: .leading) {
|
||||||
|
Theme.card
|
||||||
|
edge.frame(width: 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
struct PillButtonStyle: ButtonStyle {
|
||||||
|
var fill: Color
|
||||||
|
var foreground: Color = .black
|
||||||
|
|
||||||
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
|
configuration.label
|
||||||
|
.font(.system(size: 16, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(foreground)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(fill.opacity(configuration.isPressed ? 0.7 : 1), in: Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A smaller button for choices on a card, such as Allow and Deny.
|
||||||
|
struct ChoiceButtonStyle: ButtonStyle {
|
||||||
|
var fill: Color = Theme.control
|
||||||
|
var foreground: Color = .white
|
||||||
|
|
||||||
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
|
configuration.label
|
||||||
|
.font(.system(size: 13, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(foreground)
|
||||||
|
.lineLimit(2)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
.background(fill.opacity(configuration.isPressed ? 0.7 : 1), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Format {
|
||||||
|
/// A short age for a card, such as "now", "4 min", "2 h" or "3 d".
|
||||||
|
static func age(of date: Date, now: Date = .now) -> String {
|
||||||
|
let minutes = Int(now.timeIntervalSince(date) / 60)
|
||||||
|
if minutes < 1 { return "now" }
|
||||||
|
if minutes < 60 { return "\(minutes) min" }
|
||||||
|
let hours = minutes / 60
|
||||||
|
if hours < 24 { return "\(hours) h" }
|
||||||
|
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".
|
||||||
|
static func modelName(_ id: String) -> String {
|
||||||
|
var words: [String] = []
|
||||||
|
var number: [String] = []
|
||||||
|
func flush() {
|
||||||
|
if !number.isEmpty { words.append(number.joined(separator: ".")) }
|
||||||
|
number = []
|
||||||
|
}
|
||||||
|
for part in id.split(whereSeparator: { $0 == "-" || $0 == "_" }).map(String.init) {
|
||||||
|
if part.allSatisfy(\.isNumber) {
|
||||||
|
number.append(part)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
switch part.lowercased() {
|
||||||
|
case "gpt": words.append("GPT")
|
||||||
|
default: words.append(part.prefix(1).uppercased() + part.dropFirst())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
return words.joined(separator: " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lays children out left to right and wraps them onto new lines, like the mock's reply chips.
|
||||||
|
struct FlowLayout: Layout {
|
||||||
|
var spacing: CGFloat = 6
|
||||||
|
|
||||||
|
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||||
|
let rows = arrange(width: proposal.width ?? .infinity, subviews: subviews)
|
||||||
|
return CGSize(width: proposal.width ?? rows.map(\.width).max() ?? 0, height: rows.last.map { $0.y + $0.height } ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||||
|
for row in arrange(width: bounds.width, subviews: subviews) {
|
||||||
|
var x = bounds.minX
|
||||||
|
for index in row.indices {
|
||||||
|
let size = subviews[index].sizeThatFits(.unspecified)
|
||||||
|
subviews[index].place(at: CGPoint(x: x, y: bounds.minY + row.y), proposal: ProposedViewSize(size))
|
||||||
|
x += size.width + spacing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Row {
|
||||||
|
var indices: [Int] = []
|
||||||
|
var y: CGFloat = 0
|
||||||
|
var width: CGFloat = 0
|
||||||
|
var height: CGFloat = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private func arrange(width: CGFloat, subviews: Subviews) -> [Row] {
|
||||||
|
var rows: [Row] = []
|
||||||
|
var row = Row()
|
||||||
|
for index in subviews.indices {
|
||||||
|
let size = subviews[index].sizeThatFits(.unspecified)
|
||||||
|
if !row.indices.isEmpty, row.width + spacing + size.width > width {
|
||||||
|
rows.append(row)
|
||||||
|
row = Row(y: row.y + row.height + spacing)
|
||||||
|
}
|
||||||
|
row.width += (row.indices.isEmpty ? 0 : spacing) + size.width
|
||||||
|
row.height = max(row.height, size.height)
|
||||||
|
row.indices.append(index)
|
||||||
|
}
|
||||||
|
if !row.indices.isEmpty { rows.append(row) }
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A content-sized chip, for one-tap replies.
|
||||||
|
struct ChipButtonStyle: ButtonStyle {
|
||||||
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
|
configuration.label
|
||||||
|
.font(.system(size: 13, weight: .semibold, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.background(Theme.card.opacity(configuration.isPressed ? 0.6 : 1), in: Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,709 @@
|
|||||||
|
// One thread: the agent's latest message large, older messages one scroll up with Load earlier above
|
||||||
|
// 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
|
||||||
|
|
||||||
|
struct ThreadView: View {
|
||||||
|
@Environment(AppModel.self) private var model
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
let hostId: String
|
||||||
|
let threadId: String
|
||||||
|
|
||||||
|
@State private var detail: ThreadDetail?
|
||||||
|
/// Why the last load failed. The next good load clears it.
|
||||||
|
@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 visible = false
|
||||||
|
@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
|
||||||
|
/// 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 {
|
||||||
|
if scrolls {
|
||||||
|
screen
|
||||||
|
} else {
|
||||||
|
column
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var column: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
if let detail {
|
||||||
|
content(detail)
|
||||||
|
}
|
||||||
|
LoadStatus(loaded: detail != nil, error: error)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var screen: some View {
|
||||||
|
ScrollViewReader { proxy in
|
||||||
|
ScrollView {
|
||||||
|
column
|
||||||
|
}
|
||||||
|
.onChange(of: detail?.messages.last?.id) {
|
||||||
|
guard !scrolledToLatest, detail != nil else { return }
|
||||||
|
scrolledToLatest = true
|
||||||
|
proxy.scrollTo("latest", anchor: .top)
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
if detail != nil, !scrolledToLatest {
|
||||||
|
scrolledToLatest = true
|
||||||
|
proxy.scrollTo("latest", anchor: .top)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(detail?.item.title ?? "Thread")
|
||||||
|
.onAppear {
|
||||||
|
if detail == nil { detail = model.cachedThread(hostId: hostId, threadId: threadId) }
|
||||||
|
visible = true
|
||||||
|
}
|
||||||
|
.onDisappear { visible = false }
|
||||||
|
.task(id: visible && scenePhase == .active) {
|
||||||
|
guard visible && scenePhase == .active else { return }
|
||||||
|
while !Task.isCancelled {
|
||||||
|
await load()
|
||||||
|
try? await Task.sleep(for: .seconds(10))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func content(_ detail: ThreadDetail) -> some View {
|
||||||
|
let split = MessageSplit(history.messages(before: detail.messages))
|
||||||
|
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
|
||||||
|
MessageRow(message: message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
.foregroundStyle(Theme.bodyText)
|
||||||
|
.lineSpacing(1)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.id(item.via == nil ? "latest" : "message")
|
||||||
|
.padding(.top, split.earlier.isEmpty || item.via != nil ? 0 : 6)
|
||||||
|
|
||||||
|
ForEach(split.after) { message in
|
||||||
|
MessageRow(message: message)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(statusLine(item))
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.foregroundStyle(item.phase.color)
|
||||||
|
|
||||||
|
if item.via == nil, let request = item.request {
|
||||||
|
requestCards(request, from: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if item.phase == .completed && item.request == nil {
|
||||||
|
QuickReplies { send($0) }
|
||||||
|
.disabled(sending)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let actionError {
|
||||||
|
actionFailure(actionError)
|
||||||
|
}
|
||||||
|
|
||||||
|
let settleable = (item.phase == .completed || item.phase == .failed) && item.request == nil
|
||||||
|
TextFieldLink(prompt: Text("Reply")) {
|
||||||
|
Label("Reply", systemImage: "mic.fill")
|
||||||
|
} onSubmit: { text in
|
||||||
|
send(text)
|
||||||
|
}
|
||||||
|
.buttonStyle(settleable ? PillButtonStyle(fill: Theme.card, foreground: .white) : PillButtonStyle(fill: Theme.work))
|
||||||
|
.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 {
|
||||||
|
Button(role: .destructive) { stop() } label: {
|
||||||
|
Label("Stop", systemImage: "stop.fill")
|
||||||
|
}
|
||||||
|
.buttonStyle(PillButtonStyle(fill: Theme.control, foreground: .red))
|
||||||
|
.disabled(sending)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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) {
|
||||||
|
subagentLabel(via)
|
||||||
|
if let edit = request.fileEdit {
|
||||||
|
FileEditRows(edit: edit, inThread: true)
|
||||||
|
} else if let prompt = request.prompt {
|
||||||
|
Text(prompt)
|
||||||
|
.font(.system(size: 13, design: .monospaced))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
} else {
|
||||||
|
Text(request.summary)
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
}
|
||||||
|
if let reason = request.reason {
|
||||||
|
Text(reason)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Theme.secondaryText)
|
||||||
|
.padding(.top, 3)
|
||||||
|
}
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
ForEach(Array(request.options.enumerated()), id: \.offset) { index, option in
|
||||||
|
Button(option.label) { respond(.decision(requestId: request.id, decision: option.decision)) }
|
||||||
|
.buttonStyle(ChoiceButtonStyle(
|
||||||
|
fill: index == 0 ? Theme.you : Theme.control,
|
||||||
|
foreground: index == 0 ? .black : .white))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
.disabled(sending)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func questions(_ request: PendingRequest, from via: FeedItem.Via?) -> some View {
|
||||||
|
ForEach(request.questions) { question in
|
||||||
|
Card(edge: Theme.you) {
|
||||||
|
subagentLabel(via)
|
||||||
|
Text(question.header)
|
||||||
|
.font(.system(size: 11, weight: .semibold))
|
||||||
|
.foregroundStyle(Theme.you)
|
||||||
|
Text(question.question)
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.padding(.top, 1)
|
||||||
|
if question.multiSelect {
|
||||||
|
Text("Choose any")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(Theme.secondaryText)
|
||||||
|
.padding(.top, 1)
|
||||||
|
}
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
if question.multiSelect {
|
||||||
|
multiSelectOptions(request, question)
|
||||||
|
} else {
|
||||||
|
ForEach(question.options, id: \.value) { choice in
|
||||||
|
let chosen = answers[question.id] == .text(choice.value)
|
||||||
|
Button(choice.label) { answer(request, question.id, .text(choice.value)) }
|
||||||
|
.buttonStyle(ChoiceButtonStyle(
|
||||||
|
fill: chosen ? Theme.you : Theme.control,
|
||||||
|
foreground: chosen ? .black : .white))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if question.allowCustomAnswer {
|
||||||
|
TextFieldLink(prompt: Text(question.question)) {
|
||||||
|
Text("Other…")
|
||||||
|
} onSubmit: { text in
|
||||||
|
answer(request, question.id, .text(text))
|
||||||
|
}
|
||||||
|
.buttonStyle(ChoiceButtonStyle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
.disabled(sending)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
let place = "\(item.hostLabel) · \(item.projectTitle)"
|
||||||
|
return switch item.phase {
|
||||||
|
case .waitingForApproval: "Needs approval · \(place)"
|
||||||
|
case .waitingForInput: "Has a question · \(place)"
|
||||||
|
case .starting, .running: "Working · \(place)"
|
||||||
|
case .failed: "Failed · \(place)"
|
||||||
|
default: place
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
private func load() async {
|
||||||
|
let askGeneration = askFirstGeneration
|
||||||
|
do {
|
||||||
|
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
|
||||||
|
model.saveThread(fresh)
|
||||||
|
} catch {
|
||||||
|
if let message = model.message(for: error) { self.error = message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
actionError = nil
|
||||||
|
unsent = nil
|
||||||
|
model.beginChange()
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await action()
|
||||||
|
} catch let failure as GatewayError where failure.isStale {
|
||||||
|
actionError = "Already answered."
|
||||||
|
} catch {
|
||||||
|
if let message = model.message(for: error) {
|
||||||
|
actionError = message
|
||||||
|
unsent = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await load()
|
||||||
|
await model.refreshFeed()
|
||||||
|
sending = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func send(_ text: String) {
|
||||||
|
let text = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !text.isEmpty else { return }
|
||||||
|
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) {
|
||||||
|
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.
|
||||||
|
private func answer(_ request: PendingRequest, _ questionId: String, _ value: RespondRequest.Answer) {
|
||||||
|
answers[questionId] = value
|
||||||
|
let all = request.questions.allSatisfy { answers[$0.id] != nil }
|
||||||
|
guard all else { return }
|
||||||
|
let payload = answers.filter { key, _ in request.questions.contains { $0.id == key } }
|
||||||
|
answers = [:]
|
||||||
|
selections = [:]
|
||||||
|
respond(.answers(requestId: request.id, answers: payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stop() {
|
||||||
|
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.
|
||||||
|
struct MessageSplit {
|
||||||
|
var earlier: [Message]
|
||||||
|
var latest: Message?
|
||||||
|
var after: [Message]
|
||||||
|
|
||||||
|
init(_ messages: [Message]) {
|
||||||
|
guard let index = messages.lastIndex(where: { $0.role == .assistant }) else {
|
||||||
|
earlier = messages
|
||||||
|
latest = nil
|
||||||
|
after = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
earlier = Array(messages[..<index])
|
||||||
|
latest = messages[index]
|
||||||
|
after = Array(messages[(index + 1)...])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
||||||
|
var message: Message
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let mine = message.role == .user
|
||||||
|
Text(Markdown.render(message.text))
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(mine ? Color.white : Theme.secondaryText)
|
||||||
|
.padding(.horizontal, mine ? 9 : 0)
|
||||||
|
.padding(.vertical, mine ? 6 : 0)
|
||||||
|
.background(mine ? Theme.card : Color.clear, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||||
|
.frame(maxWidth: .infinity, alignment: mine ? .trailing : .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
private struct QuickReplies: View {
|
||||||
|
var send: (String) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
FlowLayout {
|
||||||
|
ForEach(["Yes", "Continue", "Commit it"], id: \.self) { reply in
|
||||||
|
Button(reply) { send(reply) }
|
||||||
|
.buttonStyle(ChipButtonStyle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Finished") {
|
||||||
|
NavigationStack {
|
||||||
|
ThreadView(hostId: "littleman", threadId: "t-next")
|
||||||
|
}
|
||||||
|
.environment(AppModel.preview())
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Approval") {
|
||||||
|
NavigationStack {
|
||||||
|
ThreadView(hostId: "mac-mini", threadId: "t-ocr")
|
||||||
|
}
|
||||||
|
.environment(AppModel.preview())
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Subagent") {
|
||||||
|
NavigationStack {
|
||||||
|
ThreadView(hostId: "mac-mini", threadId: "t-homelab")
|
||||||
|
}
|
||||||
|
.environment(AppModel.preview())
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Question") {
|
||||||
|
NavigationStack {
|
||||||
|
ThreadView(hostId: "server-gib", threadId: "t-db")
|
||||||
|
}
|
||||||
|
.environment(AppModel.preview())
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("Multi-select") {
|
||||||
|
NavigationStack {
|
||||||
|
ThreadView(hostId: "mac-mini", threadId: "t-release", selections: ["checks": ["unit", "lint"]])
|
||||||
|
}
|
||||||
|
.environment(AppModel.preview())
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?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>aps-environment</key>
|
||||||
|
<string>development</string>
|
||||||
|
<key>keychain-access-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>$(AppIdentifierPrefix)org.gbrown.wristcode.shared</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// 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 WatchKit
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct WristCodeApp: App {
|
||||||
|
@WKApplicationDelegateAdaptor(AppDelegate.self) private var delegate
|
||||||
|
@State private var model = AppModel.shared
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
RootView()
|
||||||
|
.environment(model)
|
||||||
|
}
|
||||||
|
WKNotificationScene(controller: InputNotificationController.self, category: PushCategory.input.rawValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RootView: View {
|
||||||
|
@Environment(AppModel.self) private var model
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
@Bindable var model = model
|
||||||
|
Group {
|
||||||
|
if model.mode == .unpaired {
|
||||||
|
PairView()
|
||||||
|
} else {
|
||||||
|
NavigationStack(path: $model.path) {
|
||||||
|
FeedView()
|
||||||
|
.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 {
|
||||||
|
case let .thread(hostId, threadId):
|
||||||
|
ThreadView(hostId: hostId, threadId: threadId)
|
||||||
|
case .newThread:
|
||||||
|
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)
|
||||||
|
.tint(Theme.work)
|
||||||
|
.onOpenURL { model.open(url: $0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class AppDelegate: NSObject, WKApplicationDelegate {
|
||||||
|
func applicationDidFinishLaunching() {
|
||||||
|
NotificationHandler.shared.install()
|
||||||
|
Task { await AppModel.shared.enableNotifications() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func didRegisterForRemoteNotifications(withDeviceToken deviceToken: Data) {
|
||||||
|
AppModel.shared.didRegister(apnsToken: deviceToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
func didFailToRegisterForRemoteNotificationsWithError(_ error: any Error) {
|
||||||
|
print("APNs registration failed: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) == [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
name: WristCode
|
||||||
|
options:
|
||||||
|
bundleIdPrefix: org.gbrown
|
||||||
|
createIntermediateGroups: true
|
||||||
|
deploymentTarget:
|
||||||
|
watchOS: "27.0"
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
DEVELOPMENT_TEAM: GQJNM5YK2M
|
||||||
|
CODE_SIGN_STYLE: Automatic
|
||||||
|
SWIFT_VERSION: "6.0"
|
||||||
|
SWIFT_STRICT_CONCURRENCY: complete
|
||||||
|
MARKETING_VERSION: "0.1.0"
|
||||||
|
# Default build number; scripts/testflight.sh overrides it with CURRENT_PROJECT_VERSION=<n>.
|
||||||
|
CURRENT_PROJECT_VERSION: "1"
|
||||||
|
targets:
|
||||||
|
WristCode:
|
||||||
|
type: application
|
||||||
|
platform: watchOS
|
||||||
|
sources:
|
||||||
|
- 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:
|
||||||
|
path: WristCode/Info.plist
|
||||||
|
properties:
|
||||||
|
CFBundleDisplayName: WristCode
|
||||||
|
CFBundleShortVersionString: $(MARKETING_VERSION)
|
||||||
|
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
|
||||||
|
WKApplication: true
|
||||||
|
# Watch only, with no iPhone companion. watchOS refuses to install an app that sets both
|
||||||
|
# this and WKRunsIndependentlyOfCompanionApp, and requires WKCompanionAppBundleIdentifier
|
||||||
|
# when WKRunsIndependentlyOfCompanionApp is set without WKWatchOnly.
|
||||||
|
WKWatchOnly: true
|
||||||
|
# HTTPS only, so no export compliance documentation is needed.
|
||||||
|
ITSAppUsesNonExemptEncryption: false
|
||||||
|
# wristcode://thread/<hostId>/<threadId>, which the complication opens.
|
||||||
|
CFBundleURLTypes:
|
||||||
|
- CFBundleURLName: org.gbrown.wristcode
|
||||||
|
CFBundleURLSchemes: [wristcode]
|
||||||
|
WristCodeKeychainGroup: $(AppIdentifierPrefix)org.gbrown.wristcode.shared
|
||||||
|
entitlements:
|
||||||
|
path: WristCode/WristCode.entitlements
|
||||||
|
properties:
|
||||||
|
# Archives are signed for development; exporting for App Store Connect re-signs with the
|
||||||
|
# distribution profile, which turns this into production.
|
||||||
|
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:
|
||||||
|
base:
|
||||||
|
# The container's bundle ID plus .watchkitapp, as App Store validation requires. This is also
|
||||||
|
# the APNs topic.
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: org.gbrown.wristcode.watchkitapp
|
||||||
|
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
|
||||||
|
# "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.
|
||||||
|
WristCodeContainer:
|
||||||
|
type: application.watchapp2-container
|
||||||
|
platform: iOS
|
||||||
|
deploymentTarget: "27.0"
|
||||||
|
dependencies:
|
||||||
|
- target: WristCode
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
PRODUCT_NAME: WristCode
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: org.gbrown.wristcode
|
||||||
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName: WristCode
|
||||||
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
|
||||||
|
TARGETED_DEVICE_FAMILY: "1"
|
||||||
|
WristCodeTests:
|
||||||
|
type: bundle.unit-test
|
||||||
|
platform: watchOS
|
||||||
|
sources:
|
||||||
|
- WristCodeTests
|
||||||
|
dependencies:
|
||||||
|
- target: WristCode
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: org.gbrown.wristcode.tests
|
||||||
|
GENERATE_INFOPLIST_FILE: YES
|
||||||
|
TEST_HOST: $(BUILT_PRODUCTS_DIR)/WristCode.app/WristCode
|
||||||
|
BUNDLE_LOADER: $(TEST_HOST)
|
||||||
|
schemes:
|
||||||
|
WristCode:
|
||||||
|
build:
|
||||||
|
targets:
|
||||||
|
WristCode: all
|
||||||
|
WristCodeContainer: [archive]
|
||||||
|
WristCodeTests: [test]
|
||||||
|
run:
|
||||||
|
config: Debug
|
||||||
|
test:
|
||||||
|
config: Debug
|
||||||
|
targets:
|
||||||
|
- WristCodeTests
|
||||||
|
archive:
|
||||||
|
config: Release
|
||||||
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 123 KiB |