214 lines
14 KiB
Markdown
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 |
|