Compare commits
48
Commits
2e7b0e3419
..
main
| 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 | ||
|
|
ba1624eb9e | ||
|
|
6645fc9150 | ||
|
|
6ca098b701 | ||
|
|
972f6e4be5 | ||
|
|
0fd556e313 | ||
|
|
e7f5b7e45b | ||
|
|
e79eaf43aa |
No files matched your search
@@ -0,0 +1,96 @@
|
||||
name: Gateway
|
||||
|
||||
# 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 junior.gib (the VPS) to deploy it
|
||||
# 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
|
||||
# DEPLOY_SSH_KEY_VPS_B64, a base64 encoded key that junior.gib only accepts for
|
||||
# ~/Server/ci-deploy.sh.
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'gateway/**'
|
||||
- '.gitea/workflows/gateway.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'gateway/**'
|
||||
|
||||
concurrency:
|
||||
group: gateway-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: build
|
||||
steps:
|
||||
- name: Checkout
|
||||
# Plain git instead of actions/checkout: rootless podman on some runners rejects symlinks
|
||||
# inside cached actions.
|
||||
env:
|
||||
GITEA_TOKEN: ${{ gitea.token }}
|
||||
run: |
|
||||
git init -q .
|
||||
git remote add origin "${{ gitea.server_url }}/${{ gitea.repository }}.git"
|
||||
auth="$(printf 'x-access-token:%s' "$GITEA_TOKEN" | base64 -w0)"
|
||||
git -c http.extraHeader="Authorization: basic $auth" \
|
||||
fetch -q --depth 1 origin "${{ gitea.sha }}"
|
||||
git checkout -q FETCH_HEAD
|
||||
|
||||
- name: Set up Bun
|
||||
# The install script instead of oven-sh/setup-bun, for the same symlink reason.
|
||||
run: |
|
||||
curl -fsSL https://bun.sh/install | bash -s "bun-v1.4.0" >/dev/null
|
||||
echo "$HOME/.bun/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Typecheck and test
|
||||
working-directory: gateway
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
bun run typecheck
|
||||
bun test
|
||||
|
||||
image:
|
||||
needs: [check]
|
||||
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/main'
|
||||
runs-on: build
|
||||
env:
|
||||
IMAGE: git.gbrown.org/gib/wristcode-gateway
|
||||
steps:
|
||||
- name: Checkout
|
||||
env:
|
||||
GITEA_TOKEN: ${{ gitea.token }}
|
||||
run: |
|
||||
git init -q .
|
||||
git remote add origin "${{ gitea.server_url }}/${{ gitea.repository }}.git"
|
||||
auth="$(printf 'x-access-token:%s' "$GITEA_TOKEN" | base64 -w0)"
|
||||
git -c http.extraHeader="Authorization: basic $auth" \
|
||||
fetch -q --depth 1 origin "${{ gitea.sha }}"
|
||||
git checkout -q FETCH_HEAD
|
||||
|
||||
- name: Build
|
||||
run: docker build -f gateway/Containerfile -t "$IMAGE:${{ gitea.sha }}" -t "$IMAGE:latest" gateway
|
||||
|
||||
- name: Push
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login git.gbrown.org -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
docker push "$IMAGE:${{ gitea.sha }}"
|
||||
docker push "$IMAGE:latest"
|
||||
|
||||
- name: Deploy
|
||||
# 192.168.2.2 is junior's WireGuard address, reachable from every runner. The secret is the
|
||||
# private key base64 encoded on one line. The runner prints each step's
|
||||
# env, and Gitea only masks a secret it can match in full, which a multi-line key defeats.
|
||||
env:
|
||||
DEPLOY_SSH_KEY_B64: ${{ secrets.DEPLOY_SSH_KEY_VPS_B64 }}
|
||||
run: |
|
||||
install -m600 /dev/null "$HOME/.deploy_key"
|
||||
printf '%s' "$DEPLOY_SSH_KEY_B64" | base64 -d > "$HOME/.deploy_key"
|
||||
ssh -i "$HOME/.deploy_key" \
|
||||
-o BatchMode=yes \
|
||||
-o StrictHostKeyChecking=accept-new \
|
||||
-o ConnectTimeout=15 \
|
||||
[email protected] wristcode \
|
||||
|| echo "::warning::deploy trigger failed; the nightly update timer will deploy instead"
|
||||
rm -f "$HOME/.deploy_key"
|
||||
@@ -11,3 +11,6 @@ xcuserdata/
|
||||
DerivedData/
|
||||
build/
|
||||
*.p8
|
||||
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,42 +1,69 @@
|
||||
# WristCode
|
||||
|
||||
An Apple Watch client for [T3 Code](https://t3.codes). Browse threads across every T3 Code
|
||||
environment you run, reply by dictation, start new threads, and get a notification when an agent
|
||||
finishes. It works over the watch's own cellular connection, with no phone nearby.
|
||||
An Apple Watch app for [T3 Code](https://t3.codes). It shows every T3 Code environment you run on
|
||||
your wrist: which agents need you, what each one is doing right now, and what finished. You can
|
||||
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
|
||||
|
||||
watchOS only allows WebSockets for audio streaming apps, and T3 Code clients talk to their server
|
||||
over a WebSocket. So the watch talks HTTPS to a small gateway, and the gateway talks to each T3 Code
|
||||
watchOS only lets audio apps use WebSockets, and T3 Code clients talk to their server over one. So
|
||||
the watch talks HTTPS to a small gateway, and the gateway keeps a WebSocket open to each T3 Code
|
||||
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
|
||||
```
|
||||
|
||||
- `gateway/` is the gateway: Bun and TypeScript, shipped as a container image.
|
||||
- `watch/` is the watchOS app: SwiftUI, watch only, no iPhone companion.
|
||||
- `docs/` holds the design notes and the T3 Code protocol reference.
|
||||
- `mocks/` holds static UI mocks of the watch screens.
|
||||
- `gateway/` is the gateway, in Bun and TypeScript, shipped as a container image.
|
||||
- `watch/` is the watchOS app in SwiftUI. It is watch only, with no iPhone app.
|
||||
- `deploy/` has a compose file and an example `.env` for the gateway.
|
||||
- `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.
|
||||
|
||||
## Auth
|
||||
## Setting it up
|
||||
|
||||
The gateway signs in to each T3 Code server with a scoped bearer session, limited to reading and
|
||||
driving threads:
|
||||
You need:
|
||||
|
||||
```sh
|
||||
t3 auth session issue \
|
||||
--scope orchestration:read --scope orchestration:operate \
|
||||
--ttl 30d --label "WristCode gateway" --token-only
|
||||
```
|
||||
- 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.
|
||||
|
||||
The watch pairs with the gateway once by entering a short code, and keeps the device token it gets
|
||||
back in its Keychain.
|
||||
[docs/setup.md](docs/setup.md) walks through running the gateway, connecting your T3 Code
|
||||
machines, building the app and pairing your watch.
|
||||
|
||||
## Status
|
||||
|
||||
I use it every day with five machines. It is young, and the gateway API can change between
|
||||
versions, so update the gateway and the app together.
|
||||
|
||||
## 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
|
||||
@@ -0,0 +1,24 @@
|
||||
# The WristCode gateway as one container with one SQLite file. Put it behind a reverse proxy that
|
||||
# serves HTTPS on a public name, such as wristcode.example.com, and forwards to port 8080. The
|
||||
# watch needs that name reachable over cellular.
|
||||
#
|
||||
# .env beside this file (mode 600). See .env.example.
|
||||
#
|
||||
# Operator CLI: docker compose exec gateway wristcode <host add|pair|host list|device list|...>
|
||||
services:
|
||||
gateway:
|
||||
image: ${WRISTCODE_IMAGE:-wristcode-gateway:latest}
|
||||
build:
|
||||
context: ../gateway
|
||||
dockerfile: Containerfile
|
||||
container_name: wristcode-gateway
|
||||
env_file: [.env]
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080" # for a proxy on the same machine. Drop it if the proxy shares a network.
|
||||
volumes:
|
||||
- ./data:/data # the image runs as uid 1000, which must be able to write here
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: wget -qO- http://127.0.0.1:8080/v1/health || exit 1
|
||||
interval: 30s
|
||||
start_period: 10s
|
||||
+408
@@ -0,0 +1,408 @@
|
||||
# Gateway API
|
||||
|
||||
The watch app talks to the gateway over HTTPS with JSON bodies. The TypeScript types in
|
||||
[`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.
|
||||
|
||||
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.
|
||||
|
||||
Errors return a non-2xx status with `{ "error": "<message for a person>" }`.
|
||||
|
||||
| Status | Meaning |
|
||||
| --- | --- |
|
||||
| 400 | The request body is wrong |
|
||||
| 401 | The device token is missing or revoked. Show the pairing screen |
|
||||
| 404 | Unknown host, thread, or project |
|
||||
| 409 | The request is stale, for example an approval that was already answered |
|
||||
| 429 | Too many pairing attempts |
|
||||
| 502 | The T3 server rejected the call. The message says why |
|
||||
| 503 | That host is offline |
|
||||
|
||||
## Pairing
|
||||
|
||||
`wristcode pair` on the gateway prints a 6 digit code that is good for 5 minutes. The watch sends it
|
||||
once and stores the token in its Keychain.
|
||||
|
||||
```
|
||||
POST /v1/pair
|
||||
{ "code": "482913", "deviceName": "Gabriel's Apple Watch" }
|
||||
|
||||
200 { "deviceId": "9b0c...", "deviceToken": "wc_..." }
|
||||
```
|
||||
|
||||
After pairing, and again whenever APNs hands the watch a new token:
|
||||
|
||||
```
|
||||
PUT /v1/device/push
|
||||
{ "apnsToken": "<hex>", "apnsEnvironment": "sandbox" }
|
||||
|
||||
204
|
||||
```
|
||||
|
||||
Debug builds use `sandbox`, TestFlight and App Store builds use `production`.
|
||||
|
||||
To unpair, the watch revokes its own token:
|
||||
|
||||
```
|
||||
DELETE /v1/device
|
||||
204
|
||||
```
|
||||
|
||||
## Feed
|
||||
|
||||
```
|
||||
GET /v1/feed
|
||||
|
||||
200 {
|
||||
"needsYou": [FeedItem],
|
||||
"working": [FeedItem],
|
||||
"recent": [FeedItem],
|
||||
"recentTotal": 12,
|
||||
"snoozedTotal": 2,
|
||||
"hosts": [{ "id": "...", "label": "Mac mini", "status": "online", "error": null }]
|
||||
}
|
||||
```
|
||||
|
||||
`needsYou` holds threads waiting for an approval or an answer, oldest first. `working` holds running
|
||||
threads, most recently updated first. `recent` holds threads that completed or failed and are not
|
||||
settled 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`.
|
||||
|
||||
```
|
||||
FeedItem {
|
||||
"hostId": "...", "hostLabel": "Mac mini",
|
||||
"threadId": "...", "projectId": "...", "projectTitle": "bill-tracker",
|
||||
"title": "Receipt OCR",
|
||||
"phase": "waiting_for_approval",
|
||||
"updatedAt": "2026-11-01T09:41:00.000Z",
|
||||
"lastMessage": "Parser and tests are written. I want to run the suite.",
|
||||
"activity": { "kind": "command", "text": "bun test", "startedAt": "..." } | null,
|
||||
"changes": { "files": 3, "additions": 42, "deletions": 8 } | null,
|
||||
"snoozedUntil": "..." | null,
|
||||
"request": PendingRequest | null,
|
||||
"via": { "hostId": "...", "threadId": "...", "title": "Check Panama PATH" } | null
|
||||
}
|
||||
```
|
||||
|
||||
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
|
||||
`failed`. `lastMessage` is the agent's latest message trimmed to about 280 characters. It can be null
|
||||
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 {
|
||||
"id": "...",
|
||||
"kind": "command",
|
||||
"prompt": "bun test --filter ocr",
|
||||
"reason": null,
|
||||
"options": [{ "decision": "accept", "label": "Allow" }, { "decision": "decline", "label": "Deny" }],
|
||||
"files": [],
|
||||
"questions": []
|
||||
}
|
||||
```
|
||||
|
||||
For `kind: "file-change"`, `files` lists the files the agent wants to change as
|
||||
`{ path, name, additions, deletions, preview }`. `name` is the last part of `path`. Most providers,
|
||||
Claude included, send the diff only after the edit runs, so `additions` and `deletions` are usually
|
||||
null and `preview` is empty. When a diff comes with the request, `preview` holds its first 4
|
||||
changed lines, each starting with `+` or `-`.
|
||||
|
||||
An option may also carry `warning`, a short note about a risky choice such as "Applies to every
|
||||
thread". For questions (`kind: "user_input"`), `options` is empty and `questions` lists
|
||||
`{ id, header, question, options: [{ label, value }], allowCustomAnswer, multiSelect }`.
|
||||
|
||||
## Thread
|
||||
|
||||
```
|
||||
GET /v1/threads/:hostId/:threadId
|
||||
|
||||
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": "..." }
|
||||
```
|
||||
|
||||
`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
|
||||
{ "text": "Yes, commit it" }
|
||||
202
|
||||
```
|
||||
|
||||
A message to a busy thread waits until the current turn ends. Nothing is interrupted.
|
||||
|
||||
```
|
||||
POST /v1/threads/:hostId/:threadId/respond
|
||||
{ "requestId": "...", "decision": "accept" }
|
||||
or
|
||||
{ "requestId": "...", "answers": { "<questionId>": "<value>" } }
|
||||
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
|
||||
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
|
||||
|
||||
```
|
||||
GET /v1/projects
|
||||
|
||||
200 { "hosts": [{ "id": "...", "label": "Mac mini", "status": "online", "error": null,
|
||||
"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" }] }
|
||||
```
|
||||
|
||||
`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
|
||||
{ "hostId": "...", "projectId": "...", "text": "Add a complication for the waiting count",
|
||||
"model": { "instanceId": "claudeAgent", "slug": "claude-sonnet-5-5" }, "askFirst": true }
|
||||
|
||||
201 { "hostId": "...", "threadId": "..." }
|
||||
```
|
||||
|
||||
`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
|
||||
|
||||
The gateway pushes when a thread starts waiting for you, finishes, or fails. Each push has an
|
||||
`aps.category` and the custom keys `hostId`, `threadId` (absent on `host` pushes), and, for
|
||||
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 |
|
||||
| --- | --- | --- |
|
||||
| `approval` | A thread needs an approval | Allow (`accept`), Deny (`decline`) |
|
||||
| `input` | A thread asked a question | Answer, Open. With `question`: one button per option, Say an answer (text input) when `allowCustomAnswer`, Open |
|
||||
| `finished` | A turn completed | Reply (text input), Settle, Open |
|
||||
| `failed` | A turn failed | Open |
|
||||
| `host` | A host's T3 token expires within 7 days, or was rejected | none |
|
||||
| `test` | The watch asked for a test push | none |
|
||||
|
||||
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
|
||||
|
||||
```
|
||||
GET /v1/health
|
||||
200 { "ok": true, "hosts": [HostSummary] }
|
||||
```
|
||||
@@ -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.
|
||||
+113
@@ -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.
|
||||
@@ -0,0 +1,332 @@
|
||||
# How a headless client talks to a T3 Code server (v0.1.5)
|
||||
|
||||
This is the reference the WristCode gateway is built against. Every claim cites the upstream source at tag `v0.1.5`, checked out at `WristCode-refs/t3code`. Citation prefixes:
|
||||
|
||||
| Prefix | Path in the T3 repo |
|
||||
| --- | --- |
|
||||
| `contracts/` | `packages/contracts/src/` |
|
||||
| `client-runtime/` | `packages/client-runtime/src/` |
|
||||
| `server/` | `apps/server/src/` |
|
||||
| `shared/` | `packages/shared/src/` |
|
||||
| `effect/` | `[email protected]` package, `src/` (the version the T3 catalog pins in `pnpm-workspace.yaml`) |
|
||||
|
||||
## 1. Transport
|
||||
|
||||
### Endpoints
|
||||
|
||||
All paths hang off the server's HTTP origin (for example `https://host:port`). The WebSocket uses the same origin with `ws:` or `wss:`.
|
||||
|
||||
| Method and path | Auth | Purpose | Source |
|
||||
| --- | --- | --- | --- |
|
||||
| `GET /.well-known/t3/environment` | none | Environment descriptor: `environmentId`, `label`, `serverVersion`, `orchestrationProtocolVersion`, `capabilities` | `contracts/environmentHttp.ts:528`, `contracts/environment.ts:159` |
|
||||
| `GET /api/auth/session` | Bearer | Who am I: `authenticated`, `permissions`, `expiresAt` | `contracts/environmentHttp.ts:536`, `contracts/auth.ts:392` |
|
||||
| `POST /api/auth/websocket-ticket/rpc` | Bearer | Mint a 5 minute WebSocket ticket | `contracts/environmentHttp.ts:565`, `server/auth/SessionStore.ts:428` |
|
||||
| `POST /oauth/token` | none (pairing credential in body) | Exchange a pairing token for a session | `contracts/environmentHttp.ts:550` |
|
||||
| `GET /ws?wsTicket=...&orchestrationProtocol=2` | ticket | The RPC WebSocket | `server/ws.ts:3268` |
|
||||
| `GET /api/orchestration/shell` | Bearer, `orchestration:read` | All projects and active threads in one JSON document | `contracts/environmentHttp.ts:683`, `server/orchestration-v2/http.ts:168` |
|
||||
| `GET /api/orchestration/threads/:threadId/bounded` | same | Recent window of one thread | `contracts/environmentHttp.ts:707` |
|
||||
| `GET /api/orchestration/threads/:threadId/history?cursor=...` | same | Older pages of that window | `contracts/environmentHttp.ts:716` |
|
||||
| `GET /api/orchestration/threads/:threadId` | same | Full thread projection (can be large) | `contracts/environmentHttp.ts:691` |
|
||||
| `GET /api/projects` | Bearer | Full project records | `contracts/environmentHttp.ts:727` |
|
||||
| `POST /mcp` | MCP client session only | MCP tools (`t3_thread_list`, `t3_thread_send`, ...). See section 3 | `server/mcp/McpHttpServer.ts:1142` |
|
||||
|
||||
The four `/api/orchestration/*` routes require the header `x-t3-orchestration-protocol: 2` (`contracts/environmentHttp.ts:80-84`, `contracts/environment.ts:13-16`). They are read only. Every write (send, launch, interrupt, respond) goes over the WebSocket.
|
||||
|
||||
### Presenting the bearer token
|
||||
|
||||
- HTTP: `Authorization: Bearer <token>` (`server/auth/EnvironmentAuth.ts:569`, `:618-625`). A `DPoP ` prefix means something else and fails for a bearer session (`:796-803`).
|
||||
- WebSocket: the official clients never put the long-lived token in the URL. They `POST /api/auth/websocket-ticket/rpc` with the bearer header, receive `{ ticket, expiresAt }`, and open `/ws?wsTicket=<ticket>` (`client-runtime/authorization/remote.ts:188-189`, `:267-301`). The server reads `wsTicket` from the query and checks that the ticket's audience is `rpc` (`server/auth/EnvironmentAuth.ts:1223-1272`).
|
||||
- If the upgrade request carries no `wsTicket`, the server falls back to normal request auth, so an `Authorization: Bearer` header on the upgrade also works (`server/auth/EnvironmentAuth.ts:1274`). Bun's `WebSocket` accepts a `headers` option, so the gateway could skip the ticket. The ticket path is what upstream documents and tests (`docs/internals/environment-auth.md:73-86`), so prefer it.
|
||||
- Tickets expire after 5 minutes (`server/auth/SessionStore.ts:428`). They are checked only at upgrade time, so an open socket outlives its ticket. Mint a fresh one for every connect.
|
||||
|
||||
### Handshake
|
||||
|
||||
There is no hello message. The server rejects the upgrade with HTTP 426 unless the query has `orchestrationProtocol=2`:
|
||||
|
||||
```json
|
||||
{ "code": "orchestration_protocol_incompatible", "message": "...", "orchestrationProtocolVersion": 2 }
|
||||
```
|
||||
|
||||
(`server/ws.ts:3274-3284`, `server/ws.ts:635-640`). After the upgrade the socket speaks Effect RPC JSON (section 2) right away. The official client then does two things a gateway can copy or skip:
|
||||
|
||||
1. Opens `subscribeServerConfig` and waits for its first `snapshot` before calling the connection ready (`client-runtime/rpc/session.ts:269-343`).
|
||||
2. Compares `config.environment.environmentId` with the id it expected and fails on a mismatch (`client-runtime/rpc/session.ts:334-341`).
|
||||
|
||||
Optional analytics query params (`clientSurface`, `clientAppVersion`, `clientDeviceType`, ...) are ignored when absent (`server/ws.ts:650-694`).
|
||||
|
||||
### Heartbeat and reconnect
|
||||
|
||||
- The client sends `{"_tag":"Ping"}` every 5 seconds. The server answers `{"_tag":"Pong"}` (`effect/rpc/RpcClient.ts:1249`, `effect/rpc/RpcServer.ts:880-881`). Any inbound frame counts as liveness.
|
||||
- The official client drops the socket after 15 seconds without a frame (`client-runtime/rpc/protocol.ts:10-12`) and never reconnects a socket in place (`client-runtime/rpc/session.ts:216-237`). Its supervisor builds a new session with jittered exponential backoff from 1 s up to 5 min (`client-runtime/connection/supervisor.ts:42-43`, `:137-138`).
|
||||
- After a reconnect, resume streams with `afterSequence` (section 5).
|
||||
|
||||
## 2. RPC framing
|
||||
|
||||
### Wire format
|
||||
|
||||
The server uses `RpcServer.makeProtocolWithHttpEffectWebsocket` with `RpcSerialization.layerJson` (`server/ws.ts:3301-3330`). Each WebSocket text frame is one JSON object, or a JSON array of them (`effect/rpc/RpcSerialization.ts:129-140`). The frame shapes are in `effect/rpc/RpcMessage.ts`:
|
||||
|
||||
```ts
|
||||
// client to server
|
||||
type FromClient =
|
||||
| { _tag: "Request", id: string | number, tag: string, payload: unknown,
|
||||
headers: Array<[string, string]>, traceId?: string, spanId?: string, sampled?: boolean }
|
||||
| { _tag: "Ack", requestId: string | number } // after every stream Chunk
|
||||
| { _tag: "Interrupt", requestId: string | number } // cancel a call or stream
|
||||
| { _tag: "Ping" }
|
||||
| { _tag: "Eof" }
|
||||
|
||||
// server to client
|
||||
type FromServer =
|
||||
| { _tag: "Chunk", requestId: string | number, values: [unknown, ...unknown[]] }
|
||||
| { _tag: "Exit", requestId: string | number, exit: Exit }
|
||||
| { _tag: "Defect", defect: unknown }
|
||||
| { _tag: "Pong" }
|
||||
| { _tag: "ClientProtocolError", error: unknown }
|
||||
|
||||
type Exit =
|
||||
| { _tag: "Success", value: unknown }
|
||||
| { _tag: "Failure", cause: Array<
|
||||
| { _tag: "Fail", error: unknown } // a typed error, e.g. EnvironmentAuthorizationError
|
||||
| { _tag: "Die", defect: unknown }
|
||||
| { _tag: "Interrupt", fiberId: number | null } > }
|
||||
```
|
||||
|
||||
- `tag` is the method name, for example `"orchestration.dispatchCommand"`. `headers` must be present, `[]` is fine. The official client uses an incrementing integer `id` (`effect/rpc/RpcClient.ts:219`, `:275`).
|
||||
- A unary call gets one `Exit`. A stream call (`stream: true` in the contract) gets zero or more `Chunk` frames and then an `Exit` when the stream ends or fails.
|
||||
- **Streams are flow controlled.** After sending a `Chunk` the server waits for `{"_tag":"Ack","requestId":...}` before it sends the next one (`effect/rpc/RpcServer.ts:441-460`, `supportsAck: true` at `:1575`). A client that forgets to Ack sees exactly one chunk per stream and then silence.
|
||||
- Payloads and values are the schema's encoded JSON form. `DateTimeUtc` fields are ISO strings and branded ids are plain strings.
|
||||
- Typed errors arrive inside `exit.cause[].error` with a `_tag`, for example `{"_tag":"EnvironmentAuthorizationError","message":"...","requiredScope":"orchestration:operate"}` (`contracts/auth.ts:340-347`).
|
||||
|
||||
### Method names
|
||||
|
||||
`contracts/orchestrationV2.ts:3500-3519` and `contracts/rpc.ts:489-599`. The ones the gateway needs:
|
||||
|
||||
| Method | Kind | Scope | Payload / result |
|
||||
| --- | --- | --- | --- |
|
||||
| `orchestration.subscribeShell` | stream | `orchestration:read` | `OrchestrationV2SubscribeShellInput` / `OrchestrationV2ShellStreamItem` |
|
||||
| `orchestration.subscribeThread` | stream | `orchestration:read` | `OrchestrationV2SubscribeThreadInput` / `OrchestrationV2ThreadStreamItem` |
|
||||
| `orchestration.getThreadProjection` | unary | `orchestration:read` | `{ threadId }` / thread projection |
|
||||
| `orchestration.dispatchCommand` | unary | `orchestration:operate` | `OrchestrationV2Command` / `{ sequence }` |
|
||||
| `orchestration.launchThread` | unary | `orchestration:operate` | `OrchestrationV2ThreadLaunchInput` / `OrchestrationV2ThreadLaunchResult` |
|
||||
| `server.getConfig` | unary | `orchestration:read` | `{}` / `ServerConfig` (holds `settings.defaultModelSelection`, providers) |
|
||||
| `server.probe` | unary | `orchestration:read` | `{}` / `{}` (cheap liveness check) |
|
||||
| `subscribeServerConfig` | stream | `orchestration:read` | config snapshot then updates |
|
||||
|
||||
Scopes come from `server/auth/RpcAuthorization.ts:40-207`. The middleware checks them before any handler runs and fails with `EnvironmentAuthorizationError` (`:220-224`, `:275-286`). The two scopes in the gateway token cover every row above.
|
||||
|
||||
### Import the packages or write a client?
|
||||
|
||||
- `@t3tools/contracts` and `@t3tools/client-runtime` are `"private": true` and not on npm (`packages/contracts/package.json:4`, `npm view @t3tools/contracts` returns 404).
|
||||
- `contracts` depends only on `effect` 4.0.5 (`packages/contracts/package.json:27-29`), and its sources import nothing else outside the package. It ships TypeScript sources with no build step (`"exports"` points at `./src/*.ts`). The license is MIT. Vendoring it is feasible, but `orchestrationV2.ts` alone is 155 KB and pins us to Effect 4 (a 51 MB unpacked dependency) and to T3's internal schema churn.
|
||||
- `client-runtime` is the wrong layer. It pulls in relay, DPoP, cloud session, device identity and UI state services (`client-runtime/authorization/service.ts:106-114`).
|
||||
|
||||
**Recommendation.** Write a small client: about 150 lines of Bun TypeScript for the frame loop (request ids, pending map, Ack on Chunk, Ping timer, reconnect). Hand-write narrow types, or Zod or Valibot schemas, for only the fields we read: shell stream items, the thread shell, and the commands we send. Decode leniently and ignore unknown fields and unknown `kind` values, which is what upstream does for forward compatibility (`contracts/orchestrationV2.ts:3776-3806`). Keep the vendored contracts as a reference for regenerating types, not as a runtime dependency.
|
||||
|
||||
## 3. Auth
|
||||
|
||||
### Sessions
|
||||
|
||||
- `t3 auth session issue` runs against the server's database directly, not over HTTP (`server/cli/auth.ts:159-191`). It calls `issueSession` with method `bearer-access-token`, device type `bot` and the requested scopes (`server/auth/EnvironmentAuth.ts:1068-1095`).
|
||||
- The default TTL is 30 days (`server/auth/SessionStore.ts:427`). `--ttl` accepts `5m`, `1h`, `30d` (`server/cli/auth.ts:49-51`). The CLI sets no maximum.
|
||||
- The token is an HMAC-signed claim blob, `<base64url payload>.<signature>`. The server verifies the signature, then the stored row for revocation and expiry (`server/auth/SessionStore.ts:745-844`).
|
||||
- **There is no refresh or renew.** `/oauth/token` only accepts a pairing (bootstrap) credential as `subject_token` (`contracts/auth.ts:219-229`). Before expiry, someone has to issue a new token, either the operator on the host or a client holding `access:write` via `POST /api/auth/pairing-token` followed by `/oauth/token`.
|
||||
- **Expiry closes live sockets.** For as long as the socket is open the server races it against `sessions.awaitInvalidation`, which resolves at `expiresAt` or on revocation (`server/ws.ts:3364-3378`, `server/auth/SessionStore.ts:1037-1067`). Afterwards, ticket minting returns HTTP 401:
|
||||
|
||||
```json
|
||||
{ "_tag": "EnvironmentAuthInvalidError", "code": "auth_invalid", "reason": "invalid_credential", "traceId": "..." }
|
||||
```
|
||||
|
||||
(`contracts/environmentHttp.ts:99-102`, `:148-166`). A missing scope on HTTP returns 403 `{ "code": "insufficient_scope", "requiredScope": ... }` (`:168-180`). On RPC it is `EnvironmentAuthorizationError` in the Exit cause.
|
||||
|
||||
- **Revocation.** `t3 auth session revoke <session-id>` (`server/cli/auth.ts:214-234`), or the Connections UI. It kills the open socket immediately through the same `awaitInvalidation` race.
|
||||
- `GET /api/auth/session` with the bearer header returns `expiresAt` and `permissions` (`server/auth/EnvironmentAuth.ts:811-830`). The gateway should poll it daily and warn the operator well before expiry.
|
||||
|
||||
### Pairing tokens (for later)
|
||||
|
||||
`t3 auth pairing create` or `POST /api/auth/pairing-token` issues a one-time credential that is valid for 5 minutes by default (`server/auth/PairingGrantStore.ts:249`). Exchange it with a form-encoded POST:
|
||||
|
||||
```
|
||||
POST /oauth/token
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
|
||||
&subject_token=<pairing credential>
|
||||
&subject_token_type=urn:t3:params:oauth:token-type:environment-bootstrap
|
||||
&requested_token_type=urn:ietf:params:oauth:token-type:access_token
|
||||
&scope=orchestration:read orchestration:operate
|
||||
&client_label=WristCode gateway
|
||||
&client_device_type=bot
|
||||
```
|
||||
|
||||
Response (`contracts/auth.ts:231-238`):
|
||||
|
||||
```json
|
||||
{ "access_token": "...", "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
|
||||
"token_type": "Bearer", "expires_in": 2592000, "scope": "orchestration:read orchestration:operate" }
|
||||
```
|
||||
|
||||
The server grants the intersection of the requested scopes and the pairing grant, and fails if that is empty (`server/auth/EnvironmentAuth.ts:934-990`). Without a `DPoP` header the result is a 30 day bearer session. With one it is a 1 hour DPoP session (`:955-958`). Upstream sends this from `bootstrapRemoteBearerSession` (`client-runtime/authorization/remote.ts:142-167`).
|
||||
|
||||
### The MCP endpoint is a different audience
|
||||
|
||||
The `t3 thread` CLI does not use the WebSocket. It mints a short-lived MCP client session and calls `/mcp` tools over HTTP (`server/cli/thread.ts:200-245`). `/mcp` accepts only sessions with subject `mcp-client` (`server/auth/EnvironmentAuth.ts:72`, `:1277-1300`), and `/ws` plus every other route rejects that subject (`:598-605`). MCP clients get access through an OAuth flow with owner approval (`docs/internals/environment-auth.md:42-66`). Our gateway token cannot call `/mcp`. MCP has no push stream either, so it does not help with events.
|
||||
|
||||
## 4. Operations the gateway needs
|
||||
|
||||
All ids (`CommandId`, `MessageId`, `ThreadId`, ...) are non-empty strings (`contracts/baseSchemas.ts:246-252`). The official client uses a fresh UUID v4 for each `commandId` (`client-runtime/operations/commands.ts:258-264`). `dispatchCommand` returns `{ sequence }` (`contracts/orchestrationV2.ts:3588-3591`) or fails with `OrchestrationV2DispatchCommandError { commandId, commandType, message, detail? }` (`:3833-3842`).
|
||||
|
||||
### List projects and threads
|
||||
|
||||
Use one call, `GET /api/orchestration/shell` (HTTP), or the first chunk of `orchestration.subscribeShell` (WS). Both return `OrchestrationV2ShellSnapshot` (`contracts/orchestrationV2.ts:2164-2177`):
|
||||
|
||||
```ts
|
||||
{ schemaVersion: number, snapshotSequence: number,
|
||||
projects: Array<{ id, title, workspaceRoot, defaultModelSelection: ModelSelection | null, ... }>,
|
||||
threads: OrchestrationV2ThreadShell[], archivedThreads: OrchestrationV2ThreadShell[] }
|
||||
```
|
||||
|
||||
Project shape: `contracts/orchestrationProject.ts:8-22`. Filter threads by `projectId`. The thread shell fields the watch needs (`contracts/orchestrationV2.ts:2023-2101`):
|
||||
|
||||
```ts
|
||||
{ id, projectId, title, updatedAt, createdAt, archivedAt, deletedAt,
|
||||
status: "idle" | "preparing" | "queued" | "starting" | "running" | "waiting"
|
||||
| "completed" | "interrupted" | "failed" | "cancelled" | "rolled_back",
|
||||
activityRunStatus?: "preparing" | "starting" | "running" | "waiting" | null,
|
||||
activeRunId: string | null, latestRunId: string | null,
|
||||
pendingRuntimeRequest: { id, kind, createdAt, blocking?: boolean } | null,
|
||||
latestVisibleMessage: { id, role: "user" | "assistant" | "system", text, updatedAt } | null,
|
||||
lastError?: string | null, lastErrorClass?: ..., modelSelection, runtimeMode,
|
||||
lineage: { relationshipToParent, ... }, settledOverride, settledAt }
|
||||
```
|
||||
|
||||
- `status` is the latest run's status, and `"idle"` only means the thread has never run (`server/orchestration-v2/ProjectionStore.ts:1629-1647`). "Needs approval" is `pendingRuntimeRequest !== null`.
|
||||
- Upstream already maps a shell to watch-friendly phases (`starting`, `running`, `waiting_for_approval`, `waiting_for_input`, `completed`, `failed`) in `resolveThreadAwarenessPhaseV2` (`shared/agentAwareness.ts:97-135`). It skips subagent children and settled finished threads (`:59-76`). Port this function as is.
|
||||
- `omitIdleSubagents: true` and `omitOlderSettled: true` shrink the snapshot. Send them only to servers whose config advertises `shellThreadFamilies` and `shellOlderSettled` (`contracts/orchestrationV2.ts:3599-3613`).
|
||||
|
||||
### Read a thread's latest messages
|
||||
|
||||
- For a quick view, use `latestVisibleMessage` on the shell.
|
||||
- For a page, call `GET /api/orchestration/threads/:id/bounded`, which returns `{ snapshotSequence, projection, historyCursor, hasMoreHistory }` (`contracts/orchestrationV2.ts:3727-3744`). Read `projection.messages` (`{ id, role, text, streaming, createdAt, updatedAt }`, `:1260-1290`), or `projection.visibleTurnItems` for tool and approval rows. Pass `historyCursor` to `/history` for older pages (`:3746-3765`). Leave the `compactIds`, `dedupeBodies` and `compactTurnItems` flags off, since each one changes the wire shape.
|
||||
- For incremental updates, call `orchestration.subscribeThread` with `{ threadId, afterSequence: snapshotSequence }`. That gives `{ kind: "event", sequence, event }` items from then on (`contracts/orchestrationV2.ts:3697-3711`, `:3808-3831`). `message.updated` events carry the full `ConversationMessage` (`:1816-1960`). Without `afterSequence`, the first item is a `snapshot`.
|
||||
|
||||
### Send a message
|
||||
|
||||
```json
|
||||
{ "type": "message.dispatch", "commandId": "<uuid>", "threadId": "<id>", "messageId": "<uuid>",
|
||||
"text": "...", "attachments": [], "createdBy": "user", "creationSource": "mobile",
|
||||
"dispatchMode": { "type": "queue_after_active" } }
|
||||
```
|
||||
|
||||
(`contracts/orchestrationV2.ts:3082-3125`). Busy-thread semantics, from `server/orchestration-v2/Orchestrator.ts:5246-5516` and `server/orchestration-v2/CommandPolicy.ts:91-178`:
|
||||
|
||||
- With `start_immediately` or `queue_after_active` and no `deliveryIntent`, a message sent to a busy thread queues behind the active run, and one sent to an idle thread starts right away (`Orchestrator.ts:5510-5517`). Nothing is interrupted. `t3 thread send` does the same (`server/cli/thread.ts:626-640`).
|
||||
- `deliveryIntent: "auto"` lets the server steer the running turn when the provider supports it, otherwise it queues (`CommandPolicy.ts:143-178`). `"steer"` and `"restart"` force those modes.
|
||||
- `createdBy: "user"` with `creationSource: "mobile"` (or `"web"`) also releases a queue that is on hold after a failure (`Orchestrator.ts:5528-5550`). Use `"mobile"`.
|
||||
|
||||
### Start a thread
|
||||
|
||||
`orchestration.launchThread` (`contracts/orchestrationV2.ts:3556-3577`):
|
||||
|
||||
```json
|
||||
{ "commandId": "<uuid>", "creationSource": "mobile", "projectId": "<id>",
|
||||
"title": "<first line of the prompt>", "generateTitle": true,
|
||||
"modelSelection": { "instanceId": "<provider instance>", "model": "<model>" },
|
||||
"runtimeMode": "full-access", "interactionMode": "default",
|
||||
"workspaceStrategy": { "type": "root" },
|
||||
"initialMessage": { "messageId": "<uuid>", "text": "...", "attachments": [] } }
|
||||
```
|
||||
|
||||
It returns `{ threadId, projection, resumed, sequence? }` (`:3579-3586`). Every field except `threadId` is required, and the server fills in no defaults. Resolve them like this:
|
||||
|
||||
- `modelSelection` is `project.defaultModelSelection`, falling back to `serverConfig.settings.defaultModelSelection`. Both can be null (`contracts/settings.ts:1186-1188`). The MCP launch path refuses when the project has none (`server/mcp/OrchestratorMcpService.ts:989-999`). Do the same.
|
||||
- `runtimeMode` is `settings.defaultRuntimeMode`, which defaults to `"full-access"` (`contracts/settings.ts:1192-1194`, `contracts/providerPolicy.ts:25-32`). For an unattended watch, consider `"approval-required"` so approvals actually reach the wrist.
|
||||
- `interactionMode` is `"default"` (`contracts/providerPolicy.ts:34-36`). `workspaceStrategy` is `{ "type": "root" }` (`contracts/orchestrationV2.ts:644-661`).
|
||||
|
||||
### Approvals and user input
|
||||
|
||||
- Find pending requests through the shell's `pendingRuntimeRequest` (`kind` is `command`, `file-read`, `file-change`, `mcp-elicitation`, `permission`, `user_input`, `dynamic_tool_call` or `auth_refresh`, `contracts/orchestrationV2.ts:1177-1201`, `contracts/providerPolicy.ts:38-44`).
|
||||
- Details live in the thread's turn items. `approval_request` has `requestId`, `requestKind`, `prompt`, `reason` and `options` (`contracts/orchestrationV2.ts:1644-1655`). `user_input_request` has `requestId` and `questions[]` (`:1601-1608`, `:1301-1318`).
|
||||
- Answer with `dispatchCommand` (`contracts/orchestrationV2.ts:3213-3228`):
|
||||
|
||||
```json
|
||||
{ "type": "runtime-request.respond", "commandId": "<uuid>", "threadId": "<id>",
|
||||
"requestId": "<id>", "decision": "accept" }
|
||||
```
|
||||
|
||||
`decision` is one of `accept`, `acceptForSession`, `acceptAlways`, `decline` or `cancel` (`contracts/providerPolicy.ts:47-53`). For user input, send `answers: { [questionId]: value }` instead (`:68`). `{ "type": "thread.user-input.dismiss", commandId, threadId, requestId }` dismisses a question.
|
||||
|
||||
### Interrupt
|
||||
|
||||
```json
|
||||
{ "type": "run.interrupt", "commandId": "<uuid>", "threadId": "<id>", "runId": "<activeRunId>" }
|
||||
```
|
||||
|
||||
(`contracts/orchestrationV2.ts:3164-3171`). Take `runId` from the shell's `activeRunId`. When the server config advertises `capabilities.threadStop`, the official client sends `{ "type": "thread.stop", commandId, threadId }` instead, which needs no run id and also stops delegated tasks (`:2843-2851`, `client-runtime/operations/commands.ts:800-810`).
|
||||
|
||||
## 5. Events
|
||||
|
||||
Use `orchestration.subscribeShell` as the gateway's one event source per server. It covers every thread in one stream (`contracts/orchestrationV2.ts:2179-2211`):
|
||||
|
||||
```ts
|
||||
type ShellStreamItem =
|
||||
| { kind: "snapshot", snapshot: OrchestrationV2ShellSnapshot }
|
||||
| { kind: "synchronized" } // only with requestCompletionMarker: true
|
||||
| { kind: "project.updated", sequence, project }
|
||||
| { kind: "project.removed", sequence, projectId }
|
||||
| { kind: "thread.updated", sequence, location: "active" | "archive", thread: ThreadShell }
|
||||
| { kind: "thread.removed", sequence, location, threadId }
|
||||
```
|
||||
|
||||
`thread.updated` carries the whole new shell, and the server drops updates where nothing changed (`server/ws.ts:1078-1084`). Detect transitions by diffing the previous shell against the new one, keyed by `thread.id`:
|
||||
|
||||
| Signal | Condition on the new shell |
|
||||
| --- | --- |
|
||||
| Turn finished | `status` moves to `"completed"` from a live status. Ignore it if `latestRunStopRequested` is true or background tasks are still pending (`shared/agentAwareness.ts:68`, `:122-125`) |
|
||||
| Needs approval | `pendingRuntimeRequest` becomes non-null with a kind other than `user_input` or `auth_refresh` |
|
||||
| Needs input | `pendingRuntimeRequest.kind === "user_input"` |
|
||||
| Errored | `status` becomes `"failed"`. Show `lastError` |
|
||||
|
||||
Easiest of all, compute `resolveThreadAwarenessPhaseV2` on both shells and push when the phase changes.
|
||||
|
||||
**Resume.** Store the highest `sequence` seen (and the snapshot's `snapshotSequence`). On reconnect, send `{ afterSequence }`. The server replays the missed items if the gap is at most 1000 events and the replay payload is under 8 MB, and otherwise sends a fresh `snapshot` (`server/ws.ts:580-583`, `:716-740`, `:1164-1175`). Handle a `snapshot` at any point in the stream by replacing local state and diffing against it, so transitions that happened while disconnected still fire. `subscribeThread` resumes the same way, with a 128 event replay cap (`server/orchestration-v2/ThreadStream.ts:15`).
|
||||
|
||||
Raw domain event types (on `subscribeThread` only): `thread.created`, `run.created`, `run.updated`, `runtime-request.updated`, `message.updated`, `turn-item.updated`, `provider-turn.updated`, and others (`contracts/orchestrationV2.ts:1816-1960`). The gateway does not need them for notifications.
|
||||
|
||||
## 6. T3's own push notifications
|
||||
|
||||
- T3 does send pushes, but only through **T3 Connect**, the hosted relay. When a server is linked to a T3 Connect account and activity publishing is on, `AgentAwarenessRelay` projects each thread with `projectThreadAwarenessV2` and POSTs a signed activity state to the relay (`server/relay/AgentAwarenessRelay.ts:1-80`, `contracts/relay.ts:119-131`). The relay sends APNs or FCM notifications and Live Activity updates to devices registered at `POST /v1/mobile/devices` with a push token and per-phase preferences (`contracts/relay.ts:22-56`, `:1024-1044`, `infra/relay/README.md:15-25`).
|
||||
- "Relay" here means that hosted Cloudflare service. It handles account linking, managed tunnels, short-lived connect credentials and mobile notifications. It is not in the data path after a client connects (`infra/relay/README.md:3-12`).
|
||||
- **We cannot reuse it.** APNs delivery is bound to the T3 app's bundle id and signing key, which the relay holds (`contracts/relay.ts:53-55`). The relay also requires a Clerk-backed T3 Connect account and DPoP-signed device registration. We can reuse the logic, though. Porting `resolveThreadAwarenessPhaseV2` (about 40 lines) and our own APNs sender gives the same notification behavior.
|
||||
|
||||
## 7. Gotchas
|
||||
|
||||
- **Protocol version.** `ORCHESTRATION_PROTOCOL_VERSION = 2` must match exactly on `/ws` and on the HTTP orchestration routes (`contracts/environment.ts:13`, `server/ws.ts:635-640`). Read `orchestrationProtocolVersion` from `/.well-known/t3/environment` first, and treat a missing value as 1 (`client-runtime/connection/compatibility.ts:9-27`). A future T3 release that bumps it will lock the gateway out until we update.
|
||||
- **Forgetting Ack** stalls every stream after its first chunk (section 2).
|
||||
- **Forward compatibility.** Enums keep growing (run statuses, request kinds, turn item types). Upstream decodes unknown thread events as `unknown-event` and keeps advancing the cursor (`contracts/orchestrationV2.ts:3776-3806`). Strict decoding in the gateway would break on minor upgrades.
|
||||
- **Payload size.** A full thread projection holds every turn item and command output, which is large. Use the bounded snapshot plus `latestVisibleMessage`. Command output is withheld (`outputOmitted`) and has to be fetched separately (`contracts/orchestrationV2.ts:1621-1632`). Large `/transcript` reads fail with `thread_transcript_too_large` (`server/orchestration-v2/http.ts:205-207`).
|
||||
- **Opt-in compact encodings** (`acceptCompactIds`, `acceptDedupedBodies`, `acceptCompactTurnItems`, `idTable`) change the JSON shape. Never send them.
|
||||
- **One socket can carry many streams**, multiplexed by `requestId`. Interrupt a stream with `{"_tag":"Interrupt","requestId"}` instead of closing the socket.
|
||||
- **Defects.** The server runs with `disableFatalDefects: true`, so a handler bug surfaces as one call's `Exit` with a `Die` cause, not as a socket-wide `Defect` (`server/ws.ts:3254-3257`).
|
||||
- **Rate limits.** I found none on `/ws` or the orchestration routes in `server/ws.ts` or `server/orchestration-v2/http.ts`. Each `thread.updated` costs the server a shell read, which it batches at 512 events or 50 ms (`server/ws.ts:1078-1084`).
|
||||
- **Cookie origins** only apply to browser cookies (`server/auth/EnvironmentAuth.ts:752-772`). Bearer requests from the gateway are not origin-checked.
|
||||
- **Session expiry** kills the socket and there is no refresh (section 3). Use one token per server, track `expiresAt`, and alert the operator.
|
||||
|
||||
## Recommended gateway integration
|
||||
|
||||
- For each T3 server, store `{ httpBaseUrl, bearerToken, environmentId }`. Check `/.well-known/t3/environment` at startup and refuse on a `environmentId` mismatch or an `orchestrationProtocolVersion` other than 2.
|
||||
- Connect with `POST /api/auth/websocket-ticket/rpc`, then `wss://host/ws?wsTicket=...&orchestrationProtocol=2`. Mint a fresh ticket on every reconnect.
|
||||
- Hand-roll the Effect RPC JSON client: integer request ids, Ack every `Chunk`, Ping every 5 s, drop the socket after 15 s of silence, reconnect with jittered backoff capped at a few minutes.
|
||||
- Open one long-lived `orchestration.subscribeShell` per server. Keep the thread shells in memory and persist the last `sequence` so restarts resume with `afterSequence`.
|
||||
- Port `resolveThreadAwarenessPhaseV2` from `shared/agentAwareness.ts` and send an APNs push whenever a top-level thread's phase changes to `completed`, `failed`, `waiting_for_approval` or `waiting_for_input`.
|
||||
- Serve the watch's list views (projects, threads, status, last message) from the in-memory shell. The watch never waits on T3 for a list.
|
||||
- Serve thread detail with `GET /api/orchestration/threads/:id/bounded` and send only the last few `messages` plus any pending approval or question, trimmed for the watch.
|
||||
- Map writes to `orchestration.dispatchCommand`: `message.dispatch` with `queue_after_active` and `creationSource: "mobile"`, `runtime-request.respond`, and `thread.stop` (or `run.interrupt` on servers without `threadStop`). Always generate UUID `commandId` and `messageId`.
|
||||
- Map new threads to `orchestration.launchThread`, with defaults from `project.defaultModelSelection`, then `server.getConfig` settings, `workspaceStrategy: root` and `interactionMode: default`. Return an error to the watch if no model is configured.
|
||||
- Decode leniently with narrow hand-written schemas, poll `/api/auth/session` daily, and alert the operator about 7 days before a token's `expiresAt`. The fix is a fresh `t3 auth session issue` on that host.
|
||||
|
||||
### Open questions
|
||||
|
||||
- I have not seen a live server answer `/ws` with an `Authorization` header and no ticket. The code path exists (`server/auth/EnvironmentAuth.ts:1274`), but the ticket flow is the tested one.
|
||||
- `ServerConfig.environment.capabilities` lists `threadStop`, `shellThreadFamilies` and `shellOlderSettled`. I did not confirm their values on a running 0.1.5 host.
|
||||
- I did not verify end to end how a `runtime-request.respond` for `mcp-elicitation` or `dynamic_tool_call` should be shaped. Approvals (`command`, `file-change`, `permission`) and `user_input` are covered above.
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
data
|
||||
test
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
.containerignore
|
||||
@@ -0,0 +1,26 @@
|
||||
# The WristCode gateway. The gateway has no runtime dependencies, so the image is the Bun base plus
|
||||
# src/. State lives in /data (one SQLite file), which compose mounts from the host.
|
||||
# Builds the same under docker and podman: `docker build -f gateway/Containerfile gateway`.
|
||||
|
||||
# Pinned by digest. Bump the tag and digest together.
|
||||
FROM docker.io/oven/bun:1.4.0-alpine@sha256:07235578f79ef8c6f97d94aee7938e76f5cdba5f21ae5dbfdd3d3d38058437eb
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json tsconfig.json ./
|
||||
COPY src ./src
|
||||
|
||||
# `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 \
|
||||
&& chmod +x /usr/local/bin/wristcode \
|
||||
&& mkdir -p /data && chown bun:bun /data
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
PORT=8080 \
|
||||
WRISTCODE_DB=/data/wristcode.sqlite
|
||||
|
||||
USER bun
|
||||
EXPOSE 8080
|
||||
VOLUME /data
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD wget -q -O /dev/null http://127.0.0.1:8080/v1/health || exit 1
|
||||
CMD ["bun", "src/main.ts"]
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"lockfileVersion": 2,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "wristcode-gateway",
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"typescript": "^5.9.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@types/bun": ["@types/[email protected]", "", { "dependencies": { "bun-types": "1.4.4" } }, "sha512-9wkRDXUXK124pEnAutE6n/l18j+i9GC5IAHK8TmX/2nlySETmky0OU6yL5U8pW1AoKh++AN9auDCr647BfurjQ=="],
|
||||
|
||||
"@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog=="],
|
||||
|
||||
"bun-types": ["[email protected]", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-yjrJh5oGX7mMPKGd19YXyIjmEuj4G4zNC30bs7PM9Az7NQ3ndF3j6hbeRdvJTRQEhJ/FYQwW0a2moweEi2hMxQ=="],
|
||||
|
||||
"typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["[email protected]", "", {}, "sha512-EXE4ydL3S76dFb27UFjNVZHTyv/TLdATj/KUe29ptdcg6qXL48J2tZ3126E5kICj5jn781OR+XlVH1N6Mz8jbA=="],
|
||||
|
||||
"@types/node/undici-types": ["[email protected]", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "wristcode-gateway",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": { "wristcode": "src/cli.ts" },
|
||||
"scripts": {
|
||||
"dev": "bun --watch src/main.ts",
|
||||
"start": "bun src/main.ts",
|
||||
"test": "bun test",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"typescript": "^5.9.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
// The watch's REST API, as described in docs/api.md and typed in contract.ts.
|
||||
|
||||
import type { BunRequest } from "bun";
|
||||
|
||||
import type {
|
||||
ErrorResponse,
|
||||
NewThreadRequest,
|
||||
NewThreadResponse,
|
||||
NotificationPrefs,
|
||||
PairRequest,
|
||||
PairResponse,
|
||||
ProjectsResponse,
|
||||
PushRegistration,
|
||||
RespondRequest,
|
||||
PushResult,
|
||||
SettleRecentResponse,
|
||||
SnoozeRequest,
|
||||
SendMessageRequest,
|
||||
StatusResponse,
|
||||
ThreadListResponse,
|
||||
ThreadModeRequest,
|
||||
} from "./contract.ts";
|
||||
import type { Db, DeviceRow } from "./db.ts";
|
||||
import type { Hub } from "./hub.ts";
|
||||
import { notificationPrefs, type Pusher } from "./push.ts";
|
||||
import { HostError, type T3Host } from "./t3/host.ts";
|
||||
import { RpcError } from "./t3/rpc.ts";
|
||||
import type { ApprovalDecision, ModelSelection, ThreadShell } from "./t3/types.ts";
|
||||
import {
|
||||
agentProviders,
|
||||
ASK_FIRST,
|
||||
buildFeed,
|
||||
buildRecent,
|
||||
buildSnoozed,
|
||||
buildSummary,
|
||||
buildThreadDetail,
|
||||
feedCandidates,
|
||||
hostStatusDetail,
|
||||
hostSummary,
|
||||
loadHistory,
|
||||
projectThreads,
|
||||
searchThreads,
|
||||
type SummaryCache,
|
||||
} from "./views.ts";
|
||||
|
||||
export interface ApiDeps {
|
||||
db: Db;
|
||||
hub: Hub;
|
||||
cache: SummaryCache;
|
||||
pusher: Pusher;
|
||||
/** When the gateway started, for the status screen. */
|
||||
startedAt: string;
|
||||
log?: (message: string) => void;
|
||||
}
|
||||
|
||||
/** Wrong pairing codes allowed before every open code is voided. */
|
||||
const PAIR_ATTEMPTS = 10;
|
||||
const PAIR_WINDOW_MS = 15 * 60_000;
|
||||
const DECISIONS: ReadonlySet<string> = new Set<ApprovalDecision>([
|
||||
"accept",
|
||||
"acceptForSession",
|
||||
"acceptAlways",
|
||||
"decline",
|
||||
"cancel",
|
||||
]);
|
||||
|
||||
class HttpError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
const SNOOZE_MAX_MS = 30 * 24 * 60 * 60_000;
|
||||
|
||||
const json = (body: unknown, status = 200) => Response.json(body, { status });
|
||||
const accepted = () => new Response(null, { status: 202 });
|
||||
|
||||
export function createRoutes({ db, hub, cache, pusher, startedAt, log = () => {} }: ApiDeps) {
|
||||
let pairFailures: number[] = [];
|
||||
|
||||
/** Wraps a handler with device auth and error mapping. */
|
||||
const authed =
|
||||
<P extends string>(handler: (req: BunRequest<P>, device: DeviceRow) => Promise<Response> | Response) =>
|
||||
(req: BunRequest<P>) =>
|
||||
handle(async () => {
|
||||
const token = req.headers.get("authorization")?.match(/^Bearer (.+)$/)?.[1];
|
||||
const device = token ? db.devices.authenticate(token) : null;
|
||||
if (!device) throw new HttpError(401, "This watch is not paired.");
|
||||
return handler(req, device);
|
||||
});
|
||||
|
||||
async function handle(run: () => Promise<Response> | Response): Promise<Response> {
|
||||
try {
|
||||
return await run();
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError) return json({ error: error.message } satisfies ErrorResponse, error.status);
|
||||
if (error instanceof HostError) {
|
||||
return json({ error: error.message } satisfies ErrorResponse, error.status === 503 ? 503 : 502);
|
||||
}
|
||||
if (error instanceof RpcError) return json({ error: error.message } satisfies ErrorResponse, 502);
|
||||
log(`unhandled: ${error instanceof Error ? error.stack : String(error)}`);
|
||||
return json({ error: "Something went wrong in the gateway." } satisfies ErrorResponse, 500);
|
||||
}
|
||||
}
|
||||
|
||||
const findHost = (hostId: string): T3Host => {
|
||||
const host = hub.get(hostId);
|
||||
if (!host) throw new HttpError(404, "Unknown host.");
|
||||
return host;
|
||||
};
|
||||
|
||||
const findThread = (hostId: string, threadId: string): { host: T3Host; thread: ThreadShell } => {
|
||||
const host = findHost(hostId);
|
||||
const thread = host.threads.get(threadId);
|
||||
if (!thread) throw new HttpError(404, "Unknown thread.");
|
||||
return { host, thread };
|
||||
};
|
||||
|
||||
const resolveModel = (host: T3Host, projectId: string): ModelSelection | 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 {
|
||||
"/v1/health": {
|
||||
GET: () => json({ ok: true, hosts: hub.list().map(hostSummary) }),
|
||||
},
|
||||
|
||||
"/v1/pair": {
|
||||
POST: (req: BunRequest<"/v1/pair">) =>
|
||||
handle(async () => {
|
||||
const body = await readBody<PairRequest>(req);
|
||||
const code = String(body.code ?? "").trim();
|
||||
const name = String(body.deviceName ?? "").trim() || "Apple Watch";
|
||||
pairFailures = pairFailures.filter((at) => Date.now() - at < PAIR_WINDOW_MS);
|
||||
if (pairFailures.length >= PAIR_ATTEMPTS) throw new HttpError(429, "Too many attempts. Make a new code.");
|
||||
if (!/^\d{6}$/.test(code) || !db.pairing.redeem(code)) {
|
||||
pairFailures.push(Date.now());
|
||||
if (pairFailures.length >= PAIR_ATTEMPTS) db.pairing.voidAll();
|
||||
throw new HttpError(400, "That code is wrong or has expired.");
|
||||
}
|
||||
const deviceToken = `wc_${Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url")}`;
|
||||
const deviceId = db.devices.create(name, deviceToken);
|
||||
log(`paired device ${name} (${deviceId})`);
|
||||
return json({ deviceId, deviceToken } satisfies PairResponse);
|
||||
}),
|
||||
},
|
||||
|
||||
"/v1/device/push": {
|
||||
PUT: authed<"/v1/device/push">(async (req, device) => {
|
||||
const body = await readBody<PushRegistration>(req);
|
||||
if (!/^[0-9a-fA-F]{32,200}$/.test(body.apnsToken ?? "")) throw new HttpError(400, "apnsToken must be hex.");
|
||||
if (body.apnsEnvironment !== "sandbox" && body.apnsEnvironment !== "production") {
|
||||
throw new HttpError(400, "apnsEnvironment must be sandbox or production.");
|
||||
}
|
||||
db.devices.setPush(device.id, body.apnsToken.toLowerCase(), body.apnsEnvironment);
|
||||
return new Response(null, { status: 204 });
|
||||
}),
|
||||
},
|
||||
|
||||
"/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": {
|
||||
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": {
|
||||
GET: authed(async () => {
|
||||
await Promise.all(hub.list().map((host) => host.refreshConfig()));
|
||||
return json({
|
||||
hosts: hub.list().map((host) => ({
|
||||
...hostSummary(host),
|
||||
projects: [...host.projects.values()]
|
||||
.sort((a, b) => a.title.localeCompare(b.title))
|
||||
.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);
|
||||
}),
|
||||
},
|
||||
|
||||
"/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": {
|
||||
POST: authed<"/v1/threads">(async (req) => {
|
||||
const body = await readBody<NewThreadRequest>(req);
|
||||
const text = String(body.text ?? "").trim();
|
||||
if (!text) throw new HttpError(400, "Say what the thread should do.");
|
||||
const host = findHost(String(body.hostId));
|
||||
if (!host.projects.has(String(body.projectId))) throw new HttpError(404, "Unknown project.");
|
||||
const modelSelection = pickModel(host, body.projectId, body.model);
|
||||
const result = await host.launchThread({
|
||||
commandId: crypto.randomUUID(),
|
||||
creationSource: "mobile",
|
||||
projectId: body.projectId,
|
||||
title: titleFrom(text),
|
||||
generateTitle: true,
|
||||
modelSelection,
|
||||
runtimeMode: runtimeMode(host, body.askFirst === true),
|
||||
interactionMode: "default",
|
||||
workspaceStrategy: { type: "root" },
|
||||
initialMessage: { messageId: crypto.randomUUID(), text, attachments: [] },
|
||||
});
|
||||
return json({ hostId: host.id, threadId: result.threadId } satisfies NewThreadResponse, 201);
|
||||
}),
|
||||
},
|
||||
|
||||
"/v1/threads/:hostId/:threadId": {
|
||||
GET: authed<"/v1/threads/:hostId/:threadId">(async (req) => {
|
||||
const { host, thread } = findThread(req.params.hostId, req.params.threadId);
|
||||
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));
|
||||
}),
|
||||
},
|
||||
|
||||
"/v1/threads/:hostId/:threadId/messages": {
|
||||
POST: authed<"/v1/threads/:hostId/:threadId/messages">(async (req) => {
|
||||
const { host, thread } = findThread(req.params.hostId, req.params.threadId);
|
||||
const text = String((await readBody<SendMessageRequest>(req)).text ?? "").trim();
|
||||
if (!text) throw new HttpError(400, "The message is empty.");
|
||||
await host.dispatch({
|
||||
type: "message.dispatch",
|
||||
commandId: crypto.randomUUID(),
|
||||
threadId: thread.id,
|
||||
messageId: crypto.randomUUID(),
|
||||
text,
|
||||
attachments: [],
|
||||
createdBy: "user",
|
||||
creationSource: "mobile",
|
||||
dispatchMode: { type: "queue_after_active" },
|
||||
});
|
||||
return accepted();
|
||||
}),
|
||||
},
|
||||
|
||||
"/v1/threads/:hostId/:threadId/respond": {
|
||||
POST: authed<"/v1/threads/:hostId/:threadId/respond">(async (req) => {
|
||||
const { host, thread } = findThread(req.params.hostId, req.params.threadId);
|
||||
const body = await readBody<RespondRequest>(req);
|
||||
if (thread.pendingRuntimeRequest?.id !== body.requestId) {
|
||||
throw new HttpError(409, "That request was already answered.");
|
||||
}
|
||||
const base = { type: "runtime-request.respond", commandId: crypto.randomUUID(), threadId: thread.id, requestId: body.requestId } as const;
|
||||
if ("decision" in body) {
|
||||
if (!DECISIONS.has(body.decision)) throw new HttpError(400, "Unknown decision.");
|
||||
await host.dispatch({ ...base, decision: body.decision });
|
||||
} else if ("answers" in body && isAnswers(body.answers)) {
|
||||
await host.dispatch({ ...base, answers: body.answers });
|
||||
} else {
|
||||
throw new HttpError(400, "Send a decision or answers.");
|
||||
}
|
||||
cache.forget(host, thread.id);
|
||||
return accepted();
|
||||
}),
|
||||
},
|
||||
|
||||
"/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": {
|
||||
POST: authed<"/v1/threads/:hostId/:threadId/stop">(async (req) => {
|
||||
const { host, thread } = findThread(req.params.hostId, req.params.threadId);
|
||||
if (host.supports("threadStop")) {
|
||||
await host.dispatch({ type: "thread.stop", commandId: crypto.randomUUID(), threadId: thread.id });
|
||||
} else if (thread.activeRunId) {
|
||||
await host.dispatch({
|
||||
type: "run.interrupt",
|
||||
commandId: crypto.randomUUID(),
|
||||
threadId: thread.id,
|
||||
runId: thread.activeRunId,
|
||||
});
|
||||
} else {
|
||||
throw new HttpError(409, "Nothing is running.");
|
||||
}
|
||||
return accepted();
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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> {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
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. */
|
||||
function titleFrom(text: string): string {
|
||||
const line = text.split("\n")[0]?.trim() ?? text;
|
||||
return line.length > 80 ? `${line.slice(0, 79)}…` : line;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// Sends push notifications through APNs with token-based auth: an ES256 JWT signed by the .p8 key
|
||||
// from the Apple Developer portal, over HTTP/2. No dependencies.
|
||||
|
||||
import { createPrivateKey, sign, type KeyObject } from "node:crypto";
|
||||
import { connect, type ClientHttp2Session } from "node:http2";
|
||||
|
||||
export interface ApnsConfig {
|
||||
keyId: string;
|
||||
teamId: string;
|
||||
/** The .p8 key's PEM text. */
|
||||
key: string;
|
||||
/** The watch app's bundle ID, which is its APNs topic. */
|
||||
topic: string;
|
||||
}
|
||||
|
||||
export interface ApnsNotification {
|
||||
deviceToken: string;
|
||||
environment: "sandbox" | "production";
|
||||
payload: object;
|
||||
/** Replaces an earlier notification with the same id on the device. */
|
||||
collapseId?: string;
|
||||
}
|
||||
|
||||
/** Device tokens APNs reported as no longer valid. */
|
||||
export type ApnsResult = { ok: true } | { ok: false; status: number; reason: string; tokenInvalid: boolean };
|
||||
|
||||
const HOSTS = {
|
||||
sandbox: "https://api.sandbox.push.apple.com",
|
||||
production: "https://api.push.apple.com",
|
||||
} as const;
|
||||
|
||||
// Apple rejects tokens older than an hour and throttles ones refreshed more often than every 20 minutes.
|
||||
const JWT_LIFETIME_MS = 40 * 60_000;
|
||||
|
||||
export class Apns {
|
||||
private readonly key: KeyObject;
|
||||
private jwt: { value: string; issuedAt: number } | null = null;
|
||||
private readonly sessions = new Map<string, ClientHttp2Session>();
|
||||
|
||||
constructor(private readonly config: ApnsConfig) {
|
||||
this.key = createPrivateKey(config.key);
|
||||
}
|
||||
|
||||
async send(notification: ApnsNotification): Promise<ApnsResult> {
|
||||
const body = JSON.stringify(notification.payload);
|
||||
const session = this.session(HOSTS[notification.environment]);
|
||||
return new Promise((resolve) => {
|
||||
const request = session.request({
|
||||
":method": "POST",
|
||||
":path": `/3/device/${notification.deviceToken}`,
|
||||
authorization: `bearer ${this.token()}`,
|
||||
"apns-topic": this.config.topic,
|
||||
"apns-push-type": "alert",
|
||||
"apns-priority": "10",
|
||||
...(notification.collapseId ? { "apns-collapse-id": notification.collapseId } : {}),
|
||||
"content-type": "application/json",
|
||||
});
|
||||
let status = 0;
|
||||
let response = "";
|
||||
request.on("response", (headers) => {
|
||||
status = Number(headers[":status"]);
|
||||
});
|
||||
request.setEncoding("utf8");
|
||||
request.on("data", (chunk: string) => (response += chunk));
|
||||
request.on("end", () => {
|
||||
if (status === 200) return resolve({ ok: true });
|
||||
const reason = safeReason(response);
|
||||
resolve({
|
||||
ok: false,
|
||||
status,
|
||||
reason,
|
||||
tokenInvalid: status === 410 || reason === "BadDeviceToken" || reason === "DeviceTokenNotForTopic",
|
||||
});
|
||||
});
|
||||
request.on("error", (error) => resolve({ ok: false, status: 0, reason: error.message, tokenInvalid: false }));
|
||||
request.end(body);
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
for (const session of this.sessions.values()) session.close();
|
||||
this.sessions.clear();
|
||||
}
|
||||
|
||||
private session(origin: string): ClientHttp2Session {
|
||||
const existing = this.sessions.get(origin);
|
||||
if (existing && !existing.closed && !existing.destroyed) return existing;
|
||||
const session = connect(origin);
|
||||
session.on("error", () => this.sessions.delete(origin));
|
||||
session.on("close", () => this.sessions.delete(origin));
|
||||
this.sessions.set(origin, session);
|
||||
return session;
|
||||
}
|
||||
|
||||
private token(): string {
|
||||
if (this.jwt && Date.now() - this.jwt.issuedAt < JWT_LIFETIME_MS) return this.jwt.value;
|
||||
const issuedAt = Date.now();
|
||||
const header = base64url(JSON.stringify({ alg: "ES256", kid: this.config.keyId }));
|
||||
const claims = base64url(JSON.stringify({ iss: this.config.teamId, iat: Math.floor(issuedAt / 1000) }));
|
||||
const signature = sign("sha256", Buffer.from(`${header}.${claims}`), { key: this.key, dsaEncoding: "ieee-p1363" });
|
||||
this.jwt = { value: `${header}.${claims}.${signature.toString("base64url")}`, issuedAt };
|
||||
return this.jwt.value;
|
||||
}
|
||||
}
|
||||
|
||||
const base64url = (text: string) => Buffer.from(text).toString("base64url");
|
||||
|
||||
function safeReason(body: string): string {
|
||||
try {
|
||||
return (JSON.parse(body) as { reason?: string }).reason ?? body;
|
||||
} catch {
|
||||
return body;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// What a thread is doing, from the watch's point of view. Ported from T3 Code's
|
||||
// `packages/shared/src/agentAwareness.ts` (MIT), which drives T3's own mobile notifications.
|
||||
|
||||
import type { ThreadShell } from "./t3/types.ts";
|
||||
|
||||
export type Phase =
|
||||
| "starting"
|
||||
| "running"
|
||||
| "waiting_for_approval"
|
||||
| "waiting_for_input"
|
||||
| "completed"
|
||||
| "failed";
|
||||
|
||||
type AwarenessFields = Pick<
|
||||
ThreadShell,
|
||||
| "activityRunStatus"
|
||||
| "delegatedFrom"
|
||||
| "lineage"
|
||||
| "latestRunStopRequested"
|
||||
| "pendingBackgroundTasks"
|
||||
| "pendingRuntimeRequest"
|
||||
| "settledOverride"
|
||||
| "status"
|
||||
>;
|
||||
|
||||
/**
|
||||
* 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. Delegated
|
||||
* threads show only while they wait on the user, so their approvals still reach the watch.
|
||||
*/
|
||||
export function threadPhase(thread: AwarenessFields): Phase | null {
|
||||
if (thread.lineage.relationshipToParent === "subagent") return null;
|
||||
const phase = resolvePhase(thread);
|
||||
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 (
|
||||
thread.settledOverride === "settled" &&
|
||||
(phase === "completed" || phase === "failed") &&
|
||||
!backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? [])
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return phase;
|
||||
}
|
||||
|
||||
/** 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 request = thread.pendingRuntimeRequest;
|
||||
if (
|
||||
request?.kind === "user_input" &&
|
||||
(request.blocking !== false || !(runStatus === "preparing" || runStatus === "starting" || runStatus === "running"))
|
||||
) {
|
||||
return "waiting_for_input";
|
||||
}
|
||||
if (request && request.kind !== "user_input" && request.kind !== "auth_refresh") {
|
||||
return "waiting_for_approval";
|
||||
}
|
||||
switch (runStatus) {
|
||||
case "preparing":
|
||||
case "starting":
|
||||
return "starting";
|
||||
case "running":
|
||||
case "waiting":
|
||||
return "running";
|
||||
case "completed":
|
||||
return backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? []) ? "running" : "completed";
|
||||
case "failed":
|
||||
return "failed";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
/**
|
||||
* 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 {
|
||||
return tasks.some((task) => task.awaited === true || task.kind === "subagent" || task.kind === "background_task");
|
||||
}
|
||||
Executable
+129
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env bun
|
||||
// Operator commands. They edit the same SQLite file the running gateway reads, so changes apply
|
||||
// within a few seconds. In the container: `podman exec -it wristcode-gateway wristcode <command>`.
|
||||
|
||||
import { loadConfig } from "./config.ts";
|
||||
import { openDb } from "./db.ts";
|
||||
import { hostUrls } from "./hub.ts";
|
||||
import { ORCHESTRATION_PROTOCOL_VERSION, type EnvironmentDescriptor } from "./t3/types.ts";
|
||||
|
||||
const USAGE = `wristcode <command>
|
||||
|
||||
host add <url> <pairing-credential> [--label <name>] [--alt-url <url>]...
|
||||
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"
|
||||
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 remove <id or label>
|
||||
pair
|
||||
Print a 6 digit code to enter on the watch. Good for 5 minutes.
|
||||
device list
|
||||
device revoke <id or name>`;
|
||||
|
||||
const PAIRING_CODE_TTL_MS = 5 * 60_000;
|
||||
|
||||
const db = openDb(loadConfig().dbPath);
|
||||
const [group, command, ...rest] = process.argv.slice(2);
|
||||
|
||||
try {
|
||||
await run();
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
|
||||
async function run() {
|
||||
switch (`${group} ${command ?? ""}`.trim()) {
|
||||
case "host add":
|
||||
return hostAdd(rest);
|
||||
case "host list":
|
||||
for (const host of db.hosts.list()) {
|
||||
console.log(`${host.label}\t${hostUrls(host).join(" ")}\t${host.id}\ttoken expires ${host.token_expires_at ?? "unknown"}`);
|
||||
}
|
||||
return;
|
||||
case "host remove":
|
||||
if (!rest[0]) throw new Error(USAGE);
|
||||
console.log(db.hosts.remove(rest[0]) ? "Removed." : "No such host.");
|
||||
return;
|
||||
case "pair": {
|
||||
const code = String(crypto.getRandomValues(new Uint32Array(1))[0]! % 1_000_000).padStart(6, "0");
|
||||
db.pairing.create(code, PAIRING_CODE_TTL_MS);
|
||||
console.log(`Enter this code on the watch within 5 minutes: ${code}`);
|
||||
return;
|
||||
}
|
||||
case "device list":
|
||||
for (const device of db.devices.list()) {
|
||||
const state = device.revoked_at ? "revoked" : device.apns_token ? `push ${device.apns_env}` : "no push";
|
||||
console.log(`${device.name}\t${device.id}\t${state}\tlast seen ${device.last_seen_at ?? "never"}`);
|
||||
}
|
||||
return;
|
||||
case "device revoke":
|
||||
if (!rest[0]) throw new Error(USAGE);
|
||||
console.log(`Revoked ${db.devices.revoke(rest[0])} device(s).`);
|
||||
return;
|
||||
default:
|
||||
console.log(USAGE);
|
||||
}
|
||||
}
|
||||
|
||||
/** Exchanges a T3 pairing credential for a bearer session and saves the host. */
|
||||
async function hostAdd(args: string[]) {
|
||||
const positional: string[] = [];
|
||||
const altUrls: string[] = [];
|
||||
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);
|
||||
const url = new URL(rawUrl).origin;
|
||||
|
||||
const descriptor = await getJson<EnvironmentDescriptor>(`${url}/.well-known/t3/environment`);
|
||||
if ((descriptor.orchestrationProtocolVersion ?? 1) !== ORCHESTRATION_PROTOCOL_VERSION) {
|
||||
throw new Error(`${url} speaks orchestration protocol ${descriptor.orchestrationProtocolVersion ?? 1}, this gateway speaks ${ORCHESTRATION_PROTOCOL_VERSION}.`);
|
||||
}
|
||||
|
||||
const response = await fetch(`${url}/oauth/token`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
|
||||
subject_token: credential,
|
||||
subject_token_type: "urn:t3:params:oauth:token-type:environment-bootstrap",
|
||||
requested_token_type: "urn:ietf:params:oauth:token-type:access_token",
|
||||
scope: "orchestration:read orchestration:operate",
|
||||
client_label: "WristCode gateway",
|
||||
client_device_type: "bot",
|
||||
}),
|
||||
});
|
||||
if (!response.ok) throw new Error(`${url} refused the pairing credential (${response.status}): ${await response.text()}`);
|
||||
const token = (await response.json()) as { access_token: string; expires_in?: number; scope?: string };
|
||||
const scopes = token.scope?.split(" ") ?? [];
|
||||
if (!scopes.includes("orchestration:read") || !scopes.includes("orchestration:operate")) {
|
||||
throw new Error(`The credential only granted "${token.scope}". Make one with orchestration:read and orchestration:operate.`);
|
||||
}
|
||||
|
||||
db.hosts.upsert({
|
||||
id: descriptor.environmentId,
|
||||
label: label ?? descriptor.label,
|
||||
url,
|
||||
alt_urls: altUrls.length > 0 ? JSON.stringify(altUrls) : null,
|
||||
environment_id: descriptor.environmentId,
|
||||
token: token.access_token,
|
||||
token_expires_at: token.expires_in ? new Date(Date.now() + token.expires_in * 1000).toISOString() : null,
|
||||
});
|
||||
console.log(`Added ${label ?? descriptor.label} (T3 ${descriptor.serverVersion}). The gateway connects within 15 seconds.`);
|
||||
}
|
||||
|
||||
async function getJson<T>(url: string): Promise<T> {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`${url} returned ${response.status}`);
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Settings from the environment. Only the database path is needed to run. Without the APNs
|
||||
// settings the gateway still serves the watch, it just logs pushes instead of sending them.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import type { ApnsConfig } from "./apns.ts";
|
||||
|
||||
export interface Config {
|
||||
port: number;
|
||||
dbPath: string;
|
||||
apns: ApnsConfig | null;
|
||||
}
|
||||
|
||||
export function loadConfig(env = process.env): Config {
|
||||
const keyId = env.APNS_KEY_ID;
|
||||
const teamId = env.APNS_TEAM_ID;
|
||||
const key = env.APNS_KEY ?? (env.APNS_KEY_PATH ? readFileSync(env.APNS_KEY_PATH, "utf8") : undefined);
|
||||
return {
|
||||
port: Number(env.PORT ?? 8080),
|
||||
dbPath: env.WRISTCODE_DB ?? "data/wristcode.sqlite",
|
||||
apns:
|
||||
keyId && teamId && key
|
||||
? { keyId, teamId, key: key.replace(/\\n/g, "\n"), topic: env.APNS_TOPIC ?? "org.gbrown.wristcode.watchkitapp" }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
// The JSON the watch app reads and sends. This is the contract between the gateway and the
|
||||
// watch's Codable models (watch/WristCode/API/Models.swift). docs/api.md describes it for humans.
|
||||
// Change both sides together.
|
||||
|
||||
import type { Phase } from "./awareness.ts";
|
||||
import type { ApprovalDecision } from "./t3/types.ts";
|
||||
import type { HostStatus } from "./t3/host.ts";
|
||||
|
||||
export interface HostSummary {
|
||||
id: string;
|
||||
label: string;
|
||||
status: HostStatus;
|
||||
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. */
|
||||
export interface PendingRequest {
|
||||
id: string;
|
||||
/** `command`, `file-change`, `permission`, `user_input`, ... */
|
||||
kind: string;
|
||||
/** What the agent wants to do, such as the command line. Null when the server gave none. */
|
||||
prompt: string | null;
|
||||
reason: string | null;
|
||||
/** Approval choices, in the server's order. Empty for questions. `warning` flags a risky choice. */
|
||||
options: Array<{ decision: ApprovalDecision; label: string; warning?: string }>;
|
||||
/**
|
||||
* On `file-change` approvals: the files the agent wants to change. Counts and preview are null
|
||||
* and empty when the server sends no diff before approval, which is the usual case.
|
||||
*/
|
||||
files: PendingFile[];
|
||||
/** Questions for the user. Empty for approvals. */
|
||||
questions: Array<{
|
||||
id: string;
|
||||
header: string;
|
||||
question: string;
|
||||
options: Array<{ label: string; value: string }>;
|
||||
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 {
|
||||
hostId: string;
|
||||
hostLabel: string;
|
||||
threadId: string;
|
||||
projectId: string;
|
||||
projectTitle: string;
|
||||
title: string;
|
||||
phase: Phase;
|
||||
updatedAt: string;
|
||||
/** The agent's latest message, trimmed for the watch. Null until the gateway has read it. */
|
||||
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;
|
||||
/**
|
||||
* 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 {
|
||||
/** Waiting for approval or input. Oldest request first. */
|
||||
needsYou: FeedItem[];
|
||||
/** Starting or running. Most recently updated first. */
|
||||
working: FeedItem[];
|
||||
/** Completed or failed and not yet settled. Most recent first, at most 10. */
|
||||
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[];
|
||||
}
|
||||
|
||||
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 {
|
||||
id: string;
|
||||
role: "user" | "assistant" | "system";
|
||||
text: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ThreadDetail {
|
||||
item: FeedItem;
|
||||
/** Oldest first, at most 20. */
|
||||
messages: Message[];
|
||||
/** True while a run is active, so Stop makes sense. */
|
||||
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 {
|
||||
hosts: Array<
|
||||
HostSummary & {
|
||||
projects: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
/** The model a new thread will use, such as `claude-opus-5-5`. Null when none is configured. */
|
||||
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 {
|
||||
code: string;
|
||||
deviceName: string;
|
||||
}
|
||||
|
||||
export interface PairResponse {
|
||||
deviceId: string;
|
||||
deviceToken: string;
|
||||
}
|
||||
|
||||
export interface PushRegistration {
|
||||
apnsToken: string;
|
||||
apnsEnvironment: "sandbox" | "production";
|
||||
}
|
||||
|
||||
export interface SendMessageRequest {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export type RespondRequest =
|
||||
| { requestId: string; decision: ApprovalDecision }
|
||||
/** 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 {
|
||||
hostId: string;
|
||||
projectId: 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 {
|
||||
hostId: string;
|
||||
threadId: string;
|
||||
}
|
||||
|
||||
export interface ErrorResponse {
|
||||
error: string;
|
||||
}
|
||||
|
||||
/** Custom keys on every push notification, beside `aps`. */
|
||||
export interface PushPayload {
|
||||
/** Absent on `test` pushes. */
|
||||
hostId?: string;
|
||||
/** Absent on `host` pushes, which are about a host rather than a thread. */
|
||||
threadId?: 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. */
|
||||
export type PushCategory = "approval" | "finished" | "failed" | "input" | "host" | "test";
|
||||
@@ -0,0 +1,150 @@
|
||||
// SQLite state: the T3 hosts the gateway connects to, the watches paired with it, and pending
|
||||
// pairing codes. The CLI and the server open the same file, and the server rereads `hosts` so a
|
||||
// `wristcode host add` takes effect without a restart.
|
||||
|
||||
import { Database } from "bun:sqlite";
|
||||
|
||||
export interface HostRow {
|
||||
id: string;
|
||||
label: 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;
|
||||
token: string;
|
||||
token_expires_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface DeviceRow {
|
||||
id: string;
|
||||
name: string;
|
||||
token_hash: string;
|
||||
apns_token: string | null;
|
||||
apns_env: "sandbox" | "production" | null;
|
||||
/** The watch's NotificationPrefs as JSON. Null until the watch changes one. */
|
||||
notifications: string | null;
|
||||
created_at: string;
|
||||
last_seen_at: string | null;
|
||||
revoked_at: string | null;
|
||||
}
|
||||
|
||||
const SCHEMA = `
|
||||
create table if not exists hosts (
|
||||
id text primary key,
|
||||
label text not null,
|
||||
url text not null unique,
|
||||
environment_id text not null unique,
|
||||
token text not null,
|
||||
token_expires_at text,
|
||||
created_at text not null
|
||||
);
|
||||
create table if not exists devices (
|
||||
id text primary key,
|
||||
name text not null,
|
||||
token_hash text not null unique,
|
||||
apns_token text,
|
||||
apns_env text,
|
||||
created_at text not null,
|
||||
last_seen_at text,
|
||||
revoked_at text
|
||||
);
|
||||
create table if not exists pairing_codes (
|
||||
code_hash text primary key,
|
||||
expires_at text not null,
|
||||
used_at text
|
||||
);
|
||||
`;
|
||||
|
||||
export const sha256 = (value: string) => new Bun.CryptoHasher("sha256").update(value).digest("hex");
|
||||
|
||||
const now = () => new Date().toISOString();
|
||||
|
||||
export function openDb(path: string) {
|
||||
const db = new Database(path, { create: true, strict: true });
|
||||
db.run("pragma journal_mode = wal");
|
||||
db.run("pragma busy_timeout = 5000");
|
||||
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 {
|
||||
hosts: {
|
||||
list: () => db.query<HostRow, []>("select * from hosts order by created_at").all(),
|
||||
upsert(host: Omit<HostRow, "created_at">) {
|
||||
db.query(
|
||||
`insert into hosts (id, label, url, alt_urls, 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
|
||||
label = excluded.label, url = excluded.url, alt_urls = excluded.alt_urls,
|
||||
token = excluded.token, token_expires_at = excluded.token_expires_at`,
|
||||
).run({ ...host, created_at: now() });
|
||||
},
|
||||
remove: (idOrLabel: string) =>
|
||||
db.query("delete from hosts where id = $v or label = $v").run({ v: idOrLabel }).changes > 0,
|
||||
},
|
||||
|
||||
devices: {
|
||||
list: () => db.query<DeviceRow, []>("select * from devices order by created_at").all(),
|
||||
active: () => db.query<DeviceRow, []>("select * from devices where revoked_at is null").all(),
|
||||
create(name: string, token: string): string {
|
||||
const id = crypto.randomUUID();
|
||||
db.query(
|
||||
"insert into devices (id, name, token_hash, created_at) values ($id, $name, $hash, $created_at)",
|
||||
).run({ id, name, hash: sha256(token), created_at: now() });
|
||||
return id;
|
||||
},
|
||||
/** The active device that owns this bearer token, marking it seen. */
|
||||
authenticate(token: string): DeviceRow | null {
|
||||
const device = db
|
||||
.query<DeviceRow, { hash: string }>("select * from devices where token_hash = $hash and revoked_at is null")
|
||||
.get({ hash: sha256(token) });
|
||||
if (device) db.query("update devices set last_seen_at = $at where id = $id").run({ at: now(), id: device.id });
|
||||
return device;
|
||||
},
|
||||
setPush(id: string, apnsToken: string, apnsEnv: "sandbox" | "production") {
|
||||
db.query("update devices set apns_token = $apnsToken, apns_env = $apnsEnv where id = $id").run({
|
||||
id,
|
||||
apnsToken,
|
||||
apnsEnv,
|
||||
});
|
||||
},
|
||||
setNotifications: (id: string, json: string) =>
|
||||
db.query("update devices set notifications = $json where id = $id").run({ id, json }),
|
||||
clearPush: (apnsToken: string) =>
|
||||
db.query("update devices set apns_token = null where apns_token = $apnsToken").run({ apnsToken }),
|
||||
revoke: (idOrName: string) =>
|
||||
db.query("update devices set revoked_at = $at where (id = $v or name = $v) and revoked_at is null").run({
|
||||
at: now(),
|
||||
v: idOrName,
|
||||
}).changes,
|
||||
},
|
||||
|
||||
pairing: {
|
||||
create(code: string, ttlMs: number) {
|
||||
db.query("insert into pairing_codes (code_hash, expires_at) values ($hash, $expires_at)").run({
|
||||
hash: sha256(code),
|
||||
expires_at: new Date(Date.now() + ttlMs).toISOString(),
|
||||
});
|
||||
},
|
||||
/** Marks the code used and returns true if it was valid. */
|
||||
redeem(code: string): boolean {
|
||||
return (
|
||||
db
|
||||
.query(
|
||||
"update pairing_codes set used_at = $at where code_hash = $hash and used_at is null and expires_at > $at",
|
||||
)
|
||||
.run({ hash: sha256(code), at: now() }).changes > 0
|
||||
);
|
||||
},
|
||||
/** Voids every open code, after too many wrong guesses. */
|
||||
voidAll: () => db.query("update pairing_codes set used_at = $at where used_at is null").run({ at: now() }),
|
||||
},
|
||||
|
||||
close: () => db.close(),
|
||||
};
|
||||
}
|
||||
|
||||
export type Db = ReturnType<typeof openDb>;
|
||||
@@ -0,0 +1,69 @@
|
||||
// The set of T3 hosts, kept in step with the `hosts` table. The CLI edits the table and the running
|
||||
// gateway picks the change up on its next sync, so adding or renewing a host needs no restart.
|
||||
|
||||
import type { Db, HostRow } from "./db.ts";
|
||||
import { T3Host, type HostOptions } from "./t3/host.ts";
|
||||
|
||||
export class Hub {
|
||||
private readonly hosts = new Map<string, { host: T3Host; row: HostRow }>();
|
||||
|
||||
constructor(
|
||||
private readonly db: Db,
|
||||
private readonly options: HostOptions,
|
||||
) {}
|
||||
|
||||
/** Starts new hosts, restarts ones whose URL or token changed, and stops removed ones. */
|
||||
sync() {
|
||||
const rows = this.db.hosts.list();
|
||||
const seen = new Set<string>();
|
||||
for (const row of rows) {
|
||||
seen.add(row.id);
|
||||
const current = this.hosts.get(row.id);
|
||||
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;
|
||||
continue;
|
||||
}
|
||||
current?.host.stop();
|
||||
const host = new T3Host(
|
||||
{ id: row.id, label: row.label, urls: hostUrls(row), token: row.token, environmentId: row.environment_id },
|
||||
this.options,
|
||||
);
|
||||
this.hosts.set(row.id, { host, row });
|
||||
host.start();
|
||||
}
|
||||
for (const [id, { host }] of this.hosts) {
|
||||
if (seen.has(id)) continue;
|
||||
host.stop();
|
||||
this.hosts.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
list(): T3Host[] {
|
||||
return [...this.hosts.values()].map(({ host }) => host);
|
||||
}
|
||||
|
||||
get(id: string): T3Host | undefined {
|
||||
return this.hosts.get(id)?.host;
|
||||
}
|
||||
|
||||
rows(): HostRow[] {
|
||||
return [...this.hosts.values()].map(({ row }) => row);
|
||||
}
|
||||
|
||||
stop() {
|
||||
for (const { host } of this.hosts.values()) host.stop();
|
||||
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") : [])];
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Starts the gateway: connects to every T3 host in the database, serves the watch API, and pushes
|
||||
// notifications.
|
||||
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
import { Apns } from "./apns.ts";
|
||||
import { createRoutes } from "./api.ts";
|
||||
import { loadConfig } from "./config.ts";
|
||||
import { openDb } from "./db.ts";
|
||||
import { Hub } from "./hub.ts";
|
||||
import { Notifier } from "./notify.ts";
|
||||
import { Pusher } from "./push.ts";
|
||||
import { SummaryCache } from "./views.ts";
|
||||
|
||||
const HOST_SYNC_MS = 15_000;
|
||||
const TOKEN_CHECK_MS = 6 * 60 * 60_000;
|
||||
|
||||
const log = (message: string) => console.log(`${new Date().toISOString()} ${message}`);
|
||||
|
||||
const startedAt = new Date().toISOString();
|
||||
const config = loadConfig();
|
||||
mkdirSync(dirname(config.dbPath), { recursive: true });
|
||||
const db = openDb(config.dbPath);
|
||||
const cache = new SummaryCache(log);
|
||||
const apns = config.apns ? new Apns(config.apns) : null;
|
||||
if (!apns) log("APNs is not configured. Pushes will only be logged.");
|
||||
|
||||
const pusher = new Pusher(db, apns, log);
|
||||
|
||||
const notifier = new Notifier(pusher.sendAll, cache, 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();
|
||||
setInterval(() => hub.sync(), HOST_SYNC_MS);
|
||||
notifier.checkTokenExpiry(hub.rows());
|
||||
setInterval(() => notifier.checkTokenExpiry(hub.rows()), TOKEN_CHECK_MS);
|
||||
|
||||
const server = Bun.serve({
|
||||
port: config.port,
|
||||
routes: createRoutes({ db, hub, cache, pusher, startedAt, log }),
|
||||
fetch: () => Response.json({ error: "Not found." }, { status: 404 }),
|
||||
});
|
||||
log(`WristCode gateway listening on :${server.port} with ${hub.list().length} host(s)`);
|
||||
|
||||
for (const signal of ["SIGINT", "SIGTERM"] as const) {
|
||||
process.on(signal, () => {
|
||||
log(`${signal}: shutting down`);
|
||||
void server.stop();
|
||||
hub.stop();
|
||||
apns?.close();
|
||||
db.close();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// Decides when to push. A thread notifies when its phase becomes one the user cares about
|
||||
// (waiting for them, finished, failed), and again only after it moves on. A new approval on a
|
||||
// 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.
|
||||
|
||||
import { isSnoozed, threadPhase, type Phase } from "./awareness.ts";
|
||||
import type { PendingRequest, PushCategory, PushPayload } from "./contract.ts";
|
||||
import type { HostRow } from "./db.ts";
|
||||
import type { T3Host, ThreadChange } from "./t3/host.ts";
|
||||
import { trimText, type SummaryCache, type ThreadSummary } from "./views.ts";
|
||||
|
||||
export interface Push {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
body: string;
|
||||
category: PushCategory;
|
||||
/** True for a subagent's request, which watches can mute apart from their own threads'. */
|
||||
fromSubagent?: boolean;
|
||||
data: PushPayload;
|
||||
/** Replaces an earlier push about the same thing on the watch. */
|
||||
collapseId: string;
|
||||
}
|
||||
|
||||
export type SendPush = (push: Push) => Promise<void>;
|
||||
|
||||
const NOTIFYING: ReadonlySet<Phase> = new Set(["waiting_for_approval", "waiting_for_input", "completed", "failed"]);
|
||||
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 TOKEN_WARNING_MS = 7 * 24 * 60 * 60_000;
|
||||
|
||||
export class Notifier {
|
||||
/** The last phase key seen per thread, `phase:requestId`. */
|
||||
private readonly keys = new Map<string, string>();
|
||||
/** Hosts already warned about, so each warning goes out once a day at most. */
|
||||
private readonly warned = new Map<string, number>();
|
||||
|
||||
constructor(
|
||||
private readonly send: SendPush,
|
||||
private readonly cache: SummaryCache,
|
||||
private readonly log: (message: string) => void = () => {},
|
||||
) {}
|
||||
|
||||
onThreadChange = ({ host, previous, next, initial }: ThreadChange) => {
|
||||
const id = `${host.id}:${(next ?? previous)?.id}`;
|
||||
if (!next) {
|
||||
this.keys.delete(id);
|
||||
return;
|
||||
}
|
||||
const phase = threadPhase(next);
|
||||
const key = `${phase}:${next.pendingRuntimeRequest?.id ?? ""}`;
|
||||
const before = this.keys.get(id);
|
||||
this.keys.set(id, key);
|
||||
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) =>
|
||||
this.log(`push for ${id} failed: ${error instanceof Error ? error.message : String(error)}`),
|
||||
);
|
||||
};
|
||||
|
||||
/** Warns once when a host starts rejecting the gateway's token. */
|
||||
onHostStatus = (host: T3Host) => {
|
||||
if (host.status !== "auth_invalid") return;
|
||||
this.warnHost(host.id, host.label, `T3 rejected the gateway's token. Pair ${host.label} again.`);
|
||||
};
|
||||
|
||||
/** Warns about tokens that expire within a week. Run it daily. */
|
||||
checkTokenExpiry(rows: HostRow[], now = Date.now()) {
|
||||
for (const row of rows) {
|
||||
if (!row.token_expires_at) continue;
|
||||
const left = Date.parse(row.token_expires_at) - now;
|
||||
if (left > TOKEN_WARNING_MS) continue;
|
||||
const days = Math.max(0, Math.ceil(left / (24 * 60 * 60_000)));
|
||||
this.warnHost(row.id, row.label, `The gateway's T3 token expires in ${days} day${days === 1 ? "" : "s"}. Pair ${row.label} again.`);
|
||||
}
|
||||
}
|
||||
|
||||
private warnHost(hostId: string, label: string, body: string) {
|
||||
const last = this.warned.get(hostId) ?? 0;
|
||||
if (Date.now() - last < 24 * 60 * 60_000) return;
|
||||
this.warned.set(hostId, Date.now());
|
||||
void this.send({
|
||||
title: label,
|
||||
body,
|
||||
category: "host",
|
||||
data: { hostId },
|
||||
collapseId: `host:${hostId}`,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
private async notify(host: T3Host, thread: NonNullable<ThreadChange["next"]>, phase: Phase) {
|
||||
const summary = await Promise.race([
|
||||
this.cache.get(host, thread).catch(() => undefined),
|
||||
Bun.sleep(SUMMARY_WAIT_MS).then(() => undefined),
|
||||
]);
|
||||
const project = host.projects.get(thread.projectId)?.title;
|
||||
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({
|
||||
title: trimText(parent ?? thread.title, TITLE_CHARS),
|
||||
subtitle: project ? `${host.label} · ${project}` : host.label,
|
||||
body: parent ? `Subagent ${trimText(thread.title, TITLE_CHARS)}: ${text}` : text,
|
||||
category: category(phase),
|
||||
fromSubagent: parent !== undefined,
|
||||
data: { hostId: host.id, threadId: thread.id, ...(requestId ? { requestId } : {}), ...pushQuestion(phase, summary) },
|
||||
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 {
|
||||
switch (phase) {
|
||||
case "waiting_for_approval":
|
||||
return "approval";
|
||||
case "waiting_for_input":
|
||||
return "input";
|
||||
case "failed":
|
||||
return "failed";
|
||||
default:
|
||||
return "finished";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
switch (phase) {
|
||||
case "waiting_for_approval":
|
||||
return approvalBody(summary?.request ?? null);
|
||||
case "waiting_for_input": {
|
||||
const question = summary?.request?.questions[0]?.question;
|
||||
return question ? trimText(question, BODY_CHARS) : "Has a question for you.";
|
||||
}
|
||||
case "failed":
|
||||
return lastError ? trimText(lastError, BODY_CHARS) : "The run failed.";
|
||||
default:
|
||||
return summary?.lastAssistantMessage ? trimText(summary.lastAssistantMessage, BODY_CHARS) : "Finished.";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
// One T3 Code server. Keeps a shell stream open, holds every project and thread shell in memory,
|
||||
// and reconnects with backoff. Writes go over the same socket, and thread pages go over HTTP.
|
||||
|
||||
import { RpcError, RpcSocket } from "./rpc.ts";
|
||||
import {
|
||||
ORCHESTRATION_PROTOCOL_VERSION,
|
||||
type BoundedThread,
|
||||
type Command,
|
||||
type EnvironmentDescriptor,
|
||||
type HistoryPage,
|
||||
type SearchMatch,
|
||||
type LaunchThreadInput,
|
||||
type Project,
|
||||
type ServerConfig,
|
||||
type ShellStreamItem,
|
||||
type ThreadShell,
|
||||
} from "./types.ts";
|
||||
|
||||
export type HostStatus =
|
||||
| "connecting"
|
||||
| "online"
|
||||
| "offline"
|
||||
/** The token expired or was revoked. A new one has to be paired in. */
|
||||
| "auth_invalid"
|
||||
/** The server speaks another protocol version or is a different environment. */
|
||||
| "incompatible";
|
||||
|
||||
export interface HostConfig {
|
||||
id: string;
|
||||
label: 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;
|
||||
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 {
|
||||
host: T3Host;
|
||||
previous: ThreadShell | undefined;
|
||||
next: ThreadShell | undefined;
|
||||
/** True while loading the first snapshot after start, when nothing should notify. */
|
||||
initial: boolean;
|
||||
}
|
||||
|
||||
export interface HostOptions {
|
||||
onThreadChange?: (change: ThreadChange) => void;
|
||||
onStatus?: (host: T3Host) => void;
|
||||
log?: (message: string) => void;
|
||||
}
|
||||
|
||||
const BACKOFF_MIN_MS = 1_000;
|
||||
const BACKOFF_MAX_MS = 5 * 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 {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "HostError";
|
||||
}
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
status: HostStatus = "connecting";
|
||||
lastError: string | null = null;
|
||||
readonly projects = new Map<string, Project>();
|
||||
readonly threads = new Map<string, ThreadShell>();
|
||||
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 sequence: number | null = null;
|
||||
private hasSnapshot = false;
|
||||
private stopped = false;
|
||||
private attempt = 0;
|
||||
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(
|
||||
readonly config: HostConfig,
|
||||
private readonly options: HostOptions = {},
|
||||
) {
|
||||
const [first] = config.urls;
|
||||
if (!first) throw new Error(`${config.label} has no URL`);
|
||||
this.activeUrl = first;
|
||||
}
|
||||
|
||||
get id() {
|
||||
return this.config.id;
|
||||
}
|
||||
|
||||
get label() {
|
||||
return this.config.label;
|
||||
}
|
||||
|
||||
/** The route in use, or the one that will be tried first. */
|
||||
get url() {
|
||||
return this.activeUrl;
|
||||
}
|
||||
|
||||
start() {
|
||||
this.stopped = false;
|
||||
void this.connect();
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.stopped = true;
|
||||
if (this.retryTimer) clearTimeout(this.retryTimer);
|
||||
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. */
|
||||
async dispatch(command: Command): Promise<{ sequence: number }> {
|
||||
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 }> {
|
||||
return this.requireSocket().call("orchestration.launchThread", input);
|
||||
}
|
||||
|
||||
/** The recent window of one thread: its messages and turn items. */
|
||||
bounded(threadId: string): Promise<BoundedThread> {
|
||||
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`. */
|
||||
async sessionExpiresAt(): Promise<string | null> {
|
||||
const session = await this.http<{ expiresAt?: string }>("/api/auth/session");
|
||||
return session.expiresAt ?? null;
|
||||
}
|
||||
|
||||
supports(capability: string): boolean {
|
||||
return this.serverConfig?.environment?.capabilities?.[capability] === true;
|
||||
}
|
||||
|
||||
private requireSocket(): RpcSocket {
|
||||
if (this.socket === null || this.status !== "online") {
|
||||
throw new HostError(`${this.label} is ${this.status}`, 503);
|
||||
}
|
||||
return this.socket;
|
||||
}
|
||||
|
||||
private async http<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(new URL(path, this.activeUrl), {
|
||||
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
||||
...init,
|
||||
headers: {
|
||||
authorization: `Bearer ${this.config.token}`,
|
||||
"x-t3-orchestration-protocol": String(ORCHESTRATION_PROTOCOL_VERSION),
|
||||
...init.headers,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new HostError(`${this.label} ${path} returned ${response.status}: ${body.slice(0, 200)}`, response.status);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
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.lastError = error;
|
||||
this.options.onStatus?.(this);
|
||||
}
|
||||
|
||||
private async connect() {
|
||||
if (this.stopped) return;
|
||||
this.nextRetryAt = null;
|
||||
this.setStatus("connecting", this.lastError);
|
||||
try {
|
||||
const descriptor = await this.pickRoute();
|
||||
const protocol = descriptor.orchestrationProtocolVersion ?? 1;
|
||||
if (protocol !== ORCHESTRATION_PROTOCOL_VERSION) {
|
||||
throw new HostError(`speaks orchestration protocol ${protocol}, the gateway speaks ${ORCHESTRATION_PROTOCOL_VERSION}`, 426);
|
||||
}
|
||||
if (descriptor.environmentId !== this.config.environmentId) {
|
||||
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 wsUrl = new URL("/ws", this.activeUrl);
|
||||
wsUrl.protocol = wsUrl.protocol === "https:" ? "wss:" : "ws:";
|
||||
wsUrl.searchParams.set("wsTicket", ticket);
|
||||
wsUrl.searchParams.set("orchestrationProtocol", String(ORCHESTRATION_PROTOCOL_VERSION));
|
||||
const socket = await RpcSocket.connect(wsUrl.href);
|
||||
this.socket = socket;
|
||||
this.serverConfig = await socket.call<ServerConfig>("server.getConfig", {});
|
||||
this.subscribe(socket);
|
||||
this.attempt = 0;
|
||||
this.setStatus("online");
|
||||
await socket.closed;
|
||||
this.socket = null;
|
||||
if (this.reconnecting) {
|
||||
this.reconnecting = false;
|
||||
void this.connect();
|
||||
return;
|
||||
}
|
||||
this.scheduleRetry("offline", "connection closed");
|
||||
} catch (error) {
|
||||
this.socket?.close();
|
||||
this.socket = null;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (error instanceof HostError && error.status === 401) this.scheduleRetry("auth_invalid", message);
|
||||
else if (error instanceof HostError && (error.status === 426 || error.status === 409)) this.scheduleRetry("incompatible", message);
|
||||
else this.scheduleRetry("offline", message);
|
||||
}
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
const input = this.hasSnapshot && this.sequence !== null ? { afterSequence: this.sequence } : {};
|
||||
const { done } = socket.stream("orchestration.subscribeShell", input, (value) =>
|
||||
this.apply(value as ShellStreamItem),
|
||||
);
|
||||
// The stream only ends when the socket does, or on a server error. Either way, start over.
|
||||
done.then(
|
||||
() => socket.close(),
|
||||
(error: RpcError) => {
|
||||
this.options.log?.(`${this.label}: shell stream ended: ${error.message}`);
|
||||
socket.close();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private scheduleRetry(status: HostStatus, error: string) {
|
||||
if (this.stopped) return;
|
||||
this.setStatus(status, error);
|
||||
this.options.log?.(`${this.label}: ${status}: ${error}`);
|
||||
const delay =
|
||||
status === "auth_invalid" || status === "incompatible"
|
||||
? AUTH_RETRY_MS
|
||||
: 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);
|
||||
}
|
||||
|
||||
/** Applies one shell stream item to the in-memory state, reporting each thread that changed. */
|
||||
apply(item: ShellStreamItem) {
|
||||
switch (item.kind) {
|
||||
case "snapshot": {
|
||||
const { snapshot } = item;
|
||||
for (const project of snapshot.projects) this.projects.set(project.id, project);
|
||||
const known = new Set(snapshot.projects.map((project) => project.id));
|
||||
for (const id of this.projects.keys()) if (!known.has(id)) this.projects.delete(id);
|
||||
this.sequence = Math.max(this.sequence ?? 0, snapshot.snapshotSequence);
|
||||
// The server also sends project-only snapshots with no threads: one first on every resume,
|
||||
// and more as project metadata fills in. T3's own client keeps its threads for those, and
|
||||
// so does the gateway.
|
||||
if (snapshot.threads.length === 0 && this.hasSnapshot) return;
|
||||
const initial = !this.hasSnapshot;
|
||||
this.hasSnapshot = true;
|
||||
const next = new Map(snapshot.threads.map((thread) => [thread.id, thread]));
|
||||
for (const [id, previous] of this.threads) {
|
||||
if (!next.has(id)) this.changeThread(previous, undefined, initial);
|
||||
}
|
||||
for (const thread of next.values()) this.changeThread(this.threads.get(thread.id), thread, initial);
|
||||
return;
|
||||
}
|
||||
case "project.updated":
|
||||
this.projects.set(item.project.id, item.project);
|
||||
this.sequence = item.sequence;
|
||||
return;
|
||||
case "project.removed":
|
||||
this.projects.delete(item.projectId);
|
||||
this.sequence = item.sequence;
|
||||
return;
|
||||
case "thread.updated":
|
||||
this.sequence = item.sequence;
|
||||
if (item.location === "active") this.changeThread(this.threads.get(item.thread.id), item.thread, false);
|
||||
else this.changeThread(this.threads.get(item.thread.id), undefined, false);
|
||||
return;
|
||||
case "thread.removed": {
|
||||
this.sequence = item.sequence;
|
||||
const previous = this.threads.get(item.threadId);
|
||||
if (previous) this.changeThread(previous, undefined, false);
|
||||
return;
|
||||
}
|
||||
case "synchronized":
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private changeThread(previous: ThreadShell | undefined, next: ThreadShell | undefined, initial: boolean) {
|
||||
if (next) this.threads.set(next.id, next);
|
||||
else if (previous) this.threads.delete(previous.id);
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// A minimal client for T3 Code's WebSocket RPC, which speaks Effect RPC's JSON framing.
|
||||
// One socket multiplexes unary calls and streams by request id. Streams are flow controlled: the
|
||||
// server waits for an Ack after every Chunk, so `stream` acks each one after its handler runs.
|
||||
// See docs/t3-protocol.md, section 2.
|
||||
|
||||
type FromServer =
|
||||
| { _tag: "Chunk"; requestId: string; values: unknown[] }
|
||||
| { _tag: "Exit"; requestId: string; exit: Exit }
|
||||
| { _tag: "Defect"; defect: unknown }
|
||||
| { _tag: "Pong" }
|
||||
| { _tag: "ClientProtocolError"; error: unknown };
|
||||
|
||||
type Exit =
|
||||
| { _tag: "Success"; value: unknown }
|
||||
| { _tag: "Failure"; cause: Array<{ _tag: string; error?: unknown; defect?: unknown }> };
|
||||
|
||||
/** A call that ended in a Failure exit, or a socket that closed under it. */
|
||||
export class RpcError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
/** The typed error from the server, such as `{ _tag: "EnvironmentAuthorizationError", ... }`. */
|
||||
readonly error?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "RpcError";
|
||||
}
|
||||
}
|
||||
|
||||
/** How long a unary call waits for its answer. */
|
||||
const CALL_TIMEOUT_MS = 20_000;
|
||||
|
||||
interface Pending {
|
||||
onChunk?: (value: unknown) => void;
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: RpcError) => void;
|
||||
}
|
||||
|
||||
export interface RpcSocketOptions {
|
||||
pingIntervalMs?: number;
|
||||
/** Close the socket when the server has sent nothing for this long. */
|
||||
silenceTimeoutMs?: number;
|
||||
}
|
||||
|
||||
/** One open RPC WebSocket. It never reconnects; the caller makes a new one. */
|
||||
export class RpcSocket {
|
||||
private nextId = 1;
|
||||
private readonly pending = new Map<string, Pending>();
|
||||
private lastFrameAt = Date.now();
|
||||
private readonly timer: ReturnType<typeof setInterval>;
|
||||
/** Resolves when the socket closes, for any reason. */
|
||||
readonly closed: Promise<void>;
|
||||
|
||||
private constructor(
|
||||
private readonly ws: WebSocket,
|
||||
options: RpcSocketOptions,
|
||||
) {
|
||||
const pingIntervalMs = options.pingIntervalMs ?? 5_000;
|
||||
const silenceTimeoutMs = options.silenceTimeoutMs ?? 15_000;
|
||||
this.closed = new Promise((resolve) => ws.addEventListener("close", () => resolve()));
|
||||
ws.addEventListener("message", (event) => this.onMessage(event.data));
|
||||
ws.addEventListener("close", () => this.failAll("socket closed"));
|
||||
this.timer = setInterval(() => {
|
||||
if (Date.now() - this.lastFrameAt > silenceTimeoutMs) {
|
||||
this.close();
|
||||
return;
|
||||
}
|
||||
this.send({ _tag: "Ping" });
|
||||
}, pingIntervalMs);
|
||||
void this.closed.then(() => clearInterval(this.timer));
|
||||
}
|
||||
|
||||
/** Opens a socket and resolves once it is connected. */
|
||||
static connect(url: string, options: RpcSocketOptions = {}): Promise<RpcSocket> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(url);
|
||||
const onError = () => reject(new RpcError(`could not connect to ${new URL(url).host}`));
|
||||
ws.addEventListener("error", onError, { once: true });
|
||||
ws.addEventListener(
|
||||
"open",
|
||||
() => {
|
||||
ws.removeEventListener("error", onError);
|
||||
resolve(new RpcSocket(ws, options));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Calls a unary method and resolves with its success value. Fails after `timeoutMs`. */
|
||||
call<T = unknown>(tag: string, payload: unknown, timeoutMs = CALL_TIMEOUT_MS): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a stream. `onChunk` runs once per value, in order. The promise settles when the stream
|
||||
* ends or fails. Call `cancel` to interrupt it without closing the socket.
|
||||
*/
|
||||
stream(tag: string, payload: unknown, onChunk: (value: unknown) => void) {
|
||||
let id: string | null = null;
|
||||
const done = new Promise<void>((resolve, reject) => {
|
||||
id = this.request(tag, payload, { onChunk, resolve: () => resolve(), reject });
|
||||
if (id === null) reject(new RpcError("socket is not open"));
|
||||
});
|
||||
return {
|
||||
done,
|
||||
cancel: () => {
|
||||
if (id !== null) this.send({ _tag: "Interrupt", requestId: id });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
close() {
|
||||
clearInterval(this.timer);
|
||||
this.ws.close();
|
||||
}
|
||||
|
||||
private request(tag: string, payload: unknown, pending: Pending): string | null {
|
||||
if (this.ws.readyState !== WebSocket.OPEN) return null;
|
||||
const id = String(this.nextId++);
|
||||
this.pending.set(id, pending);
|
||||
this.send({ _tag: "Request", id, tag, payload, headers: [] });
|
||||
return id;
|
||||
}
|
||||
|
||||
private send(frame: object) {
|
||||
if (this.ws.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(frame));
|
||||
}
|
||||
|
||||
private onMessage(data: unknown) {
|
||||
this.lastFrameAt = Date.now();
|
||||
if (typeof data !== "string") return;
|
||||
const parsed: unknown = JSON.parse(data);
|
||||
const frames = (Array.isArray(parsed) ? parsed : [parsed]) as FromServer[];
|
||||
for (const frame of frames) this.onFrame(frame);
|
||||
}
|
||||
|
||||
private onFrame(frame: FromServer) {
|
||||
switch (frame._tag) {
|
||||
case "Chunk": {
|
||||
const requestId = String(frame.requestId);
|
||||
const pending = this.pending.get(requestId);
|
||||
if (!pending) return;
|
||||
for (const value of frame.values) pending.onChunk?.(value);
|
||||
this.send({ _tag: "Ack", requestId: frame.requestId });
|
||||
return;
|
||||
}
|
||||
case "Exit": {
|
||||
const requestId = String(frame.requestId);
|
||||
const pending = this.pending.get(requestId);
|
||||
if (!pending) return;
|
||||
this.pending.delete(requestId);
|
||||
if (frame.exit._tag === "Success") pending.resolve(frame.exit.value);
|
||||
else pending.reject(failureToError(frame.exit.cause));
|
||||
return;
|
||||
}
|
||||
case "Defect":
|
||||
this.failAll(`server defect: ${JSON.stringify(frame.defect)}`);
|
||||
this.close();
|
||||
return;
|
||||
case "ClientProtocolError":
|
||||
this.failAll(`protocol error: ${JSON.stringify(frame.error)}`);
|
||||
this.close();
|
||||
return;
|
||||
case "Pong":
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private failAll(message: string) {
|
||||
for (const pending of this.pending.values()) pending.reject(new RpcError(message));
|
||||
this.pending.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function failureToError(cause: Extract<Exit, { _tag: "Failure" }>["cause"]): RpcError {
|
||||
const first = cause[0];
|
||||
if (first?.error !== undefined) {
|
||||
const error = first.error as { _tag?: string; message?: string };
|
||||
return new RpcError(error.message ?? error._tag ?? "call failed", first.error);
|
||||
}
|
||||
if (first?._tag === "Interrupt") return new RpcError("interrupted");
|
||||
return new RpcError(`call failed: ${JSON.stringify(first?.defect ?? cause)}`);
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
// Narrow types for the parts of T3 Code's orchestration protocol (v2) the gateway reads and writes.
|
||||
// They cover only the fields we use. Unknown fields pass through untouched, and every enum leaves
|
||||
// room for values a newer server may add. Source of truth: docs/t3-protocol.md.
|
||||
|
||||
/** A string union that still accepts values a newer server adds. */
|
||||
type Open<T extends string> = T | (string & {});
|
||||
|
||||
export type RunStatus = Open<
|
||||
| "idle"
|
||||
| "preparing"
|
||||
| "queued"
|
||||
| "starting"
|
||||
| "running"
|
||||
| "waiting"
|
||||
| "completed"
|
||||
| "interrupted"
|
||||
| "failed"
|
||||
| "cancelled"
|
||||
| "rolled_back"
|
||||
>;
|
||||
|
||||
export type RuntimeRequestKind = Open<
|
||||
| "command"
|
||||
| "file-read"
|
||||
| "file-change"
|
||||
| "mcp-elicitation"
|
||||
| "permission"
|
||||
| "user_input"
|
||||
| "dynamic_tool_call"
|
||||
| "auth_refresh"
|
||||
>;
|
||||
|
||||
export type ApprovalDecision = "accept" | "acceptForSession" | "acceptAlways" | "decline" | "cancel";
|
||||
|
||||
export interface ModelSelection {
|
||||
instanceId: string;
|
||||
model: string;
|
||||
options?: unknown;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
title: string;
|
||||
workspaceRoot: string;
|
||||
defaultModelSelection: ModelSelection | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ThreadShell {
|
||||
id: string;
|
||||
projectId: string;
|
||||
title: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
archivedAt?: string | null;
|
||||
deletedAt?: string | null;
|
||||
status: RunStatus;
|
||||
activityRunStatus?: RunStatus | null;
|
||||
activeRunId: string | null;
|
||||
latestRunStopRequested?: boolean;
|
||||
pendingRuntimeRequest: {
|
||||
id: string;
|
||||
kind: RuntimeRequestKind;
|
||||
createdAt: string;
|
||||
blocking?: boolean;
|
||||
} | null;
|
||||
pendingBackgroundTasks?: Array<{ kind: Open<"command" | "monitor" | "subagent" | "background_task">; awaited?: boolean }>;
|
||||
lastError?: string | null;
|
||||
modelSelection: ModelSelection;
|
||||
runtimeMode?: string;
|
||||
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;
|
||||
/** 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 {
|
||||
snapshotSequence: number;
|
||||
projects: Project[];
|
||||
threads: ThreadShell[];
|
||||
}
|
||||
|
||||
export type ShellStreamItem =
|
||||
| { kind: "snapshot"; snapshot: ShellSnapshot }
|
||||
| { kind: "project.updated"; sequence: number; project: Project }
|
||||
| { kind: "project.removed"; sequence: number; projectId: string }
|
||||
| { kind: "thread.updated"; sequence: number; location: Open<"active" | "archive">; thread: ThreadShell }
|
||||
| { kind: "thread.removed"; sequence: number; location: Open<"active" | "archive">; threadId: string }
|
||||
| { kind: "synchronized" };
|
||||
|
||||
export interface ConversationMessage {
|
||||
id: string;
|
||||
role: Open<"user" | "assistant" | "system">;
|
||||
text: string;
|
||||
streaming?: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ApprovalOption {
|
||||
decision: ApprovalDecision;
|
||||
label: string;
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
export interface UserInputQuestion {
|
||||
id: string;
|
||||
header: string;
|
||||
question: string;
|
||||
options: Array<{ label: string; description: string; value?: string }>;
|
||||
allowCustomAnswer?: boolean;
|
||||
multiSelect?: boolean;
|
||||
}
|
||||
|
||||
/** Any turn item. Narrow with the guards below to read the two types the gateway uses. */
|
||||
export interface TurnItem {
|
||||
type: string;
|
||||
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 {
|
||||
type: "approval_request";
|
||||
requestId: string;
|
||||
requestKind: RuntimeRequestKind;
|
||||
prompt?: string;
|
||||
reason?: string;
|
||||
options?: ApprovalOption[];
|
||||
}
|
||||
|
||||
export interface UserInputRequestItem extends TurnItem {
|
||||
type: "user_input_request";
|
||||
requestId: string;
|
||||
questions: UserInputQuestion[];
|
||||
}
|
||||
|
||||
export const isApprovalRequest = (item: TurnItem): item is ApprovalRequestItem =>
|
||||
item.type === "approval_request";
|
||||
|
||||
export const isUserInputRequest = (item: TurnItem): item is UserInputRequestItem =>
|
||||
item.type === "user_input_request";
|
||||
|
||||
export interface BoundedThread {
|
||||
snapshotSequence: number;
|
||||
/** Where `GET /history` continues from, going back. */
|
||||
historyCursor?: string | null;
|
||||
hasMoreHistory?: boolean;
|
||||
projection: {
|
||||
thread: ThreadShell;
|
||||
messages: ConversationMessage[];
|
||||
turnItems: TurnItem[];
|
||||
};
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
environmentId: string;
|
||||
label: string;
|
||||
serverVersion: string;
|
||||
orchestrationProtocolVersion?: number;
|
||||
}
|
||||
|
||||
export interface ServerConfig {
|
||||
environment?: { capabilities?: Record<string, unknown> };
|
||||
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`. */
|
||||
export type Command =
|
||||
| {
|
||||
type: "message.dispatch";
|
||||
commandId: string;
|
||||
threadId: string;
|
||||
messageId: string;
|
||||
text: string;
|
||||
attachments: [];
|
||||
createdBy: "user";
|
||||
creationSource: "mobile";
|
||||
dispatchMode: { type: "queue_after_active" };
|
||||
}
|
||||
| {
|
||||
type: "runtime-request.respond";
|
||||
commandId: string;
|
||||
threadId: string;
|
||||
requestId: string;
|
||||
decision?: ApprovalDecision;
|
||||
answers?: Record<string, unknown>;
|
||||
}
|
||||
| { 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 };
|
||||
|
||||
export interface LaunchThreadInput {
|
||||
commandId: string;
|
||||
creationSource: "mobile";
|
||||
projectId: string;
|
||||
title: string;
|
||||
generateTitle: boolean;
|
||||
modelSelection: ModelSelection;
|
||||
runtimeMode: string;
|
||||
interactionMode: "default";
|
||||
workspaceStrategy: { type: "root" };
|
||||
initialMessage: { messageId: string; text: string; attachments: [] };
|
||||
}
|
||||
|
||||
/** The protocol version this gateway speaks. A server on another version is refused. */
|
||||
export const ORCHESTRATION_PROTOCOL_VERSION = 2;
|
||||
@@ -0,0 +1,702 @@
|
||||
// Turns T3 state into the shapes in contract.ts. Thread shells carry status but not text, so the
|
||||
// last message and the pending request's details come from each thread's bounded snapshot. Those
|
||||
// are large (hundreds of KB), so they are reduced to a small summary and cached per thread until
|
||||
// the thread's `updatedAt` moves.
|
||||
|
||||
import { isSnoozed, resolvePhase, threadPhase, type Phase } from "./awareness.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 {
|
||||
isApprovalRequest,
|
||||
isUserInputRequest,
|
||||
type BoundedThread,
|
||||
type ConversationMessage,
|
||||
type ThreadShell,
|
||||
type TurnItem,
|
||||
} from "./t3/types.ts";
|
||||
|
||||
const LAST_MESSAGE_CHARS = 280;
|
||||
const MESSAGE_CHARS = 2000;
|
||||
const MESSAGES_PER_THREAD = 20;
|
||||
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;
|
||||
/** How long a feed request waits for summaries it does not have yet. */
|
||||
const SUMMARY_WAIT_MS = 2_000;
|
||||
|
||||
export interface ThreadSummary {
|
||||
messages: Message[];
|
||||
lastAssistantMessage: string | 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`. */
|
||||
export function trimText(text: string, max: number): string {
|
||||
const flat = text.replace(/\s+/g, " ").trim();
|
||||
if (flat.length <= max) return flat;
|
||||
const cut = flat.slice(0, max);
|
||||
const space = cut.lastIndexOf(" ");
|
||||
return `${(space > max * 0.6 ? cut.slice(0, space) : cut).trimEnd()}…`;
|
||||
}
|
||||
|
||||
/** Reduces a bounded thread snapshot to what the watch shows. */
|
||||
export function summarize(bounded: BoundedThread, thread: ThreadShell): ThreadSummary {
|
||||
const { messages, turnItems } = bounded.projection;
|
||||
const visible = visibleMessages(messages);
|
||||
const lastAssistant = visible.findLast((message) => message.role === "assistant");
|
||||
const start = Math.max(0, visible.length - MESSAGES_PER_THREAD);
|
||||
return {
|
||||
messages: visible.slice(start).map(toMessage),
|
||||
lastAssistantMessage: lastAssistant ? trimText(lastAssistant.text, LAST_MESSAGE_CHARS) : null,
|
||||
request: pendingRequest(thread, turnItems),
|
||||
activity: currentActivity(turnItems),
|
||||
changedFiles: latestChanges(turnItems),
|
||||
earlierCursor: windowCursor(start, visible, bounded),
|
||||
};
|
||||
}
|
||||
|
||||
const visibleMessages = (messages: ConversationMessage[]) =>
|
||||
messages.filter((message) => message.text.trim().length > 0 && (message.role === "user" || message.role === "assistant"));
|
||||
|
||||
const toMessage = (message: Pick<ConversationMessage, "id" | "role" | "text" | "createdAt">): Message => ({
|
||||
id: message.id,
|
||||
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 {
|
||||
const pending = thread.pendingRuntimeRequest;
|
||||
if (!pending || pending.kind === "auth_refresh") return null;
|
||||
const base = { id: pending.id, kind: pending.kind, prompt: null, reason: null, options: [], files: [], questions: [] };
|
||||
const item = turnItems.findLast(
|
||||
(candidate) => (isApprovalRequest(candidate) || isUserInputRequest(candidate)) && candidate.requestId === pending.id,
|
||||
);
|
||||
if (item && isApprovalRequest(item)) {
|
||||
const options = item.options?.length
|
||||
? item.options.map(({ decision, label, warning }) => ({ decision, label, ...(warning ? { warning } : {}) }))
|
||||
: [
|
||||
{ decision: "accept" as const, label: "Allow" },
|
||||
{ decision: "decline" as const, label: "Deny" },
|
||||
];
|
||||
const prompt = item.prompt ?? item.title ?? null;
|
||||
const files = pending.kind === "file-change" ? pendingFiles(turnItems, item.runId, prompt) : [];
|
||||
return { ...base, prompt, reason: item.reason ?? null, options, files };
|
||||
}
|
||||
if (item && isUserInputRequest(item)) {
|
||||
return {
|
||||
...base,
|
||||
questions: item.questions.map((question) => ({
|
||||
id: question.id,
|
||||
header: question.header,
|
||||
question: question.question,
|
||||
options: question.options.map((option) => ({ label: option.label, value: option.value ?? option.label })),
|
||||
allowCustomAnswer: question.allowCustomAnswer ?? false,
|
||||
multiSelect: question.multiSelect ?? false,
|
||||
})),
|
||||
};
|
||||
}
|
||||
// The turn item may not be in the bounded window yet. Offer the plain choices for approvals.
|
||||
return pending.kind === "user_input"
|
||||
? base
|
||||
: {
|
||||
...base,
|
||||
options: [
|
||||
{ decision: "accept", label: "Allow" },
|
||||
{ decision: "decline", label: "Deny" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const PREVIEW_LINES = 4;
|
||||
const PREVIEW_CHARS = 60;
|
||||
|
||||
/**
|
||||
* The files a file-change approval would touch: the run's unfinished file changes, or failing
|
||||
* those, the path in the prompt, which Claude words as `Edit: <path>`. Most providers send the
|
||||
* diff only after the edit runs, so counts and preview are usually empty.
|
||||
*/
|
||||
export function pendingFiles(turnItems: TurnItem[], runId: string | null | undefined, prompt: string | null): PendingFile[] {
|
||||
const files = turnItems
|
||||
.filter(
|
||||
(item) =>
|
||||
item.type === "file_change" &&
|
||||
item.fileName &&
|
||||
(!runId || item.runId === runId) &&
|
||||
item.status !== "completed" &&
|
||||
item.status !== "failed" &&
|
||||
item.status !== "cancelled",
|
||||
)
|
||||
.map((item) => {
|
||||
const preview = (item.diffStr ?? "")
|
||||
.split("\n")
|
||||
.filter((line) => /^[+-]/.test(line) && !/^(\+\+\+|---)( |$)/.test(line))
|
||||
.slice(0, PREVIEW_LINES)
|
||||
.map((line) => (line.length > PREVIEW_CHARS ? `${line.slice(0, PREVIEW_CHARS)}…` : line));
|
||||
return {
|
||||
path: item.fileName!,
|
||||
name: item.fileName!.split("/").pop()!,
|
||||
additions: item.additions ?? null,
|
||||
deletions: item.deletions ?? null,
|
||||
preview,
|
||||
};
|
||||
});
|
||||
if (files.length > 0) return files;
|
||||
const path = prompt?.match(/^\w+: (\S.*)$/)?.[1]?.trim();
|
||||
return path ? [{ path, name: path.split("/").pop()!, additions: null, deletions: null, preview: [] }] : [];
|
||||
}
|
||||
|
||||
/** Per-thread summaries, refetched when a thread's `updatedAt` changes. */
|
||||
export class SummaryCache {
|
||||
private readonly entries = new Map<string, { updatedAt: string; fetchedAt: number; summary: ThreadSummary }>();
|
||||
private readonly inflight = new Map<string, Promise<ThreadSummary>>();
|
||||
|
||||
constructor(private readonly log: (message: string) => void = () => {}) {}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const entry = this.entries.get(key(host, thread.id));
|
||||
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> {
|
||||
if (this.fresh(host, thread)) return Promise.resolve(this.entries.get(key(host, thread.id))!.summary);
|
||||
const k = `${key(host, thread.id)}@${thread.updatedAt}`;
|
||||
const running = this.inflight.get(k);
|
||||
if (running) return running;
|
||||
const promise = host
|
||||
.bounded(thread.id)
|
||||
.then((bounded) => {
|
||||
const summary = summarize(bounded, thread);
|
||||
this.entries.set(key(host, thread.id), { updatedAt: thread.updatedAt, fetchedAt: Date.now(), summary });
|
||||
return summary;
|
||||
})
|
||||
.finally(() => this.inflight.delete(k));
|
||||
this.inflight.set(k, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
forget(host: T3Host, threadId: string) {
|
||||
this.entries.delete(key(host, threadId));
|
||||
}
|
||||
}
|
||||
|
||||
const key = (host: T3Host, threadId: string) => `${host.id}:${threadId}`;
|
||||
|
||||
export const hostSummary = (host: T3Host): HostSummary => ({
|
||||
id: host.id,
|
||||
label: host.label,
|
||||
status: host.status,
|
||||
error: host.lastError,
|
||||
});
|
||||
|
||||
/** 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 {
|
||||
hostId: host.id,
|
||||
hostLabel: host.label,
|
||||
threadId: thread.id,
|
||||
projectId: thread.projectId,
|
||||
projectTitle: host.projects.get(thread.projectId)?.title ?? "",
|
||||
title: thread.title,
|
||||
phase,
|
||||
updatedAt: thread.updatedAt,
|
||||
lastMessage: summary?.lastAssistantMessage ?? null,
|
||||
activity: summary?.activity ?? null,
|
||||
snoozedUntil: isSnoozed(thread, Date.now()) ? thread.snoozedUntil! : null,
|
||||
changes: summary?.changedFiles.length
|
||||
? {
|
||||
files: summary.changedFiles.length,
|
||||
additions: summary.changedFiles.reduce((sum, file) => sum + file.additions, 0),
|
||||
deletions: summary.changedFiles.reduce((sum, file) => sum + file.deletions, 0),
|
||||
}
|
||||
: null,
|
||||
request: via ? currentRequest(via.thread, via.summary) : currentRequest(thread, summary),
|
||||
via: via ? { hostId: via.host.id, threadId: via.thread.id, title: via.thread.title } : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** The longest waiting subagent that `parent` delegated to, if any. */
|
||||
function waitingDelegate(hosts: T3Host[], parentHost: T3Host, parent: ThreadShell): Candidate | undefined {
|
||||
let oldest: Candidate | undefined;
|
||||
for (const host of hosts) {
|
||||
for (const thread of host.threads.values()) {
|
||||
const from = thread.delegatedFrom;
|
||||
if (from?.threadId !== parent.id || from.environmentId !== parentHost.config.environmentId) continue;
|
||||
const phase = threadPhase(thread);
|
||||
if (!isWaiting(phase) || thread.archivedAt || thread.deletedAt) continue;
|
||||
if (!oldest || requestAge(thread) < requestAge(oldest.thread)) oldest = { host, thread, phase: phase! };
|
||||
}
|
||||
}
|
||||
return oldest;
|
||||
}
|
||||
|
||||
/** Counts for the complication, from thread shells alone. */
|
||||
/**
|
||||
* Counts for the complication, 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 thread of host.threads.values()) {
|
||||
const phase = threadPhase(thread);
|
||||
if (phase === null || thread.archivedAt || thread.deletedAt) continue;
|
||||
found.push({ host, thread, phase });
|
||||
}
|
||||
}
|
||||
const candidates = attachDelegates(found, hosts);
|
||||
|
||||
const byNewest = (a: { thread: ThreadShell }, b: { thread: ThreadShell }) =>
|
||||
b.thread.updatedAt.localeCompare(a.thread.updatedAt);
|
||||
const asker = (candidate: Candidate) => candidate.via?.thread ?? candidate.thread;
|
||||
const finished = candidates.filter(({ phase }) => phase === "completed" || phase === "failed");
|
||||
return {
|
||||
needsYou: candidates
|
||||
.filter(({ phase }) => isWaiting(phase))
|
||||
.sort((a, b) => requestAge(asker(a)).localeCompare(requestAge(asker(b)))),
|
||||
working: candidates.filter(({ phase }) => phase === "starting" || phase === "running").sort(byNewest),
|
||||
/** Finished and failed threads not yet settled or snoozed, however old. */
|
||||
recent: finished.filter(({ thread }) => !isSnoozed(thread, now)).sort(byNewest),
|
||||
/** Finished threads snoozed for later, the soonest to come back first. */
|
||||
snoozed: finished
|
||||
.filter(({ thread }) => isSnoozed(thread, now))
|
||||
.sort((a, b) => a.thread.snoozedUntil!.localeCompare(b.thread.snoozedUntil!)),
|
||||
};
|
||||
}
|
||||
|
||||
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
|
||||
.filter(({ host, thread }) => !cache.fresh(host, thread))
|
||||
.map(({ host, thread }) => cache.get(host, thread).catch(() => undefined));
|
||||
if (missing.length > 0) await Promise.race([Promise.all(missing), Bun.sleep(SUMMARY_WAIT_MS)]);
|
||||
|
||||
return {
|
||||
needsYou: needsYou.map(toItem(cache)),
|
||||
working: working.map(toItem(cache)),
|
||||
recent: recent.map(toItem(cache)),
|
||||
recentTotal: allRecent.length,
|
||||
snoozedTotal: snoozed.length,
|
||||
hosts: hosts.map(hostSummary),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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 {
|
||||
item: feedItem(host, thread, child?.phase ?? own, summary, via),
|
||||
messages: summary.messages,
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// 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 { isSnoozed, threadPhase } from "../src/awareness.ts";
|
||||
import type { ThreadShell } from "../src/t3/types.ts";
|
||||
|
||||
const thread = (overrides: Partial<ThreadShell> = {}): ThreadShell => ({
|
||||
id: "thread-2",
|
||||
projectId: "project-1",
|
||||
title: "Integrate orchestration",
|
||||
createdAt: "2026-05-22T12:00:00.000Z",
|
||||
updatedAt: "2026-05-22T12:00:00.000Z",
|
||||
status: "running",
|
||||
activeRunId: "run-1",
|
||||
pendingRuntimeRequest: null,
|
||||
modelSelection: { instanceId: "codex", model: "gpt-5.4" },
|
||||
lineage: { relationshipToParent: null },
|
||||
settledOverride: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const request = (kind: string, blocking?: boolean): ThreadShell["pendingRuntimeRequest"] => ({
|
||||
id: `request-${kind}`,
|
||||
kind,
|
||||
createdAt: "2026-05-22T12:00:00.000Z",
|
||||
...(blocking === undefined ? {} : { blocking }),
|
||||
});
|
||||
|
||||
describe("threadPhase", () => {
|
||||
it("does not announce completion after a manual stop", () => {
|
||||
expect(threadPhase(thread({ status: "completed" }))).toBe("completed");
|
||||
expect(threadPhase(thread({ status: "completed", latestRunStopRequested: true }))).toBeNull();
|
||||
});
|
||||
|
||||
it.each(["running", "completed", "failed"])("hides %s subagent activity", (status) => {
|
||||
expect(threadPhase(thread({ status, lineage: { relationshipToParent: "subagent" } }))).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps an older activity run visible over a newer cancelled run", () => {
|
||||
expect(threadPhase(thread({ status: "cancelled", activityRunStatus: "running" }))).toBe("running");
|
||||
});
|
||||
|
||||
it.each(["completed", "failed"])("hides settled %s work", (status) => {
|
||||
expect(threadPhase(thread({ status, settledOverride: "settled" }))).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["starting", "starting"],
|
||||
["running", "running"],
|
||||
["waiting", "running"],
|
||||
] as const)("keeps settled %s work visible", (status, phase) => {
|
||||
expect(threadPhase(thread({ status, settledOverride: "settled" }))).toBe(phase);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["user_input", "waiting_for_input"],
|
||||
["command", "waiting_for_approval"],
|
||||
] as const)("keeps a settled terminal thread with a pending %s request", (kind, phase) => {
|
||||
expect(
|
||||
threadPhase(thread({ status: "completed", settledOverride: "settled", pendingRuntimeRequest: request(kind) })),
|
||||
).toBe(phase);
|
||||
});
|
||||
|
||||
it("keeps settled work held open by an awaited monitor", () => {
|
||||
expect(
|
||||
threadPhase(
|
||||
thread({
|
||||
status: "completed",
|
||||
settledOverride: "settled",
|
||||
pendingBackgroundTasks: [{ kind: "monitor", awaited: true }],
|
||||
}),
|
||||
),
|
||||
).toBe("running");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["only a dev server", "completed", [{ kind: "command" }]],
|
||||
["a dev server's watchdog monitor", "completed", [{ kind: "monitor" }]],
|
||||
["a command the agent waits on", "running", [{ kind: "command", awaited: true }]],
|
||||
["a dev server and a subagent", "running", [{ kind: "command" }, { kind: "subagent" }]],
|
||||
] as const)("reports a completed run waiting on %s as %s", (_case, phase, tasks) => {
|
||||
expect(threadPhase(thread({ status: "completed", pendingBackgroundTasks: [...tasks] }))).toBe(phase);
|
||||
});
|
||||
|
||||
it("reports an async question as input only after the working run completes", () => {
|
||||
const asking = thread({ pendingRuntimeRequest: request("user_input", false) });
|
||||
expect(threadPhase(asking)).toBe("running");
|
||||
expect(threadPhase({ ...asking, status: "completed" })).toBe("waiting_for_input");
|
||||
});
|
||||
|
||||
it("does not present authentication refreshes as approvals", () => {
|
||||
expect(threadPhase(thread({ pendingRuntimeRequest: request("auth_refresh") }))).toBe("running");
|
||||
});
|
||||
|
||||
it("hides threads that never ran", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
import { approvalBody, Notifier, type Push } from "../src/notify.ts";
|
||||
import type { T3Host } from "../src/t3/host.ts";
|
||||
import type { ThreadShell } from "../src/t3/types.ts";
|
||||
import { SummaryCache } from "../src/views.ts";
|
||||
|
||||
const host = {
|
||||
id: "host-1",
|
||||
label: "Mac mini",
|
||||
projects: new Map([["project-1", { id: "project-1", title: "wristcode" }]]),
|
||||
bounded: () => Promise.reject(new Error("offline")),
|
||||
} as unknown as T3Host;
|
||||
|
||||
const shell = (overrides: Partial<ThreadShell> = {}): ThreadShell => ({
|
||||
id: "thread-1",
|
||||
projectId: "project-1",
|
||||
title: "Watch app scaffold",
|
||||
createdAt: "2026-11-01T09:00:00.000Z",
|
||||
updatedAt: "2026-11-01T09:00:00.000Z",
|
||||
status: "running",
|
||||
activeRunId: "run-1",
|
||||
pendingRuntimeRequest: null,
|
||||
modelSelection: { instanceId: "claudeAgent", model: "claude-opus-5-5" },
|
||||
lineage: { relationshipToParent: null },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const request = (id: string) => ({ id, kind: "command", createdAt: "2026-11-01T09:00:00.000Z" });
|
||||
|
||||
function setup() {
|
||||
const pushes: Push[] = [];
|
||||
const notifier = new Notifier(async (push) => void pushes.push(push), new SummaryCache());
|
||||
let previous: ThreadShell | undefined;
|
||||
const change = (next: ThreadShell | undefined, initial = false) => {
|
||||
notifier.onThreadChange({ host, previous, next, initial });
|
||||
previous = next;
|
||||
};
|
||||
// Pushes are sent after a summary lookup, which fails fast here.
|
||||
const settle = () => Bun.sleep(5);
|
||||
return { pushes, change, settle, notifier };
|
||||
}
|
||||
|
||||
describe("Notifier", () => {
|
||||
it("stays quiet while loading the first snapshot", async () => {
|
||||
const { pushes, change, settle } = setup();
|
||||
change(shell({ status: "completed" }), true);
|
||||
change(shell({ status: "completed" }));
|
||||
await settle();
|
||||
expect(pushes).toEqual([]);
|
||||
});
|
||||
|
||||
it("notifies once when a running thread finishes", async () => {
|
||||
const { pushes, change, settle } = setup();
|
||||
change(shell(), true);
|
||||
change(shell({ status: "completed" }));
|
||||
change(shell({ status: "completed", updatedAt: "2026-11-01T09:01:00.000Z" }));
|
||||
await settle();
|
||||
expect(pushes.map((push) => [push.category, push.subtitle, push.data.threadId])).toEqual([
|
||||
["finished", "Mac mini · wristcode", "thread-1"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("notifies for each new approval request, with its request id", async () => {
|
||||
const { pushes, change, settle } = setup();
|
||||
change(shell(), true);
|
||||
change(shell({ pendingRuntimeRequest: request("r1") }));
|
||||
change(shell({ pendingRuntimeRequest: request("r2") }));
|
||||
await settle();
|
||||
expect(pushes.map((push) => [push.category, push.data.requestId])).toEqual([
|
||||
["approval", "r1"],
|
||||
["approval", "r2"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not announce a turn the user stopped", async () => {
|
||||
const { pushes, change, settle } = setup();
|
||||
change(shell(), true);
|
||||
change(shell({ status: "completed", latestRunStopRequested: true }));
|
||||
await settle();
|
||||
expect(pushes).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports failures with the server's error", async () => {
|
||||
const { pushes, change, settle } = setup();
|
||||
change(shell(), true);
|
||||
change(shell({ status: "failed", lastError: "Provider quota exceeded" }));
|
||||
await settle();
|
||||
expect(pushes.map((push) => [push.category, push.body])).toEqual([["failed", "Provider quota exceeded"]]);
|
||||
});
|
||||
|
||||
it("warns about a token that expires within a week, once a day", async () => {
|
||||
const { pushes, notifier } = setup();
|
||||
const row = {
|
||||
id: "host-1",
|
||||
label: "Mac mini",
|
||||
url: "https://t3-mac.example.com",
|
||||
alt_urls: null,
|
||||
environment_id: "host-1",
|
||||
token: "t",
|
||||
token_expires_at: new Date(Date.now() + 3 * 24 * 60 * 60_000).toISOString(),
|
||||
created_at: "",
|
||||
};
|
||||
notifier.checkTokenExpiry([row]);
|
||||
notifier.checkTokenExpiry([row]);
|
||||
expect(pushes.map((push) => [push.category, push.body])).toEqual([
|
||||
["host", "The gateway's T3 token expires in 3 days. Pair Mac mini again."],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
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" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
// The RPC client against an in-process server that frames messages the way Effect RPC does.
|
||||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
import type { Server, ServerWebSocket } from "bun";
|
||||
|
||||
import { RpcError, RpcSocket } from "../src/t3/rpc.ts";
|
||||
|
||||
type Frame = { _tag: string; id?: string; requestId?: string; tag?: string; payload?: unknown };
|
||||
|
||||
let server: Server<undefined> | undefined;
|
||||
afterEach(() => server?.stop(true));
|
||||
|
||||
/** Starts a server whose `onRequest` decides how to answer each Request frame. */
|
||||
function serve(onRequest: (ws: ServerWebSocket<undefined>, frame: Frame, received: Frame[]) => void) {
|
||||
const received: Frame[] = [];
|
||||
server = Bun.serve({
|
||||
port: 0,
|
||||
fetch: (req, srv) => (srv.upgrade(req) ? undefined : new Response("no", { status: 400 })),
|
||||
websocket: {
|
||||
message(ws, data) {
|
||||
const frame = JSON.parse(String(data)) as Frame;
|
||||
received.push(frame);
|
||||
if (frame._tag === "Request") onRequest(ws, frame, received);
|
||||
},
|
||||
},
|
||||
});
|
||||
return { url: `ws://localhost:${server.port}`, received };
|
||||
}
|
||||
|
||||
const send = (ws: ServerWebSocket<undefined>, frame: object) => ws.send(JSON.stringify(frame));
|
||||
|
||||
describe("RpcSocket", () => {
|
||||
it("resolves a unary call with its success value", async () => {
|
||||
const { url } = serve((ws, frame) =>
|
||||
send(ws, { _tag: "Exit", requestId: frame.id, exit: { _tag: "Success", value: { echoed: frame.payload } } }),
|
||||
);
|
||||
const socket = await RpcSocket.connect(url);
|
||||
expect(await socket.call("server.probe", { a: 1 })).toEqual({ echoed: { a: 1 } });
|
||||
socket.close();
|
||||
});
|
||||
|
||||
it("rejects with the server's typed error", async () => {
|
||||
const error = { _tag: "EnvironmentAuthorizationError", message: "missing scope", requiredScope: "orchestration:operate" };
|
||||
const { url } = serve((ws, frame) =>
|
||||
send(ws, { _tag: "Exit", requestId: frame.id, exit: { _tag: "Failure", cause: [{ _tag: "Fail", error }] } }),
|
||||
);
|
||||
const socket = await RpcSocket.connect(url);
|
||||
const failure = await socket.call("orchestration.dispatchCommand", {}).catch((e: unknown) => e);
|
||||
expect(failure).toBeInstanceOf(RpcError);
|
||||
expect((failure as RpcError).error).toEqual(error);
|
||||
socket.close();
|
||||
});
|
||||
|
||||
it("acks every chunk so the server sends the next one", async () => {
|
||||
const { url, received } = serve((ws, frame) => {
|
||||
// Like Effect RPC, send one chunk, then wait for its Ack before the next.
|
||||
const values = [[1, 2], [3]];
|
||||
const next = () => {
|
||||
const value = values.shift();
|
||||
if (value) send(ws, { _tag: "Chunk", requestId: frame.id, values: value });
|
||||
else send(ws, { _tag: "Exit", requestId: frame.id, exit: { _tag: "Success", value: null } });
|
||||
};
|
||||
const waitForAck = setInterval(() => {
|
||||
const acks = received.filter((f) => f._tag === "Ack").length;
|
||||
if (acks === 2 - values.length) next();
|
||||
if (values.length === 0 && acks === 2) clearInterval(waitForAck);
|
||||
}, 2);
|
||||
next();
|
||||
});
|
||||
const socket = await RpcSocket.connect(url);
|
||||
const seen: unknown[] = [];
|
||||
await socket.stream("orchestration.subscribeShell", {}, (value) => seen.push(value)).done;
|
||||
expect(seen).toEqual([1, 2, 3]);
|
||||
expect(received.filter((f) => f._tag === "Ack")).toHaveLength(2);
|
||||
socket.close();
|
||||
});
|
||||
|
||||
it("fails pending calls when the socket closes", async () => {
|
||||
const { url } = serve((ws) => ws.close());
|
||||
const socket = await RpcSocket.connect(url);
|
||||
expect(socket.call("server.probe", {})).rejects.toThrow("socket closed");
|
||||
await socket.closed;
|
||||
});
|
||||
|
||||
it("closes a silent socket", async () => {
|
||||
// This server never answers Pings.
|
||||
const { url } = serve(() => {});
|
||||
const socket = await RpcSocket.connect(url, { pingIntervalMs: 10, silenceTimeoutMs: 40 });
|
||||
await socket.closed;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,399 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
import type { BoundedThread, ThreadShell } from "../src/t3/types.ts";
|
||||
import type { T3Host } from "../src/t3/host.ts";
|
||||
import { agentProviders, buildFeed, currentActivity, feedCandidates, latestChanges, pendingFiles, searchThreads, loadHistory, projectThreads, buildSummary, routeKind, summarize, SummaryCache, trimText } from "../src/views.ts";
|
||||
|
||||
const thread = (overrides: Partial<ThreadShell> = {}): ThreadShell => ({
|
||||
id: "thread-1",
|
||||
projectId: "project-1",
|
||||
title: "Receipt OCR",
|
||||
createdAt: "2026-11-01T09:00:00.000Z",
|
||||
updatedAt: "2026-11-01T09:00:00.000Z",
|
||||
status: "running",
|
||||
activeRunId: "run-1",
|
||||
pendingRuntimeRequest: null,
|
||||
modelSelection: { instanceId: "claudeAgent", model: "claude-opus-5-5" },
|
||||
lineage: { relationshipToParent: null },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const message = (role: string, text: string, id = text) => ({
|
||||
id,
|
||||
role,
|
||||
text,
|
||||
createdAt: "2026-11-01T09:00:00.000Z",
|
||||
updatedAt: "2026-11-01T09:00:00.000Z",
|
||||
});
|
||||
|
||||
const bounded = (turnItems: BoundedThread["projection"]["turnItems"], messages = [message("assistant", "Tests written.")]) =>
|
||||
({ snapshotSequence: 1, projection: { thread: thread(), messages, turnItems } }) as BoundedThread;
|
||||
|
||||
describe("trimText", () => {
|
||||
it("collapses whitespace and cuts at a word", () => {
|
||||
expect(trimText("one two\n\nthree", 50)).toBe("one two three");
|
||||
expect(trimText("alpha beta gamma delta", 15)).toBe("alpha beta…");
|
||||
});
|
||||
});
|
||||
|
||||
describe("summarize", () => {
|
||||
it("takes the last assistant message and skips empty or system ones", () => {
|
||||
const summary = summarize(
|
||||
bounded([], [message("user", "Add OCR"), message("assistant", "Done, all green."), message("system", "noise"), message("assistant", " ")]),
|
||||
thread(),
|
||||
);
|
||||
expect(summary.lastAssistantMessage).toBe("Done, all green.");
|
||||
expect(summary.messages.map((m) => m.role)).toEqual(["user", "assistant"]);
|
||||
});
|
||||
|
||||
it("reads the pending approval's prompt and options", () => {
|
||||
const summary = summarize(
|
||||
bounded([
|
||||
{
|
||||
type: "approval_request",
|
||||
requestId: "r1",
|
||||
requestKind: "command",
|
||||
prompt: "bun test --filter ocr",
|
||||
options: [
|
||||
{ decision: "accept", label: "Allow once" },
|
||||
{ decision: "acceptAlways", label: "Always allow", warning: "Applies to every thread" },
|
||||
{ decision: "decline", label: "Deny" },
|
||||
],
|
||||
} as BoundedThread["projection"]["turnItems"][number],
|
||||
]),
|
||||
thread({ pendingRuntimeRequest: { id: "r1", kind: "command", createdAt: "2026-11-01T09:00:00.000Z" } }),
|
||||
);
|
||||
expect(summary.request).toEqual({
|
||||
id: "r1",
|
||||
kind: "command",
|
||||
prompt: "bun test --filter ocr",
|
||||
reason: null,
|
||||
options: [
|
||||
{ decision: "accept", label: "Allow once" },
|
||||
{ decision: "acceptAlways", label: "Always allow", warning: "Applies to every thread" },
|
||||
{ decision: "decline", label: "Deny" },
|
||||
],
|
||||
files: [],
|
||||
questions: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("offers Allow and Deny when the approval's turn item is not loaded yet", () => {
|
||||
const summary = summarize(
|
||||
bounded([]),
|
||||
thread({ pendingRuntimeRequest: { id: "r9", kind: "file-change", createdAt: "2026-11-01T09:00:00.000Z" } }),
|
||||
);
|
||||
expect(summary.request?.options.map((o) => o.decision)).toEqual(["accept", "decline"]);
|
||||
});
|
||||
|
||||
it("reads questions, using the label when an option has no value", () => {
|
||||
const summary = summarize(
|
||||
bounded([
|
||||
{
|
||||
type: "user_input_request",
|
||||
requestId: "q1",
|
||||
questions: [
|
||||
{ id: "db", header: "Database", question: "Which database?", options: [{ label: "SQLite", description: "" }] },
|
||||
],
|
||||
} as BoundedThread["projection"]["turnItems"][number],
|
||||
]),
|
||||
thread({ pendingRuntimeRequest: { id: "q1", kind: "user_input", createdAt: "2026-11-01T09:00:00.000Z" } }),
|
||||
);
|
||||
expect(summary.request?.questions).toEqual([
|
||||
{ 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,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "Preserve",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ESNext"],
|
||||
"types": ["bun"],
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
Executable
+61
@@ -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"
|
||||
+225
@@ -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 }
|
||||
}
|
||||
Binary file not shown.
|
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) == [])
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
Loaded 100 of 133 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user