diff --git a/TODO.md b/TODO.md index 8adda0d..b0feef4 100644 --- a/TODO.md +++ b/TODO.md @@ -16,8 +16,6 @@ # M8 — macOS client -- macOS client store and API - Generated client from `api/openapi.yaml`, hand-written SSE client, `Store` and `Feed` ported from `state.ts`, startup sequence, connection screen with 401/403/TLS/unreachable errors and Change Server…, `Palette.swift` generated from `web/style.css`. - macOS client panes and transcript Window and two equal panes, tab strips with More, Lead and task transcripts via `TranscriptBuilder`, composer, drafts, Markdown port, images, native flowchart diagrams, pane-focus reporting, identity banner. - macOS client tabs diff --git a/docs/macos.md b/docs/macos.md index c09e2d0..d8b1cf6 100644 --- a/docs/macos.md +++ b/docs/macos.md @@ -1,6 +1,6 @@ # byre for macOS -This is a design. What exists so far is the spike under `mac/`: a harness window with one tab per row of "Gaps in SwiftUI" (composer, transcript, terminal, font, SVG), not the client. The work to build the client is under "M8 — macOS client" in `TODO.md`. +This is a design. What exists so far under `mac/` is the layer beneath the views: the generated API client, the SSE client, `Store` and `Feed`, the startup sequence, the connection screen with Change Server…, and the generated palette. Once started, the window shows the spike harness from the "Gaps in SwiftUI" checks (composer, transcript, terminal, font, SVG tabs), now driven by `Store`: its task picker lists the active tasks and sets focus, and its transcript tab reads the selected task's `Feed`. The panes replace it. The rest of the work is under "M8 — macOS client" in `TODO.md`. ## Decision @@ -28,8 +28,8 @@ Values the web client defines are generated into Swift, never copied by hand, so | Value | Source | Swift | | --- | --- | --- | -| API types and client | `api/openapi.yaml` | Generated at build time by the `swift-openapi-generator` plugin from a symlink to the spec | -| Palette, light and dark | Custom properties at the top of `web/style.css` (`:root` and its `prefers-color-scheme: dark` block) | `go generate ./mac` writes `Palette.swift`: one dynamic `Color` per property, resolving light or dark with the window's appearance | +| API types and client | `api/openapi.yaml` | Generated at build time by the `swift-openapi-generator` plugin (`mac/Sources/Byre/openapi-generator-config.yaml`, idiomatic naming: `display_name` is `displayName`) from the symlink `mac/Sources/Byre/openapi.yaml` to the spec. The output lands in `mac/.build/plugins/outputs/`, not in git | +| Palette, light and dark | Custom properties at the top of `web/style.css` (`:root` and its `prefers-color-scheme: dark` block) | `go generate ./mac` (`mac/palette`) writes `Palette.swift`: one dynamic `Color` per hex property, `--accent-tint` as `Palette.accentTint`, resolving light or dark with the window's appearance. Properties that aren't a hex colour (`--task`, `--shadow`, `--font`) are left out. The file is committed, so `swift build` needs no Go; a property missing from either block fails the generator | | Font | `web/fonts/` | The build script copies the woff2 files into the bundle; the app loads each with `CTFontManagerCreateFontDescriptorsFromURL` (see "Gaps in SwiftUI") | | App icon | `web/favicon-tile.svg` | The build script renders it to an `.icns` with `rsvg-convert` and `iconutil` | @@ -65,11 +65,17 @@ The Swift files follow the TypeScript ones, so a change in one points at the sam `Store` holds what `state` in `web/src/state.ts` holds (me, projects, tasks by project, sessions by task, inbox, selected task, focus, lead sessions, summary reads, connection) and ports its functions one for one: `handleEvent` with the same `switch` on event kinds, `taskState` with the same precedence and labels, `setFocus` with the optimistic apply and the latest-request-wins guard (`claimRequest`), `loadTasks` creating a feed for each active task, and `dropClosedFeeds`. -`Feed` ports the class of the same name: history paged from `GET /api/tasks/{taskId}/events` 1000 at a time, live events held back until the history is in, duplicates dropped by `seq`, unstored `agent.delta` events passed straight to the streaming view only while the task is selected, and events that arrive together delivered as one batch. +`Feed` ports the class of the same name: history paged from `GET /api/tasks/{taskId}/events` 1000 at a time, live events held back until the history is in, duplicates dropped by `seq`, unstored `agent.delta` events passed straight to the streaming view only while the task is selected, and events that arrive together delivered as one batch. The lead's feed starts from the last 50 turns of `GET /api/me/lead/events` and pages back with `loadOlderLead`. -`EventStream` is a hand-written SSE client over `URLSession.bytes(for:)`. It splits lines itself, because `AsyncBytes.lines` drops the blank lines that end SSE events. It handles `id`, `event: ping`, `event: ready` and `data`, and follows `subscribe` in `api.ts`: it tracks the last seq, reconnects from it after the same stale threshold and retry delay, when the network comes back (`NWPathMonitor`), and when the app becomes active with a stale stream. As in the web client there are two streams, global (after the bootstrap `last_event_seq`) and lead (`?lead=true`, `after=0`), and only the global one drives the connection dot. +The web client's topics become Observation: views read `Store` and `Feed` properties and SwiftUI re-renders them on change. The topics that carry a value are `Listeners` instead, whose `add` returns the function that removes the listener: `Store.events` (every event, the `event` topic), `Feed.appended` (each batch of new stored events, `transcript` and `lead.transcript`) and `Feed.streamed` (each `agent.delta`, `stream` and `lead.stream`). Applying focus selects its task, which is what the web client's hash route does. -Startup follows `main` in `app.ts`: `GET /api/admin/status`, then me, projects, inbox, lead sessions and summary reads in parallel, then drafts, focus, the lead stream, the window, the global stream. A failure before the window shows replaces it with `byre failed to start: `, as the web page does. +API calls go through the generated `Client` over `URLSessionTransport`. A middleware turns any response of 400 or more into `ApiError(status, message)`, the message being the body's `Error.message` for JSON and ` : ` otherwise, as `request` in `api.ts` does; the generated client wraps it in `ClientError`, and `errorText` unwraps it. Dates go through one transcoder, ISO 8601 with optional fractional seconds, which reads Go's RFC 3339 times with any number of fraction digits or none. + +`EventStream` is a hand-written SSE client over `URLSession.bytes(for:)`. It splits lines itself, because `AsyncBytes.lines` drops the blank lines that end SSE events, and parses off the main actor, handing each frame to it. It handles `id`, `event: ping`, `event: ready` and `data`, and follows `subscribe` in `api.ts`: it tracks the last seq, reconnects from it after the same stale threshold (40 s, checked every 5 s) and retry delay (2 s), when the network comes back (`NWPathMonitor`), and when the app becomes active with a stale stream. An event whose data doesn't decode is logged and skipped. As in the web client there are two streams, global (after the bootstrap `last_event_seq`) and lead (`?lead=true`, after the last event of the lead's first page), and only the global one drives the connection dot. + +Startup follows `main` in `app.ts`: `GET /api/admin/status`, then me, projects, inbox, lead sessions and summary reads in parallel, then drafts, focus, the lead stream, the window, the global stream. A failure before the window shows replaces it with `byre failed to start: `, as the web page does; Change Server… leaves that screen. Drafts are not loaded yet; they come with the composer. Likewise `handleEvent` doesn't call `notifyInbox` or `notifyTurn` until `Notifications.swift` exists. + +The app logs through `os.Logger` with subsystem `run.fc.byre.mac`, as the web client logs to the console: `log show --last 10m --predicate 'subsystem == "run.fc.byre.mac"'` prints it. There is no URL hash. The web client mirrors focus into `#/task/`; the app keeps focus in `Store` only, and in-app task links call `focusTask` directly. Focus stays shared through the server, so the app and a browser follow each other as two browser tabs do. @@ -144,7 +150,7 @@ The app menu has About, Change Server…, Hide, Hide Others and Quit; Edit has t ## Connection -On first launch the window shows one field, Server URL (an `https` origin with no path), and Connect. Connect calls `GET /api/admin/status` and explains a failure: an untrusted certificate, an unreachable server ("Is Tailscale connected?"), 401 (no Tailscale identity: use the `ts.net` HTTPS address), 403 (the request didn't come through `tailscale serve`). Certificate validation is never relaxed. The origin is kept in `UserDefaults`. Change Server… flushes drafts, reports pane focus false, drops the store and streams, and starts over against the new origin. +On first launch the window shows one field, Server URL (an `https` origin with no path), and Connect. Connect calls `GET /api/admin/status` and explains a failure: an untrusted certificate, an unreachable server ("Is Tailscale connected?"), 401 (no Tailscale identity: use the `ts.net` HTTPS address), 403 (the request didn't come through `tailscale serve`); anything else shows the error's own text. Certificate validation is never relaxed. On success the origin, normalized to a lower-case host with any port, is kept in the `byre.server` default and startup runs; later launches start against it directly. Change Server… (in the app menu) flushes drafts, reports pane focus false, drops the store and streams, and shows the connection screen again, its field holding the current origin. Drafts and pane focus don't exist yet, so for now it only drops the store and streams. ## Gaps in SwiftUI diff --git a/docs/operations.md b/docs/operations.md index e75d24f..7c48481 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -13,7 +13,7 @@ - `internal/tailscale`: `tailscale serve` for task previews (see "Previews"). - `internal/workflow`: validating workflow definitions and reading and writing their YAML files (see "Workflows"). - `web/`: the SPA, frameworkless TypeScript compiled file-by-file to ES modules (no bundler). `web/src/*.ts` is the source; `web/src/api.gen.ts` holds types generated from `api/openapi.yaml`, and `web/src/api.ts` is the typed client over them. `go generate ./web` runs `npm ci`, regenerates the types and compiles `web/dist/`, which is committed so `go install` needs no Node. The binary embeds `index.html`, `style.css`, `favicon.svg` (a lead dot in the accent colour with spokes to five task-coloured dots; its colours are saturated versions of the `web/style.css` accent and task hues, not copies of the palette, since the palette's pastels blur together at 16px; they switch with the light/dark scheme), `favicon.png` (the same icon in its dark colours on an opaque dark `--surface` square at 180×180, rendered from `web/favicon-tile.svg`, which is not embedded; opaque because Orion puts a transparent icon on a white tile; linked as a second icon and as the `apple-touch-icon`, which Orion prefers over the SVG for its tab icon; Orion's icon store (`~/Library/Application Support/Orion/Defaults/website_icons`, kept apart from its cache) keys icons by hostname without the port, so another server on the same hostname can supply byre's tab icon; `favicon-tile.svg` repeats the shapes and dark colours of `favicon.svg`, so change both together, then regenerate the PNG with `rsvg-convert --width 180 --height 180 --output web/favicon.png web/favicon-tile.svg`), `dist/`, `src/` (for source maps), `fonts/` and `vendor/` (Mermaid, see "Inbox"; xterm.js, see "Terminals"). `byre serve --dev` serves `./web` from disk instead (run from the repo root); pair it with `npm --prefix web exec tsc -- --watch`. -- There is no macOS client yet; `docs/macos.md` is its design. `mac/` holds the spike that checks the design's SwiftUI gaps: `mac/bundle.sh` builds `mac/.build/byre.app`, which connects to the byre `https` origin typed into it (the same `tailscale serve` URL a browser uses). +- `mac/` is the macOS client in progress; `docs/macos.md` is its design and says what exists. `mac/bundle.sh` builds `mac/.build/byre.app`, which connects to the byre `https` origin typed into its connection screen (the same `tailscale serve` URL a browser uses). Its API client is generated from `api/openapi.yaml` at build time, and `go generate ./mac` regenerates the committed `mac/Sources/Byre/Palette.swift` from the custom properties in `web/style.css`, so run it after changing the palette. ## Data diff --git a/mac/Package.resolved b/mac/Package.resolved index 5629100..2fdd971 100644 --- a/mac/Package.resolved +++ b/mac/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "e5f19b7bfab6a34c2470eda6b14c1682772530dafe85011c2def5e6456f2bb55", + "originHash" : "c8eddc113e1728df643cb0e886195a9376637e4c97c431bbf2201b07be34a7c6", "pins" : [ { "identity" : "h", @@ -10,6 +10,24 @@ "version" : "1.0.1" } }, + { + "identity" : "openapikit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mattpolzin/OpenAPIKit", + "state" : { + "revision" : "5b72eb9a6b23b1fec439d9a0bf38f115400421f9", + "version" : "6.4.0" + } + }, + { + "identity" : "swift-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-algorithms", + "state" : { + "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", + "version" : "1.2.1" + } + }, { "identity" : "swift-argument-parser", "kind" : "remoteSourceControl", @@ -19,6 +37,60 @@ "version" : "1.8.2" } }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "35a1edcb8ebd2bfe251d587f0b69e62af28f508c", + "version" : "1.7.3" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types", + "state" : { + "revision" : "bff4b6903cdc99dda49649dd52f46c11cfd3ed50", + "version" : "1.8.0" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics.git", + "state" : { + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-openapi-generator", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-openapi-generator", + "state" : { + "revision" : "bf578ce357a0d2aa07af9de41b083cc2c986a498", + "version" : "1.14.1" + } + }, + { + "identity" : "swift-openapi-runtime", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-openapi-runtime", + "state" : { + "revision" : "ad7a252e215870ea2de9cf8605ff9336b004183a", + "version" : "1.13.1" + } + }, + { + "identity" : "swift-openapi-urlsession", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-openapi-urlsession", + "state" : { + "revision" : "77effea09e780f6f3f119ffb8150ff25e31c78b1", + "version" : "1.3.2" + } + }, { "identity" : "swift-png", "kind" : "remoteSourceControl", @@ -36,6 +108,15 @@ "revision" : "2dad6a1e516f755bed77cf9bbd09455062f54758", "version" : "1.99.0" } + }, + { + "identity" : "yams", + "kind" : "remoteSourceControl", + "location" : "https://github.com/jpsim/Yams", + "state" : { + "revision" : "a27b21e0c81c5bf42049b897a62aaf387e80f279", + "version" : "6.2.2" + } } ], "version" : 3 diff --git a/mac/Package.swift b/mac/Package.swift index d3a282c..c815d58 100644 --- a/mac/Package.swift +++ b/mac/Package.swift @@ -6,11 +6,19 @@ let package = Package( platforms: [.macOS("27.0")], dependencies: [ .package(url: "https://github.com/migueldeicaza/SwiftTerm", from: "1.2.0"), + .package(url: "https://github.com/apple/swift-openapi-generator", from: "1.10.0"), + .package(url: "https://github.com/apple/swift-openapi-runtime", from: "1.8.0"), + .package(url: "https://github.com/apple/swift-openapi-urlsession", from: "1.1.0"), ], targets: [ .executableTarget( name: "Byre", - dependencies: [.product(name: "SwiftTerm", package: "SwiftTerm")] + dependencies: [ + .product(name: "SwiftTerm", package: "SwiftTerm"), + .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"), + .product(name: "OpenAPIURLSession", package: "swift-openapi-urlsession"), + ], + plugins: [.plugin(name: "OpenAPIGenerator", package: "swift-openapi-generator")] ), ] ) diff --git a/mac/Sources/Byre/API.swift b/mac/Sources/Byre/API.swift new file mode 100644 index 0000000..9b0a2d1 --- /dev/null +++ b/mac/Sources/Byre/API.swift @@ -0,0 +1,133 @@ +import Foundation +import HTTPTypes +import OpenAPIRuntime +import OpenAPIURLSession +import os + +let log = Logger(subsystem: "run.fc.byre.mac", category: "byre") + +typealias User = Components.Schemas.User +typealias Project = Components.Schemas.Project +typealias ByreTask = Components.Schemas.Task +typealias AgentSession = Components.Schemas.AgentSession +typealias InboxItem = Components.Schemas.InboxItem +typealias ByreEvent = Components.Schemas.Event +typealias EventPage = Components.Schemas.EventPage +typealias Focus = Components.Schemas.Focus +typealias TaskTab = Components.Schemas.TaskTab +typealias Snapshot = Components.Schemas.Snapshot +typealias ServerStatus = Components.Schemas.Status + +struct ApiError: Error, CustomStringConvertible { + let status: Int + let message: String + + var description: String { + message + } +} + +struct ByreDates: DateTranscoder { + private static let style = Date.ISO8601FormatStyle(includingFractionalSeconds: true) + + func encode(_ date: Date) throws -> String { + date.formatted(Self.style) + } + + func decode(_ string: String) throws -> Date { + try Self.style.parse(string) + } +} + +func jsonDecoder() -> JSONDecoder { + let d = JSONDecoder() + d.dateDecodingStrategy = .custom { decoder in + try ByreDates().decode(try decoder.singleValueContainer().decode(String.self)) + } + return d +} + +func decodePayload(_ payload: OpenAPIObjectContainer, as type: T.Type) throws -> T { + try jsonDecoder().decode(T.self, from: try JSONEncoder().encode(payload)) +} + +private struct ErrorMiddleware: ClientMiddleware { + func intercept( + _ request: HTTPRequest, + body: HTTPBody?, + baseURL: URL, + operationID: String, + next: @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) + ) async throws -> (HTTPResponse, HTTPBody?) { + let (response, responseBody) = try await next(request, body, baseURL) + let code = response.status.code + if code < 400 { + return (response, responseBody) + } + var data = Data() + if let responseBody { + data = try await Data(collecting: responseBody, upTo: 1 << 20) + } + if response.headerFields[.contentType]?.hasPrefix("application/json") == true { + throw ApiError(status: code, message: try JSONDecoder().decode(Components.Schemas._Error.self, from: data).message) + } + let text = String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) + throw ApiError(status: code, message: "\(code) \(HTTPURLResponse.localizedString(forStatusCode: code))\(text.isEmpty ? "" : ": \(text)")") + } +} + +func rootError(_ error: any Error) -> any Error { + if let e = error as? ClientError { + return rootError(e.underlyingError) + } + return error +} + +func errorText(_ error: any Error) -> String { + let e = rootError(error) + if let api = e as? ApiError { + return api.message + } + if let url = e as? URLError { + return url.localizedDescription + } + return String(describing: e) +} + +final class API: Sendable { + let origin: URL + let session: URLSession + let client: Client + + init(origin: URL) { + self.origin = origin + session = URLSession(configuration: .default) + client = Client( + serverURL: origin, + configuration: Configuration(dateTranscoder: ByreDates()), + transport: URLSessionTransport(configuration: .init(session: session)), + middlewares: [ErrorMiddleware()] + ) + } + + func url(_ path: String, _ query: [URLQueryItem] = [], scheme: String = "https") -> URL { + var c = URLComponents(url: origin, resolvingAgainstBaseURL: false)! + c.scheme = scheme + c.path = path + if !query.isEmpty { + c.queryItems = query + } + return c.url! + } +} + +func parseOrigin(_ text: String) -> URL? { + guard let c = URLComponents(string: text.trimmingCharacters(in: .whitespaces)), c.scheme?.lowercased() == "https", let host = c.host, !host.isEmpty, c.path.isEmpty || c.path == "/", c.query == nil, c.fragment == nil, c.user == nil else { + return nil + } + var origin = URLComponents() + origin.scheme = "https" + origin.host = host.lowercased() + origin.port = c.port + return origin.url +} diff --git a/mac/Sources/Byre/ByreApp.swift b/mac/Sources/Byre/ByreApp.swift index 215aafe..12fd89f 100644 --- a/mac/Sources/Byre/ByreApp.swift +++ b/mac/Sources/Byre/ByreApp.swift @@ -6,35 +6,42 @@ typealias ViewState = State @main struct ByreApp: App { - @ViewState private var server = Server() + @ViewState private var connection = Connection() + @ViewState private var spike = SpikeLog() var body: some Scene { Window("byre", id: "main") { - SpikeView() - .environment(server) + RootView() + .environment(connection) + .environment(spike) .font(Fonts.font(13)) .onAppear { - installCtrlMonitor(server) + installCtrlMonitor(spike) } } .commands { + CommandGroup(after: .appInfo) { + Button("Change Server…") { + connection.changeServer() + } + } PasteboardCommands() } } } @MainActor -private func installCtrlMonitor(_ server: Server) { +private func installCtrlMonitor(_ spike: SpikeLog) { NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) guard flags.contains(.control), !flags.contains(.command), let key = event.charactersIgnoringModifiers, ["k", "p", "l", "s", "/"].contains(key) else { return event } if event.window?.firstResponder is TerminalView { - server.log("ctrl-\(key) passed to the terminal") + spike.log("ctrl-\(key) passed to the terminal") return event } - server.log("ctrl-\(key) taken as a Go shortcut") + spike.log("ctrl-\(key) taken as a Go shortcut") return nil } } diff --git a/mac/Sources/Byre/Composer.swift b/mac/Sources/Byre/Composer.swift index c401fe6..ea9f2b5 100644 --- a/mac/Sources/Byre/Composer.swift +++ b/mac/Sources/Byre/Composer.swift @@ -1,4 +1,5 @@ import AppKit +import OpenAPIRuntime import SwiftUI import UniformTypeIdentifiers @@ -35,24 +36,26 @@ func pngFromTIFF(_ data: Data) -> Data? { @Observable final class Attachments { var images: [PendingImage] = [] - let server: Server + let spike: SpikeLog + let api: API let name: String - init(server: Server, name: String) { - self.server = server + init(spike: SpikeLog, api: API, name: String) { + self.spike = spike + self.api = api self.name = name } func add(_ data: Data, from source: String) { guard let kind = imageType(data), let image = NSImage(data: data) else { - server.log("\(name): \(source) gave \(data.count) bytes that aren't PNG, JPEG, GIF or WebP") + spike.log("\(name): \(source) gave \(data.count) bytes that aren't PNG, JPEG, GIF or WebP") return } if data.count > maxImageBytes { - server.log("\(name): \(source) image is over \(maxImageBytes >> 20) MiB") + spike.log("\(name): \(source) image is over \(maxImageBytes >> 20) MiB") return } - server.log("\(name): \(source) attached a \(kind), \(data.count) bytes") + spike.log("\(name): \(source) attached a \(kind), \(data.count) bytes") images.append(PendingImage(data: data, image: image)) } @@ -81,7 +84,7 @@ final class Attachments { } func add(providers: [NSItemProvider], from source: String) { - server.log("\(name): \(source) with types \(providers.map { $0.registeredTypeIdentifiers })") + spike.log("\(name): \(source) with types \(providers.map { $0.registeredTypeIdentifiers })") for p in providers { let types = [UTType.png, .jpeg, .gif, .webP, .tiff, .fileURL].filter { p.hasItemConformingToTypeIdentifier($0.identifier) } guard let t = types.first else { @@ -90,7 +93,7 @@ final class Attachments { _ = p.loadDataRepresentation(for: t) { data, error in Task { @MainActor in guard let data else { - self.server.log("\(self.name): \(source) load failed: \(String(describing: error))") + self.spike.log("\(self.name): \(source) load failed: \(String(describing: error))") return } if t == .fileURL, let url = URL(dataRepresentation: data, relativeTo: nil), let file = try? Data(contentsOf: url) { @@ -108,17 +111,28 @@ final class Attachments { func send(_ text: String) { let images = self.images self.images = [] - server.log("\(name): send \(text.debugDescription) with \(images.count) images") + spike.log("\(name): send \(text.debugDescription) with \(images.count) images") Task { for img in images { do { - server.log("\(name): uploaded blob \(try await server.upload(img.data))") + spike.log("\(name): uploaded blob \(try await upload(img.data))") } catch { - server.log("\(name): upload failed: \(error)") + spike.log("\(name): upload failed: \(errorText(error))") } } } } + + private func upload(_ data: Data) async throws -> String { + let body = HTTPBody(data) + let typed: Operations.UploadBlob.Input.Body = switch imageType(data) { + case "png": .png(body) + case "jpeg": .jpeg(body) + case "gif": .imageGif(body) + default: .imageWebp(body) + } + return try await api.client.uploadBlob(body: typed).created.body.json.id + } } struct Thumbnails: View { @@ -143,13 +157,14 @@ struct Thumbnails: View { } struct ComposerSpike: View { - @Environment(Server.self) private var server + @Environment(Store.self) private var store + @Environment(SpikeLog.self) private var spike var body: some View { HStack(alignment: .top, spacing: 16) { - SwiftUIComposer(attachments: Attachments(server: server, name: "TextEditor")) - AppKitComposerPanel(attachments: Attachments(server: server, name: "NSTextView")) - RoutedComposer(attachments: Attachments(server: server, name: "Routed")) + SwiftUIComposer(attachments: Attachments(spike: spike, api: store.api, name: "TextEditor")) + AppKitComposerPanel(attachments: Attachments(spike: spike, api: store.api, name: "NSTextView")) + RoutedComposer(attachments: Attachments(spike: spike, api: store.api, name: "Routed")) } .padding(8) } @@ -215,7 +230,7 @@ struct RoutedComposer: View { .focusedValue(\.composer, attachments) .onKeyPress(.return, phases: .down) { press in let marked = (NSApp.keyWindow?.firstResponder as? NSTextView)?.hasMarkedText() ?? false - attachments.server.log("Routed: onKeyPress return, modifiers \(press.modifiers.rawValue), hasMarkedText \(marked)") + attachments.spike.log("Routed: onKeyPress return, modifiers \(press.modifiers.rawValue), hasMarkedText \(marked)") if press.modifiers.contains(.shift) || marked { return .ignored } @@ -233,7 +248,7 @@ struct RoutedComposer: View { } } .onChange(of: targeted) { - attachments.server.log("Routed: overlay drop targeted \(targeted)") + attachments.spike.log("Routed: overlay drop targeted \(targeted)") } } } @@ -254,7 +269,7 @@ struct SwiftUIComposer: View { .border(Color.secondary.opacity(0.4)) .onKeyPress(.return, phases: .down) { press in let marked = (NSApp.keyWindow?.firstResponder as? NSTextView)?.hasMarkedText() ?? false - attachments.server.log("TextEditor: onKeyPress return, modifiers \(press.modifiers.rawValue), hasMarkedText \(marked)") + attachments.spike.log("TextEditor: onKeyPress return, modifiers \(press.modifiers.rawValue), hasMarkedText \(marked)") if press.modifiers.contains(.shift) { return .ignored } @@ -296,7 +311,7 @@ final class ComposerTextView: NSTextView { override func keyDown(with event: NSEvent) { if event.keyCode == 36 || event.keyCode == 76 { - attachments.server.log("NSTextView: keyDown return, modifiers \(event.modifierFlags.intersection(.deviceIndependentFlagsMask).rawValue), hasMarkedText \(hasMarkedText())") + attachments.spike.log("NSTextView: keyDown return, modifiers \(event.modifierFlags.intersection(.deviceIndependentFlagsMask).rawValue), hasMarkedText \(hasMarkedText())") } super.keyDown(with: event) } diff --git a/mac/Sources/Byre/EventStream.swift b/mac/Sources/Byre/EventStream.swift new file mode 100644 index 0000000..ef98cf9 --- /dev/null +++ b/mac/Sources/Byre/EventStream.swift @@ -0,0 +1,222 @@ +import AppKit +import Foundation +import Network + +private let staleAfter: TimeInterval = 40 +private let retryAfter: Duration = .seconds(2) +private let checkEvery: Duration = .seconds(5) + +private enum Frame: Sendable { + case open + case ping + case ready + case event(id: Int64?, ByreEvent) +} + +@MainActor +final class EventStream { + private let api: API + private let lead: Bool + private let onEvent: (ByreEvent) -> Void + private let onState: (Bool) -> Void + private let onReady: () -> Void + private var last: Int64 + private var heard = Date() + private var reading: Task? + private var retry: Task? + private var checker: Task? + private var generation = 0 + private let monitor = NWPathMonitor() + private var online: Bool? + private var activeObserver: (any NSObjectProtocol)? + + init(api: API, after: Int64, lead: Bool, onEvent: @escaping (ByreEvent) -> Void, onState: @escaping (Bool) -> Void, onReady: @escaping () -> Void) { + self.api = api + self.lead = lead + self.onEvent = onEvent + self.onState = onState + self.onReady = onReady + last = after + checker = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: checkEvery) + self?.check() + } + } + monitor.pathUpdateHandler = { [weak self] path in + let up = path.status == .satisfied + Task { @MainActor in + self?.pathChanged(up) + } + } + monitor.start(queue: .main) + activeObserver = NotificationCenter.default.addObserver(forName: NSApplication.didBecomeActiveNotification, object: nil, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { + self?.check() + } + } + connect() + } + + func stop() { + generation += 1 + checker?.cancel() + retry?.cancel() + reading?.cancel() + monitor.cancel() + if let activeObserver { + NotificationCenter.default.removeObserver(activeObserver) + } + activeObserver = nil + } + + private func pathChanged(_ up: Bool) { + let was = online + online = up + if was == false && up { + connect() + } + } + + private func check() { + if Date().timeIntervalSince(heard) < staleAfter { + return + } + onState(false) + connect() + } + + private func connect() { + retry?.cancel() + reading?.cancel() + generation += 1 + let gen = generation + heard = Date() + var query = [URLQueryItem(name: "after", value: String(last))] + if lead { + query.append(URLQueryItem(name: "lead", value: "true")) + } + var request = URLRequest(url: api.url("/api/events", query)) + request.setValue("text/event-stream", forHTTPHeaderField: "Accept") + let session = api.session + reading = Task.detached { [weak self] in + do { + try await Self.read(session, request) { frame in + self?.receive(frame, gen) + } + await self?.failed(gen) + } catch { + await self?.failed(gen) + } + } + } + + private func receive(_ frame: Frame, _ gen: Int) { + guard gen == generation else { + return + } + heard = Date() + switch frame { + case .open: + onState(true) + case .ping: + break + case .ready: + onReady() + case .event(let id, let ev): + if let id, id > last { + last = id + } + if ev.seq > last { + last = ev.seq + } + onEvent(ev) + } + } + + private func failed(_ gen: Int) { + guard gen == generation else { + return + } + onState(false) + retry = Task { [weak self] in + try? await Task.sleep(for: retryAfter) + if !Task.isCancelled && self?.generation == gen { + self?.connect() + } + } + } + + nonisolated private static func read(_ session: URLSession, _ request: URLRequest, _ deliver: @escaping @MainActor @Sendable (Frame) -> Void) async throws { + let (bytes, response) = try await session.bytes(for: request) + let code = (response as! HTTPURLResponse).statusCode + if code != 200 { + throw ApiError(status: code, message: "\(code) \(HTTPURLResponse.localizedString(forStatusCode: code))") + } + await deliver(.open) + let decoder = jsonDecoder() + var line: [UInt8] = [] + var afterCR = false + var kind = "" + var data: [String] = [] + var id: Int64? + for try await byte in bytes { + if byte == 0x0A && afterCR { + afterCR = false + continue + } + afterCR = byte == 0x0D + if byte != 0x0A && byte != 0x0D { + line.append(byte) + continue + } + let text = String(decoding: line, as: UTF8.self) + line.removeAll(keepingCapacity: true) + if !text.isEmpty { + if text.hasPrefix(":") { + continue + } + let colon = text.firstIndex(of: ":") ?? text.endIndex + let field = String(text[.. { + private var fns: [(id: UUID, fn: (T) -> Void)] = [] + + func add(_ fn: @escaping (T) -> Void) -> () -> Void { + let id = UUID() + fns.append((id, fn)) + return { [weak self] in + self?.fns.removeAll { $0.id == id } + } + } + + func send(_ value: T) { + for entry in fns { + entry.fn(value) + } + } +} + +@MainActor +@Observable +final class Feed { + private(set) var events: [ByreEvent] = [] + private(set) var ready = false + let appended = Listeners<[ByreEvent]>() + let streamed = Listeners() + @ObservationIgnored private var last: Int64 = 0 + @ObservationIgnored private var buffer: [ByreEvent] = [] + @ObservationIgnored private var scheduled = false + @ObservationIgnored private let shown: () -> Bool + + init(shown: @escaping () -> Bool) { + self.shown = shown + } + + func push(_ ev: ByreEvent) { + if ev.seq == 0 && (!ready || !shown()) { + return + } + buffer.append(ev) + if ready && !scheduled { + scheduled = true + Task { @MainActor in + self.flush() + } + } + } + + func load(_ history: [ByreEvent]) { + if ready { + return + } + ready = true + buffer = history + buffer + flush() + } + + func prepend(_ older: [ByreEvent]) { + events.insert(contentsOf: older, at: 0) + } + + private func flush() { + scheduled = false + let pending = buffer + buffer = [] + var stored: [ByreEvent] = [] + for ev in pending { + if ev.seq != 0 { + stored.append(ev) + continue + } + append(stored) + stored = [] + if shown() { + streamed.send(ev) + } + } + append(stored) + } + + private func append(_ batch: [ByreEvent]) { + var fresh: [ByreEvent] = [] + for ev in batch where ev.seq > last { + fresh.append(ev) + last = ev.seq + } + if fresh.isEmpty { + return + } + events.append(contentsOf: fresh) + if shown() { + appended.send(fresh) + } + } +} diff --git a/mac/Sources/Byre/FontAndSVG.swift b/mac/Sources/Byre/FontAndSVG.swift index 5951fac..dae5fb0 100644 --- a/mac/Sources/Byre/FontAndSVG.swift +++ b/mac/Sources/Byre/FontAndSVG.swift @@ -1,5 +1,6 @@ import AppKit import CoreText +import OpenAPIRuntime import SwiftUI func runFonts(_ font: CTFont, _ text: String) -> String { @@ -52,7 +53,7 @@ struct FontSpike: View { } struct SVGSpike: View { - @Environment(Server.self) private var server + @Environment(Store.self) private var store @ViewState private var path = "web/favicon.svg" @ViewState private var image: NSImage? @ViewState private var failure = "" @@ -92,18 +93,21 @@ struct SVGSpike: View { private func load() async { image = nil failure = "" - guard !server.taskId.isEmpty else { + guard let taskId = store.selectedTaskId else { return } do { - let data = try await server.data(URLRequest(url: try server.url("/api/tasks/\(server.taskId)/image", [URLQueryItem(name: "path", value: path)]))) + let body = switch try await store.api.client.getTaskImage(path: .init(taskId: taskId), query: .init(path: path)).ok.body { + case .png(let b), .jpeg(let b), .imageGif(let b), .imageWebp(let b), .imageSvgXml(let b): b + } + let data = try await Data(collecting: body, upTo: 32 << 20) guard let img = NSImage(data: data) else { failure = "image not available: \(path)" return } image = img } catch { - failure = "image not available: \(path) (\(error))" + failure = "image not available: \(path) (\(errorText(error)))" } } } diff --git a/mac/Sources/Byre/Palette.swift b/mac/Sources/Byre/Palette.swift new file mode 100644 index 0000000..905dda2 --- /dev/null +++ b/mac/Sources/Byre/Palette.swift @@ -0,0 +1,42 @@ +// Code generated by go generate ./mac from web/style.css. DO NOT EDIT. + +import AppKit +import SwiftUI + +enum Palette { + static let bg = dynamic(0xfbfbfa, 0x15171a) + static let surface = dynamic(0xf2f3f5, 0x1c1f23) + static let raised = dynamic(0xffffff, 0x23272c) + static let border = dynamic(0xd6d9de, 0x353b43) + static let text = dynamic(0x16181b, 0xeceff3) + static let muted = dynamic(0x4a5260, 0xa9b1bc) + static let accent = dynamic(0x1a45bd, 0x8ab4ff) + static let accentTint = dynamic(0xe7edfb, 0x1e2a40) + static let danger = dynamic(0xb42318, 0xff9488) + static let dangerTint = dynamic(0xfdecea, 0x3a1f1d) + static let warn = dynamic(0x855600, 0xf2c66d) + static let warnTint = dynamic(0xfdf3dc, 0x352a14) + static let ok = dynamic(0x1a7f37, 0x7fd88f) + static let okTint = dynamic(0xe5f4e9, 0x1b3322) + static let task0 = dynamic(0xa4273f, 0xfe9fa6) + static let task1 = dynamic(0x9f3501, 0xffa383) + static let task2 = dynamic(0x376e00, 0x99d079) + static let task3 = dynamic(0x027048, 0x68d7a1) + static let task4 = dynamic(0x026d64, 0x37d8c9) + static let task5 = dynamic(0x046a77, 0x2fd4ec) + static let task6 = dynamic(0x06658e, 0x69caff) + static let task7 = dynamic(0x1f58b6, 0x98bffe) + static let task8 = dynamic(0x5849b2, 0xb6b4fe) + static let task9 = dynamic(0x793c9f, 0xd7a6fc) + static let task10 = dynamic(0x942e79, 0xf59cd8) + + private static func rgb(_ hex: UInt32) -> NSColor { + NSColor(srgbRed: CGFloat(hex >> 16 & 0xff) / 255, green: CGFloat(hex >> 8 & 0xff) / 255, blue: CGFloat(hex & 0xff) / 255, alpha: 1) + } + + private static func dynamic(_ light: UInt32, _ dark: UInt32) -> Color { + Color(nsColor: NSColor(name: nil) { appearance in + appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua ? rgb(dark) : rgb(light) + }) + } +} diff --git a/mac/Sources/Byre/Server.swift b/mac/Sources/Byre/Server.swift deleted file mode 100644 index 1497422..0000000 --- a/mac/Sources/Byre/Server.swift +++ /dev/null @@ -1,94 +0,0 @@ -import Foundation -import Observation - -struct ProjectRow: Decodable { - let id: String - let name: String -} - -struct TaskRow: Decodable, Hashable { - let id: String - let title: String - let status: String -} - -struct BlobRow: Decodable { - let id: String -} - -struct SpikeError: Error, CustomStringConvertible { - let description: String -} - -@MainActor -@Observable -final class Server { - var origin = UserDefaults.standard.string(forKey: "byre.server") ?? "" - var tasks: [TaskRow] = [] - var taskId = "" - var status = "" - var lines: [String] = [] - - func log(_ line: String) { - lines.append(line) - } - - private var base: URLComponents? - - func url(_ path: String, _ query: [URLQueryItem] = []) throws -> URL { - guard var c = base else { - throw SpikeError(description: "not connected") - } - c.path = path - if !query.isEmpty { - c.queryItems = query - } - guard let u = c.url else { - throw SpikeError(description: "bad URL for \(path)") - } - return u - } - - func data(_ request: URLRequest) async throws -> Data { - let (data, response) = try await URLSession.shared.data(for: request) - let code = (response as! HTTPURLResponse).statusCode - if !(200..<300).contains(code) { - throw SpikeError(description: "\(code) \(String(decoding: data, as: UTF8.self))") - } - return data - } - - func get(_ type: T.Type, _ path: String, _ query: [URLQueryItem] = []) async throws -> T { - try JSONDecoder().decode(T.self, from: try await data(URLRequest(url: try url(path, query)))) - } - - func connect() async { - guard let c = URLComponents(string: origin), c.scheme == "https", let host = c.host, !host.isEmpty, c.path.isEmpty || c.path == "/", c.url != nil else { - base = nil - status = "Server URL must be an https origin with no path, such as https://host.ts.net:8270" - return - } - base = c - UserDefaults.standard.set(origin, forKey: "byre.server") - do { - var all: [TaskRow] = [] - for p in try await get([ProjectRow].self, "/api/projects") { - all += try await get([TaskRow].self, "/api/projects/\(p.id)/tasks") - } - tasks = all.filter { $0.status == "active" } + all.filter { $0.status != "active" } - if !tasks.contains(where: { $0.id == taskId }) { - taskId = tasks.first?.id ?? "" - } - status = "\(tasks.count) tasks" - } catch { - status = "\(error)" - } - } - - func upload(_ image: Data) async throws -> String { - var req = URLRequest(url: try url("/api/blobs")) - req.httpMethod = "POST" - req.httpBody = image - return try JSONDecoder().decode(BlobRow.self, from: try await data(req)).id - } -} diff --git a/mac/Sources/Byre/SpikeView.swift b/mac/Sources/Byre/SpikeView.swift index 634c872..a93f689 100644 --- a/mac/Sources/Byre/SpikeView.swift +++ b/mac/Sources/Byre/SpikeView.swift @@ -1,26 +1,32 @@ import SwiftUI +@MainActor +@Observable +final class SpikeLog { + var lines: [String] = [] + + func log(_ line: String) { + lines.append(line) + } +} + struct SpikeView: View { - @Environment(Server.self) private var server + @Environment(Store.self) private var store + @Environment(SpikeLog.self) private var spike var body: some View { - @Bindable var server = server + let selection = Binding(get: { store.selectedTaskId ?? "" }, set: { store.focusTask($0) }) VStack(spacing: 0) { HStack { - TextField("Server URL", text: $server.origin) - .frame(width: 320) - Button("Connect") { - Task { - await server.connect() + Picker("Task", selection: selection) { + Text("None").tag("") + ForEach(store.activeTasks(), id: \.task.id) { row in + Text(verbatim: "\(row.project.name): \(row.task.title) · \(store.taskState(row.task).label)").tag(row.task.id) } } - Picker("Task", selection: $server.taskId) { - ForEach(server.tasks, id: \.id) { t in - Text(verbatim: "\(t.title) · \(t.status) · \(t.id)").tag(t.id) - } - } - .frame(width: 280) - Text(server.status) + .frame(width: 420) + Text(verbatim: "\(store.me?.displayName ?? "") · \(store.activeTasks().count) active tasks · \(store.inbox.count) inbox · \(store.transcriptLoading() ? "loading" : "\(store.transcript.count) events") · \(store.connected ? "connected" : "reconnecting…")") + .foregroundStyle(Palette.muted) Spacer() } .padding(8) @@ -31,25 +37,25 @@ struct SpikeView: View { } Tab("Transcript", systemImage: "text.alignleft") { TranscriptSpike() - .id(server.taskId) + .id(store.selectedTaskId) } Tab("Terminal", systemImage: "terminal") { TerminalSpike() - .id(server.taskId) + .id(store.selectedTaskId) } Tab("Font", systemImage: "textformat") { FontSpike() } Tab("SVG", systemImage: "photo") { SVGSpike() - .id(server.taskId) + .id(store.selectedTaskId) } } .padding(8) Divider() ScrollView { VStack(alignment: .leading, spacing: 2) { - ForEach(Array(server.lines.enumerated()), id: \.offset) { _, line in + ForEach(Array(spike.lines.enumerated()), id: \.offset) { _, line in Text(line) } } @@ -60,10 +66,5 @@ struct SpikeView: View { .frame(height: 140) } .frame(minWidth: 1000, minHeight: 700) - .task { - if !server.origin.isEmpty { - await server.connect() - } - } } } diff --git a/mac/Sources/Byre/Startup.swift b/mac/Sources/Byre/Startup.swift new file mode 100644 index 0000000..cfe8447 --- /dev/null +++ b/mac/Sources/Byre/Startup.swift @@ -0,0 +1,202 @@ +import Foundation +import Observation +import SwiftUI + +private let originKey = "byre.server" + +@MainActor +@Observable +final class Connection { + enum Phase { + case connect + case starting + case failed(String) + case running(Store) + } + + var phase: Phase = .connect + var serverText: String + var problem: String? + var connecting = false + @ObservationIgnored private var generation = 0 + + init() { + let saved = UserDefaults.standard.string(forKey: originKey) + serverText = saved ?? "" + if let saved, let origin = parseOrigin(saved) { + phase = .starting + Task { + await start(origin) + } + } + } + + var store: Store? { + if case .running(let store) = phase { + return store + } + return nil + } + + func connect() async { + guard let origin = parseOrigin(serverText) else { + problem = "The server URL must be an https origin with no path, such as https://byre.example.ts.net." + return + } + connecting = true + problem = nil + let gen = generation + do { + _ = try await API(origin: origin).client.getStatus().ok + } catch { + if gen == generation { + connecting = false + problem = explain(error, origin) + } + return + } + guard gen == generation else { + return + } + connecting = false + UserDefaults.standard.set(origin.absoluteString, forKey: originKey) + serverText = origin.absoluteString + phase = .starting + await start(origin) + } + + func changeServer() { + generation += 1 + store?.stop() + connecting = false + problem = nil + phase = .connect + } + + private func start(_ origin: URL) async { + generation += 1 + let gen = generation + let store = Store(api: API(origin: origin)) + do { + let status = try await store.api.client.getStatus().ok.body.json + async let me: Void = store.loadMe() + async let projects: Void = store.loadProjects() + async let inbox: Void = store.loadInbox() + async let lead: Void = store.loadLeadSessions() + async let reads: Void = store.loadSummaryReads() + _ = try await (me, projects, inbox, lead, reads) + try await store.loadFocus() + guard gen == generation else { + store.stop() + return + } + Task { + await store.startLeadStream() + } + phase = .running(store) + store.startGlobalStream(after: status.lastEventSeq) + } catch { + store.stop() + if gen == generation { + phase = .failed("byre failed to start: \(errorText(error))") + } + } + } +} + +private let tlsErrors: Set = [ + .serverCertificateUntrusted, .serverCertificateHasBadDate, .serverCertificateNotYetValid, .serverCertificateHasUnknownRoot, + .secureConnectionFailed, .clientCertificateRejected, .clientCertificateRequired, +] + +private let unreachableErrors: Set = [ + .cannotFindHost, .cannotConnectToHost, .dnsLookupFailed, .timedOut, .notConnectedToInternet, .networkConnectionLost, +] + +func explain(_ error: any Error, _ origin: URL) -> String { + let host = origin.host() ?? origin.absoluteString + let e = rootError(error) + if let api = e as? ApiError { + switch api.status { + case 401: + return "\(host) answered 401, no Tailscale identity: \(api.message). Use byre's ts.net HTTPS address, the one tailscale serve gives it, so the request carries your Tailscale login." + case 403: + return "\(host) answered 403, the request didn't come through tailscale serve: \(api.message). Use the HTTPS address tailscale serve gives byre." + default: + return api.message + } + } + if let url = e as? URLError { + if tlsErrors.contains(url.code) { + return "\(host)'s certificate isn't trusted: \(url.localizedDescription) Use byre's ts.net HTTPS address, whose certificate tailscale serve provides." + } + if unreachableErrors.contains(url.code) { + return "Can't reach \(host): \(url.localizedDescription) Is Tailscale connected?" + } + return url.localizedDescription + } + return errorText(e) +} + +struct ConnectView: View { + @Bindable var connection: Connection + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Server URL") + TextField("https://byre.example.ts.net", text: $connection.serverText) + .textFieldStyle(.roundedBorder) + .onSubmit(connect) + Button("Connect", action: connect) + .disabled(connection.connecting) + if let problem = connection.problem { + Text(problem) + .foregroundStyle(Palette.danger) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + } + .frame(width: 460) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func connect() { + Task { + await connection.connect() + } + } +} + +struct FatalView: View { + let message: String + + var body: some View { + Text(message) + .foregroundStyle(Palette.danger) + .textSelection(.enabled) + .padding(24) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } +} + +struct RootView: View { + @Environment(Connection.self) private var connection + + var body: some View { + Group { + switch connection.phase { + case .connect: + ConnectView(connection: connection) + case .starting: + Color.clear + case .failed(let message): + FatalView(message: message) + case .running(let store): + SpikeView() + .environment(store) + } + } + .foregroundStyle(Palette.text) + .background(Palette.bg) + } +} diff --git a/mac/Sources/Byre/Store.swift b/mac/Sources/Byre/Store.swift new file mode 100644 index 0000000..5b54a0f --- /dev/null +++ b/mac/Sources/Byre/Store.swift @@ -0,0 +1,496 @@ +import Foundation +import Observation + +let taskTabs: [TaskTab] = [.transcript, .changes, .files, .terminal, .inbox, .reviews, .workflow, .project] + +private let historyPage = 1000 +private let leadPage = 50 +private let visitsKey = "byre.visits" + +struct TaskState { + enum Kind { + case answer, merging, held, error, working, idle + } + + let kind: Kind + let label: String + let title: String +} + +private let workingTitles: [ByreTask.StatePayload: String] = [ + .running: "The agent is mid-turn", + .reviewPending: "A review of the turn's work is about to start", + .reviewing: "A review is running", + .discarding: "Discarding", +] + +@MainActor +@Observable +final class Store { + let api: API + var me: User? + var projects: [Project] = [] + var tasks: [String: [ByreTask]] = [:] + var sessions: [String: [AgentSession]] = [:] + var inbox: [InboxItem] = [] + private(set) var selectedTaskId: String? + var pinned: Int? + var reviewed: Int? + var connected = false + private(set) var focus = Focus(tabs: [.transcript], tab: .transcript) + var leadSessions: [AgentSession] = [] + let leadFeed: Feed + var jump: (taskId: String, seq: Int64)? + var summaryReads: [String: Int64] = [:] + private(set) var leadMore = false + let events = Listeners() + + private var taskFeeds: [String: Feed] = [:] + @ObservationIgnored private var historyLoads: Set = [] + @ObservationIgnored private var requestSeqs: [String: Int] = [:] + @ObservationIgnored private var globalStream: EventStream? + @ObservationIgnored private var leadStream: EventStream? + @ObservationIgnored private var stopped = false + + init(api: API) { + self.api = api + leadFeed = Feed(shown: { true }) + } + + func stop() { + stopped = true + globalStream?.stop() + leadStream?.stop() + } + + var transcript: [ByreEvent] { + selectedFeed?.events ?? [] + } + + var selectedFeed: Feed? { + selectedTaskId.flatMap { taskFeeds[$0] } + } + + func activeTasks() -> [(project: Project, task: ByreTask)] { + projects.flatMap { project in + (tasks[project.id] ?? []).filter { $0.status == .active }.map { (project, $0) } + } + } + + func findTask(_ id: String) -> ByreTask? { + for list in tasks.values { + if let task = list.first(where: { $0.id == id }) { + return task + } + } + return nil + } + + func currentSession(_ taskId: String) -> AgentSession? { + sessions[taskId]?.last(where: { $0.state != .completed }) ?? sessions[taskId]?.last + } + + func taskState(_ task: ByreTask) -> TaskState { + let open = inbox.filter { $0.taskId == task.id } + if open.contains(where: { $0.kind == .question }) { + return TaskState(kind: .answer, label: "Needs your answer", title: "The agent asked a question in the inbox") + } + if task.merging { + return TaskState(kind: .merging, label: "Merging", title: "The merge turn is running") + } + if task.mergingIntoParent { + return TaskState(kind: .merging, label: "Merging", title: "Waiting for the parent task's agent to merge it") + } + if task.mergeQueuedAt != nil { + return TaskState(kind: .held, label: "Merge held", title: "Waiting for the merge in progress in this project to finish") + } + if task.mergeAfterReview { + return TaskState(kind: .held, label: "Merge after review", title: "Merges when the running review finishes with no findings") + } + if !open.isEmpty { + return TaskState(kind: .error, label: "Error", title: "The agent's session failed or hit a rate limit; see the inbox") + } + if let working = workingTitles[task.state] { + return TaskState(kind: .working, label: "Working", title: working) + } + return TaskState(kind: .idle, label: "Idle", title: "Waiting for a message") + } + + func currentLead() -> AgentSession? { + leadSessions.last(where: { $0.state != .completed }) ?? leadSessions.last + } + + func snapshots() -> [Snapshot] { + transcript.filter { $0.kind == "snapshot.created" }.map { try! decodePayload($0.payload, as: Snapshot.self) } + } + + func pinSnapshot(_ n: Int?) { + pinned = n + } + + func pickReview(_ n: Int) { + reviewed = n + } + + func showReview(_ n: Int) { + pickReview(n) + focusTab(.reviews) + } + + func loadMe() async throws { + me = try await api.client.getMe().ok.body.json + } + + func loadProjects() async throws { + projects = try await api.client.listProjects().ok.body.json + try await each(projects.map(\.id)) { id in + try await self.loadTasks(id) + } + } + + private func claimRequest(_ key: String) -> () -> Bool { + let seq = (requestSeqs[key] ?? 0) + 1 + requestSeqs[key] = seq + return { [weak self] in + self?.requestSeqs[key] == seq + } + } + + func loadTasks(_ projectId: String) async throws { + let latest = claimRequest("tasks:\(projectId)") + let list = try await api.client.listTasks(path: .init(projectId: projectId)).ok.body.json + if !latest() { + return + } + tasks[projectId] = list + for t in list where t.status == .active { + _ = taskFeed(t.id) + } + dropClosedFeeds() + try await each(list.filter { $0.status == .active }.map(\.id)) { id in + try await self.loadSessions(id) + } + } + + func loadSessions(_ taskId: String) async throws { + let latest = claimRequest("sessions:\(taskId)") + let list = try await api.client.listSessions(path: .init(taskId: taskId)).ok.body.json + if !latest() { + return + } + sessions[taskId] = list + } + + func loadInbox() async throws { + let latest = claimRequest("inbox") + let items = try await api.client.listInbox().ok.body.json + if !latest() { + return + } + inbox = items + } + + func loadLeadSessions() async throws { + leadSessions = try await api.client.listLeadSessions().ok.body.json + } + + func loadFocus() async throws { + let latest = claimRequest("focus") + let f = try await api.client.getFocus().ok.body.json + if !latest() { + return + } + applyFocus(f) + } + + func applyFocus(_ f: Focus) { + focus = f + if f.taskId != selectedTaskId { + selectTask(f.taskId) + } + } + + func focusedProject() -> Project? { + projects.first { $0.id == focus.projectId } + } + + func setFocus(taskId: String?? = .none, projectId: String?? = .none, tab: TaskTab? = nil) async throws { + let task = taskId ?? focus.taskId + let project = task != nil ? findTask(task!)?.projectId : (projectId ?? focus.projectId) + var next = Focus(tabs: focus.tabs, tab: tab ?? focus.tab) + if let task, !task.isEmpty { + next.taskId = task + } + if let project, !project.isEmpty { + next.projectId = project + } + let latest = claimRequest("focus") + applyFocus(next) + let f = try await api.client.setFocus(body: .json(next)).ok.body.json + if !latest() { + return + } + applyFocus(f) + } + + func focusTask(_ taskId: String, _ tab: TaskTab = .transcript) { + spawn("set focus") { + try await self.setFocus(taskId: taskId, tab: tab) + } + } + + func focusProject(_ projectId: String) { + spawn("set focus") { + try await self.setFocus(taskId: .some(nil), projectId: projectId, tab: .project) + } + } + + func focusTab(_ tab: TaskTab) { + spawn("set focus") { + try await self.setFocus(tab: tab) + } + } + + private func loadHistory(_ taskId: String, _ feed: Feed) async throws { + var got: [ByreEvent] = [] + while true { + let page = try await api.client.listTaskEvents(path: .init(taskId: taskId), query: .init(after: got.last?.seq ?? 0, limit: historyPage)).ok.body.json + got += page + if page.count < historyPage { + break + } + } + feed.load(got) + } + + private func taskFeed(_ taskId: String) -> Feed { + let feed = taskFeeds[taskId] ?? Feed(shown: { [weak self] in + self?.selectedTaskId == taskId + }) + taskFeeds[taskId] = feed + let key = ObjectIdentifier(feed) + if feed.ready || historyLoads.contains(key) { + return feed + } + historyLoads.insert(key) + Task { + do { + try await loadHistory(taskId, feed) + } catch { + log.error("load task history \(taskId, privacy: .public): \(errorText(error), privacy: .public)") + } + historyLoads.remove(key) + } + return feed + } + + private func dropClosedFeeds() { + for taskId in taskFeeds.keys { + if taskId != selectedTaskId, let task = findTask(taskId), task.status != .active { + taskFeeds[taskId] = nil + } + } + } + + func transcriptLoading() -> Bool { + selectedTaskId != nil && selectedFeed?.ready != true + } + + func leadLoading() -> Bool { + !leadFeed.ready + } + + func lastVisits() -> [String: Double] { + UserDefaults.standard.dictionary(forKey: visitsKey) as? [String: Double] ?? [:] + } + + private func markVisited(_ taskId: String?) { + guard let taskId else { + return + } + var visits = lastVisits() + visits[taskId] = Date().timeIntervalSince1970 * 1000 + UserDefaults.standard.set(visits, forKey: visitsKey) + } + + func loadSummaryReads() async throws { + summaryReads = try await api.client.getSummaryReads().ok.body.json.additionalProperties + } + + func unreadSummary(_ task: ByreTask) -> Bool { + task.workflow == nil && task.summarySeq > (summaryReads[task.id] ?? 0) + } + + private func noteSummaryRead(_ taskId: String, _ seq: Int64) -> Bool { + if (summaryReads[taskId] ?? 0) >= seq { + return false + } + summaryReads[taskId] = seq + return true + } + + func markSummaryRead(_ taskId: String, _ seq: Int64) { + if !noteSummaryRead(taskId, seq) { + return + } + spawn("mark summary read") { + _ = try await self.api.client.markSummaryRead(path: .init(taskId: taskId), body: .json(.init(seq: seq))).noContent + } + } + + func openSummary(_ task: ByreTask) { + jump = (task.id, task.summarySeq) + focusTask(task.id, .transcript) + } + + func selectTask(_ taskId: String?) { + markVisited(selectedTaskId) + markVisited(taskId) + selectedTaskId = taskId + if let taskId { + _ = taskFeed(taskId) + } + pinned = nil + reviewed = nil + dropClosedFeeds() + } + + private func fetchLeadTail() async -> EventPage? { + while !stopped { + do { + return try await api.client.listLeadEvents(query: .init(turns: leadPage)).ok.body.json + } catch { + log.error("load lead history: \(errorText(error), privacy: .public)") + try? await Task.sleep(for: .seconds(2)) + } + } + return nil + } + + func startLeadStream() async { + guard let page = await fetchLeadTail(), !stopped else { + return + } + leadMore = page.more + leadStream = EventStream(api: api, after: page.events.last?.seq ?? 0, lead: true, onEvent: { [weak self] ev in + self?.leadFeed.push(ev) + }, onState: { _ in }, onReady: { [weak self] in + self?.leadFeed.load(page.events) + }) + } + + func startGlobalStream(after: Int64) { + globalStream = EventStream(api: api, after: after, lead: false, onEvent: { [weak self] ev in + self?.handleEvent(ev) + }, onState: { [weak self] up in + self?.connected = up + }, onReady: {}) + } + + func leadHasOlder() -> Bool { + leadFeed.ready && leadMore + } + + func loadOlderLead() async throws -> [ByreEvent] { + guard let first = leadFeed.events.first else { + return [] + } + let page = try await api.client.listLeadEvents(query: .init(before: first.seq, turns: leadPage)).ok.body.json + leadMore = page.more + leadFeed.prepend(page.events) + return page.events + } + + func handleEvent(_ ev: ByreEvent) { + if let taskId = ev.taskId { + taskFeeds[taskId]?.push(ev) + } + if ev.seq == 0 { + events.send(ev) + return + } + + switch ev.kind { + case "user.updated": + if ev.payload.value["login"] as? String == me?.login { + spawn("load me") { + try await self.loadMe() + } + } + case "project.created": + spawn("load projects") { + try await self.loadProjects() + } + case "task.created", "task.merge_title", "task.merge_queued", "task.merge_unqueued", "task.merge_after_review", "task.merge_after_review_ended", "task.merge_after_review_cancelled", "task.parent_merge_started", "task.parent_merge_stopped", "task.merge_started", "task.merge_ended", "task.discard_started", "task.merged", "task.discarded", "task.state": + if let projectId = ev.projectId { + spawn("load tasks") { + try await self.loadTasks(projectId) + } + } + case "session.created", "session.state", "session.model": + if ev.owner != nil { + spawn("load lead sessions") { + try await self.loadLeadSessions() + } + } else if let taskId = ev.taskId { + spawn("load sessions") { + try await self.loadSessions(taskId) + } + } + case "inbox.created", "inbox.updated": + spawn("load inbox") { + try await self.loadInbox() + } + case "turn.ended": + if ev.payload.value["summary"] as? Bool == true, let taskId = ev.taskId { + updateTask(taskId) { task in + if ev.seq > task.summarySeq { + task.summarySeq = ev.seq + } + } + } + case "summary.read": + if let taskId = ev.payload.value["task_id"] as? String, let seq = ev.payload.value["seq"] as? Int { + _ = noteSummaryRead(taskId, Int64(seq)) + } + case "focus.updated": + if ev.actor != me?.login { + _ = claimRequest("focus") + applyFocus(try! decodePayload(ev.payload, as: Focus.self)) + } + default: + break + } + + events.send(ev) + } + + private func updateTask(_ id: String, _ change: (inout ByreTask) -> Void) { + for (projectId, list) in tasks { + if let i = list.firstIndex(where: { $0.id == id }) { + change(&tasks[projectId]![i]) + } + } + } + + func spawn(_ what: String, _ op: @escaping @MainActor () async throws -> Void) { + Task { + do { + try await op() + } catch { + log.error("\(what, privacy: .public): \(errorText(error), privacy: .public)") + } + } + } + + private func each(_ ids: [String], _ op: @escaping @Sendable @MainActor (String) async throws -> Void) async throws { + try await withThrowingTaskGroup(of: Void.self) { group in + for id in ids { + group.addTask { + try await op(id) + } + } + try await group.waitForAll() + } + } +} diff --git a/mac/Sources/Byre/Terminal.swift b/mac/Sources/Byre/Terminal.swift index 42bbd95..64e966c 100644 --- a/mac/Sources/Byre/Terminal.swift +++ b/mac/Sources/Byre/Terminal.swift @@ -6,7 +6,7 @@ let exitedCode = 4000 let internalErrorCode = 1011 struct TerminalSpike: View { - @Environment(Server.self) private var server + @Environment(Store.self) private var store @ViewState private var status = "" var body: some View { @@ -16,24 +16,26 @@ struct TerminalSpike: View { Text(status) .foregroundStyle(.secondary) } - if server.taskId.isEmpty { - Text("No task selected.") - } else if server.tasks.first(where: { $0.id == server.taskId })?.status != "active" { - Text("The task is closed; its worktree is gone.") + if let taskId = store.selectedTaskId { + if store.findTask(taskId)?.status != .active { + Text("The task is closed; its worktree is gone.") + } else { + TerminalHost(api: store.api, taskId: taskId, status: $status) + } } else { - TerminalHost(server: server, taskId: server.taskId, status: $status) + Text("No task selected.") } } } } struct TerminalHost: NSViewRepresentable { - let server: Server + let api: API let taskId: String @Binding var status: String func makeCoordinator() -> TerminalSession { - TerminalSession(server: server, taskId: taskId) { s in + TerminalSession(api: api, taskId: taskId) { s in status = s } } @@ -59,14 +61,14 @@ struct TerminalHost: NSViewRepresentable { @MainActor final class TerminalSession: NSObject, TerminalViewDelegate { - let server: Server + let api: API let taskId: String let show: (String) -> Void weak var view: TerminalView? private var ws: URLSessionWebSocketTask? - init(server: Server, taskId: String, show: @escaping (String) -> Void) { - self.server = server + init(api: API, taskId: String, show: @escaping (String) -> Void) { + self.api = api self.taskId = taskId self.show = show } @@ -76,16 +78,8 @@ final class TerminalSession: NSObject, TerminalViewDelegate { return } let t = view.terminalDimensions - guard let http = try? server.url("/api/tasks/\(taskId)/terminal", [URLQueryItem(name: "cols", value: String(t.cols)), URLQueryItem(name: "rows", value: String(t.rows))]), var c = URLComponents(url: http, resolvingAgainstBaseURL: false) else { - show("not connected") - return - } - c.scheme = "wss" - guard let wss = c.url else { - show("bad terminal URL") - return - } - let task = URLSession.shared.webSocketTask(with: wss) + let wss = api.url("/api/tasks/\(taskId)/terminal", [URLQueryItem(name: "cols", value: String(t.cols)), URLQueryItem(name: "rows", value: String(t.rows))], scheme: "wss") + let task = api.session.webSocketTask(with: wss) ws = task show("connecting…") task.resume() diff --git a/mac/Sources/Byre/Transcript.swift b/mac/Sources/Byre/Transcript.swift index ce64c50..af45cbc 100644 --- a/mac/Sources/Byre/Transcript.swift +++ b/mac/Sources/Byre/Transcript.swift @@ -1,51 +1,5 @@ import SwiftUI -enum JSON: Decodable { - case string(String) - case number(Double) - case bool(Bool) - case object([String: JSON]) - case array([JSON]) - case null - - init(from decoder: Decoder) throws { - let c = try decoder.singleValueContainer() - if c.decodeNil() { - self = .null - } else if let v = try? c.decode(Bool.self) { - self = .bool(v) - } else if let v = try? c.decode(Double.self) { - self = .number(v) - } else if let v = try? c.decode(String.self) { - self = .string(v) - } else if let v = try? c.decode([JSON].self) { - self = .array(v) - } else { - self = .object(try c.decode([String: JSON].self)) - } - } - - subscript(key: String) -> JSON? { - if case .object(let o) = self { - return o[key] - } - return nil - } - - var string: String? { - if case .string(let s) = self { - return s - } - return nil - } -} - -struct Event: Decodable { - let seq: Int64 - let kind: String - let payload: JSON -} - struct Row: Identifiable { let id: Int64 let kind: String @@ -53,16 +7,16 @@ struct Row: Identifiable { var tools: [String] = [] } -func buildRows(_ events: [Event]) -> [Row] { +func buildRows(_ events: [ByreEvent]) -> [Row] { var rows: [Row] = [] for e in events { switch e.kind { case "user.message", "agent.message", "agent.thinking": - rows.append(Row(id: e.seq, kind: e.kind, text: e.payload["text"]?.string ?? "")) + rows.append(Row(id: e.seq, kind: e.kind, text: e.payload.value["text"] as? String ?? "")) case "agent.tool_call": - let name = e.payload["name"]?.string ?? "?" - let input = e.payload["input"] - let detail = input?["file_path"]?.string ?? input?["command"]?.string ?? input?["pattern"]?.string ?? "" + let name = e.payload.value["name"] as? String ?? "?" + let input = e.payload.value["input"] as? [String: (any Sendable)?] + let detail = input?["file_path"] as? String ?? input?["command"] as? String ?? input?["pattern"] as? String ?? "" let line = "\(name) \(detail)" if rows.last?.kind == "tools" { rows[rows.count - 1].tools.append(line) @@ -139,7 +93,7 @@ enum Container: String, CaseIterable { } struct TranscriptSpike: View { - @Environment(Server.self) private var server + @Environment(Store.self) private var store @ViewState private var all: [Row] = [] @ViewState private var shown = 0 @ViewState private var container = Container.lazyVStack @@ -219,8 +173,8 @@ struct TranscriptSpike: View { } } } - .task { - await load() + .onChange(of: store.transcript.count, initial: true) { + load() } .task(id: playing) { while playing && shown < all.count { @@ -234,26 +188,12 @@ struct TranscriptSpike: View { } } - private func load() async { - guard !server.taskId.isEmpty else { + private func load() { + guard let feed = store.selectedFeed, feed.ready else { return } let start = Date() - var got: [Event] = [] - var after: Int64 = 0 - do { - while true { - let page = try await server.get([Event].self, "/api/tasks/\(server.taskId)/events", [URLQueryItem(name: "after", value: String(after)), URLQueryItem(name: "limit", value: "1000")]) - got += page - if page.count < 1000 { - break - } - after = page.last!.seq - } - } catch { - server.log("transcript: \(error)") - return - } + let got = feed.events events = got.count all = buildRows(got) shown = all.count > 200 ? all.count - 100 : all.count diff --git a/mac/Sources/Byre/openapi-generator-config.yaml b/mac/Sources/Byre/openapi-generator-config.yaml new file mode 100644 index 0000000..ee9d836 --- /dev/null +++ b/mac/Sources/Byre/openapi-generator-config.yaml @@ -0,0 +1,5 @@ +generate: + - types + - client +accessModifier: internal +namingStrategy: idiomatic diff --git a/mac/Sources/Byre/openapi.yaml b/mac/Sources/Byre/openapi.yaml new file mode 120000 index 0000000..8b94000 --- /dev/null +++ b/mac/Sources/Byre/openapi.yaml @@ -0,0 +1 @@ +../../../api/openapi.yaml \ No newline at end of file diff --git a/mac/generate.go b/mac/generate.go new file mode 100644 index 0000000..15eac86 --- /dev/null +++ b/mac/generate.go @@ -0,0 +1,3 @@ +package mac + +//go:generate go run ./palette diff --git a/mac/palette/main.go b/mac/palette/main.go new file mode 100644 index 0000000..1c4a3d7 --- /dev/null +++ b/mac/palette/main.go @@ -0,0 +1,95 @@ +package main + +import ( + "fmt" + "log" + "os" + "regexp" + "strings" +) + +var ( + property = regexp.MustCompile(`^\s*--([a-z0-9-]+):\s*#([0-9a-f]{6});\s*$`) + darkOpen = regexp.MustCompile(`^@media \(prefers-color-scheme: dark\) \{$`) +) + +func block(lines []string, start int) (map[string]string, []string) { + colors := map[string]string{} + order := []string{} + for _, line := range lines[start+1:] { + if strings.TrimSpace(line) == "}" { + return colors, order + } + m := property.FindStringSubmatch(line) + if m == nil { + continue + } + colors[m[1]] = m[2] + order = append(order, m[1]) + } + log.Fatalf("unterminated block at line %d", start+1) + return nil, nil +} + +func swiftName(prop string) string { + parts := strings.Split(prop, "-") + for i := 1; i < len(parts); i++ { + parts[i] = strings.ToUpper(parts[i][:1]) + parts[i][1:] + } + return strings.Join(parts, "") +} + +func main() { + css, err := os.ReadFile("../web/style.css") + if err != nil { + log.Fatal(err) + } + lines := strings.Split(string(css), "\n") + lightAt, darkAt := -1, -1 + for i, line := range lines { + if line == ":root {" && lightAt < 0 { + lightAt = i + } + if darkOpen.MatchString(line) && i+1 < len(lines) && strings.TrimSpace(lines[i+1]) == ":root {" { + darkAt = i + 1 + } + } + if lightAt < 0 || darkAt < 0 { + log.Fatal("web/style.css has no :root block or no prefers-color-scheme: dark :root block") + } + light, order := block(lines, lightAt) + dark, _ := block(lines, darkAt) + + var b strings.Builder + b.WriteString("// Code generated by go generate ./mac from web/style.css. DO NOT EDIT.\n\n") + b.WriteString("import AppKit\nimport SwiftUI\n\n") + b.WriteString("enum Palette {\n") + for _, prop := range order { + d, ok := dark[prop] + if !ok { + log.Fatalf("--%s has no dark value", prop) + } + fmt.Fprintf(&b, " static let %s = dynamic(0x%s, 0x%s)\n", swiftName(prop), light[prop], d) + } + for prop := range dark { + if _, ok := light[prop]; !ok { + log.Fatalf("--%s has no light value", prop) + } + } + b.WriteString(` + private static func rgb(_ hex: UInt32) -> NSColor { + NSColor(srgbRed: CGFloat(hex >> 16 & 0xff) / 255, green: CGFloat(hex >> 8 & 0xff) / 255, blue: CGFloat(hex & 0xff) / 255, alpha: 1) + } + + private static func dynamic(_ light: UInt32, _ dark: UInt32) -> Color { + Color(nsColor: NSColor(name: nil) { appearance in + appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua ? rgb(dark) : rgb(light) + }) + } +} +`) + err = os.WriteFile("Sources/Byre/Palette.swift", []byte(b.String()), 0o644) + if err != nil { + log.Fatal(err) + } +}