Files
wristcode/watch/README.md
T
gib edc4b88990
Gateway / check (push) Successful in 9s
Gateway / image (push) Successful in 22s
Watch: snooze finished threads, name the files a file approval would edit, Settle on finished notifications, an instant launch from the saved feed, plain Loading states, and no more cancelled errors
Gateway: an approval push for a file change says which files it would edit
2026-11-02 14:45:34 -05:00

214 lines
14 KiB
Markdown

# 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.
## 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.
On every refresh the app saves the last good feed as JSON in its caches folder, and it saves each
thread's detail when the thread loads. 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.
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 a message shows once by id. 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 |