From 00cd1ed9585477d03b654ac58f7b4f3c7b4511e5 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Fri, 16 Oct 2026 23:30:57 -0700 Subject: [PATCH] macOS client panes and transcript --- TODO.md | 2 - docs/macos.md | 13 +- docs/operations.md | 2 +- mac/Sources/Byre/ByreApp.swift | 55 +- mac/Sources/Byre/Chat.swift | 964 +++++++++++++++++++++++ mac/Sources/Byre/Composer.swift | 591 +++++++------- mac/Sources/Byre/Diagram.swift | 572 ++++++++++++++ mac/Sources/Byre/Dialogs.swift | 176 +++++ mac/Sources/Byre/Drafts.swift | 204 +++++ mac/Sources/Byre/FontAndSVG.swift | 113 --- mac/Sources/Byre/Fonts.swift | 15 +- mac/Sources/Byre/Format.swift | 354 +++++++++ mac/Sources/Byre/Images.swift | 143 ++++ mac/Sources/Byre/Markdown.swift | 328 ++++++++ mac/Sources/Byre/MarkdownView.swift | 425 ++++++++++ mac/Sources/Byre/PaneFocus.swift | 182 +++++ mac/Sources/Byre/Panes.swift | 547 +++++++++++++ mac/Sources/Byre/SpikeView.swift | 70 -- mac/Sources/Byre/Startup.swift | 14 +- mac/Sources/Byre/Store.swift | 35 +- mac/Sources/Byre/Terminal.swift | 24 - mac/Sources/Byre/Transcript.swift | 222 ------ mac/Sources/Byre/TranscriptBuilder.swift | 747 ++++++++++++++++++ 23 files changed, 5059 insertions(+), 739 deletions(-) create mode 100644 mac/Sources/Byre/Chat.swift create mode 100644 mac/Sources/Byre/Diagram.swift create mode 100644 mac/Sources/Byre/Dialogs.swift create mode 100644 mac/Sources/Byre/Drafts.swift delete mode 100644 mac/Sources/Byre/FontAndSVG.swift create mode 100644 mac/Sources/Byre/Format.swift create mode 100644 mac/Sources/Byre/Images.swift create mode 100644 mac/Sources/Byre/Markdown.swift create mode 100644 mac/Sources/Byre/MarkdownView.swift create mode 100644 mac/Sources/Byre/PaneFocus.swift create mode 100644 mac/Sources/Byre/Panes.swift delete mode 100644 mac/Sources/Byre/SpikeView.swift delete mode 100644 mac/Sources/Byre/Transcript.swift create mode 100644 mac/Sources/Byre/TranscriptBuilder.swift diff --git a/TODO.md b/TODO.md index b0feef4..cdb30b4 100644 --- a/TODO.md +++ b/TODO.md @@ -16,8 +16,6 @@ # M8 — macOS client -- 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 Changes, Files, Terminal, Inbox, Reviews, Workflow (native stage graph), Project, Status, Settings. - macOS client overlays, menus and notifications diff --git a/docs/macos.md b/docs/macos.md index d8b1cf6..3a6e206 100644 --- a/docs/macos.md +++ b/docs/macos.md @@ -1,6 +1,6 @@ # byre for macOS -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`. +This is a design, partly built. What exists under `mac/`: the generated API client, the SSE client, `Store` and `Feed`, the startup sequence, the connection screen with Change Server…, the generated palette, the window with its two panes and tab strips (More included), the Lead and task transcripts through `TranscriptBuilder`, the composer with drafts and images, the Markdown port, native flowchart diagrams, pane-focus reporting and the identity banner. Overlays so far are alerts, the More menu and the full-size image view. The other tabs (Changes, Files, Terminal, Inbox, Reviews, Workflow, Project, Status, Settings) show ` isn't in the macOS app yet.`. Until the switcher exists, the task picker is a plain menu of the active tasks, and the transcript head has Compact and Sync but not yet Merge or Discard. The rest of the work is under "M8 — macOS client" in `TODO.md`. ## Decision @@ -59,7 +59,8 @@ The Swift files follow the TypeScript ones, so a change in one points at the sam | `switcher.ts` | `Switcher.swift` | ⌘K overlay and the shortcuts help | | `drafts.ts` | `Drafts.swift` | Composer and answer drafts | | `notifications.ts` | `Notifications.swift` | `UNUserNotificationCenter` delivery | -| `dom.ts` | `Dialogs.swift`, `Format.swift` | Alert and confirm overlays, `ago`, byte sizes, Copy | +| `dom.ts` | `Dialogs.swift`, `Format.swift` | Overlays (alerts, the More menu, the image view; confirm to come), `ago`, Copy, shared button and layout styles | +| `images.ts` | `Images.swift` | Blob, worktree and `data:` images, their cache and the full-size overlay | ## Store and events @@ -73,7 +74,7 @@ API calls go through the generated `Client` over `URLSessionTransport`. A middle `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. +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. `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. @@ -150,11 +151,11 @@ 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`); 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. +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. Quitting does the same flush and report, waiting up to 2 s for the report before exiting (`applicationShouldTerminate` in `ByreApp.swift`). ## Gaps in SwiftUI -Places where SwiftUI alone may not match the web client. Each is checked in the spike before the rest is built, and the fallback is used only if the check fails. The spike's tabs put the SwiftUI way and the fallback side by side and log each key press, paste, drop and upload they see: +Places where SwiftUI alone may not match the web client, each checked side by side against its fallback; the fallback is used only where the check failed: | Need | SwiftUI | Fallback | Spike result | | --- | --- | --- | --- | @@ -166,7 +167,7 @@ Places where SwiftUI alone may not match the web client. Each is checked in the | Font from `web/fonts/` | Core Text | Convert the files to TTF in the build script if Core Text refuses them | No fallback: Core Text reads the woff2 files and their `wght` axis (200–800) | | SVG images from the worktree image endpoint | `NSImage(data:)` | Show the "image not available" text | No fallback: `NSImage(data:)` decodes the SVG from the worktree image endpoint (`_NSSVGImageRep`) and draws it sharp at any size | -The font files need one thing beyond loading. The latin and latin-ext files are subsets of one variable font with the same PostScript names, so registering both and looking the font up by name finds only the latin file, and characters such as `ő` and `Ł` fall back to a system font. The app instead makes a descriptor from each file's URL, sets the weight with `CTFontDescriptorCreateCopyWithVariation`, and puts the latin-ext descriptor in the latin one's `kCTFontCascadeListAttribute`; characters in neither file then fall back to the system cascade (`→` to Menlo). `Fonts.swift` in the spike does this. SwiftUI gets the result through `Font(CTFont)`. +The font files need one thing beyond loading. The latin and latin-ext files are subsets of one variable font with the same PostScript names, so registering both and looking the font up by name finds only the latin file, and characters such as `ő` and `Ł` fall back to a system font. The app instead makes a descriptor from each file's URL, sets the weight with `CTFontDescriptorCreateCopyWithVariation`, and puts the latin-ext descriptor in the latin one's `kCTFontCascadeListAttribute`; characters in neither file then fall back to the system cascade (`→` to Menlo). `Fonts.swift` does this. SwiftUI gets the result through `Font(CTFont)`. ## Differences from the web client diff --git a/docs/operations.md b/docs/operations.md index f6458c5..056ebe2 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`. -- `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. +- `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. The app keeps its state in its own defaults domain (`defaults read run.fc.byre.mac` prints it): the server origin in `byre.server`, last task visits in `byre.visits`, and composer and answer drafts as JSON in `byre.drafts..`, one key per server and login. Pasted images stay in memory with the connection and are dropped on Change Server… and quit. It reports `PUT /api/me/pane-focus` as the web client does, and sends `focused: false` on Change Server…, on closing the window and on quit. ## Data diff --git a/mac/Sources/Byre/ByreApp.swift b/mac/Sources/Byre/ByreApp.swift index 12fd89f..f2e348f 100644 --- a/mac/Sources/Byre/ByreApp.swift +++ b/mac/Sources/Byre/ByreApp.swift @@ -1,24 +1,55 @@ import AppKit -import SwiftTerm import SwiftUI typealias ViewState = State +@MainActor +final class AppDelegate: NSObject, NSApplicationDelegate { + var connection: Connection? + private var quitting = false + + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + guard let store = connection?.store, !quitting else { + return .terminateNow + } + quitting = true + var replied = false + let reply = { + if !replied { + replied = true + sender.reply(toApplicationShouldTerminate: true) + } + } + Task { + await store.stop() + reply() + } + Task { + try? await Task.sleep(for: .seconds(2)) + reply() + } + return .terminateLater + } +} + @main struct ByreApp: App { + @NSApplicationDelegateAdaptor private var delegate: AppDelegate @ViewState private var connection = Connection() - @ViewState private var spike = SpikeLog() + @ViewState private var overlays = Overlays() var body: some Scene { Window("byre", id: "main") { RootView() .environment(connection) - .environment(spike) + .environment(overlays) .font(Fonts.font(13)) + .frame(minWidth: 640, minHeight: 400) .onAppear { - installCtrlMonitor(spike) + delegate.connection = connection } } + .defaultSize(width: 1280, height: 820) .commands { CommandGroup(after: .appInfo) { Button("Change Server…") { @@ -29,19 +60,3 @@ struct ByreApp: App { } } } - -@MainActor -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 { - spike.log("ctrl-\(key) passed to the terminal") - return event - } - spike.log("ctrl-\(key) taken as a Go shortcut") - return nil - } -} diff --git a/mac/Sources/Byre/Chat.swift b/mac/Sources/Byre/Chat.swift new file mode 100644 index 0000000..a070e5c --- /dev/null +++ b/mac/Sources/Byre/Chat.swift @@ -0,0 +1,964 @@ +import AppKit +import SwiftUI + +enum ChatKind: Equatable { + case lead + case task(String?) +} + +@MainActor +@Observable +final class ChatModel { + let kind: ChatKind + let store: Store + let builder: TranscriptBuilder + private(set) var scrollRequest: (seq: Int, id: Int, anchor: UnitPoint)? + private(set) var fetching = false + private(set) var olderGeneration = 0 + @ObservationIgnored var stuck = true + @ObservationIgnored private var offs: [() -> Void] = [] + @ObservationIgnored private var focusStart = true + @ObservationIgnored private var visibleSummaries: Set = [] + @ObservationIgnored private var activeObserver: (any NSObjectProtocol)? + @ObservationIgnored private var started = false + + init(_ kind: ChatKind, _ store: Store) { + self.kind = kind + self.store = store + let taskId: String? = if case .task(let id) = kind { id } else { nil } + let session: () -> AgentSession? = { [weak store] in + switch kind { + case .lead: store?.currentLead() + case .task(let id): id.flatMap { store?.currentSession($0) } + } + } + builder = TranscriptBuilder(taskId: taskId, worktree: { [weak store] in + taskId.flatMap { store?.findTask($0)?.worktreePath } ?? "" + }, running: { + session()?.state == .running + }) + } + + var key: String? { + switch kind { + case .lead: "lead" + case .task(let id): id + } + } + + var taskId: String? { + if case .task(let id) = kind { + return id + } + return nil + } + + private var feed: Feed? { + switch kind { + case .lead: store.leadFeed + case .task(let id): id != nil ? store.selectedFeed : nil + } + } + + var session: AgentSession? { + switch kind { + case .lead: store.currentLead() + case .task(let id): id.flatMap { store.currentSession($0) } + } + } + + var liveSession: AgentSession? { + session.flatMap { $0.state != .completed ? $0 : nil } + } + + var loading: Bool { + switch kind { + case .lead: store.leadLoading() + case .task: store.transcriptLoading() + } + } + + var placeholder: String { + kind == .lead ? "Message your lead. Paste or drop images to attach them." : "Message the agent. Paste or drop images to attach them." + } + + var empty: String { + kind == .lead ? "" : "No task selected. Ask your lead, or pick one with ⌘K." + } + + var running: Bool { + builder.open && session?.state == .running + } + + var hasOlder: Bool { + kind == .lead && store.leadHasOlder() + } + + func start() { + if started { + return + } + started = true + guard let feed else { + return + } + appended(feed.events) + offs.append(feed.appended.add { [weak self] events in + self?.appended(events) + }) + offs.append(feed.streamed.add { [weak self] ev in + self?.builder.appendDelta(ev) + }) + activeObserver = NotificationCenter.default.addObserver(forName: NSApplication.didBecomeActiveNotification, object: nil, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { + self?.markShown() + } + } + } + + func stop() { + for off in offs { + off() + } + offs = [] + if let activeObserver { + NotificationCenter.default.removeObserver(activeObserver) + } + activeObserver = nil + builder.stop() + store.drafts?.flush() + } + + private func appended(_ events: [ByreEvent]) { + _ = builder.append(events) + jump() + } + + func takeFocusStart() -> Bool { + let take = focusStart && session == nil + focusStart = false + return take + } + + func summaryVisible(_ seq: Int64, _ visible: Bool) { + if visible { + visibleSummaries.insert(seq) + } else { + visibleSummaries.remove(seq) + } + markShown() + } + + private func markShown() { + guard let taskId, NSApp.isActive else { + return + } + for seq in visibleSummaries { + store.markSummaryRead(taskId, seq) + } + } + + func jump() { + guard let target = store.jump, target.taskId == taskId else { + return + } + let rows = builder.root.rows + guard let i = rows.firstIndex(where: { $0.summarySeq == target.seq }) else { + return + } + store.jump = nil + stuck = false + if i > 0, case .agent = rows[i - 1].kind { + requestScroll(rows[i - 1].id, .top) + } else { + requestScroll(rows[i].id, .bottom) + } + } + + private func requestScroll(_ id: Int, _ anchor: UnitPoint) { + scrollRequest = ((scrollRequest?.seq ?? 0) + 1, id, anchor) + } + + func loadOlder() { + if fetching || !hasOlder { + return + } + fetching = true + Task { + do { + let events = try await store.loadOlderLead() + let first = builder.root.rows.first?.id + builder.prepend(events) + if let first { + requestScroll(first, .top) + } + } catch { + log.error("load earlier events: \(errorText(error), privacy: .public)") + } + fetching = false + olderGeneration += 1 + } + } + + func send(sessionId: String?, text: String, images: [String]?, model: String?, firstModel: String) async throws { + let api = store.api.client + switch kind { + case .lead: + _ = try await api.messageLead(body: .json(.init(text: text, images: images, model: sessionId != nil ? model : firstModel))).accepted + case .task(let id): + if let sessionId { + _ = try await api.sendMessage(path: .init(sessionId: sessionId), body: .json(.init(text: text, images: images, model: model))).accepted + return + } + _ = try await api.createSession(path: .init(taskId: id ?? ""), body: .json(.init(prompt: text, model: firstModel, images: images))).created + } + } + + func interrupt(_ sessionId: String, _ overlays: Overlays) { + Task { + do { + _ = try await store.api.client.interruptSession(path: .init(sessionId: sessionId)).accepted + } catch { + overlays.showAlert(errorText(error)) + } + } + } +} + +struct ChatView: View { + let kind: ChatKind + @Environment(Store.self) private var store + @ViewState private var model: ChatModel? + + var body: some View { + VStack(spacing: 0) { + if let model { + ChatHead(model: model) + TranscriptScroll(model: model) + if let key = model.key { + ComposerView(chat: model, key: key) + } + } else { + Spacer() + } + } + .onAppear { + let m = model ?? ChatModel(kind, store) + model = m + m.start() + } + .onDisappear { + model?.stop() + } + .onChange(of: store.jump?.seq) { + model?.jump() + } + } +} + +private struct ChatHead: View { + let model: ChatModel + @Environment(Store.self) private var store + + var body: some View { + switch model.kind { + case .lead: + let live = model.liveSession + header { + CompactButton(sessionId: live?.id, state: live?.state, usage: live.flatMap { model.builder.context[$0.id] }) + } + case .task(let id): + if let id, let task = store.findTask(id) { + if task.status != .active { + header { + Text(task.status.rawValue) + } + } else { + let sess = store.currentSession(task.id) + header { + CompactButton(sessionId: sess?.id, state: sess?.state, usage: sess.flatMap { model.builder.context[$0.id] }) + SyncButton(task: task, busy: sess?.state == .running) + } + } + } + } + } + + private func header(@ViewBuilder _ content: () -> Content) -> some View { + HStack(spacing: 8) { + content() + Spacer(minLength: 0) + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .overlay(alignment: .bottom) { + Rectangle().fill(Palette.border).frame(height: 1) + } + } +} + +private struct SyncButton: View { + let task: ByreTask + let busy: Bool + @Environment(Store.self) private var store + @Environment(Overlays.self) private var overlays + @ViewState private var sending = false + + var body: some View { + let target = store.projects.first { $0.id == task.projectId }?.defaultBranch ?? "the default branch" + Button("Sync from \(target)") { + sending = true + Task { + do { + _ = try await store.api.client.mergeIn(path: .init(taskId: task.id)).accepted + } catch { + overlays.showAlert(errorText(error)) + sending = false + } + } + } + .buttonStyle(WebButtonStyle()) + .disabled(busy || sending) + .help("Ask the agent to bring \(target) into this branch and resolve any conflicts") + .onChange(of: busy) { + sending = false + } + } +} + +struct CompactButton: View { + let sessionId: String? + let state: AgentSession.StatePayload? + let usage: Usage? + @Environment(Store.self) private var store + @Environment(Overlays.self) private var overlays + @Environment(\.isEnabled) private var enabled + @ViewState private var sending = false + + var body: some View { + let pct = usage.map { $0.window > 0 ? min(100, Double($0.used) / Double($0.window) * 100) : 0 } ?? 0 + let level = pct >= 90 ? Palette.danger : pct >= 70 ? Palette.warn : Palette.accent + let detail = usage.map { u in u.window > 0 ? "\ncontext: \(u.used.formatted()) of \(u.window.formatted()) tokens\(u.model.isEmpty ? "" : " (\(u.model))")" : "" } ?? "" + let disabled = sessionId == nil || state != .idle || sending + Button { + guard let sessionId else { + return + } + sending = true + Task { + do { + _ = try await store.api.client.compactSession(path: .init(sessionId: sessionId)).accepted + } catch { + overlays.showAlert(errorText(error)) + sending = false + } + } + } label: { + Text("Compact") + .foregroundStyle(Palette.text) + .padding(.horizontal, 12) + .padding(.vertical, 4) + .background { + GeometryReader { g in + HStack(spacing: 0) { + ZStack { + Palette.raised + level.opacity(0.25) + } + .frame(width: g.size.width * pct / 100) + Palette.raised + } + } + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + .overlay(RoundedRectangle(cornerRadius: 6).stroke(pct >= 70 ? level : Palette.border)) + .opacity(disabled ? 0.45 : 1) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(disabled) + .help("Ask the agent for a handoff, then continue in a new session with a fresh context\(detail)") + .onChange(of: state) { + sending = false + } + } +} + +private struct ScrollMetrics: Equatable { + let content: CGFloat + let visible: CGFloat + let distance: CGFloat +} + +private struct TranscriptScroll: View { + let model: ChatModel + @Environment(Store.self) private var store + @Environment(\.paneAccent) private var accent + @ViewState private var position = ScrollPosition(edge: .bottom) + + var body: some View { + let running = model.running + ScrollView { + LazyVStack(alignment: .leading, spacing: 12) { + if model.key == nil { + Muted(text: model.empty) + } else { + if model.loading { + Muted(text: "loading…") + } + if model.fetching { + Muted(text: "loading earlier…") + } + if model.hasOlder && !model.fetching { + Color.clear + .frame(height: 1) + .id("older-\(model.olderGeneration)") + .onAppear { + model.loadOlder() + } + } + ForEach(model.builder.root.rows) { row in + RowView(row: row, model: model, nested: false) + } + ForEach(model.builder.pending) { p in + PendingView(pending: p) + } + if running { + Scanner(color: accent) + } + } + } + .scrollTargetLayout() + .padding(16) + } + .scrollPosition($position) + .defaultScrollAnchor(.bottom) + .onScrollGeometryChange(for: ScrollMetrics.self) { g in + ScrollMetrics(content: g.contentSize.height, visible: g.visibleRect.height, distance: g.contentSize.height - g.visibleRect.maxY) + } action: { old, new in + if new.content != old.content || new.visible != old.visible { + if model.stuck && new.distance > 1 { + position.scrollTo(edge: .bottom) + } + return + } + model.stuck = new.distance < 80 + } + .onChange(of: model.builder.pending.count) { + model.stuck = true + position.scrollTo(edge: .bottom) + } + .onChange(of: model.scrollRequest?.seq, initial: true) { + if let r = model.scrollRequest { + position.scrollTo(id: r.id, anchor: r.anchor) + } + } + .overlay { + Rectangle().stroke(running ? accent : .clear, lineWidth: 1) + .animation(.easeInOut(duration: 0.3), value: running) + } + } +} + +private struct Scanner: View { + let color: Color + @ViewState private var phase = false + + var body: some View { + Capsule() + .fill(LinearGradient(colors: [.clear, color, .clear], startPoint: .leading, endPoint: .trailing)) + .frame(width: 30, height: 2) + .offset(x: phase ? 54 : 0) + .frame(width: 84, height: 2, alignment: .leading) + .onAppear { + withAnimation(.easeInOut(duration: 1.2).repeatForever(autoreverses: true)) { + phase = true + } + } + } +} + +private struct PendingView: View { + let pending: PendingSend + + var body: some View { + TrailingBubble { + VStack(alignment: .leading, spacing: 2) { + Text(pending.queued ? "queued…" : "sending…") + .font(Fonts.font(11, italic: true)) + .foregroundStyle(Palette.muted) + if !pending.images.isEmpty { + FlowLayout(spacing: 6) { + ForEach(pending.images) { img in + Image(nsImage: img.image) + .resizable() + .scaledToFit() + .frame(maxWidth: 240, maxHeight: 180) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(Palette.border)) + } + } + .padding(.vertical, 4) + } + if !pending.md.isEmpty { + MarkdownView(blocks: pending.md) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Palette.accentTint, in: RoundedRectangle(cornerRadius: 10)) + } + } +} + +struct DetailsBox: View { + var boxed = true + var nested = false + var working = false + @ViewBuilder let summary: Summary + @ViewBuilder let content: Content + @Environment(\.paneAccent) private var accent + @ViewState private var open = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Button { + open.toggle() + } label: { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(open ? "▾" : "▸") + .foregroundStyle(Palette.muted) + summary + if working { + Spinner(color: accent) + .padding(.leading, 4) + } + Spacer(minLength: 0) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + if open { + content + } + } + .padding(.horizontal, boxed ? 10 : 0) + .padding(.vertical, boxed ? 4 : 0) + .background(boxed ? (nested ? Palette.raised : Palette.surface) : .clear, in: RoundedRectangle(cornerRadius: 8)) + .overlay { + if boxed { + RoundedRectangle(cornerRadius: 8).stroke(working ? accent : Palette.border) + } + } + } +} + +private struct WhenText: View { + let when: Date + + var body: some View { + Text(ago(when)) + .help(isoText(when)) + } +} + +private struct SubagentTag: View { + let show: Bool + + var body: some View { + if show { + Text("↳ subagent") + .font(Fonts.font(11)) + .foregroundStyle(Palette.accent) + .padding(.trailing, 4) + } + } +} + +struct RowView: View { + let row: Row + let model: ChatModel + let nested: Bool + @Environment(Store.self) private var store + + var body: some View { + if !row.hidden { + content + } + } + + private var md: MDContext { + MDContext(taskId: row.taskId) + } + + @ViewBuilder + private var content: some View { + switch row.kind { + case .user(let actor, let when, let images, let blocks): + TrailingBubble { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Text(actor) + WhenText(when: when) + } + .font(Fonts.font(11)) + .foregroundStyle(Palette.muted) + if !images.isEmpty { + MessageImages(ids: images) + } + if let blocks { + MarkdownView(blocks: blocks, ctx: md) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Palette.accentTint, in: RoundedRectangle(cornerRadius: 10)) + } + + case .byrePrompt(let brief, let when, let blocks): + DetailsBox(nested: nested) { + Text("byre").font(Fonts.font(12, weight: 600)) + Text(brief).font(Fonts.font(12)).foregroundStyle(Palette.muted).lineLimit(1) + Spacer(minLength: 12) + WhenText(when: when).font(Fonts.font(12)).foregroundStyle(Palette.muted) + } content: { + MarkdownView(blocks: blocks, ctx: md) + .padding(.vertical, 6) + } + + case .agent(let blocks): + MarkdownView(blocks: blocks, ctx: md) + + case .subagentMessage(let when, let blocks): + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + SubagentTag(show: row.tag) + WhenText(when: when) + } + .font(Fonts.font(11)) + .foregroundStyle(Palette.muted) + MarkdownView(blocks: blocks, ctx: md) + } + .padding(.leading, 10) + .overlay(alignment: .leading) { + Rectangle().fill(Palette.accent).frame(width: 1) + } + + case .stream: + MarkdownView(blocks: row.md, ctx: md) + + case .thinking(let blocks): + DetailsBox(nested: nested) { + SubagentTag(show: row.tag) + Text("thinking").foregroundStyle(Palette.muted) + } content: { + MarkdownView(blocks: blocks, ctx: MDContext(color: Palette.muted, taskId: row.taskId)) + .padding(.vertical, 6) + } + + case .tool(let name, let brief, let input): + DetailsBox(nested: nested, working: model.running && row.pending) { + SubagentTag(show: row.tag) + Text(styled([(name, 600, nil), (" ", 400, nil), (brief, 400, Palette.muted)])) + } content: { + VStack(alignment: .leading, spacing: 6) { + PreBox(text: input) + ToolResult(result: row.result, unfinished: row.unfinished) + } + .padding(.vertical, 6) + } + + case .subagent(let title, let description, let prompt): + DetailsBox(nested: nested, working: model.running && row.pending) { + SubagentTag(show: row.tag) + Text(styled([(title, 600, nil), (" ", 400, nil), (description, 400, Palette.muted)])) + Spacer(minLength: 12) + Text(row.status) + .font(Fonts.font(12)) + .foregroundStyle(row.statusState == .done ? Palette.muted : row.statusState == .error ? Palette.danger : Palette.accent) + } content: { + VStack(alignment: .leading, spacing: 6) { + MarkdownView(blocks: prompt, ctx: md) + .padding(.vertical, 6) + ForEach(row.body!.rows) { child in + RowView(row: child, model: model, nested: true) + } + ToolResult(result: row.result, unfinished: row.unfinished) + } + } + + case .group: + DetailsBox(nested: nested, working: model.running && row.pending) { + SubagentTag(show: row.tag) + RunSummary(calls: row.calls) + } content: { + VStack(alignment: .leading, spacing: 6) { + ForEach(row.body!.rows) { child in + RowView(row: child, model: model, nested: true) + } + } + .padding(.top, 6) + .padding(.bottom, 4) + } + + case .orphan(let text): + PreBox(text: text) + + case .turnEnd(let error): + Group { + if let error { + Text(error) + .font(Fonts.font(11)) + .foregroundStyle(Palette.danger) + } else { + Rectangle().fill(Palette.border).frame(height: 1) + } + } + .onScrollVisibilityChange(threshold: 0.01) { visible in + if let seq = row.summarySeq { + model.summaryVisible(seq, visible) + } + } + + case .note(let note): + NoteView(note: note, cancelVisible: row.id == model.builder.lastMergeAfterReview) + + case .handoff: + DetailsBox(boxed: false) { + NoteView(note: row.summary ?? Note(parts: []), cancelVisible: false) + } content: { + VStack(alignment: .leading, spacing: 12) { + ForEach(row.body!.rows) { child in + RowView(row: child, model: model, nested: false) + } + } + .padding(.leading, 10) + .overlay(alignment: .leading) { + Rectangle().fill(Palette.border).frame(width: 1) + } + .padding(.top, 8) + } + + case .snapshot(let snap): + SnapshotRow(snap: snap, review: row.review, taskId: row.taskId) + } + } +} + +private struct ToolResult: View { + let result: String? + let unfinished: Bool + + var body: some View { + if let result { + PreBox(text: result) + } else { + Text(unfinished ? "no result" : "running…") + .font(Fonts.font(12, italic: true)) + .foregroundStyle(Palette.muted) + .padding(.vertical, 4) + } + } +} + +private struct RunSummary: View { + let calls: [Call] + + var body: some View { + var order: [String] = [] + var counts: [String: (call: Call, n: Int)] = [:] + for c in calls { + let key = "\(c.name)\0\(c.target)" + if counts[key] == nil { + order.append(key) + counts[key] = (c, 0) + } + counts[key]!.n += 1 + } + if order.isEmpty { + return Text(styled([("thinking", 400, Palette.muted)])) + } + var parts: [(String, CGFloat, Color?)] = [] + for (i, key) in order.enumerated() { + let (call, n) = counts[key]! + if i > 0 { + parts.append((", ", 400, nil)) + } + parts.append((call.name, 600, nil)) + if !call.target.isEmpty { + parts.append((" \(call.target)", 400, Palette.muted)) + } + if n > 1 { + parts.append((" ×\(n)", 400, Palette.muted)) + } + } + return Text(styled(parts)) + } +} + +private func styled(_ parts: [(String, CGFloat, Color?)], size: CGFloat = 12) -> AttributedString { + var out = AttributedString() + for (text, weight, color) in parts { + var a = AttributedString(text) + a.font = Fonts.font(size, weight: weight) + if let color { + a.foregroundColor = color + } + out += a + } + return out +} + +struct NoteView: View { + let note: Note + let cancelVisible: Bool + @Environment(Store.self) private var store + @Environment(Overlays.self) private var overlays + @ViewState private var cancelling = false + + var body: some View { + let color = note.style == .error ? Palette.danger : note.style == .ask ? Palette.accent : Palette.muted + HStack(alignment: .firstTextBaseline, spacing: 12) { + Text(text()) + .font(Fonts.font(11)) + .foregroundStyle(color) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 0) + if let when = note.when { + WhenText(when: when) + .font(Fonts.font(11)) + .foregroundStyle(color) + } + if let taskId = note.cancelMergeAfterReview, cancelVisible, store.findTask(taskId)?.mergeAfterReview == true { + Button("Cancel") { + cancelling = true + Task { + do { + _ = try await store.api.client.cancelMergeAfterReview(path: .init(taskId: taskId)).ok + } catch { + overlays.showAlert(errorText(error)) + } + cancelling = false + } + } + .buttonStyle(SmallLinkButtonStyle()) + .disabled(cancelling) + .help("Withdraw this request; nothing is merged") + } + } + } + + private func text() -> AttributedString { + var out = AttributedString() + for part in note.parts { + switch part { + case .text(let s): + out += AttributedString(s) + case .task(let id): + guard let task = store.findTask(id) else { + out += AttributedString(id) + continue + } + var a = AttributedString(task.title) + if task.status == .active { + a.link = AppLink.task(task.id) + a.foregroundColor = taskPalette[task.color] + } + out += a + case .url(let port): + let url = previewURL(store.api.origin, Int(port) ?? 0) + var a = AttributedString(url.absoluteString) + a.link = url + a.foregroundColor = Palette.accent + out += a + } + } + return out + } +} + +func previewURL(_ origin: URL, _ port: Int) -> URL { + var c = URLComponents() + c.scheme = "https" + c.host = origin.host() + c.port = port + c.path = "/" + return c.url! +} + +private struct SnapshotRow: View { + let snap: Snapshot + let review: Review? + let taskId: String? + @Environment(Store.self) private var store + @Environment(Overlays.self) private var overlays + @ViewState private var forking = false + + var body: some View { + let time = snap.createdAt.formatted(Date.FormatStyle().hour(.twoDigits(amPM: .omitted)).minute(.twoDigits).second(.twoDigits)) + HStack(spacing: 8) { + Rectangle().fill(Palette.border).frame(height: 1) + Text("snapshot #\(snap.n)") + Button("changes") { + store.pinSnapshot(snap.n) + store.focusTab(.changes) + } + .buttonStyle(SmallLinkButtonStyle()) + .help("Show snapshot \(snap.n) in the Changes tab") + Button("fork") { + guard let taskId else { + return + } + forking = true + Task { + do { + let task = try await store.api.client.forkSnapshot(path: .init(taskId: taskId, n: snap.n)).created.body.json + store.focusTask(task.id) + } catch { + forking = false + overlays.showAlert(errorText(error)) + } + } + } + .buttonStyle(SmallLinkButtonStyle()) + .disabled(forking) + .help("Start a new task from this snapshot, its agent continuing the conversation as it stood here") + reviewButton + Rectangle().fill(Palette.border).frame(height: 1) + } + .font(Fonts.font(11)) + .foregroundStyle(Palette.muted) + .help("\(snap.trigger.rawValue.replacingOccurrences(of: "_", with: " ")) · \(time)") + } + + private var reviewButton: some View { + let n = review.map(findingCount) ?? 0 + return Button { + store.showReview(snap.n) + } label: { + HStack(spacing: 2) { + Text("review") + .font(Fonts.font(11, weight: n > 0 ? 700 : 400)) + if let review { + switch review.state { + case .running: + Spinner(color: Palette.accent).font(Fonts.font(10)).help("review in progress") + case .failed: + EmptyView() + case .skipped: + Text("●").font(Fonts.font(11, weight: 700)).foregroundStyle(Palette.muted).help("skipped: no changes") + case .done: + if n == 0 { + Text("✓").font(Fonts.font(11, weight: 700)).foregroundStyle(Palette.ok).help(review.findingStates.additionalProperties.isEmpty ? "no findings" : "every finding addressed") + } else { + Badge(count: n).help("\(n) open findings") + } + } + } + } + } + .buttonStyle(SmallLinkButtonStyle(color: n > 0 ? Palette.warn : Palette.accent)) + .help("Show the review of snapshot \(snap.n) in the Reviews tab") + } +} diff --git a/mac/Sources/Byre/Composer.swift b/mac/Sources/Byre/Composer.swift index ea9f2b5..d61e718 100644 --- a/mac/Sources/Byre/Composer.swift +++ b/mac/Sources/Byre/Composer.swift @@ -5,6 +5,20 @@ import UniformTypeIdentifiers let maxImageBytes = 5 << 20 +let models = [ + "claude-opus-5-5", + "claude-sonnet-5-5", + "claude-haiku-5-5", + "claude-fable-5-1", + "gpt-6.1-sol", + "gpt-6-astra", + "gpt-6-sol", + "gpt-6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", +] + struct PendingImage: Identifiable { let id = UUID() let data: Data @@ -32,150 +46,128 @@ func pngFromTIFF(_ data: Data) -> Data? { NSBitmapImageRep(data: data)?.representation(using: .png, properties: [:]) } -@MainActor -@Observable -final class Attachments { - var images: [PendingImage] = [] - let spike: SpikeLog - let api: API - let name: String - - init(spike: SpikeLog, api: API, name: String) { - self.spike = spike - self.api = api - self.name = name +func uploadImages(_ api: API, _ imgs: [PendingImage]) async throws -> [String]? { + if imgs.isEmpty { + return nil } - - func add(_ data: Data, from source: String) { - guard let kind = imageType(data), let image = NSImage(data: data) else { - spike.log("\(name): \(source) gave \(data.count) bytes that aren't PNG, JPEG, GIF or WebP") - return - } - if data.count > maxImageBytes { - spike.log("\(name): \(source) image is over \(maxImageBytes >> 20) MiB") - return - } - spike.log("\(name): \(source) attached a \(kind), \(data.count) bytes") - images.append(PendingImage(data: data, image: image)) - } - - func add(pasteboard pb: NSPasteboard, from source: String) -> Bool { - if let urls = pb.readObjects(forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true]) as? [URL], !urls.isEmpty { - var any = false - for url in urls { - if let data = try? Data(contentsOf: url), imageType(data) != nil || pngFromTIFF(data) != nil && NSImage(data: data) != nil { - add(imageType(data) != nil ? data : pngFromTIFF(data)!, from: "\(source) file \(url.lastPathComponent)") - any = true - } - } - return any - } - for t in [NSPasteboard.PasteboardType.png, NSPasteboard.PasteboardType(UTType.jpeg.identifier), NSPasteboard.PasteboardType(UTType.gif.identifier), NSPasteboard.PasteboardType(UTType.webP.identifier)] { - if let data = pb.data(forType: t) { - add(data, from: "\(source) \(t.rawValue)") - return true - } - } - if let tiff = pb.data(forType: .tiff), let png = pngFromTIFF(tiff) { - add(png, from: "\(source) public.tiff converted to PNG") - return true - } - return false - } - - func add(providers: [NSItemProvider], from source: String) { - 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 { - continue - } - _ = p.loadDataRepresentation(for: t) { data, error in - Task { @MainActor in - guard let data else { - 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) { - self.add(imageType(file) != nil ? file : (pngFromTIFF(file) ?? file), from: "\(source) file \(url.lastPathComponent)") - } else if t == .tiff, let png = pngFromTIFF(data) { - self.add(png, from: "\(source) tiff converted to PNG") - } else { - self.add(data, from: "\(source) \(t.identifier)") - } - } - } - } - } - - func send(_ text: String) { - let images = self.images - self.images = [] - spike.log("\(name): send \(text.debugDescription) with \(images.count) images") - Task { - for img in images { - do { - spike.log("\(name): uploaded blob \(try await upload(img.data))") - } catch { - 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) { + var ids: [String] = [] + for img in imgs { + let body = HTTPBody(img.data) + let typed: Operations.UploadBlob.Input.Body = switch imageType(img.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 + ids.append(try await api.client.uploadBlob(body: typed).created.body.json.id) } + return ids } -struct Thumbnails: View { - @Bindable var attachments: Attachments +@MainActor +var modelChoices: [String: String] = [:] - var body: some View { - HStack { - ForEach(attachments.images) { img in - ZStack(alignment: .topTrailing) { - Image(nsImage: img.image) - .resizable() - .scaledToFit() - .frame(width: 64, height: 64) - Button("×") { - attachments.images.removeAll { $0.id == img.id } +func vendorOf(_ model: String) -> String { + model.hasPrefix("gpt-") ? "codex" : "claude" +} + +@MainActor +@Observable +final class ImageDrafts { + private var byKey: [String: [PendingImage]] = [:] + + func images(_ key: String) -> [PendingImage] { + byKey[key] ?? [] + } + + func remove(_ key: String, _ id: UUID) { + let rest = images(key).filter { $0.id != id } + byKey[key] = rest.isEmpty ? nil : rest + } + + func take(_ key: String) -> [PendingImage] { + let imgs = images(key) + byKey[key] = nil + return imgs + } + + func restore(_ key: String, _ imgs: [PendingImage]) { + if !imgs.isEmpty { + byKey[key] = imgs + images(key) + } + } + + @discardableResult + func add(_ key: String, _ data: Data, name: String?, _ overlays: Overlays) -> Bool { + let file = imageType(data) != nil ? data : pngFromTIFF(data) + guard let file, let image = NSImage(data: file) else { + overlays.showAlert("\(name ?? "That file") isn't a PNG, JPEG, GIF or WebP image.") + return false + } + if file.count > maxImageBytes { + overlays.showAlert("\(name ?? "That image") is over \(maxImageBytes >> 20) MiB.") + return false + } + byKey[key] = images(key) + [PendingImage(data: file, image: image)] + return true + } + + func add(_ key: String, pasteboard pb: NSPasteboard, _ overlays: Overlays) -> Bool { + if let urls = pb.readObjects(forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true]) as? [URL], !urls.isEmpty { + let images = urls.filter { (try? $0.resourceValues(forKeys: [.contentTypeKey]).contentType?.conforms(to: .image)) == true } + for url in images { + if let data = try? Data(contentsOf: url) { + add(key, data, name: url.lastPathComponent, overlays) + } + } + return !images.isEmpty + } + for t in [NSPasteboard.PasteboardType.png, NSPasteboard.PasteboardType(UTType.jpeg.identifier), NSPasteboard.PasteboardType(UTType.gif.identifier), NSPasteboard.PasteboardType(UTType.webP.identifier), .tiff] { + if let data = pb.data(forType: t) { + add(key, data, name: nil, overlays) + return true + } + } + return false + } + + func add(_ key: String, providers: [NSItemProvider], _ overlays: Overlays) { + for p in providers { + if p.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) { + _ = p.loadDataRepresentation(for: .fileURL) { data, _ in + Task { @MainActor in + guard let data, let url = URL(dataRepresentation: data, relativeTo: nil), let file = try? Data(contentsOf: url) else { + return + } + self.add(key, file, name: url.lastPathComponent, overlays) + } + } + continue + } + guard let t = [UTType.png, .jpeg, .gif, .webP, .tiff].first(where: { p.hasItemConformingToTypeIdentifier($0.identifier) }) else { + continue + } + _ = p.loadDataRepresentation(for: t) { data, _ in + Task { @MainActor in + if let data { + self.add(key, data, name: nil, overlays) } } } } - .frame(height: attachments.images.isEmpty ? 0 : 64) } } -struct ComposerSpike: View { - @Environment(Store.self) private var store - @Environment(SpikeLog.self) private var spike - - var body: some View { - HStack(alignment: .top, spacing: 16) { - 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) - } +struct ComposerTarget { + let paste: @MainActor (NSPasteboard) -> Bool } struct ComposerFocusKey: FocusedValueKey { - typealias Value = Attachments + typealias Value = ComposerTarget } extension FocusedValues { - var composer: Attachments? { + var composer: ComposerTarget? { get { self[ComposerFocusKey.self] } @@ -199,7 +191,7 @@ struct PasteboardCommands: Commands { } .keyboardShortcut("c") Button("Paste") { - if let composer, composer.add(pasteboard: .general, from: "Edit ▸ Paste") { + if let composer, composer.paste(.general) { return } NSApp.sendAction(#selector(NSText.paste(_:)), to: nil, from: nil) @@ -213,184 +205,233 @@ struct PasteboardCommands: Commands { } } -struct RoutedComposer: View { - @ViewState var attachments: Attachments - @ViewState private var text = "" - @ViewState private var overlayHits = false - @ViewState private var targeted = false +struct Thumbnails: View { + let key: String + @Environment(Store.self) private var store var body: some View { - VStack(alignment: .leading) { - Text("SwiftUI TextEditor, paste through Edit ▸ Paste, drop overlay") - Toggle("Drop overlay takes hits (off: clicks pass through to the editor)", isOn: $overlayHits) - Thumbnails(attachments: attachments) - TextEditor(text: $text) - .frame(minHeight: 160) - .border(targeted ? Color.accentColor : Color.secondary.opacity(0.4)) - .focusedValue(\.composer, attachments) - .onKeyPress(.return, phases: .down) { press in - let marked = (NSApp.keyWindow?.firstResponder as? NSTextView)?.hasMarkedText() ?? false - attachments.spike.log("Routed: onKeyPress return, modifiers \(press.modifiers.rawValue), hasMarkedText \(marked)") - if press.modifiers.contains(.shift) || marked { - return .ignored - } - attachments.send(text) - text = "" - return .handled - } - .overlay { - Color.clear - .contentShape(Rectangle()) - .allowsHitTesting(overlayHits) - .onDrop(of: [.image, .fileURL], isTargeted: $targeted) { providers in - attachments.add(providers: providers, from: "overlay onDrop") - return true + let drafts = store.images + let imgs = drafts.images(key) + if !imgs.isEmpty { + FlowLayout(spacing: 8) { + ForEach(imgs) { img in + Image(nsImage: img.image) + .resizable() + .scaledToFit() + .frame(maxWidth: 120, maxHeight: 90) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(Palette.border)) + .overlay(alignment: .topTrailing) { + Button("×") { + drafts.remove(key, img.id) + } + .buttonStyle(WebButtonStyle()) + .help("Remove image") + .offset(x: 6, y: -6) } } - .onChange(of: targeted) { - attachments.spike.log("Routed: overlay drop targeted \(targeted)") - } + } + .padding(.top, 6) } } } -struct SwiftUIComposer: View { - @ViewState var attachments: Attachments +struct ComposerView: View { + let chat: ChatModel + let key: String + @Environment(Store.self) private var store + @Environment(Overlays.self) private var overlays + @Environment(\.paneAccent) private var accent + @Environment(\.inRightPane) private var inRightPane @ViewState private var text = "" - @ViewState private var checkMarked = false + @ViewState private var pick: String? + @ViewState private var targeted = false + @FocusState private var focused: Bool + + private var draftKey: String { + "message:\(key)" + } + + private struct ModelState { + let live: AgentSession? + let current: String + let options: [String] + let modelKey: String + let selected: String + } + + private func modelState() -> ModelState { + let live = chat.liveSession + let current = live.map { $0.model.isEmpty ? chat.builder.context[$0.id]?.model ?? "" : $0.model } ?? "" + let offered = live.map { s in models.filter { vendorOf($0) == s.vendor.rawValue } } ?? models + let options = !current.isEmpty && !offered.contains(current) ? [current] + offered : offered + let modelKey = "message:\(live?.id ?? key)" + return ModelState(live: live, current: current, options: options, modelKey: modelKey, selected: pick ?? modelChoices[modelKey] ?? (current.isEmpty ? options[0] : current)) + } var body: some View { - VStack(alignment: .leading) { - Text("SwiftUI TextEditor + onKeyPress, onPasteCommand, onDrop") - Toggle("In onKeyPress, also ignore Return while the first responder hasMarkedText()", isOn: $checkMarked) - Thumbnails(attachments: attachments) - TextEditor(text: $text) - .frame(minHeight: 160) - .border(Color.secondary.opacity(0.4)) - .onKeyPress(.return, phases: .down) { press in - let marked = (NSApp.keyWindow?.firstResponder as? NSTextView)?.hasMarkedText() ?? false - attachments.spike.log("TextEditor: onKeyPress return, modifiers \(press.modifiers.rawValue), hasMarkedText \(marked)") - if press.modifiers.contains(.shift) { - return .ignored + let m = modelState() + let live = m.live + let sessionState = live?.state ?? .idle + let options = m.options + let modelKey = m.modelKey + let selected = m.selected + let lost = store.drafts?.lostImageCount(draftKey) ?? 0 + + VStack(alignment: .leading, spacing: 8) { + if lost > 0 { + HStack(spacing: 8) { + Text("\(lost == 1 ? "1 image" : "\(lost) images") from before the reload need re-attaching.") + .font(Fonts.font(12)) + Spacer() + Button("×") { + store.drafts?.forgetLostImages(draftKey) } - if checkMarked && marked { - return .ignored + .buttonStyle(WebButtonStyle()) + .help("Dismiss") + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Palette.warnTint, in: RoundedRectangle(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(Palette.border)) + } + Thumbnails(key: draftKey) + editor + HStack(spacing: 8) { + Picker("", selection: Binding(get: { selected }, set: { v in + pick = v + modelChoices[modelKey] = v + })) { + ForEach(options, id: \.self) { m in + Text(m).tag(m) } - attachments.send(text) - text = "" - return .handled } - .onPasteCommand(of: [.png, .jpeg, .gif, .webP, .tiff, .fileURL]) { providers in - attachments.add(providers: providers, from: "onPasteCommand") + .labelsHidden() + .pickerStyle(.menu) + .fixedSize() + .help("Model for the next turn") + Spacer() + Button("Interrupt") { + if let id = live?.id { + chat.interrupt(id, overlays) + } } - .onDrop(of: [.image, .fileURL], isTargeted: nil) { providers in - attachments.add(providers: providers, from: "onDrop") - return true + .buttonStyle(WebButtonStyle(danger: true)) + .disabled(sessionState != .running) + Button("Send") { + submit(modelState()) } + .buttonStyle(WebButtonStyle()) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(Palette.surface) + .overlay(alignment: .top) { + Rectangle().fill(Palette.border).frame(height: 1) + } + .onAppear { + text = store.drafts?.composerText(draftKey) ?? "" + if chat.takeFocusStart() { + focused = true + } + } + .onChange(of: text) { + store.drafts?.setComposerText(draftKey, text) + store.drafts?.setComposerImages(draftKey, store.images.images(draftKey).count) + reportDraft() + } + .onChange(of: store.images.images(draftKey).count) { + store.drafts?.setComposerImages(draftKey, store.images.images(draftKey).count) + } + .onChange(of: modelKey) { + pick = nil + } + .onChange(of: focused) { + if !focused { + store.drafts?.flush() + } + } + .onDisappear { + if inRightPane { + store.paneFocus.setDraft(draftKey, false) + } + store.drafts?.flush() } } -} -struct AppKitComposerPanel: View { - @ViewState var attachments: Attachments - - var body: some View { - VStack(alignment: .leading) { - Text("NSTextView in NSViewRepresentable") - Text("doCommand(insertNewline:), paste(_:), performDragOperation") - Thumbnails(attachments: attachments) - AppKitComposer(attachments: attachments) - .frame(minHeight: 160) - .border(Color.secondary.opacity(0.4)) - } - } -} - -final class ComposerTextView: NSTextView { - var attachments: Attachments! - - override func keyDown(with event: NSEvent) { - if event.keyCode == 36 || event.keyCode == 76 { - attachments.spike.log("NSTextView: keyDown return, modifiers \(event.modifierFlags.intersection(.deviceIndependentFlagsMask).rawValue), hasMarkedText \(hasMarkedText())") - } - super.keyDown(with: event) + private var editor: some View { + TextEditor(text: $text) + .font(Fonts.font(13)) + .scrollContentBackground(.hidden) + .focused($focused) + .focusedValue(\.composer, ComposerTarget(paste: { pb in store.images.add(draftKey, pasteboard: pb, overlays) })) + .onKeyPress(.return, phases: .down) { press in + let marked = (NSApp.keyWindow?.firstResponder as? NSTextView)?.hasMarkedText() ?? false + if press.modifiers.contains(.shift) || marked { + return .ignored + } + submit(modelState()) + return .handled + } + .padding(.horizontal, 5) + .padding(.vertical, 6) + .frame(height: 104) + .background(Palette.raised, in: RoundedRectangle(cornerRadius: 6)) + .overlay(alignment: .topLeading) { + if text.isEmpty { + Text(chat.placeholder) + .foregroundStyle(Palette.muted) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .allowsHitTesting(false) + } + } + .overlay(RoundedRectangle(cornerRadius: 6).stroke(targeted ? Palette.accent : inRightPane ? accent : focused ? Palette.accent : Palette.border, lineWidth: inRightPane && focused ? 2 : 1)) + .overlay { + Color.clear + .contentShape(Rectangle()) + .allowsHitTesting(false) + .onDrop(of: [.image, .fileURL], isTargeted: $targeted) { providers in + store.images.add(draftKey, providers: providers, overlays) + return true + } + } } - override func doCommand(by selector: Selector) { - let shift = NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false - if selector == #selector(insertNewline(_:)) && !shift { - attachments.send(string) - string = "" + private func reportDraft() { + if inRightPane { + store.paneFocus.setDraft(draftKey, !trimmed(text).isEmpty) + } + } + + private func submit(_ m: ModelState) { + let value = text + let images = store.images + if trimmed(value).isEmpty && images.images(draftKey).isEmpty { return } - if selector == #selector(insertNewline(_:)) { - insertNewlineIgnoringFieldEditor(nil) + guard let drafts = store.drafts else { return } - super.doCommand(by: selector) - } - - override func paste(_ sender: Any?) { - if attachments.add(pasteboard: .general, from: "paste(_:)") { - return + let imgs = images.take(draftKey) + text = "" + let sent = drafts.startSend(draftKey, value, imgs.count) + let cancel = chat.builder.showPending(value, imgs) + Task { + do { + let ids = try await uploadImages(store.api, imgs) + try await chat.send(sessionId: m.live?.id, text: value, images: ids, model: m.selected != m.current ? m.selected : nil, firstModel: m.selected) + modelChoices[m.modelKey] = nil + pick = nil + drafts.endSend(sent, true) + } catch { + cancel() + drafts.endSend(sent, false) + text = drafts.composerText(draftKey) + images.restore(draftKey, imgs) + overlays.showAlert(errorText(error)) + } } - super.paste(sender) - } - - override var readablePasteboardTypes: [NSPasteboard.PasteboardType] { - super.readablePasteboardTypes + [.png, .tiff, .fileURL] - } - - override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation { - if hasImage(sender.draggingPasteboard) { - return .copy - } - return super.draggingEntered(sender) - } - - override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation { - if hasImage(sender.draggingPasteboard) { - return .copy - } - return super.draggingUpdated(sender) - } - - override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool { - if hasImage(sender.draggingPasteboard) { - return attachments.add(pasteboard: sender.draggingPasteboard, from: "drop") - } - return super.performDragOperation(sender) - } - - private func hasImage(_ pb: NSPasteboard) -> Bool { - if let urls = pb.readObjects(forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true]) as? [URL], !urls.isEmpty { - return urls.contains { (try? Data(contentsOf: $0)).map { imageType($0) != nil || NSImage(data: $0) != nil } ?? false } - } - return pb.availableType(from: [.png, .tiff, NSPasteboard.PasteboardType(UTType.jpeg.identifier), NSPasteboard.PasteboardType(UTType.gif.identifier), NSPasteboard.PasteboardType(UTType.webP.identifier)]) != nil } } - -struct AppKitComposer: NSViewRepresentable { - let attachments: Attachments - - func makeNSView(context: Context) -> NSScrollView { - let scroll = NSScrollView() - scroll.hasVerticalScroller = true - let tv = ComposerTextView(frame: .zero) - tv.attachments = attachments - tv.isRichText = false - tv.allowsUndo = true - tv.font = Fonts.ct(13) as NSFont - tv.minSize = .zero - tv.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) - tv.isVerticallyResizable = true - tv.isHorizontallyResizable = false - tv.autoresizingMask = [.width] - tv.textContainer?.widthTracksTextView = true - tv.registerForDraggedTypes([.png, .tiff, .fileURL]) - scroll.documentView = tv - return scroll - } - - func updateNSView(_ nsView: NSScrollView, context: Context) {} -} diff --git a/mac/Sources/Byre/Diagram.swift b/mac/Sources/Byre/Diagram.swift new file mode 100644 index 0000000..92d1a03 --- /dev/null +++ b/mac/Sources/Byre/Diagram.swift @@ -0,0 +1,572 @@ +import AppKit +import CoreText +import SwiftUI + +enum NodeShape { + case box, rounded, diamond, circle +} + +enum EdgeStyle { + case solid, dotted, thick +} + +enum FlowDirection { + case lr, rl, td, bt +} + +struct NodeColors { + var fill: Color? + var stroke: Color? + var text: Color? + + mutating func merge(_ other: NodeColors) { + fill = other.fill ?? fill + stroke = other.stroke ?? stroke + text = other.text ?? text + } +} + +struct FlowNode { + let id: String + var label: String + var shape: NodeShape = .box + var classes: [String] = [] + var style = NodeColors() +} + +struct FlowEdge { + let from: String + let to: String + let label: String? + let style: EdgeStyle + let arrow: Bool +} + +struct Flowchart { + var direction: FlowDirection = .td + var order: [String] = [] + var nodes: [String: FlowNode] = [:] + var edges: [FlowEdge] = [] + var classDefs: [String: NodeColors] = [:] + + mutating func node(_ id: String, label: String? = nil, shape: NodeShape? = nil) { + if nodes[id] == nil { + order.append(id) + nodes[id] = FlowNode(id: id, label: id) + } + if let label { + nodes[id]!.label = label + } + if let shape { + nodes[id]!.shape = shape + } + } + + func colors(_ id: String) -> NodeColors { + let n = nodes[id]! + var c = NodeColors() + for cls in n.classes { + if let d = classDefs[cls] { + c.merge(d) + } + } + c.merge(n.style) + return c + } +} + +struct DiagramError: Error { + let reason: String +} + +private let header = Re(#"^(flowchart|graph)(?:\s+(LR|RL|TD|TB|BT))?$"#, caseless: true) +private let nodeId = Re(#"^[A-Za-z0-9_]+"#) +private let plainEdge = Re(#"^(-{2,}>|-{3,}|-\.+->|-\.+-|={2,}>|={3,})(?:\|([^|]*)\|)?"#) +private let dashLabel = Re(#"^--\s*([^->].*?)\s*(-{2,}>|-{3,})"#) +private let dotLabel = Re(#"^-\.\s*(.*?)\s*(\.+->|\.+-)"#) +private let thickLabel = Re(#"^==\s*([^=>].*?)\s*(={2,}>|={3,})"#) +private let classDefLine = Re(#"^classDef\s+([A-Za-z0-9_,]+)\s+(.+)$"#) +private let classLine = Re(#"^class\s+([A-Za-z0-9_,\s]+?)\s+([A-Za-z0-9_]+)$"#) +private let styleLine = Re(#"^style\s+([A-Za-z0-9_]+)\s+(.+)$"#) +private let hexColor = Re(#"^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$"#) + +private let shapes: [(open: String, close: String, shape: NodeShape)] = [ + ("((", "))", .circle), ("([", "])", .rounded), ("[[", "]]", .box), ("[(", ")]", .rounded), ("{{", "}}", .diamond), + ("[", "]", .box), ("(", ")", .rounded), ("{", "}", .diamond), +] + +func parseFlowchart(_ src: String) throws(DiagramError) -> Flowchart { + var lines: [String] = [] + for raw in src.components(separatedBy: "\n") { + let line = trimmed(raw) + if line.isEmpty || line.hasPrefix("%%") { + continue + } + for part in line.split(separator: ";") { + let s = trimmed(String(part)) + if !s.isEmpty { + lines.append(s) + } + } + } + guard let first = lines.first else { + throw DiagramError(reason: "empty diagram") + } + guard let h = header.exec(first) else { + let kind = first.split(separator: " ").first.map(String.init) ?? first + throw DiagramError(reason: "\(kind) diagrams aren't drawn here, only flowcharts") + } + var chart = Flowchart() + switch h[2]?.uppercased() { + case "LR": chart.direction = .lr + case "RL": chart.direction = .rl + case "BT": chart.direction = .bt + default: chart.direction = .td + } + for line in lines.dropFirst() { + if line.hasPrefix("linkStyle ") { + continue + } + if let m = classDefLine.exec(line) { + let colors = parseStyle(m[2]!) + for name in m[1]!.split(separator: ",") { + chart.classDefs[String(name)] = colors + } + continue + } + if let m = classLine.exec(line) { + for id in m[1]!.split(whereSeparator: { $0 == "," || $0 == " " }) { + chart.node(String(id)) + chart.nodes[String(id)]!.classes.append(m[2]!) + } + continue + } + if let m = styleLine.exec(line) { + chart.node(m[1]!) + chart.nodes[m[1]!]!.style.merge(parseStyle(m[2]!)) + continue + } + try parseChain(line, &chart) + } + return chart +} + +private func parseStyle(_ text: String) -> NodeColors { + var c = NodeColors() + for prop in text.split(separator: ",") { + let kv = prop.split(separator: ":", maxSplits: 1).map { trimmed(String($0)) } + guard kv.count == 2, let color = parseColor(kv[1]) else { + continue + } + switch kv[0] { + case "fill": c.fill = color + case "stroke": c.stroke = color + case "color": c.text = color + default: break + } + } + return c +} + +private func parseColor(_ text: String) -> Color? { + guard let m = hexColor.exec(text) else { + return nil + } + var hex = m[1]! + if hex.count == 3 { + hex = hex.map { "\($0)\($0)" }.joined() + } + let v = UInt32(hex, radix: 16)! + return Color(.sRGB, red: Double(v >> 16 & 0xff) / 255, green: Double(v >> 8 & 0xff) / 255, blue: Double(v & 0xff) / 255) +} + +private func unquote(_ s: String) -> String { + var t = trimmed(s) + if t.count >= 2 && t.hasPrefix("\"") && t.hasSuffix("\"") { + t = String(t.dropFirst().dropLast()) + } + return t.replacingOccurrences(of: "
", with: "\n").replacingOccurrences(of: "
", with: "\n").replacingOccurrences(of: "
", with: "\n") +} + +private func parseChain(_ line: String, _ chart: inout Flowchart) throws(DiagramError) { + var rest = Substring(line) + let fail = DiagramError(reason: "can't parse \"\(line)\"") + func skipSpaces() { + rest = rest.drop { $0 == " " || $0 == "\t" } + } + func node() throws(DiagramError) -> String { + skipSpaces() + guard let m = nodeId.match(String(rest)) else { + throw fail + } + let id = m.groups[0]! + rest = rest.dropFirst(id.count) + var label: String? + var shape: NodeShape? + for s in shapes where rest.hasPrefix(s.open) { + let inner = rest.dropFirst(s.open.count) + var after: Substring? + if inner.first == "\"", let q = inner.dropFirst().firstIndex(of: "\"") { + let tail = inner[inner.index(after: q)...] + if tail.hasPrefix(s.close) { + label = unquote(String(inner[...q])) + after = tail.dropFirst(s.close.count) + } + } + if after == nil, let r = inner.range(of: s.close) { + label = unquote(String(inner[.. (label: String?, style: EdgeStyle, arrow: Bool) { + skipSpaces() + let s = String(rest) + if let m = plainEdge.match(s) { + rest = rest.dropFirst(s.distance(from: s.startIndex, to: m.range.upperBound)) + let arrow = m.groups[1]! + return (m.groups[2].map(unquote), arrow.hasPrefix("=") ? .thick : arrow.contains(".") ? .dotted : .solid, arrow.hasSuffix(">")) + } + for (re, style) in [(dashLabel, EdgeStyle.solid), (dotLabel, .dotted), (thickLabel, .thick)] { + if let m = re.match(s) { + rest = rest.dropFirst(s.distance(from: s.startIndex, to: m.range.upperBound)) + return (unquote(m.groups[1]!), style, m.groups[2]!.hasSuffix(">")) + } + } + throw fail + } + var from = try node() + while true { + skipSpaces() + if rest.isEmpty { + return + } + let e = try edge() + let to = try node() + chart.edges.append(FlowEdge(from: from, to: to, label: e.label?.isEmpty == true ? nil : e.label, style: e.style, arrow: e.arrow)) + from = to + } +} + +struct PlacedNode { + let node: FlowNode + let center: CGPoint + let size: CGSize + let colors: NodeColors +} + +struct DiagramLayout { + let nodes: [String: PlacedNode] + let edges: [FlowEdge] + let size: CGSize +} + +nonisolated(unsafe) private let labelFont = Fonts.ct(13) +nonisolated(unsafe) private let edgeFont = Fonts.ct(12) + +func textSize(_ text: String, _ font: CTFont) -> CGSize { + let lines = text.components(separatedBy: "\n") + var width: CGFloat = 0 + for l in lines { + let line = CTLineCreateWithAttributedString(NSAttributedString(string: l, attributes: [.font: font as NSFont])) + width = max(width, CGFloat(CTLineGetTypographicBounds(line, nil, nil, nil))) + } + let lineHeight = CTFontGetAscent(font) + CTFontGetDescent(font) + CTFontGetLeading(font) + 3 + return CGSize(width: ceil(width), height: ceil(lineHeight * CGFloat(lines.count))) +} + +func layoutFlowchart(_ chart: Flowchart) -> DiagramLayout { + let ids = chart.order + var succ: [String: [String]] = [:] + for e in chart.edges where e.from != e.to { + succ[e.from, default: []].append(e.to) + } + + var state: [String: Int] = [:] + var dag: [String: [String]] = [:] + func visit(_ n: String) { + state[n] = 1 + for m in succ[n] ?? [] { + if state[m] == 1 { + dag[m, default: []].append(n) + continue + } + dag[n, default: []].append(m) + if state[m] == nil { + visit(m) + } + } + state[n] = 2 + } + for n in ids where state[n] == nil { + visit(n) + } + + var indegree: [String: Int] = [:] + for (_, ms) in dag { + for m in ms { + indegree[m, default: 0] += 1 + } + } + var layer: [String: Int] = [:] + var queue = ids.filter { (indegree[$0] ?? 0) == 0 } + for n in queue { + layer[n] = 0 + } + var qi = 0 + while qi < queue.count { + let n = queue[qi] + qi += 1 + for m in dag[n] ?? [] { + layer[m] = max(layer[m] ?? 0, layer[n]! + 1) + indegree[m]! -= 1 + if indegree[m] == 0 { + queue.append(m) + } + } + } + let depth = (layer.values.max() ?? 0) + 1 + var layers = Array(repeating: [String](), count: depth) + for n in ids { + layers[layer[n] ?? 0].append(n) + } + + var preds: [String: [String]] = [:] + for (n, ms) in dag { + for m in ms { + preds[m, default: []].append(n) + } + } + func position(_ l: Int) -> [String: Double] { + Dictionary(uniqueKeysWithValues: layers[l].enumerated().map { ($0.element, Double($0.offset)) }) + } + func reorder(_ l: Int, _ ref: Int, _ links: [String: [String]]) { + let pos = position(ref) + let current = position(l) + layers[l].sort { a, b in + func bary(_ n: String) -> Double { + let ps = (links[n] ?? []).compactMap { pos[$0] } + return ps.isEmpty ? current[n]! : ps.reduce(0, +) / Double(ps.count) + } + let (x, y) = (bary(a), bary(b)) + return x == y ? current[a]! < current[b]! : x < y + } + } + for _ in 0..<4 { + for l in layers.indices.dropFirst() { + reorder(l, l - 1, preds) + } + for l in layers.indices.dropLast().reversed() { + reorder(l, l + 1, dag) + } + } + + var sizes: [String: CGSize] = [:] + for n in ids { + let node = chart.nodes[n]! + let t = textSize(node.label, labelFont) + switch node.shape { + case .box, .rounded: + sizes[n] = CGSize(width: t.width + 30, height: t.height + 18) + case .diamond: + sizes[n] = CGSize(width: (t.width * 0.75 + 14) * 2, height: (t.height * 1.5 + 8) * 2) + case .circle: + let d = (t.width * t.width + t.height * t.height).squareRoot() + 16 + sizes[n] = CGSize(width: d, height: d) + } + } + + let horizontal = chart.direction == .lr || chart.direction == .rl + let rankGap: CGFloat = horizontal ? 70 : 56 + let nodeGap: CGFloat = horizontal ? 24 : 32 + func along(_ s: CGSize) -> CGFloat { + horizontal ? s.width : s.height + } + func across(_ s: CGSize) -> CGFloat { + horizontal ? s.height : s.width + } + let spans = layers.map { l in l.map { across(sizes[$0]!) }.reduce(0, +) + nodeGap * CGFloat(max(0, l.count - 1)) } + let widest = spans.max() ?? 0 + var centers: [String: CGPoint] = [:] + var rank: CGFloat = 0 + for (l, nodes) in layers.enumerated() { + let thick = nodes.map { along(sizes[$0]!) }.max() ?? 0 + var offset = (widest - spans[l]) / 2 + for n in nodes { + let a = across(sizes[n]!) + let c1 = rank + thick / 2 + let c2 = offset + a / 2 + centers[n] = horizontal ? CGPoint(x: c1, y: c2) : CGPoint(x: c2, y: c1) + offset += a + nodeGap + } + rank += thick + rankGap + } + let length = max(0, rank - rankGap) + let margin: CGFloat = 8 + let total = horizontal ? CGSize(width: length, height: widest) : CGSize(width: widest, height: length) + var placed: [String: PlacedNode] = [:] + for n in ids { + var c = centers[n]! + if chart.direction == .rl { + c.x = total.width - c.x + } + if chart.direction == .bt { + c.y = total.height - c.y + } + placed[n] = PlacedNode(node: chart.nodes[n]!, center: CGPoint(x: c.x + margin, y: c.y + margin), size: sizes[n]!, colors: chart.colors(n)) + } + return DiagramLayout(nodes: placed, edges: chart.edges, size: CGSize(width: total.width + margin * 2, height: total.height + margin * 2)) +} + +private func boundary(_ n: PlacedNode, toward p: CGPoint) -> CGPoint { + let dx = p.x - n.center.x + let dy = p.y - n.center.y + if dx == 0 && dy == 0 { + return n.center + } + let a = n.size.width / 2 + let b = n.size.height / 2 + let t: CGFloat + switch n.node.shape { + case .box, .rounded: + t = min(dx == 0 ? .infinity : a / abs(dx), dy == 0 ? .infinity : b / abs(dy)) + case .diamond: + t = 1 / (abs(dx) / a + abs(dy) / b) + case .circle: + t = a / (dx * dx + dy * dy).squareRoot() + } + return CGPoint(x: n.center.x + dx * t, y: n.center.y + dy * t) +} + +private func shapePath(_ n: PlacedNode) -> Path { + let r = CGRect(x: n.center.x - n.size.width / 2, y: n.center.y - n.size.height / 2, width: n.size.width, height: n.size.height) + switch n.node.shape { + case .box: + return Path(roundedRect: r, cornerRadius: 3) + case .rounded: + return Path(roundedRect: r, cornerRadius: min(r.height / 2, 14)) + case .circle: + return Path(ellipseIn: r) + case .diamond: + var p = Path() + p.move(to: CGPoint(x: r.midX, y: r.minY)) + p.addLine(to: CGPoint(x: r.maxX, y: r.midY)) + p.addLine(to: CGPoint(x: r.midX, y: r.maxY)) + p.addLine(to: CGPoint(x: r.minX, y: r.midY)) + p.closeSubpath() + return p + } +} + +struct FlowchartCanvas: View { + let layout: DiagramLayout + + var body: some View { + FitWidth(natural: layout.size) { + Canvas { context, size in + let scale = layout.size.width > 0 ? size.width / layout.size.width : 1 + context.scaleBy(x: scale, y: scale) + draw(&context) + } + } + } + + private func draw(_ context: inout GraphicsContext) { + for e in layout.edges { + guard let a = layout.nodes[e.from], let b = layout.nodes[e.to] else { + continue + } + let stroke = StrokeStyle(lineWidth: e.style == .thick ? 3 : 1.5, lineCap: .round, dash: e.style == .dotted ? [3, 4] : []) + var mid: CGPoint + if e.from == e.to { + let top = CGPoint(x: a.center.x, y: a.center.y - a.size.height / 2) + var loop = Path() + loop.move(to: CGPoint(x: top.x - 8, y: top.y)) + loop.addCurve(to: CGPoint(x: top.x + 8, y: top.y), control1: CGPoint(x: top.x - 24, y: top.y - 30), control2: CGPoint(x: top.x + 24, y: top.y - 30)) + context.stroke(loop, with: .color(Palette.muted), style: stroke) + if e.arrow { + arrowHead(&context, from: CGPoint(x: top.x + 20, y: top.y - 20), to: CGPoint(x: top.x + 8, y: top.y)) + } + mid = CGPoint(x: top.x, y: top.y - 26) + } else { + let start = boundary(a, toward: b.center) + let end = boundary(b, toward: a.center) + var line = Path() + line.move(to: start) + line.addLine(to: end) + context.stroke(line, with: .color(Palette.muted), style: stroke) + if e.arrow { + arrowHead(&context, from: start, to: end) + } + mid = CGPoint(x: (start.x + end.x) / 2, y: (start.y + end.y) / 2) + } + if let label = e.label { + let size = textSize(label, edgeFont) + let box = CGRect(x: mid.x - size.width / 2 - 4, y: mid.y - size.height / 2 - 2, width: size.width + 8, height: size.height + 4) + context.fill(Path(roundedRect: box, cornerRadius: 3), with: .color(Palette.bg)) + context.draw(Text(label).font(Font(edgeFont)).foregroundColor(Palette.text), at: mid, anchor: .center) + } + } + for (_, n) in layout.nodes { + let path = shapePath(n) + context.fill(path, with: .color(n.colors.fill ?? Palette.surface)) + context.stroke(path, with: .color(n.colors.stroke ?? Palette.border), lineWidth: 1) + for (i, line) in n.node.label.components(separatedBy: "\n").enumerated() { + let count = CGFloat(n.node.label.components(separatedBy: "\n").count) + let lineHeight = textSize("X", labelFont).height + let y = n.center.y + (CGFloat(i) - (count - 1) / 2) * lineHeight + context.draw(Text(line).font(Font(labelFont)).foregroundColor(n.colors.text ?? Palette.text), at: CGPoint(x: n.center.x, y: y), anchor: .center) + } + } + } + + private func arrowHead(_ context: inout GraphicsContext, from: CGPoint, to: CGPoint) { + let angle = atan2(to.y - from.y, to.x - from.x) + let size: CGFloat = 9 + var head = Path() + head.move(to: to) + head.addLine(to: CGPoint(x: to.x - size * cos(angle - .pi / 7), y: to.y - size * sin(angle - .pi / 7))) + head.addLine(to: CGPoint(x: to.x - size * cos(angle + .pi / 7), y: to.y - size * sin(angle + .pi / 7))) + head.closeSubpath() + context.fill(head, with: .color(Palette.muted)) + } +} + +struct DiagramView: View { + let source: String + + var body: some View { + switch Result(catching: { () throws(DiagramError) -> Flowchart in try parseFlowchart(source) }) { + case .success(let chart): + FlowchartCanvas(layout: layoutFlowchart(chart)) + .frame(maxWidth: .infinity, alignment: .leading) + case .failure(let error): + VStack(alignment: .leading, spacing: 4) { + Text(verbatim: source) + .textSelection(.enabled) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Palette.surface, in: RoundedRectangle(cornerRadius: 6)) + Text(verbatim: "diagram not rendered: \(error.reason)") + .foregroundStyle(Palette.muted) + } + } + } +} diff --git a/mac/Sources/Byre/Dialogs.swift b/mac/Sources/Byre/Dialogs.swift new file mode 100644 index 0000000..8d3e770 --- /dev/null +++ b/mac/Sources/Byre/Dialogs.swift @@ -0,0 +1,176 @@ +import AppKit +import SwiftUI + +struct MenuEntry: Identifiable { + let id: String + let label: String + let badge: TabBadge + let action: () -> Void +} + +struct OverlayMenu { + let anchor: CGRect + let entries: [MenuEntry] +} + +@MainActor +@Observable +final class Overlays { + var alerts: [String] = [] + var image: NSImage? + var menu: OverlayMenu? + @ObservationIgnored private var monitor: Any? + + var isOpen: Bool { + !alerts.isEmpty || image != nil || menu != nil + } + + func showAlert(_ text: String) { + alerts.append(text) + } + + func closeTop() { + if menu != nil { + menu = nil + } else if image != nil { + image = nil + } else if !alerts.isEmpty { + alerts.removeFirst() + } + } + + func install() { + if monitor != nil { + return + } + monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in + let code = event.keyCode + let taken = MainActor.assumeIsolated { + self?.key(code) ?? false + } + return taken ? nil : event + } + } + + private func key(_ code: UInt16) -> Bool { + if !isOpen { + return false + } + if code == 53 { + closeTop() + return true + } + if menu == nil && image == nil && !alerts.isEmpty && (code == 36 || code == 76) { + alerts.removeFirst() + return true + } + return false + } + + func uninstall() { + if let monitor { + NSEvent.removeMonitor(monitor) + } + monitor = nil + } +} + +struct OverlayHost: View { + @Environment(Overlays.self) private var overlays + + var body: some View { + ZStack(alignment: .topLeading) { + if let menu = overlays.menu { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + overlays.menu = nil + } + VStack(alignment: .leading, spacing: 0) { + ForEach(menu.entries) { entry in + MenuRow(entry: entry) { + overlays.menu = nil + entry.action() + } + } + } + .padding(4) + .background(Palette.raised, in: RoundedRectangle(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(Palette.border)) + .shadow(color: .black.opacity(0.18), radius: 14, y: 8) + .fixedSize() + .offset(x: menu.anchor.minX, y: menu.anchor.maxY + 4) + } + if let image = overlays.image { + Color.black.opacity(0.6) + .onTapGesture { + overlays.image = nil + } + Image(nsImage: image) + .resizable() + .scaledToFit() + .frame(maxWidth: image.size.width, maxHeight: image.size.height) + .padding(24) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onTapGesture { + overlays.image = nil + } + } + if let text = overlays.alerts.first { + Color.black.opacity(0.35) + .contentShape(Rectangle()) + AlertBox(text: text) { + overlays.alerts.removeFirst() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } +} + +private struct MenuRow: View { + let entry: MenuEntry + let action: () -> Void + @ViewState private var hover = false + + var body: some View { + Button(action: action) { + HStack(spacing: 6) { + Text(entry.label) + TabBadgeView(badge: entry.badge) + } + .padding(.horizontal, 12) + .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) + .background(hover ? Palette.surface : .clear, in: RoundedRectangle(cornerRadius: 4)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { hover = $0 } + } +} + +private struct AlertBox: View { + let text: String + let ok: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text(text) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + HStack { + Spacer() + Button("OK", action: ok) + .buttonStyle(WebButtonStyle(primary: true)) + } + } + .padding(16) + .frame(maxWidth: 520) + .background(Palette.raised, in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Palette.border)) + .shadow(color: .black.opacity(0.18), radius: 14, y: 8) + .padding(24) + } +} diff --git a/mac/Sources/Byre/Drafts.swift b/mac/Sources/Byre/Drafts.swift new file mode 100644 index 0000000..f90341d --- /dev/null +++ b/mac/Sources/Byre/Drafts.swift @@ -0,0 +1,204 @@ +import Foundation +import Observation + +private struct Saved: Codable { + struct Composer: Codable { + var text: String + var images: Int + } + + var composer: [String: Composer] = [:] + var checked: [String: [String]] = [:] + var texts: [String: String] = [:] +} + +final class DraftSend { + let key: String + let text: String + let images: Int + + init(key: String, text: String, images: Int) { + self.key = key + self.text = text + self.images = images + } +} + +func draftOrigin(_ origin: URL) -> String { + let c = URLComponents(url: origin, resolvingAgainstBaseURL: false)! + let scheme = (c.scheme ?? "https").lowercased() + return "\(scheme)://\((c.host ?? "").lowercased()):\(c.port ?? (scheme == "http" ? 80 : 443))" +} + +@MainActor +@Observable +final class Drafts { + private var composerTexts: [String: String] = [:] + private var composerImages: [String: Int] = [:] + private var sending: [DraftSend] = [] + private var lostImages: [String: Int] = [:] + private var checked: [String: [String]] = [:] + private var texts: [String: String] = [:] + @ObservationIgnored private let storeKey: String + @ObservationIgnored private var timer: Task? + + init(origin: URL, login: String) { + storeKey = "byre.drafts.\(draftOrigin(origin)).\(login)" + let saved = Self.read(storeKey) + for (key, d) in saved.composer { + if !d.text.isEmpty { + composerTexts[key] = d.text + } + if d.images > 0 { + lostImages[key] = d.images + } + } + checked = saved.checked + texts = saved.texts + } + + private static func read(_ key: String) -> Saved { + guard let raw = UserDefaults.standard.string(forKey: key) else { + return Saved() + } + do { + return try JSONDecoder().decode(Saved.self, from: Data(raw.utf8)) + } catch { + log.error("read drafts: \(String(describing: error), privacy: .public)") + return Saved() + } + } + + func prune(_ store: Store) { + var changed = false + for key in Set(composerTexts.keys).union(composerImages.keys).union(lostImages.keys) { + let taskId = key.hasPrefix("message:") ? String(key.dropFirst("message:".count)) : key + if taskId != "lead" && store.findTask(taskId)?.status != .active { + composerTexts[key] = nil + composerImages[key] = nil + lostImages[key] = nil + changed = true + } + } + for id in Set(checked.keys).union(texts.keys) where !store.inbox.contains(where: { $0.id == id }) { + checked[id] = nil + texts[id] = nil + changed = true + } + if changed { + save() + } + } + + private func save() { + timer?.cancel() + timer = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(400)) + if !Task.isCancelled { + self?.flush() + } + } + } + + func flush() { + timer?.cancel() + timer = nil + var out = Saved(checked: checked, texts: texts) + let keys = Set(composerTexts.keys).union(composerImages.keys).union(sending.map(\.key)).union(lostImages.keys) + for key in keys { + let inFlight = sending.filter { $0.key == key } + let text = (inFlight.map(\.text) + [composerTexts[key] ?? ""]).filter { !$0.isEmpty }.joined(separator: "\n\n") + let images = (composerImages[key] ?? 0) + inFlight.reduce(0) { $0 + $1.images } + (lostImages[key] ?? 0) + if !text.isEmpty || images > 0 { + out.composer[key] = Saved.Composer(text: text, images: images) + } + } + do { + UserDefaults.standard.set(String(decoding: try JSONEncoder().encode(out), as: UTF8.self), forKey: storeKey) + } catch { + log.error("save drafts: \(String(describing: error), privacy: .public)") + } + } + + func composerText(_ key: String) -> String { + composerTexts[key] ?? "" + } + + func setComposerText(_ key: String, _ text: String) { + if (composerTexts[key] ?? "") == text { + return + } + composerTexts[key] = text.isEmpty ? nil : text + save() + } + + func setComposerImages(_ key: String, _ n: Int) { + if (composerImages[key] ?? 0) == n { + return + } + composerImages[key] = n > 0 ? n : nil + save() + } + + func lostImageCount(_ key: String) -> Int { + lostImages[key] ?? 0 + } + + func forgetLostImages(_ key: String) { + lostImages[key] = nil + save() + } + + func startSend(_ key: String, _ text: String, _ images: Int) -> DraftSend { + let send = DraftSend(key: key, text: text, images: images) + sending.append(send) + composerTexts[key] = nil + return send + } + + func endSend(_ send: DraftSend, _ ok: Bool) { + sending.removeAll { $0 === send } + if ok { + lostImages[send.key] = nil + } else { + let text = [send.text, composerTexts[send.key] ?? ""].filter { !$0.isEmpty }.joined(separator: "\n\n") + if !text.isEmpty { + composerTexts[send.key] = text + } + } + flush() + } + + func checkedAnswer(_ id: String) -> [String] { + checked[id] ?? [] + } + + func setCheckedAnswer(_ id: String, _ options: [String]) { + checked[id] = options + save() + } + + func hasTextAnswer(_ id: String) -> Bool { + texts[id] != nil + } + + func textAnswer(_ id: String) -> String? { + texts[id] + } + + func setTextAnswer(_ id: String, _ text: String) { + texts[id] = text + save() + } + + func dropTextAnswer(_ id: String) { + texts[id] = nil + save() + } + + func clearAnswer(_ id: String) { + checked[id] = nil + texts[id] = nil + flush() + } +} diff --git a/mac/Sources/Byre/FontAndSVG.swift b/mac/Sources/Byre/FontAndSVG.swift deleted file mode 100644 index dae5fb0..0000000 --- a/mac/Sources/Byre/FontAndSVG.swift +++ /dev/null @@ -1,113 +0,0 @@ -import AppKit -import CoreText -import OpenAPIRuntime -import SwiftUI - -func runFonts(_ font: CTFont, _ text: String) -> String { - let line = CTLineCreateWithAttributedString(NSAttributedString(string: text, attributes: [.font: font as NSFont])) - return (CTLineGetGlyphRuns(line) as! [CTRun]).map { run in - let f = (CTRunGetAttributes(run) as NSDictionary)[kCTFontAttributeName] as! CTFont - let file = (CTFontCopyAttribute(f, kCTFontURLAttribute) as? URL)?.lastPathComponent ?? "system" - let r = CTRunGetStringRange(run) - let part = (text as NSString).substring(with: NSRange(location: r.location, length: r.length)) - return "\(part) → \(CTFontCopyPostScriptName(f)) (\(file))" - }.joined(separator: "\n") -} - -struct FontSpike: View { - private let sample = "The quick brown fox jumps — 0O 1lI {}[] őŁżę → │" - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 12) { - ForEach(Array([(400.0, false), (700.0, false), (400.0, true), (700.0, true)].enumerated()), id: \.offset) { _, style in - let (w, italic) = style - VStack(alignment: .leading) { - Text("weight \(Int(w))\(italic ? " italic" : "")") - .foregroundStyle(.secondary) - Text(sample) - .font(Fonts.font(16, weight: w, italic: italic)) - Text(runFonts(Fonts.ct(16, weight: w, italic: italic), sample)) - .foregroundStyle(.secondary) - } - } - VStack(alignment: .leading) { - Text("Fonts.font(16) with SwiftUI .bold() and .italic()") - .foregroundStyle(.secondary) - Text(sample) - .font(Fonts.font(16)) - .bold() - .italic() - } - VStack(alignment: .leading) { - Text("System font, for comparison") - .foregroundStyle(.secondary) - Text(sample) - .font(.system(size: 16)) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(8) - } - } -} - -struct SVGSpike: View { - @Environment(Store.self) private var store - @ViewState private var path = "web/favicon.svg" - @ViewState private var image: NSImage? - @ViewState private var failure = "" - - var body: some View { - VStack(alignment: .leading) { - HStack { - TextField("Worktree path", text: $path) - .frame(width: 320) - Button("Load") { - Task { - await load() - } - } - } - if let image { - Text(verbatim: "NSImage size \(Int(image.size.width))×\(Int(image.size.height)), reps \(image.representations.map { String(describing: type(of: $0)) })") - .foregroundStyle(.secondary) - HStack(alignment: .top, spacing: 16) { - Image(nsImage: image) - Image(nsImage: image) - .resizable() - .scaledToFit() - .frame(width: 256, height: 256) - } - } - if !failure.isEmpty { - Text(failure) - } - Spacer() - } - .task { - await load() - } - } - - private func load() async { - image = nil - failure = "" - guard let taskId = store.selectedTaskId else { - return - } - do { - 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) (\(errorText(error)))" - } - } -} diff --git a/mac/Sources/Byre/Fonts.swift b/mac/Sources/Byre/Fonts.swift index 81c50de..a24399c 100644 --- a/mac/Sources/Byre/Fonts.swift +++ b/mac/Sources/Byre/Fonts.swift @@ -24,7 +24,20 @@ enum Fonts { return CTFontCreateWithFontDescriptor(d, size, nil) } + nonisolated(unsafe) private static var fonts: [String: Font] = [:] + private static let lock = NSLock() + static func font(_ size: CGFloat, weight: CGFloat = 400, italic: Bool = false) -> Font { - Font(ct(size, weight: weight, italic: italic)) + let key = "\(size) \(weight) \(italic)" + lock.lock() + defer { + lock.unlock() + } + if let f = fonts[key] { + return f + } + let f = Font(ct(size, weight: weight, italic: italic)) + fonts[key] = f + return f } } diff --git a/mac/Sources/Byre/Format.swift b/mac/Sources/Byre/Format.swift new file mode 100644 index 0000000..5210e46 --- /dev/null +++ b/mac/Sources/Byre/Format.swift @@ -0,0 +1,354 @@ +import AppKit +import OpenAPIRuntime +import SwiftUI + +typealias JSONDict = [String: (any Sendable)?] +typealias Review = Components.Schemas.Review + +let taskPalette = [ + Palette.task0, Palette.task1, Palette.task2, Palette.task3, Palette.task4, Palette.task5, + Palette.task6, Palette.task7, Palette.task8, Palette.task9, Palette.task10, +] + +func str(_ d: JSONDict, _ key: String) -> String? { + d[key] as? String +} + +func int(_ d: JSONDict, _ key: String) -> Int? { + if let i = d[key] as? Int { + return i + } + return (d[key] as? Double).map { Int($0) } +} + +func bool(_ d: JSONDict, _ key: String) -> Bool { + d[key] as? Bool == true +} + +func dict(_ d: JSONDict, _ key: String) -> JSONDict? { + d[key] as? JSONDict +} + +func jsString(_ v: (any Sendable)??) -> String { + switch v { + case .none, .some(.none): + return "undefined" + case .some(.some(let x)): + if let s = x as? String { + return s + } + if let b = x as? Bool { + return b ? "true" : "false" + } + if let i = x as? Int { + return String(i) + } + if let d = x as? Double { + return d == d.rounded() && abs(d) < 1e15 ? String(Int(d)) : String(d) + } + return prettyJSON(x) + } +} + +func prettyJSON(_ v: (any Sendable)?) -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + guard let container = try? OpenAPIValueContainer(unvalidatedValue: v), let data = try? encoder.encode(container) else { + return String(describing: v) + } + return String(decoding: data, as: UTF8.self) +} + +func ago(_ date: Date) -> String { + let seconds = Int((Date().timeIntervalSince(date)).rounded()) + if seconds < 60 { + return "just now" + } + if seconds < 3600 { + return "\(seconds / 60)m ago" + } + if seconds < 86400 { + return "\(seconds / 3600)h ago" + } + return "\(seconds / 86400)d ago" +} + +func isoText(_ date: Date) -> String { + date.formatted(Date.ISO8601FormatStyle(includingFractionalSeconds: true)) +} + +func trimmed(_ s: String) -> String { + s.trimmingCharacters(in: .whitespacesAndNewlines) +} + +extension Store { + func taskColor(_ taskId: String) -> Color? { + findTask(taskId).map { taskPalette[$0.color] } + } +} + +private struct PaneAccentKey: EnvironmentKey { + static let defaultValue = Palette.accent +} + +private struct InRightPaneKey: EnvironmentKey { + static let defaultValue = false +} + +extension EnvironmentValues { + var paneAccent: Color { + get { + self[PaneAccentKey.self] + } + set { + self[PaneAccentKey.self] = newValue + } + } + + var inRightPane: Bool { + get { + self[InRightPaneKey.self] + } + set { + self[InRightPaneKey.self] = newValue + } + } +} + +struct Copyable: View { + let text: String + @ViewBuilder let content: Content + @ViewState private var hover = false + @ViewState private var copied = false + + var body: some View { + content + .overlay(alignment: .topTrailing) { + if hover { + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + copied = true + Task { + try? await Task.sleep(for: .milliseconds(1500)) + copied = false + } + } label: { + Image(systemName: copied ? "checkmark" : "doc.on.doc") + .font(.system(size: 11)) + .foregroundStyle(Palette.muted) + .padding(4) + .background(Palette.raised, in: RoundedRectangle(cornerRadius: 4)) + } + .buttonStyle(.plain) + .help("Copy to the clipboard") + .padding(4) + } + } + .onHover { hover = $0 } + } +} + +struct PreBox: View { + let text: String + var maxHeight: CGFloat? = 320 + var size: CGFloat = 12 + + var body: some View { + Copyable(text: text) { + ScrollView { + Text(verbatim: text) + .font(Fonts.font(size)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 8) + .padding(.vertical, 6) + } + .scrollBounceBehavior(.basedOnSize) + .frame(maxHeight: maxHeight) + .fixedSize(horizontal: false, vertical: true) + .background(Palette.raised, in: RoundedRectangle(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(Palette.border)) + } + } +} + +struct Spinner: View { + var color: Color + private static let frames = Array("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏") + + var body: some View { + TimelineView(.periodic(from: .now, by: 0.08)) { context in + let i = Int(context.date.timeIntervalSinceReferenceDate / 0.08) % Self.frames.count + Text(String(Self.frames[i])) + .foregroundStyle(color) + } + } +} + +struct Badge: View { + let count: Int + + var body: some View { + Text("\(count)") + .font(Fonts.font(11, weight: 700)) + .foregroundStyle(Palette.danger) + .padding(.horizontal, 7) + .background(Palette.dangerTint, in: Capsule()) + } +} + +struct Muted: View { + let text: String + + var body: some View { + Text(text) + .foregroundStyle(Palette.muted) + .padding(32) + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + } +} + +struct WebButtonStyle: ButtonStyle { + var primary = false + var danger = false + + func makeBody(configuration: ButtonStyleConfiguration) -> some View { + WebButton(configuration: configuration, primary: primary, danger: danger) + } +} + +private struct WebButton: View { + let configuration: ButtonStyleConfiguration + let primary: Bool + let danger: Bool + @Environment(\.isEnabled) private var enabled + @ViewState private var hover = false + + var body: some View { + configuration.label + .font(Fonts.font(13, weight: primary ? 700 : 400)) + .foregroundStyle(primary ? Palette.bg : danger ? Palette.danger : Palette.text) + .padding(.horizontal, 12) + .padding(.vertical, 4) + .background(primary ? Palette.accent : Palette.raised, in: RoundedRectangle(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(primary ? Palette.accent : hover && enabled ? Palette.muted : Palette.border)) + .opacity(enabled ? (configuration.isPressed ? 0.8 : 1) : 0.45) + .contentShape(Rectangle()) + .onHover { hover = $0 } + } +} + +struct SmallLinkButtonStyle: ButtonStyle { + var color = Palette.accent + @Environment(\.isEnabled) private var enabled + + func makeBody(configuration: ButtonStyleConfiguration) -> some View { + configuration.label + .font(Fonts.font(11)) + .foregroundStyle(color) + .padding(.horizontal, 6) + .overlay(RoundedRectangle(cornerRadius: 4).stroke(Palette.border)) + .opacity(enabled ? 1 : 0.45) + .contentShape(Rectangle()) + } +} + +struct FieldStyle: ViewModifier { + var accent: Color? = nil + var focused = false + + func body(content: Content) -> some View { + content + .textFieldStyle(.plain) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Palette.raised, in: RoundedRectangle(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(focused ? (accent ?? Palette.accent) : (accent ?? Palette.border))) + } +} + +struct TrailingBubble: Layout { + var fraction: CGFloat = 0.85 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let width = proposal.width ?? 600 + let size = subviews[0].sizeThatFits(ProposedViewSize(width: width * fraction, height: nil)) + return CGSize(width: width, height: size.height) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + let size = subviews[0].sizeThatFits(ProposedViewSize(width: bounds.width * fraction, height: nil)) + subviews[0].place(at: CGPoint(x: bounds.maxX - size.width, y: bounds.minY), proposal: ProposedViewSize(size)) + } +} + +struct FitWidth: Layout { + let natural: CGSize + + private func scaled(_ width: CGFloat?) -> CGSize { + let w = width ?? natural.width + let scale = natural.width > 0 ? min(1, w / natural.width) : 1 + return CGSize(width: natural.width * scale, height: natural.height * scale) + } + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + scaled(proposal.width) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + let size = scaled(bounds.width) + subviews[0].place(at: bounds.origin, proposal: ProposedViewSize(size)) + } +} + +struct FlowLayout: Layout { + var spacing: CGFloat = 6 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let rows = arrange(proposal.width ?? .infinity, subviews) + let height = rows.last.map { $0.y + $0.height } ?? 0 + let width = rows.map(\.width).max() ?? 0 + return CGSize(width: proposal.width ?? width, height: height) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + for row in arrange(bounds.width, subviews) { + for (i, x) in zip(row.items, row.xs) { + subviews[i].place(at: CGPoint(x: bounds.minX + x, y: bounds.minY + row.y), proposal: .unspecified) + } + } + } + + private struct Row { + var items: [Int] = [] + var xs: [CGFloat] = [] + var y: CGFloat = 0 + var width: CGFloat = 0 + var height: CGFloat = 0 + } + + private func arrange(_ maxWidth: CGFloat, _ subviews: Subviews) -> [Row] { + var rows: [Row] = [] + var row = Row() + var y: CGFloat = 0 + for (i, sub) in subviews.enumerated() { + let size = sub.sizeThatFits(.unspecified) + if !row.items.isEmpty && row.width + spacing + size.width > maxWidth { + rows.append(row) + y += row.height + spacing + row = Row(y: y) + } + let x = row.items.isEmpty ? 0 : row.width + spacing + row.items.append(i) + row.xs.append(x) + row.width = x + size.width + row.height = max(row.height, size.height) + } + if !row.items.isEmpty { + rows.append(row) + } + return rows + } +} diff --git a/mac/Sources/Byre/Images.swift b/mac/Sources/Byre/Images.swift new file mode 100644 index 0000000..12f6b65 --- /dev/null +++ b/mac/Sources/Byre/Images.swift @@ -0,0 +1,143 @@ +import AppKit +import OpenAPIRuntime +import SwiftUI + +enum ImageSource: Hashable { + case blob(String) + case worktree(taskId: String, path: String) + case data(Data) +} + +private let dataImage = Re(#"^data:image/(png|jpeg|gif|webp);"#, caseless: true) +private let fileURL = Re(#"^file://"#, caseless: true) +private let anyScheme = Re(#"^[a-z][a-z0-9+.-]*:"#, caseless: true) + +func worktreeImageSource(_ path: String, _ taskId: String?) -> ImageSource? { + if dataImage.test(path) { + guard let comma = path.firstIndex(of: ","), path[..] = [:] + + func cached(_ source: ImageSource) -> NSImage? { + done[source] + } + + func load(_ source: ImageSource, _ api: API) async throws -> NSImage { + if let img = done[source] { + return img + } + if let task = loading[source] { + return try await task.value + } + let task = Task { @MainActor in + let data = try await Self.fetch(source, api) + guard let img = NSImage(data: data) else { + throw ApiError(status: 0, message: "not an image") + } + return img + } + loading[source] = task + defer { + loading[source] = nil + } + let img = try await task.value + done[source] = img + return img + } + + private static func fetch(_ source: ImageSource, _ api: API) async throws -> Data { + switch source { + case .data(let data): + return data + case .blob(let id): + let body = switch try await api.client.getBlob(path: .init(blobId: id)).ok.body { + case .png(let b), .jpeg(let b), .imageGif(let b), .imageWebp(let b): b + } + return try await Data(collecting: body, upTo: 32 << 20) + case .worktree(let taskId, let path): + let body = switch try await 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 + } + return try await Data(collecting: body, upTo: 32 << 20) + } + } +} + +struct ByreImage: View { + let source: ImageSource + let src: String + var maxWidth: CGFloat = .infinity + var maxHeight: CGFloat = 480 + @Environment(Store.self) private var store + @Environment(Overlays.self) private var overlays + @ViewState private var image: NSImage? + @ViewState private var failed = false + + var body: some View { + Group { + if let image = image ?? ImageCache.shared.cached(source) { + Image(nsImage: image) + .resizable() + .scaledToFit() + .frame(maxWidth: min(maxWidth, image.size.width), maxHeight: min(maxHeight, image.size.height), alignment: .leading) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(Palette.border)) + .onTapGesture { + overlays.image = image + } + .help("Open full size") + } else if failed { + Text(verbatim: "image not available: \(src)") + .foregroundStyle(Palette.muted) + } else { + Color.clear.frame(width: 1, height: 1) + } + } + .task(id: source) { + if image != nil { + return + } + do { + image = try await ImageCache.shared.load(source, store.api) + } catch { + log.error("load image \(src, privacy: .public): \(errorText(error), privacy: .public)") + failed = true + } + } + } +} + +struct MessageImages: View { + let ids: [String] + + var body: some View { + FlowLayout(spacing: 6) { + ForEach(ids, id: \.self) { id in + ByreImage(source: .blob(id), src: "/api/blobs/\(id)", maxWidth: 240, maxHeight: 180) + } + } + .padding(.vertical, 4) + } +} diff --git a/mac/Sources/Byre/Markdown.swift b/mac/Sources/Byre/Markdown.swift new file mode 100644 index 0000000..481a4d8 --- /dev/null +++ b/mac/Sources/Byre/Markdown.swift @@ -0,0 +1,328 @@ +import Foundation + +indirect enum MDInline: Sendable { + case text(String) + case code(String) + case strong([MDInline]) + case em([MDInline]) + case image(src: String, alt: String) + case link(href: String, [MDInline]) + case lineBreak +} + +struct MDItem: Sendable { + var choice: String? + var inline: [MDInline] = [] + var blocks: [MDBlock] = [] +} + +indirect enum MDBlock: Sendable { + case paragraph([MDInline]) + case heading(level: Int, [MDInline]) + case rule + case quote([MDBlock]) + case list(ordered: Bool, start: Int, items: [MDItem], grid: Bool) + case code(String) + case diff([String]) + case details(summary: [MDInline], body: [MDBlock]) + case diagram(String) + case table(head: [[MDInline]], rows: [[[MDInline]]]) +} + +struct Re: @unchecked Sendable { + let re: NSRegularExpression + + init(_ pattern: String, caseless: Bool = false) { + re = try! NSRegularExpression(pattern: pattern, options: caseless ? [.caseInsensitive] : []) + } + + func test(_ s: String) -> Bool { + re.firstMatch(in: s, range: NSRange(s.startIndex..., in: s)) != nil + } + + func exec(_ s: String) -> [String?]? { + match(s)?.groups + } + + func match(_ s: String) -> (range: Range, groups: [String?])? { + guard let m = re.firstMatch(in: s, range: NSRange(s.startIndex..., in: s)) else { + return nil + } + let groups = (0.. String? in + let r = m.range(at: i) + return r.location == NSNotFound ? nil : String(s[Range(r, in: s)!]) + } + return (Range(m.range, in: s)!, groups) + } +} + +private let choiceMarker = Re(#"^(\([ *]\)|\[[ xX]\])\s+(\S.*?)\s*$"#) +private let detailsOpen = Re(#"^\s*
\s*(?:(.*?))?\s*$"#, caseless: true) +private let summaryLine = Re(#"^\s*(.*?)\s*$"#, caseless: true) +private let detailsClose = Re(#"^\s*
\s*$"#, caseless: true) +private let fence = Re(#"^\s*(```|~~~)\s*(\S*)"#) +private let heading = Re(#"^(#{1,6})\s+(.*)$"#) +private let bullet = Re(#"^(\s*)[-*+]\s+(.*)$"#) +private let ordered = Re(#"^(\s*)(\d+)[.)]\s+(.*)$"#) +private let quote = Re(#"^\s*>\s?(.*)$"#) +private let rule = Re(#"^\s*([-*_])(\s*\1){2,}\s*$"#) +private let imageOnly = Re(#"^\s*!\[[^\]]*\]\((<[^>]*>|[^)]*)\)\s*$"#) +private let tableSep = Re(#"^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$"#) +private let inlineToken = Re(#"`([^`]+)`|\*\*(.+?)\*\*|__(.+?)__|\*([^*\s][^*]*?)\*|(?]+)>|([^)]+))\)|\[([^\]]+)\]\(([^)\s]+)\)|(https?://[^\s<>()]+[^\s<>().,;:!?'"])"#) + +func parseMarkdown(_ src: String, choices: Bool = false) -> [MDBlock] { + let text = src.replacingOccurrences(of: "\r\n", with: "\n").replacingOccurrences(of: "\r", with: "\n") + return blocks(text.components(separatedBy: "\n"), choices) +} + +func parseInline(_ src: String) -> [MDInline] { + inline(src) +} + +private func trimStart(_ s: String) -> Substring { + s.drop { $0 == " " || $0 == "\t" } +} + +private func blocks(_ lines: [String], _ choices: Bool) -> [MDBlock] { + var out: [MDBlock] = [] + var i = 0 + + while i < lines.count { + let line = lines[i] + + if trimmed(line).isEmpty { + i += 1 + continue + } + + if let f = fence.exec(line) { + let marker = f[1]! + var body: [String] = [] + i += 1 + while i < lines.count && !trimStart(lines[i]).hasPrefix(marker) { + body.append(lines[i]) + i += 1 + } + i += 1 + let lang = f[2]!.lowercased() + if lang == "mermaid" { + out.append(.diagram(body.joined(separator: "\n"))) + continue + } + if lang == "diff" || lang == "patch" { + out.append(.diff(body)) + continue + } + out.append(.code(body.joined(separator: "\n"))) + continue + } + + if detailsClose.test(line) { + i += 1 + continue + } + + if let d = detailsOpen.exec(line) { + var title = d[1] + i += 1 + if title == nil && i < lines.count, let s = summaryLine.exec(lines[i]) { + title = s[1] + i += 1 + } + var body: [String] = [] + var depth = 1 + while i < lines.count { + let l = lines[i] + i += 1 + if detailsOpen.test(l) { + depth += 1 + } else if detailsClose.test(l) { + depth -= 1 + if depth == 0 { + break + } + } + body.append(l) + } + let summary = title.map(trimmed) ?? "" + out.append(.details(summary: inline(summary.isEmpty ? "Details" : summary), body: blocks(body, choices))) + continue + } + + if let hd = heading.exec(line) { + out.append(.heading(level: min(hd[1]!.count + 2, 6), inline(hd[2]!))) + i += 1 + continue + } + + if rule.test(line) { + out.append(.rule) + i += 1 + continue + } + + if quote.test(line) { + var body: [String] = [] + while i < lines.count, let q = quote.exec(lines[i]) { + body.append(q[1]!) + i += 1 + } + out.append(.quote(blocks(body, false))) + continue + } + + if bullet.test(line) || ordered.test(line) { + let (list, next) = parseList(lines, i, choices) + out.append(list) + i = next + continue + } + + if line.contains("|") && i + 1 < lines.count && tableSep.test(lines[i + 1]) { + var rows: [[String]] = [cells(line)] + i += 2 + while i < lines.count && lines[i].contains("|") && !trimmed(lines[i]).isEmpty { + rows.append(cells(lines[i])) + i += 1 + } + out.append(.table(head: rows[0].map(inline), rows: rows.dropFirst().map { $0.map(inline) })) + continue + } + + var para: [String] = [] + while i < lines.count && !trimmed(lines[i]).isEmpty && !startsBlock(lines, i) { + para.append(trimmed(lines[i])) + i += 1 + } + out.append(.paragraph(joinLines(para))) + } + + return out +} + +private func startsBlock(_ lines: [String], _ i: Int) -> Bool { + let line = lines[i] + return fence.test(line) || detailsOpen.test(line) || detailsClose.test(line) || heading.test(line) || rule.test(line) || quote.test(line) || bullet.test(line) || ordered.test(line) + || (line.contains("|") && i + 1 < lines.count && tableSep.test(lines[i + 1])) +} + +private func joinLines(_ lines: [String]) -> [MDInline] { + var out: [MDInline] = [] + for (n, l) in lines.enumerated() { + if n > 0 { + out.append(.lineBreak) + } + out += inline(l) + } + return out +} + +private func indentOf(_ line: String) -> Int { + line.count - trimStart(line).count +} + +private func parseList(_ lines: [String], _ start: Int, _ choices: Bool) -> (MDBlock, Int) { + let first = lines[start] + let isOrdered = !bullet.test(first) + let base = indentOf(first) + let startNumber = isOrdered ? Int(ordered.exec(first)![2]!) ?? 1 : 1 + let marker = isOrdered ? ordered : bullet + + var items: [MDItem] = [] + var i = start + var imageChoices = !isOrdered && choices + while i < lines.count { + let line = lines[i] + guard let m = marker.exec(line), indentOf(line) == base else { + break + } + let text = isOrdered ? m[3]! : m[2]! + var body = [text] + i += 1 + while i < lines.count { + let next = lines[i] + if trimmed(next).isEmpty { + if i + 1 < lines.count, indentOf(lines[i + 1]) > base, !trimmed(lines[i + 1]).isEmpty { + body.append("") + i += 1 + continue + } + break + } + if indentOf(next) <= base && (bullet.test(next) || ordered.test(next) || startsBlock(lines, i)) { + break + } + body.append(indentOf(next) > base ? String(next.dropFirst(min(indentOf(next), base + 2))) : trimmed(next)) + i += 1 + } + let detail = body.dropFirst().filter { !trimmed($0).isEmpty } + imageChoices = imageChoices && choiceMarker.test(text) && detail.count == 1 && imageOnly.test(detail.first!) + items.append(item(body, isOrdered ? false : choices)) + while i < lines.count && trimmed(lines[i]).isEmpty && i + 1 < lines.count && indentOf(lines[i + 1]) == base && marker.test(lines[i + 1]) { + i += 1 + } + } + return (.list(ordered: isOrdered, start: startNumber, items: items, grid: imageChoices), i) +} + +private func item(_ body: [String], _ choices: Bool) -> MDItem { + var out = MDItem() + if choices, let m = choiceMarker.exec(body[0]) { + out.choice = m[2] + } + let first = out.choice != nil ? [] : [body[0]] + let rest = Array(body.dropFirst()) + if rest.indices.allSatisfy({ !startsBlock(body, $0 + 1) }) && !rest.contains("") { + out.inline = joinLines((first + rest).map(trimmed)) + return out + } + var head = first + var k = 1 + while k < body.count && body[k] != "" && !startsBlock(body, k) { + head.append(trimmed(body[k])) + k += 1 + } + out.inline = joinLines(head) + out.blocks = blocks(Array(body[k...]), choices) + return out +} + +private func cells(_ line: String) -> [String] { + var s = Substring(trimmed(line)) + if s.hasPrefix("|") { + s = s.dropFirst() + } + if s.hasSuffix("|") { + s = s.dropLast() + } + return s.split(separator: "|", omittingEmptySubsequences: false).map { trimmed(String($0)) } +} + +private func inline(_ src: String) -> [MDInline] { + var out: [MDInline] = [] + var rest = src + while !rest.isEmpty { + guard let (range, g) = inlineToken.match(rest) else { + out.append(.text(rest)) + break + } + if range.lowerBound > rest.startIndex { + out.append(.text(String(rest[.. URL { + var c = URLComponents() + c.scheme = "byre-app" + c.host = "task" + c.queryItems = [URLQueryItem(name: "id", value: id)] + return c.url! + } + + static func todo(_ projectId: String, _ title: String, discuss: Bool) -> URL { + var c = URLComponents() + c.scheme = "byre-app" + c.host = "todo" + c.queryItems = [URLQueryItem(name: "project", value: projectId), URLQueryItem(name: "title", value: title), URLQueryItem(name: "discuss", value: discuss ? "1" : "0")] + return c.url! + } +} + +@MainActor +func openAppLink(_ url: URL, _ store: Store, _ overlays: Overlays) -> OpenURLAction.Result { + guard url.scheme == "byre-app", let c = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return .systemAction + } + let q = Dictionary((c.queryItems ?? []).map { ($0.name, $0.value ?? "") }, uniquingKeysWith: { a, _ in a }) + switch c.host { + case "task": + store.focusTask(q["id"] ?? "") + case "todo": + store.startTodo(q["project"] ?? "", q["title"] ?? "", discuss: q["discuss"] == "1", overlays) + default: + break + } + return .handled +} + +private let httpLink = Re(#"^https?://"#, caseless: true) +private let taskHref = Re(#"^#/task/(\w+)(/\w+)?$"#) +private let todoHref = Re(#"^#/todo/(\w+)/(.+)$"#) + +@MainActor +struct InlineRenderer { + let store: Store + let size: CGFloat + let taskId: String? + + private struct Style { + var bold = false + var italic = false + var link: URL? + var color: Color? + var small = false + } + + func segments(_ items: [MDInline]) -> [MDSegment] { + var out: [MDSegment] = [] + render(items, Style(), &out) + return out + } + + func text(_ items: [MDInline]) -> AttributedString { + var out = AttributedString() + for seg in segments(items) { + if case .text(let a) = seg { + out += a + } + } + return out + } + + private func run(_ s: String, _ style: Style) -> AttributedString { + var a = AttributedString(s) + a.font = Fonts.font(style.small ? 11 : size, weight: style.bold ? 700 : 400, italic: style.italic) + if let link = style.link { + a.link = link + } + if let color = style.color { + a.foregroundColor = color + } + return a + } + + private func append(_ a: AttributedString, _ out: inout [MDSegment]) { + if case .text(let prev) = out.last { + out[out.count - 1] = .text(prev + a) + return + } + out.append(.text(a)) + } + + private func render(_ items: [MDInline], _ style: Style, _ out: inout [MDSegment]) { + for item in items { + switch item { + case .text(let t): + append(run(t, style), &out) + case .lineBreak: + append(run("\n", style), &out) + case .code(let c): + var a = run(c, style) + a.backgroundColor = Palette.surface + append(a, &out) + case .strong(let inner): + var s = style + s.bold = true + render(inner, s, &out) + case .em(let inner): + var s = style + s.italic = true + render(inner, s, &out) + case .image(let src, let alt): + if let source = worktreeImageSource(src, taskId) { + out.append(.image(source, src: src)) + } else { + link(src, [.text(alt.isEmpty ? src : alt)], style, &out) + } + case .link(let href, let inner): + link(href, inner, style, &out) + } + } + } + + private func link(_ href: String, _ inner: [MDInline], _ style: Style, _ out: inout [MDSegment]) { + var s = style + if httpLink.test(href), let url = URL(string: href) { + s.link = url + s.color = Palette.accent + render(inner, s, &out) + return + } + if let m = taskHref.exec(href) { + s.link = AppLink.task(m[1]!) + s.color = store.taskColor(m[1]!) ?? Palette.accent + render(inner, s, &out) + return + } + if let m = todoHref.exec(href), let title = m[2]!.removingPercentEncoding { + todo(m[1]!, title, inner, style, &out) + return + } + render(inner, style, &out) + } + + private func todo(_ projectId: String, _ title: String, _ inner: [MDInline], _ style: Style, _ out: inout [MDSegment]) { + let task = (store.tasks[projectId] ?? []).first { $0.status == .active && $0.todoItem == title } + var s = style + if let task { + s.color = taskPalette[task.color] + render(inner, s, &out) + append(run(" ", style), &out) + let state = store.currentSession(task.id)?.state + var dot = Style(link: AppLink.task(task.id), color: state == .running ? Palette.ok : Palette.muted, small: true) + append(run(state == .running ? "●" : "○", dot), &out) + dot.color = Palette.muted + append(run(" \(state?.rawValue ?? "no agent")", dot), &out) + return + } + render(inner, style, &out) + append(run(" ", style), &out) + if store.todoStarting.contains("\(projectId)\n\(title)") { + append(run("starting…", Style(color: Palette.muted, small: true)), &out) + return + } + append(run("start", Style(link: AppLink.todo(projectId, title, discuss: false), color: Palette.accent, small: true)), &out) + append(run(" ", style), &out) + append(run("discuss", Style(link: AppLink.todo(projectId, title, discuss: true), color: Palette.accent, small: true)), &out) + } +} + +struct MDContext { + var size: CGFloat = 13 + var color: Color = Palette.text + var taskId: String? + var choice: ((MDItem) -> AnyView)? +} + +struct MarkdownView: View { + let blocks: [MDBlock] + var ctx = MDContext() + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in + BlockView(block: block, ctx: ctx) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +struct InlineView: View { + let inlines: [MDInline] + let ctx: MDContext + @Environment(Store.self) private var store + + var body: some View { + let segs = InlineRenderer(store: store, size: ctx.size, taskId: ctx.taskId).segments(inlines) + VStack(alignment: .leading, spacing: 4) { + ForEach(Array(segs.enumerated()), id: \.offset) { _, seg in + switch seg { + case .text(let a): + Text(a) + .font(Fonts.font(ctx.size)) + .foregroundStyle(ctx.color) + .lineSpacing(3) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + case .image(let source, let src): + ByreImage(source: source, src: src) + } + } + } + } +} + +private struct BlockView: View { + let block: MDBlock + let ctx: MDContext + + var body: some View { + switch block { + case .paragraph(let inlines): + InlineView(inlines: inlines, ctx: ctx) + case .heading(let level, let inlines): + InlineView(inlines: [.strong(inlines)], ctx: MDContext(size: level == 3 ? 14 : 13, color: ctx.color, taskId: ctx.taskId)) + .padding(.top, 4) + case .rule: + Rectangle() + .fill(Palette.border) + .frame(height: 1) + .padding(.vertical, 2) + case .quote(let blocks): + MarkdownView(blocks: blocks, ctx: MDContext(size: ctx.size, color: Palette.muted, taskId: ctx.taskId)) + .padding(.leading, 13) + .overlay(alignment: .leading) { + Rectangle().fill(Palette.border).frame(width: 3) + } + case .list(let ordered, let start, let items, let grid): + ListBlock(ordered: ordered, start: start, items: items, grid: grid, ctx: ctx) + case .code(let code): + Copyable(text: code) { + ScrollView(.horizontal) { + Text(verbatim: code) + .font(Fonts.font(ctx.size)) + .foregroundStyle(Palette.text) + .textSelection(.enabled) + .fixedSize() + .padding(.horizontal, 8) + .padding(.vertical, 6) + } + .scrollBounceBehavior(.basedOnSize) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Palette.surface, in: RoundedRectangle(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(Palette.border)) + } + case .diff(let lines): + DiffBlock(lines: lines, size: ctx.size) + case .details(let summary, let body): + DetailsBlock(summary: summary, blocks: body, ctx: ctx) + case .diagram(let source): + DiagramView(source: source) + case .table(let head, let rows): + TableBlock(head: head, rows: rows, ctx: ctx) + } + } +} + +private struct ListBlock: View { + let ordered: Bool + let start: Int + let items: [MDItem] + let grid: Bool + let ctx: MDContext + + var body: some View { + if grid, let choice = ctx.choice { + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8, alignment: .top), count: 4), spacing: 8) { + ForEach(Array(items.enumerated()), id: \.offset) { _, item in + choice(item) + } + } + } else { + VStack(alignment: .leading, spacing: 2) { + ForEach(Array(items.enumerated()), id: \.offset) { n, item in + if item.choice != nil, let choice = ctx.choice { + choice(item) + } else { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(verbatim: ordered ? "\(start + n)." : "•") + .font(Fonts.font(ctx.size)) + .foregroundStyle(ctx.color) + ItemBody(item: item, ctx: ctx) + } + } + } + } + } + } +} + +struct ItemBody: View { + let item: MDItem + let ctx: MDContext + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + if !item.inline.isEmpty { + InlineView(inlines: item.inline, ctx: ctx) + } + if !item.blocks.isEmpty { + MarkdownView(blocks: item.blocks, ctx: ctx) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private struct DiffBlock: View { + let lines: [String] + let size: CGFloat + + var body: some View { + Copyable(text: lines.map { $0 + "\n" }.joined()) { + VStack(alignment: .leading, spacing: 0) { + ForEach(Array(lines.enumerated()), id: \.offset) { _, line in + Text(verbatim: line.isEmpty ? " " : line) + .font(Fonts.font(size)) + .foregroundStyle(line.hasPrefix("@@") ? Palette.muted : Palette.text) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 8) + .background(background(line)) + } + } + .padding(.vertical, 6) + .background(Palette.surface, in: RoundedRectangle(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(Palette.border)) + } + } + + private func background(_ l: String) -> Color { + if l.hasPrefix("@@") { + return Palette.surface + } + if l.hasPrefix("+++") || l.hasPrefix("---") { + return .clear + } + if l.hasPrefix("+") { + return Palette.ok.opacity(0.14) + } + if l.hasPrefix("-") { + return Palette.danger.opacity(0.14) + } + return .clear + } +} + +private struct DetailsBlock: View { + let summary: [MDInline] + let blocks: [MDBlock] + let ctx: MDContext + @ViewState private var open = false + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Button { + open.toggle() + } label: { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(open ? "▾" : "▸") + .foregroundStyle(Palette.accent) + InlineView(inlines: summary, ctx: MDContext(size: ctx.size, color: Palette.accent, taskId: ctx.taskId)) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + if open { + MarkdownView(blocks: blocks, ctx: ctx) + .padding(.leading, 11) + .overlay(alignment: .leading) { + Rectangle().fill(Palette.border).frame(width: 1) + } + } + } + .padding(.vertical, 2) + } +} + +private struct TableBlock: View { + let head: [[MDInline]] + let rows: [[[MDInline]]] + let ctx: MDContext + + var body: some View { + Grid(alignment: .topLeading, horizontalSpacing: 0, verticalSpacing: 0) { + GridRow { + ForEach(Array(head.enumerated()), id: \.offset) { _, cell in + cellView([.strong(cell)]) + .background(Palette.surface) + } + } + ForEach(Array(rows.enumerated()), id: \.offset) { _, row in + GridRow { + ForEach(Array(row.enumerated()), id: \.offset) { _, cell in + cellView(cell) + } + } + } + } + .overlay(Rectangle().stroke(Palette.border)) + } + + private func cellView(_ cell: [MDInline]) -> some View { + InlineView(inlines: cell, ctx: ctx) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .overlay(Rectangle().stroke(Palette.border, lineWidth: 0.5)) + } +} diff --git a/mac/Sources/Byre/PaneFocus.swift b/mac/Sources/Byre/PaneFocus.swift new file mode 100644 index 0000000..50fa563 --- /dev/null +++ b/mac/Sources/Byre/PaneFocus.swift @@ -0,0 +1,182 @@ +import AppKit +import SwiftUI + +@MainActor +final class PaneFocus { + private let api: API + private weak var window: NSWindow? + private var paneFrame: CGRect = .zero + private var drafts: Set = [] + private var inside = false + private var focused = false + private var drafting = false + private var sending: Task? + private var observers: [any NSObjectProtocol] = [] + private var responder: NSKeyValueObservation? + private var clicks: Any? + + init(api: API) { + self.api = api + } + + func attach(_ window: NSWindow) { + if self.window === window { + return + } + detach() + self.window = window + let center = NotificationCenter.default + for name in [NSWindow.didBecomeKeyNotification, NSWindow.didResignKeyNotification] { + observers.append(center.addObserver(forName: name, object: window, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { + self?.update() + } + }) + } + for name in [NSApplication.didBecomeActiveNotification, NSApplication.didResignActiveNotification] { + observers.append(center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { + self?.update() + } + }) + } + responder = window.observe(\.firstResponder, options: [.new]) { [weak self] _, _ in + Task { @MainActor in + self?.responderChanged() + } + } + clicks = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) { [weak self] event in + let win = event.window.map(ObjectIdentifier.init) + let point = event.locationInWindow + MainActor.assumeIsolated { + if let self, let window = self.window, win == ObjectIdentifier(window) { + self.clicked(point) + } + } + return event + } + responderChanged() + } + + func detach() { + for o in observers { + NotificationCenter.default.removeObserver(o) + } + observers = [] + responder = nil + if let clicks { + NSEvent.removeMonitor(clicks) + } + clicks = nil + window = nil + } + + func setFrame(_ frame: CGRect) { + paneFrame = frame + } + + func setDraft(_ id: String, _ nonEmpty: Bool) { + if nonEmpty { + drafts.insert(id) + } else { + drafts.remove(id) + } + update() + } + + func reconnected() { + if focused { + send() + } + } + + func leave() async { + detach() + if !focused { + return + } + focused = false + await sending?.value + do { + _ = try await api.client.setPaneFocus(body: .json(.init(focused: false, drafting: drafting))).noContent + } catch { + log.error("set pane focus: \(errorText(error), privacy: .public)") + } + } + + private func contentPoint(_ rect: CGRect, in view: NSView) -> CGPoint? { + guard let content = window?.contentView else { + return nil + } + let r = content.convert(rect, from: view) + return CGPoint(x: r.midX, y: content.isFlipped ? r.midY : content.bounds.height - r.midY) + } + + private func responderChanged() { + guard let view = window?.firstResponder as? NSView, view !== window?.contentView else { + update() + return + } + if let p = contentPoint(view.bounds, in: view) { + inside = paneFrame.contains(p) + } + update() + } + + private func clicked(_ location: CGPoint) { + guard let content = window?.contentView else { + return + } + let p = content.convert(location, from: nil) + inside = paneFrame.contains(CGPoint(x: p.x, y: content.isFlipped ? p.y : content.bounds.height - p.y)) + DispatchQueue.main.async { [weak self] in + self?.responderChanged() + } + } + + private func update() { + let now = (window?.isKeyWindow ?? false) && NSApp.isActive && inside + let draft = !drafts.isEmpty + if now == focused && draft == drafting { + return + } + focused = now + drafting = draft + send() + } + + private func send() { + let body = Components.Schemas.PaneFocus(focused: focused, drafting: drafting) + let previous = sending + sending = Task { [api] in + await previous?.value + do { + _ = try await api.client.setPaneFocus(body: .json(body)).noContent + } catch { + log.error("set pane focus: \(errorText(error), privacy: .public)") + } + } + } +} + +struct WindowReader: NSViewRepresentable { + let found: (NSWindow) -> Void + + func makeNSView(context: Context) -> NSView { + let view = NSView() + DispatchQueue.main.async { + if let w = view.window { + found(w) + } + } + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + DispatchQueue.main.async { + if let w = nsView.window { + found(w) + } + } + } +} diff --git a/mac/Sources/Byre/Panes.swift b/mac/Sources/Byre/Panes.swift new file mode 100644 index 0000000..fe86bc9 --- /dev/null +++ b/mac/Sources/Byre/Panes.swift @@ -0,0 +1,547 @@ +import AppKit +import SwiftUI + +enum TabBadge { + case none + case count(Int) + case spinner +} + +struct TabBadgeView: View { + let badge: TabBadge + @Environment(\.paneAccent) private var accent + + var body: some View { + switch badge { + case .none: + EmptyView() + case .count(let n): + Badge(count: n) + case .spinner: + Spinner(color: accent) + .help("review in progress") + } + } +} + +let tabLabels: [TaskTab: String] = [ + .transcript: "Transcript", + .changes: "Changes", + .files: "Files", + .terminal: "Terminal", + .inbox: "Inbox", + .reviews: "Reviews", + .workflow: "Workflow", + .project: "Project", +] + +struct HalfSplit: Layout { + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + proposal.replacingUnspecifiedDimensions() + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + let half = bounds.width / 2 + for (i, sub) in subviews.enumerated() { + sub.place(at: CGPoint(x: bounds.minX + half * CGFloat(i), y: bounds.minY), proposal: ProposedViewSize(width: half, height: bounds.height)) + } + } +} + +struct MainView: View { + @Environment(Store.self) private var store + @Environment(Overlays.self) private var overlays + + var body: some View { + VStack(spacing: 0) { + IdentityBanner() + HalfSplit { + LeftPane() + TaskPane() + .overlay(alignment: .leading) { + Rectangle().fill(Palette.border).frame(width: 1) + } + } + } + .overlay { + OverlayHost() + } + .coordinateSpace(.named("window")) + .environment(\.openURL, OpenURLAction { url in + openAppLink(url, store, overlays) + }) + .background { + WindowReader { window in + store.paneFocus.attach(window) + } + } + .onAppear { + overlays.install() + } + .onDisappear { + overlays.uninstall() + let store = store + Task { + await store.paneFocus.leave() + } + } + } +} + +struct PaneTabButton: View { + let label: String + var active = false + var attention = false + var badge = TabBadge.none + let action: () -> Void + @Environment(\.paneAccent) private var accent + @ViewState private var hover = false + + var body: some View { + Button(action: action) { + HStack(spacing: 6) { + Text(label) + TabBadgeView(badge: badge) + } + .font(Fonts.font(13, weight: attention ? 700 : 400)) + .foregroundStyle(attention ? Palette.warn : active || hover ? Palette.text : Palette.muted) + .padding(.horizontal, 10) + .padding(.top, 8) + .padding(.bottom, 6) + .background(attention ? Palette.warnTint : .clear) + .overlay(alignment: .bottom) { + Rectangle() + .fill(active ? accent : attention ? Palette.warn : .clear) + .frame(height: 2) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .fixedSize() + .onHover { hover = $0 } + } +} + +private enum LeftTab { + case lead, status, settings +} + +private struct LeftPane: View { + @Environment(Store.self) private var store + @ViewState private var tab = LeftTab.lead + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 4) { + PaneTabButton(label: "Lead", active: tab == .lead) { + tab = .lead + } + PaneTabButton(label: "Status", active: tab == .status) { + tab = .status + } + PaneTabButton(label: "Settings", active: tab == .settings) { + tab = .settings + } + Spacer(minLength: 0) + HStack(spacing: 10) { + if !store.connected { + Text("reconnecting…") + .foregroundStyle(Palette.danger) + } + Circle() + .fill(store.connected ? Palette.ok : Palette.danger) + .frame(width: 8, height: 8) + .help(store.connected ? "live" : "reconnecting") + Text(store.me?.displayName ?? "") + .foregroundStyle(Palette.muted) + } + .lineLimit(1) + .fixedSize() + } + .padding(.horizontal, 12) + .overlay(alignment: .bottom) { + Rectangle().fill(Palette.border).frame(height: 1) + } + ZStack { + ChatView(kind: .lead) + .opacity(tab == .lead ? 1 : 0) + .allowsHitTesting(tab == .lead) + .accessibilityHidden(tab != .lead) + if tab != .lead { + NotYet(label: tab == .status ? "Status" : "Settings") + .background(Palette.bg) + } + } + } + } +} + +private struct NotYet: View { + let label: String + + var body: some View { + VStack { + Muted(text: "\(label) isn't in the macOS app yet.") + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +private struct TaskPane: View { + @Environment(Store.self) private var store + @ViewState private var seen: Set = [] + + var body: some View { + let selected = store.selectedTaskId.flatMap { store.findTask($0) } + let accent = selected.map { taskPalette[$0.color] } ?? Palette.accent + VStack(spacing: 0) { + TaskBar() + TabStrip() + tabBody + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .environment(\.paneAccent, accent) + .environment(\.inRightPane, true) + .onGeometryChange(for: CGRect.self) { g in + g.frame(in: .global) + } action: { frame in + store.paneFocus.setFrame(frame) + } + .onAppear { + seen = Set(store.inbox.map(\.id)) + } + .onChange(of: store.inbox.map(\.id)) { + let fresh = store.inbox.filter { !seen.contains($0.id) } + for item in fresh { + seen.insert(item.id) + } + if store.focus.tab == .transcript && fresh.contains(where: { $0.kind == .question && $0.taskId == store.selectedTaskId }) { + store.focusTab(.inbox) + } + } + } + + @ViewBuilder + private var tabBody: some View { + switch store.focus.tab { + case .transcript: + ChatView(kind: .task(store.selectedTaskId)) + .id(store.selectedTaskId ?? "") + default: + NotYet(label: tabLabels[store.focus.tab]!) + } + } +} + +private struct TaskBar: View { + @Environment(Store.self) private var store + @Environment(\.paneAccent) private var accent + @ViewState private var previews: [Components.Schemas.Preview] = [] + @ViewState private var target = 0 + @ViewState private var off: (() -> Void)? + + var body: some View { + let task = store.selectedTaskId.flatMap { store.findTask($0) } + let project = store.focusedProject() + let label = task.map { "\(project?.name ?? "") ▸ \($0.title)" } ?? project?.name ?? "No task" + HStack(spacing: 0) { + Color.clear.frame(maxWidth: .infinity, maxHeight: 1) + Menu { + ForEach(store.activeTasks(), id: \.task.id) { row in + Button("\(row.project.name): \(row.task.title)") { + store.focusTask(row.task.id) + } + } + } label: { + Text("\(label) ▾") + .font(Fonts.font(13, weight: 700)) + .foregroundStyle(accent) + .lineLimit(1) + .truncationMode(.tail) + } + .menuStyle(.button) + .buttonStyle(.plain) + .menuIndicator(.hidden) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .help("Switch task (⌘K)") + .layoutPriority(1) + HStack(spacing: 12) { + Spacer(minLength: 0) + ForEach(previews, id: \.port) { p in + Link(destination: previewURL(store.api.origin, p.port)) { + Text("\(p.name) ↗") + .foregroundStyle(accent) + .lineLimit(1) + } + .help("Open \(p.name) (port \(p.port)) in a new window") + } + } + .frame(maxWidth: .infinity) + } + .padding(.horizontal, 12) + .background { + ZStack { + Palette.bg + accent.opacity(0.12) + } + } + .overlay(alignment: .bottom) { + Rectangle().fill(Palette.border).frame(height: 1) + } + .task(id: store.selectedTaskId) { + previews = [] + await load() + } + .onAppear { + off = store.events.add { ev in + if ev.kind == "preview.exposed" && ev.taskId == store.selectedTaskId { + Task { + await load() + } + } + } + } + .onDisappear { + off?() + off = nil + } + } + + private func load() async { + target += 1 + let started = target + guard let taskId = store.selectedTaskId else { + previews = [] + return + } + do { + let list = try await store.api.client.listPreviews(path: .init(taskId: taskId)).ok.body.json + if started == target { + previews = list + } + } catch { + if started == target { + previews = [] + } + log.error("list previews: \(errorText(error), privacy: .public)") + } + } +} + +private struct TabStrip: View { + @Environment(Store.self) private var store + @Environment(Overlays.self) private var overlays + @ViewState private var widths: [TaskTab: CGFloat] = [:] + @ViewState private var moreWidth: CGFloat = 0 + @ViewState private var stripWidth: CGFloat = 0 + @ViewState private var moreFrame: CGRect = .zero + + private struct Info { + var order: [TaskTab] + var tab: TaskTab + var waiting: Int + var findings: Int + var reviewing: Bool + + func count(_ t: TaskTab) -> Int { + t == .inbox ? waiting : t == .reviews && !reviewing ? findings : 0 + } + + func badge(_ t: TaskTab) -> TabBadge { + if t == .reviews && reviewing { + return .spinner + } + return count(t) > 0 ? .count(count(t)) : .none + } + } + + private func info() -> Info { + var first: [TaskTab] = [] + for t in store.focus.tabs where taskTabs.contains(t) && !first.contains(t) { + first.append(t) + } + let tab = store.focus.tab + let waiting = store.inbox.filter { $0.taskId == store.selectedTaskId }.count + let transcript = store.transcript + let latest = latestReview(transcript) + let reviewing = latest?.state == .running + let findings = latest.map(findingCount) ?? 0 + let hasReviews = transcript.contains(where: isReviewEvent) + let workflow = store.selectedTaskId.flatMap { store.findTask($0) }?.workflowRun != nil + let order = (first + taskTabs.filter { !first.contains($0) }).filter { t in + if t == tab { + return true + } + switch t { + case .inbox: return waiting > 0 + case .reviews: return hasReviews + case .workflow: return workflow + default: return true + } + } + return Info(order: order, tab: tab, waiting: waiting, findings: findings, reviewing: reviewing) + } + + private func kept(_ info: Info) -> [TaskTab] { + let gap: CGFloat = 4 + let inner = stripWidth - 24 + func total(_ ts: [TaskTab]) -> CGFloat { + ts.reduce(0) { $0 + (widths[$1] ?? 0) + gap } + } + let all = info.order + if stripWidth == 0 || total(all) <= inner { + return all + } + let budget = inner - moreWidth - gap + var kept: [TaskTab] = [] + for t in [info.tab] + all.filter({ $0 != info.tab }) { + if !kept.isEmpty && total(kept + [t]) > budget { + break + } + kept.append(t) + } + return kept + } + + var body: some View { + let info = info() + let kept = kept(info) + let hidden = info.order.filter { !kept.contains($0) } + let flagged = hidden.filter { info.count($0) > 0 } + HStack(spacing: 4) { + ForEach(info.order.filter { kept.contains($0) }, id: \.self) { t in + PaneTabButton(label: tabLabels[t]!, active: t == info.tab, attention: info.count(t) > 0, badge: info.badge(t)) { + store.focusTab(t) + } + } + if !hidden.isEmpty { + moreButton(flagged.map(info.badge)) { + overlays.menu = OverlayMenu(anchor: moreFrame, entries: hidden.map { t in + MenuEntry(id: t.rawValue, label: tabLabels[t]!, badge: info.badge(t)) { + store.focusTab(t) + } + }) + } + .onGeometryChange(for: CGRect.self) { g in + g.frame(in: .named("window")) + } action: { frame in + moreFrame = frame + } + } + Spacer(minLength: 0) + } + .padding(.horizontal, 12) + .frame(maxWidth: .infinity, alignment: .leading) + .clipped() + .background { + HStack(spacing: 4) { + ForEach(info.order, id: \.self) { t in + PaneTabButton(label: tabLabels[t]!, active: t == info.tab, attention: info.count(t) > 0, badge: info.badge(t)) {} + .onGeometryChange(for: CGFloat.self) { g in + g.size.width + } action: { w in + widths[t] = w + } + } + moreButton([]) {} + .onGeometryChange(for: CGFloat.self) { g in + g.size.width + } action: { w in + moreWidth = w + } + } + .fixedSize() + .hidden() + } + .onGeometryChange(for: CGFloat.self) { g in + g.size.width + } action: { w in + stripWidth = w + } + .overlay(alignment: .bottom) { + Rectangle().fill(Palette.border).frame(height: 1) + } + } + + private func moreButton(_ badges: [TabBadge], _ action: @escaping () -> Void) -> some View { + Button(action: action) { + HStack(spacing: 6) { + Text("More ▾") + ForEach(Array(badges.enumerated()), id: \.offset) { _, b in + TabBadgeView(badge: b) + } + } + .font(Fonts.font(13, weight: badges.isEmpty ? 400 : 700)) + .foregroundStyle(badges.isEmpty ? Palette.muted : Palette.warn) + .padding(.horizontal, 10) + .padding(.top, 8) + .padding(.bottom, 6) + .background(badges.isEmpty ? .clear : Palette.warnTint) + .overlay(alignment: .bottom) { + Rectangle().fill(badges.isEmpty ? .clear : Palette.warn).frame(height: 2) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .fixedSize() + .help("More tabs") + } +} + +private let emailShape = Re(#"^[^@\s]+@[^@\s]+$"#) + +private struct IdentityBanner: View { + @Environment(Store.self) private var store + @Environment(Overlays.self) private var overlays + @ViewState private var name: String? + @ViewState private var email = "" + + var body: some View { + if let me = store.me, me.gitName.isEmpty || me.gitEmail.isEmpty { + let gitName = name ?? me.displayName + let valid = !gitName.isEmpty && emailShape.test(email) + HStack(spacing: 6) { + Text("Set the identity agents commit as: ") + TextField("Git name", text: Binding(get: { gitName }, set: { name = $0 })) + .modifier(FieldStyle()) + .frame(width: 220) + .onSubmit { + save(gitName, valid) + } + TextField("Git email", text: $email) + .modifier(FieldStyle()) + .frame(width: 260) + .onSubmit { + save(gitName, valid) + } + Button("Save") { + save(gitName, valid) + } + .buttonStyle(WebButtonStyle()) + .disabled(!valid) + Spacer(minLength: 0) + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Palette.accentTint) + .overlay(alignment: .bottom) { + Rectangle().fill(Palette.border).frame(height: 1) + } + } + } + + private func save(_ gitName: String, _ valid: Bool) { + if !valid { + return + } + Task { + do { + store.me = try await store.api.client.updateMe(body: .json(.init(gitName: gitName, gitEmail: email))).ok.body.json + } catch { + overlays.showAlert(errorText(error)) + } + } + } +} diff --git a/mac/Sources/Byre/SpikeView.swift b/mac/Sources/Byre/SpikeView.swift deleted file mode 100644 index a93f689..0000000 --- a/mac/Sources/Byre/SpikeView.swift +++ /dev/null @@ -1,70 +0,0 @@ -import SwiftUI - -@MainActor -@Observable -final class SpikeLog { - var lines: [String] = [] - - func log(_ line: String) { - lines.append(line) - } -} - -struct SpikeView: View { - @Environment(Store.self) private var store - @Environment(SpikeLog.self) private var spike - - var body: some View { - let selection = Binding(get: { store.selectedTaskId ?? "" }, set: { store.focusTask($0) }) - VStack(spacing: 0) { - HStack { - 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) - } - } - .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) - Divider() - TabView { - Tab("Composer", systemImage: "keyboard") { - ComposerSpike() - } - Tab("Transcript", systemImage: "text.alignleft") { - TranscriptSpike() - .id(store.selectedTaskId) - } - Tab("Terminal", systemImage: "terminal") { - TerminalSpike() - .id(store.selectedTaskId) - } - Tab("Font", systemImage: "textformat") { - FontSpike() - } - Tab("SVG", systemImage: "photo") { - SVGSpike() - .id(store.selectedTaskId) - } - } - .padding(8) - Divider() - ScrollView { - VStack(alignment: .leading, spacing: 2) { - ForEach(Array(spike.lines.enumerated()), id: \.offset) { _, line in - Text(line) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(8) - } - .defaultScrollAnchor(.bottom) - .frame(height: 140) - } - .frame(minWidth: 1000, minHeight: 700) - } -} diff --git a/mac/Sources/Byre/Startup.swift b/mac/Sources/Byre/Startup.swift index cfe8447..e101539 100644 --- a/mac/Sources/Byre/Startup.swift +++ b/mac/Sources/Byre/Startup.swift @@ -67,7 +67,11 @@ final class Connection { func changeServer() { generation += 1 - store?.stop() + if let store { + Task { + await store.stop() + } + } connecting = false problem = nil phase = .connect @@ -85,9 +89,10 @@ final class Connection { async let lead: Void = store.loadLeadSessions() async let reads: Void = store.loadSummaryReads() _ = try await (me, projects, inbox, lead, reads) + store.loadDrafts() try await store.loadFocus() guard gen == generation else { - store.stop() + await store.stop() return } Task { @@ -96,7 +101,7 @@ final class Connection { phase = .running(store) store.startGlobalStream(after: status.lastEventSeq) } catch { - store.stop() + await store.stop() if gen == generation { phase = .failed("byre failed to start: \(errorText(error))") } @@ -192,11 +197,12 @@ struct RootView: View { case .failed(let message): FatalView(message: message) case .running(let store): - SpikeView() + MainView() .environment(store) } } .foregroundStyle(Palette.text) + .tint(Palette.accent) .background(Palette.bg) } } diff --git a/mac/Sources/Byre/Store.swift b/mac/Sources/Byre/Store.swift index 5b54a0f..775e5c9 100644 --- a/mac/Sources/Byre/Store.swift +++ b/mac/Sources/Byre/Store.swift @@ -43,7 +43,11 @@ final class Store { var jump: (taskId: String, seq: Int64)? var summaryReads: [String: Int64] = [:] private(set) var leadMore = false + var todoStarting: Set = [] + private(set) var drafts: Drafts? + let images = ImageDrafts() let events = Listeners() + let paneFocus: PaneFocus private var taskFeeds: [String: Feed] = [:] @ObservationIgnored private var historyLoads: Set = [] @@ -55,12 +59,36 @@ final class Store { init(api: API) { self.api = api leadFeed = Feed(shown: { true }) + paneFocus = PaneFocus(api: api) } - func stop() { + func stop() async { stopped = true globalStream?.stop() leadStream?.stop() + drafts?.flush() + await paneFocus.leave() + } + + func loadDrafts() { + drafts = Drafts(origin: api.origin, login: me!.login) + drafts!.prune(self) + } + + func startTodo(_ projectId: String, _ title: String, discuss: Bool, _ overlays: Overlays) { + let key = "\(projectId)\n\(title)" + if todoStarting.contains(key) { + return + } + todoStarting.insert(key) + Task { + do { + _ = try await api.client.startTodoItem(path: .init(projectId: projectId), body: .json(.init(title: title, model: models[0], discuss: discuss))).created + } catch { + overlays.showAlert(errorText(error)) + } + todoStarting.remove(key) + } } var transcript: [ByreEvent] { @@ -167,6 +195,7 @@ final class Store { _ = taskFeed(t.id) } dropClosedFeeds() + drafts?.prune(self) try await each(list.filter { $0.status == .active }.map(\.id)) { id in try await self.loadSessions(id) } @@ -188,6 +217,7 @@ final class Store { return } inbox = items + drafts?.prune(self) } func loadLeadSessions() async throws { @@ -384,6 +414,9 @@ final class Store { self?.handleEvent(ev) }, onState: { [weak self] up in self?.connected = up + if up { + self?.paneFocus.reconnected() + } }, onReady: {}) } diff --git a/mac/Sources/Byre/Terminal.swift b/mac/Sources/Byre/Terminal.swift index 64e966c..1ac703a 100644 --- a/mac/Sources/Byre/Terminal.swift +++ b/mac/Sources/Byre/Terminal.swift @@ -5,30 +5,6 @@ import SwiftUI let exitedCode = 4000 let internalErrorCode = 1011 -struct TerminalSpike: View { - @Environment(Store.self) private var store - @ViewState private var status = "" - - var body: some View { - VStack(alignment: .leading) { - HStack { - Text("Shell in the task's worktree") - Text(status) - .foregroundStyle(.secondary) - } - 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 { - Text("No task selected.") - } - } - } -} - struct TerminalHost: NSViewRepresentable { let api: API let taskId: String diff --git a/mac/Sources/Byre/Transcript.swift b/mac/Sources/Byre/Transcript.swift deleted file mode 100644 index af45cbc..0000000 --- a/mac/Sources/Byre/Transcript.swift +++ /dev/null @@ -1,222 +0,0 @@ -import SwiftUI - -struct Row: Identifiable { - let id: Int64 - let kind: String - var text: String - var tools: [String] = [] -} - -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.value["text"] as? String ?? "")) - case "agent.tool_call": - 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) - } else { - rows.append(Row(id: e.seq, kind: "tools", text: "", tools: [line])) - } - default: - continue - } - } - return rows -} - -func toolSummary(_ tools: [String]) -> String { - var order: [String] = [] - var counts: [String: Int] = [:] - for t in tools { - let name = String(t.split(separator: " ").first ?? "") - if counts[name] == nil { - order.append(name) - } - counts[name, default: 0] += 1 - } - return order.map { counts[$0]! > 1 ? "\($0) ×\(counts[$0]!)" : $0 }.joined(separator: ", ") -} - -struct RowView: View { - let row: Row - @ViewState private var open = false - - var body: some View { - switch row.kind { - case "tools": - VStack(alignment: .leading, spacing: 2) { - Button((open ? "▾ " : "▸ ") + toolSummary(row.tools)) { - open.toggle() - } - .buttonStyle(.plain) - .foregroundStyle(.secondary) - if open { - ForEach(Array(row.tools.enumerated()), id: \.offset) { _, t in - Text(t) - .foregroundStyle(.secondary) - .textSelection(.enabled) - } - } - } - case "user.message": - Text(markdown(row.text)) - .textSelection(.enabled) - .padding(8) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.accentColor.opacity(0.12)) - case "agent.thinking": - Text(row.text) - .italic() - .foregroundStyle(.secondary) - .textSelection(.enabled) - default: - Text(markdown(row.text)) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - - private func markdown(_ s: String) -> AttributedString { - (try? AttributedString(markdown: s, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? AttributedString(s) - } -} - -enum Container: String, CaseIterable { - case lazyVStack = "ScrollView + LazyVStack" - case list = "List" -} - -struct TranscriptSpike: View { - @Environment(Store.self) private var store - @ViewState private var all: [Row] = [] - @ViewState private var shown = 0 - @ViewState private var container = Container.lazyVStack - @ViewState private var playing = false - @ViewState private var streaming = false - @ViewState private var pinned = true - @ViewState private var distance: CGFloat = 0 - @ViewState private var tick = 0 - @ViewState private var position = ScrollPosition(edge: .bottom) - @ViewState private var events = 0 - @ViewState private var loadTime = "" - - private var rows: ArraySlice { - all.prefix(shown) - } - - var body: some View { - VStack(alignment: .leading) { - HStack { - Picker("", selection: $container) { - ForEach(Container.allCases, id: \.self) { c in - Text(c.rawValue).tag(c) - } - } - .pickerStyle(.segmented) - .frame(width: 320) - Toggle("Play held-back rows", isOn: $playing) - Toggle("Stream a message", isOn: $streaming) - Text("\(events) events, \(shown)/\(all.count) rows \(loadTime) · \(pinned ? "stuck to bottom" : "scrolled up") · \(Int(distance)) pt from bottom") - .foregroundStyle(.secondary) - } - switch container { - case .lazyVStack: - ScrollView { - LazyVStack(alignment: .leading, spacing: 10) { - ForEach(rows) { r in - RowView(row: r) - } - } - .scrollTargetLayout() - .padding(.horizontal, 8) - } - .scrollPosition($position) - .defaultScrollAnchor(.bottom) - .onScrollGeometryChange(for: CGFloat.self) { g in - g.contentSize.height - g.visibleRect.maxY - } action: { _, d in - distance = d - pinned = d <= 80 - } - .onChange(of: tick) { - if pinned { - position.scrollTo(edge: .bottom) - } - } - case .list: - ScrollViewReader { proxy in - List(rows) { r in - RowView(row: r) - } - .onScrollGeometryChange(for: CGFloat.self) { g in - g.contentSize.height - g.visibleRect.maxY - } action: { _, d in - distance = d - pinned = d <= 80 - } - .onChange(of: tick) { - if pinned, let last = rows.last { - proxy.scrollTo(last.id, anchor: .bottom) - } - } - .onAppear { - if let last = rows.last { - proxy.scrollTo(last.id, anchor: .bottom) - } - } - } - } - } - .onChange(of: store.transcript.count, initial: true) { - load() - } - .task(id: playing) { - while playing && shown < all.count { - shown += 1 - tick += 1 - try? await Task.sleep(for: .milliseconds(150)) - } - } - .task(id: streaming) { - await stream() - } - } - - private func load() { - guard let feed = store.selectedFeed, feed.ready else { - return - } - let start = Date() - let got = feed.events - events = got.count - all = buildRows(got) - shown = all.count > 200 ? all.count - 100 : all.count - loadTime = String(format: "in %.2fs", Date().timeIntervalSince(start)) - tick += 1 - } - - private func stream() async { - guard streaming, let source = all.filter({ $0.kind == "agent.message" }).max(by: { $0.text.count < $1.text.count }) else { - return - } - let id = (all.last?.id ?? 0) + 1_000_000 - all.insert(Row(id: id, kind: "agent.message", text: ""), at: shown) - shown += 1 - let index = shown - 1 - for word in source.text.split(separator: " ", omittingEmptySubsequences: false) { - if !streaming { - break - } - all[index].text += (all[index].text.isEmpty ? "" : " ") + word - tick += 1 - try? await Task.sleep(for: .milliseconds(30)) - } - streaming = false - } -} diff --git a/mac/Sources/Byre/TranscriptBuilder.swift b/mac/Sources/Byre/TranscriptBuilder.swift new file mode 100644 index 0000000..c460ede --- /dev/null +++ b/mac/Sources/Byre/TranscriptBuilder.swift @@ -0,0 +1,747 @@ +import AppKit +import Foundation +import Observation +import OpenAPIRuntime + +let quietReply = "NO_REPLY" + +struct Usage { + var used: Int + var window: Int + var model: String +} + +struct Call { + let name: String + let target: String +} + +enum NotePart { + case text(String) + case task(String) + case url(String) +} + +enum NoteStyle { + case plain, error, ask +} + +struct Note { + var parts: [NotePart] + var when: Date? + var style: NoteStyle = .plain + var cancelMergeAfterReview: String? +} + +enum SubagentState { + case running, done, error +} + +enum RowKind { + case user(actor: String, when: Date, images: [String], md: [MDBlock]?) + case byrePrompt(brief: String, when: Date, md: [MDBlock]) + case agent(md: [MDBlock]) + case subagentMessage(when: Date, md: [MDBlock]) + case stream + case thinking(md: [MDBlock]) + case tool(name: String, brief: String, input: String) + case subagent(title: String, description: String, prompt: [MDBlock]) + case group + case orphan(String) + case turnEnd(error: String?) + case note(Note) + case handoff + case snapshot(Snapshot) +} + +@MainActor +private var rowIds = 0 + +@MainActor +@Observable +final class RowList { + var rows: [Row] = [] +} + +@MainActor +@Observable +final class Row: Identifiable { + let id: Int + let kind: RowKind + let taskId: String? + var hidden = false + var tag = false + var result: String? + var unfinished = false + var status = "starting…" + var statusState = SubagentState.running + var calls: [Call] = [] + var summary: Note? + var review: Review? + var text = "" + var md: [MDBlock] = [] + var summarySeq: Int64? + let body: RowList? + + var pending: Bool { + switch kind { + case .tool, .subagent: + return result == nil && !unfinished + case .group: + return body!.rows.contains { $0.pending } + default: + return false + } + } + + init(_ kind: RowKind, taskId: String?, body: Bool = false) { + rowIds += 1 + id = rowIds + self.kind = kind + self.taskId = taskId + self.body = body ? RowList() : nil + } +} + +struct PendingSend: Identifiable { + let id: Int + let text: String + let images: [PendingImage] + let queued: Bool + let md: [MDBlock] +} + +@MainActor +private final class Run { + let first: Row + let container: RowList + var group: Row? + var calls: [Call] + let parent: String + + init(first: Row, container: RowList, calls: [Call], parent: String) { + self.first = first + self.container = container + self.calls = calls + self.parent = parent + } +} + +@MainActor +private final class Subagent { + let row: Row + var run: Run? + + init(row: Row) { + self.row = row + } +} + +@MainActor +private final class Ctx { + var tools: [String: Row] = [:] + var subagents: [String: Subagent] = [:] + var reviewers: Set = [] + var reviewButtons: [Int: Row] = [:] + var run: Run? + var held: RowList? + var open = false + var drawn: [Row]? + var quiet = false + var handoff: Row? +} + +@MainActor +@Observable +final class TranscriptBuilder { + let root = RowList() + private(set) var pending: [PendingSend] = [] + private(set) var context: [String: Usage] = [:] + private(set) var open = false + private(set) var lastMergeAfterReview: Int? + @ObservationIgnored private var ctx = Ctx() + @ObservationIgnored private var sink: RowList? + @ObservationIgnored private var stream: Row? + @ObservationIgnored private var streamTimer: Task? + @ObservationIgnored private let taskId: String? + @ObservationIgnored private let worktree: () -> String + @ObservationIgnored private let running: () -> Bool + @ObservationIgnored private var pendingIds = 0 + + init(taskId: String?, worktree: @escaping () -> String, running: @escaping () -> Bool) { + self.taskId = taskId + self.worktree = worktree + self.running = running + } + + func stop() { + dropStream() + } + + func append(_ events: [ByreEvent]) -> Bool { + let changed = render(events) + open = ctx.open + return changed + } + + func prepend(_ events: [ByreEvent]) { + let live = ctx + let box = RowList() + ctx = Ctx() + sink = box + _ = render(events) + sink = nil + ctx = live + root.rows.insert(contentsOf: box.rows, at: 0) + } + + func showPending(_ text: String, _ images: [PendingImage]) -> () -> Void { + pendingIds += 1 + let entry = PendingSend(id: pendingIds, text: text, images: images, queued: running(), md: trimmed(text).isEmpty ? [] : parseMarkdown(text)) + pending.append(entry) + ctx.run = nil + return { [weak self] in + self?.pending.removeAll { $0.id == entry.id } + } + } + + private func confirmPending(_ text: String) { + if let i = pending.firstIndex(where: { $0.text == text }) { + pending.remove(at: i) + } + } + + private func release() { + guard let buffered = ctx.held else { + return + } + ctx.held = nil + ctx.open = true + (sink ?? root).rows.append(contentsOf: buffered.rows) + } + + @discardableResult + private func put(_ row: Row, keepHeld: Bool = false) -> RowList { + if let handoff = ctx.handoff { + handoff.body!.rows.append(row) + return handoff.body! + } + if !keepHeld { + release() + } + ctx.drawn?.append(row) + if let held = ctx.held { + held.rows.append(row) + return held + } + let list = sink ?? root + list.rows.append(row) + return list + } + + private func fold() { + let drawn = ctx.drawn ?? [] + (sink ?? root).rows.removeAll { r in drawn.contains { $0 === r } } + ctx.drawn = nil + ctx.held = nil + ctx.open = false + ctx.quiet = false + } + + private func subagentTag(_ parent: String) -> Bool { + !parent.isEmpty && ctx.subagents[parent] == nil + } + + private func addToRun(_ row: Row, _ call: Call?, _ parent: String) { + if let sub = ctx.subagents[parent] { + sub.run = extendRun(sub.run, row, call, parent) { r in + sub.row.body!.rows.append(r) + return sub.row.body! + } + return + } + ctx.run = extendRun(ctx.run, row, call, parent) { r in + self.put(r, keepHeld: true) + } + } + + private func extendRun(_ run: Run?, _ row: Row, _ call: Call?, _ parent: String, _ place: (Row) -> RowList) -> Run { + guard let run, run.parent == parent else { + let list = place(row) + return Run(first: row, container: list, calls: call.map { [$0] } ?? [], parent: parent) + } + if let call { + run.calls.append(call) + } + if run.group == nil { + let group = Row(.group, taskId: taskId, body: true) + for list in [run.container, sink ?? root] { + if let i = list.rows.firstIndex(where: { $0 === run.first }) { + list.rows[i] = group + break + } + } + ctx.drawn?.append(group) + group.body!.rows.append(run.first) + run.group = group + } + run.group!.body!.rows.append(row) + run.group!.calls = run.calls + run.group!.tag = subagentTag(run.parent) + return run + } + + private func dropStream() { + streamTimer?.cancel() + streamTimer = nil + guard let stream else { + return + } + root.rows.removeAll { $0 === stream } + self.stream = nil + } + + func appendDelta(_ ev: ByreEvent) { + if ctx.handoff != nil { + return + } + if let sid = ev.agentSessionId, ctx.reviewers.contains(sid) { + return + } + let row: Row + if let stream { + row = stream + } else { + row = Row(.stream, taskId: taskId) + row.hidden = true + stream = row + ctx.run = nil + put(row, keepHeld: true) + } + row.text += str(ev.payload.value, "text") ?? "" + if streamTimer != nil { + return + } + streamTimer = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(100)) + guard let self, !Task.isCancelled, let stream = self.stream else { + return + } + self.streamTimer = nil + stream.hidden = self.ctx.held != nil || quietReply.hasPrefix(trimmed(stream.text)) + stream.md = parseMarkdown(stream.text) + } + } + + private func openHandoff() { + let row = Row(.handoff, taskId: taskId, body: true) + row.summary = Note(parts: [.text("writing a handoff…")]) + put(row) + ctx.handoff = row + } + + private func closeHandoff(_ end: Row?) { + guard let handoff = ctx.handoff else { + return + } + ctx.handoff = nil + if let end, case .note(let note) = end.kind { + handoff.summary = note + } else { + handoff.summary = Note(parts: [.text("handoff not written")]) + } + } + + private func render(_ events: [ByreEvent]) -> Bool { + var contextChanged = false + for ev in events { + let p = ev.payload.value + let parent = parentOf(p) + if ev.kind == "session.created" && str(p, "role") == "reviewer", let sid = ev.agentSessionId, !sid.isEmpty { + ctx.reviewers.insert(sid) + continue + } + if let sid = ev.agentSessionId, ctx.reviewers.contains(sid) { + continue + } + if ev.kind == "agent.vendor" { + claudeTaskStatus(p) + continue + } + if sink == nil && ((ev.kind == "agent.message" && parent.isEmpty) || ev.kind == "turn.ended" || ev.kind == "user.message") { + dropStream() + } + if sink == nil && ev.kind == "user.message" && ev.actor != "byre" { + confirmPending(str(p, "text") ?? "") + } + if ev.kind == "session.context" && sink != nil { + continue + } + if ev.kind == "session.context", let sid = ev.agentSessionId, !sid.isEmpty { + let prev = context[sid] + let window = int(p, "window").flatMap { $0 == 0 ? nil : $0 } ?? prev?.window ?? 0 + context[sid] = Usage(used: int(p, "used") ?? 0, window: window, model: str(p, "model") ?? "") + contextChanged = true + continue + } + if ev.kind == "snapshot.created" { + ctx.run = nil + let snap = try! decodePayload(ev.payload, as: Snapshot.self) + let row = Row(.snapshot(snap), taskId: taskId) + ctx.reviewButtons[snap.n] = row + put(row) + continue + } + if ev.kind == "review.started" || ev.kind == "review.finished" || ev.kind == "review.finding" { + let r = try! decodePayload(ev.payload, as: Review.self) + ctx.reviewButtons[r.snapshotN]?.review = r + } + let sub = ctx.subagents[parent] + if ev.kind == "agent.tool_call" && isSubagentCall(p) { + let row = renderSubagent(p) + if let sub { + sub.run = nil + sub.row.body!.rows.append(row) + continue + } + ctx.run = nil + put(row, keepHeld: true) + continue + } + if ev.kind == "agent.tool_call" { + let name = toolLabel(jsString(p["name"])) + addToRun(renderToolCall(p), Call(name: name, target: toolTarget(p["input"] ?? nil, worktree())), parent) + continue + } + guard let row = renderEvent(ev) else { + continue + } + if ev.kind == "agent.thinking" { + addToRun(row, nil, parent) + continue + } + if let sub { + sub.run = nil + sub.row.body!.rows.append(row) + continue + } + ctx.run = nil + if ev.kind == "agent.message" && parent.isEmpty { + ctx.drawn = nil + } + if ev.kind == "turn.ended" { + ctx.open = false + } + if ev.kind == "turn.ended" && bool(p, "handoff") && ctx.handoff != nil { + closeHandoff(row) + continue + } + if ev.kind == "turn.ended" || (ev.kind == "user.message" && !bool(p, "mid_turn")) { + closeHandoff(nil) + } + if ev.kind == "user.message" && bool(p, "handoff") { + openHandoff() + } + if ev.kind == "turn.ended" && ctx.quiet && ctx.drawn != nil { + fold() + continue + } + if ev.kind == "turn.ended" || ev.kind == "user.message" { + ctx.drawn = nil + } + if ev.kind == "user.message" && ev.actor == "byre" && !(ev.owner ?? "").isEmpty && !bool(p, "mid_turn") && !bool(p, "handoff") { + ctx.held = RowList() + ctx.open = false + ctx.drawn = [] + continue + } + if ev.kind == "user.message" && !bool(p, "mid_turn") { + ctx.open = true + } + if ev.kind == "turn.ended" && bool(p, "summary") && taskId != nil { + row.summarySeq = ev.seq + } + if ev.kind == "task.merge_after_review" && sink == nil { + lastMergeAfterReview = row.id + } + put(row) + } + return contextChanged + } + + private func claudeTaskStatus(_ p: JSONDict) { + guard str(p, "type") == "system", let sub = ctx.subagents[jsString(p["tool_use_id"])] else { + return + } + switch str(p, "subtype") { + case "task_started": + sub.row.status = bool(p, "is_backgrounded") ? "running in the background" : "running" + case "task_progress": + sub.row.status = str(p, "description") ?? "running" + case "task_notification": + sub.row.statusState = str(p, "status") == "completed" ? .done : .error + sub.row.status = jsString(p["status"]) + default: + break + } + } + + private func renderToolCall(_ p: JSONDict) -> Row { + let row = Row(.tool(name: toolLabel(jsString(p["name"])), brief: toolBrief(p["input"] ?? nil, worktree()), input: prettyJSON(p["input"] ?? nil)), taskId: taskId) + row.tag = subagentTag(parentOf(p)) + ctx.tools[jsString(p["id"])] = row + return row + } + + private func renderSubagent(_ p: JSONDict) -> Row { + let input = dict(p, "input") ?? [:] + let id = jsString(p["id"]) + let type = str(input, "subagent_type") ?? "subagent" + let row = Row(.subagent(title: "\(jsString(p["name"])) \(type)", description: str(input, "description") ?? "", prompt: parseMarkdown(str(input, "prompt") ?? "")), taskId: taskId, body: true) + row.tag = subagentTag(parentOf(p)) + ctx.tools[id] = row + ctx.subagents[id] = Subagent(row: row) + return row + } + + private func renderEvent(_ ev: ByreEvent) -> Row? { + let p = ev.payload.value + let when = ev.createdAt + func note(_ parts: [NotePart], _ style: NoteStyle = .plain, time: Bool = true) -> Row { + Row(.note(Note(parts: parts, when: time ? when : nil, style: style)), taskId: taskId) + } + + switch ev.kind { + case "user.message": + ctx.quiet = false + let text = str(p, "text") ?? "" + if ev.actor == "byre" { + let first = text.components(separatedBy: "\n").first ?? "" + let brief = first.count > 120 ? "\(first.prefix(120))…" : first + let row = Row(.byrePrompt(brief: brief, when: when, md: parseMarkdown(text)), taskId: taskId) + row.hidden = bool(p, "continuation") + return row + } + let images = (p["images"] as? [(any Sendable)?] ?? []).map { jsString($0) } + return Row(.user(actor: ev.actor, when: when, images: images, md: trimmed(text).isEmpty ? nil : parseMarkdown(text)), taskId: taskId) + + case "agent.message": + let text = jsString(p["text"]) + if !parentOf(p).isEmpty { + let row = Row(.subagentMessage(when: when, md: parseMarkdown(text)), taskId: taskId) + row.tag = subagentTag(parentOf(p)) + return row + } + if trimmed(trimmed(text).components(separatedBy: "\n").last ?? "") == quietReply { + ctx.quiet = true + return nil + } + ctx.quiet = false + return Row(.agent(md: parseMarkdown(text)), taskId: taskId) + + case "agent.thinking": + let row = Row(.thinking(md: parseMarkdown(jsString(p["text"]))), taskId: taskId) + row.tag = subagentTag(parentOf(p)) + return row + + case "agent.tool_result": + let body = resultText(p["content"] ?? nil) + guard let target = ctx.tools[jsString(p["tool_use_id"])] else { + return Row(.orphan(body), taskId: taskId) + } + target.result = body + return nil + + case "turn.ended": + for row in ctx.tools.values where row.result == nil { + row.unfinished = true + } + if bool(p, "handoff") { + return note([.text("handoff written; continuing in a new session with a fresh context")]) + } + return Row(.turnEnd(error: bool(p, "is_error") ? "turn ended: \(jsString(p["subtype"]).replacingOccurrences(of: "_", with: " "))" : nil), taskId: taskId) + + case "session.created": + let model = str(p, "model") ?? "" + return note([.text("\(ev.actor) started a \(jsString(p["vendor"])) session\(model.isEmpty ? "" : " (\(model))")")]) + + case "session.model": + return note([.text("\(ev.actor) switched the model to \(jsString(p["model"]))")]) + + case "session.state": + guard let error = str(p, "error"), !error.isEmpty else { + return nil + } + return note([.text("session \(jsString(p["state"])): \(error)")], .error, time: false) + + case "inbox.created": + let q = dict(p, "payload") ?? [:] + switch str(p, "kind") { + case "question": + return note([.text("asked: \(jsString(q["question"]))")], .ask, time: false) + case "session_error", "rate_limited": + return note([.text(jsString(q["error"]))], .error, time: false) + default: + return nil + } + + case "task.created": + if let from = str(p, "forked_from") { + return note([.text("\(ev.actor) forked this task from "), .task(from), .text(" at snapshot #\(jsString(p["forked_at"])); its agent continues that task's conversation as it stood there")]) + } + if let parentTask = str(p, "parent_task_id") { + return note([.text("subtask of "), .task(parentTask), .text(", created by its agent")]) + } + return nil + + case "task.parent_merge_started": + return note([.text("\(ev.actor) asked "), .task(jsString(p["parent_task_id"])), .text("'s agent to merge this subtask into its branch")]) + + case "task.parent_merge_stopped": + return note([.text("the merge into the parent task didn't start")], .error) + + case "task.merge_title": + return note([.text("\(ev.actor) set the merge commit title to \"\(jsString(p["merge_title"]))\"")]) + + case "review.finished": + let r = try! decodePayload(ev.payload, as: Review.self) + let outcome = r.state == .failed ? "failed: \(r.error)" : r.state == .skipped ? "skipped: no changes" : reviewCounts(r) + return note([.text("review of snapshot #\(r.snapshotN): \(outcome)")], r.state == .failed ? .error : .plain) + + case "task.merge_queued": + return note([.text("\(ev.actor) asked to merge; held until the merge in progress in this project is done")]) + + case "task.merge_stopped": + return note([.text("\(ev.actor) stopped the merge for review: \(jsString(p["reason"]))")]) + + case "task.merge_after_review": + var n = Note(parts: [.text("\(ev.actor) asked to merge once the review of snapshot #\(jsString(p["snapshot_n"])) finishes with no findings")], when: when) + n.cancelMergeAfterReview = jsString(p["id"]) + return Row(.note(n), taskId: taskId) + + case "task.merge_after_review_cancelled": + return note([.text("\(ev.actor) cancelled the merge after review")]) + + case "task.merge_after_review_dropped": + return note([.text("didn't merge after the review: \(jsString(p["reason"]))")]) + + case "preview.exposed": + return note([.text("\(ev.actor) exposed \"\(jsString(p["name"]))\" at "), .url(String(int(p, "port") ?? 0))]) + + case "task.merged": + let commit = str(p, "commit") ?? "" + if let parentTask = str(p, "parent_task_id") { + return note([.text("\(ev.actor) merged the task into "), .task(parentTask), .text(", now at \(commit.prefix(7))")]) + } + let pushed = str(p, "pushed") ?? "" + return note([.text("\(ev.actor) merged the task\(commit.isEmpty ? "" : " as \(commit.prefix(7))")\(pushed.isEmpty ? "" : " and pushed it to \(pushed)")")]) + + case "task.summary": + return note([.text("\(ev.actor) set the task summary")]) + + case "task.discard_started": + return note([.text("\(ev.actor) asked to discard the task; the agent writes the task summary first")]) + + case "task.discarded": + return note([.text("\(ev.actor) discarded the task")]) + + default: + return nil + } + } +} + +private let severities = ["critical", "high", "medium", "low", "info"] + +func isReviewEvent(_ ev: ByreEvent) -> Bool { + ev.kind.hasPrefix("review.") +} + +func findings(_ r: Review) -> [JSONDict] { + (r.result?.value["findings"] as? [(any Sendable)?] ?? []).map { $0 as? JSONDict ?? [:] } +} + +func findingCount(_ r: Review) -> Int { + r.state == .done ? findings(r).indices.filter { r.findingStates.additionalProperties[String($0)] == nil }.count : 0 +} + +func latestReview(_ events: [ByreEvent]) -> Review? { + var best: Review? + for ev in events where isReviewEvent(ev) { + let r = try! decodePayload(ev.payload, as: Review.self) + if best == nil || r.snapshotN >= best!.snapshotN { + best = r + } + } + return best +} + +func reviewCounts(_ r: Review) -> String { + severityCounts(findings(r)) +} + +func severityCounts(_ fs: [JSONDict]) -> String { + let parts = severities.map { s in (s, fs.filter { str($0, "severity") == s }.count) }.filter { $0.1 > 0 } + return parts.isEmpty ? "no findings" : parts.map { "\($0.1) \($0.0)" }.joined(separator: ", ") +} + +private func parentOf(_ p: JSONDict) -> String { + str(p, "parent_tool_use_id") ?? "" +} + +private func isSubagentCall(_ p: JSONDict) -> Bool { + let name = str(p, "name") + return name == "Agent" || name == "Task" +} + +func toolLabel(_ name: String) -> String { + let prefix = "mcp__byre__" + if !name.hasPrefix(prefix) { + return name + } + return name.dropFirst(prefix.count).split(separator: "_", omittingEmptySubsequences: false).map { $0.prefix(1).uppercased() + $0.dropFirst() }.joined(separator: " ") +} + +private func toolBrief(_ input: (any Sendable)?, _ root: String) -> String { + guard let i = input as? JSONDict else { + return "" + } + let brief = ["command", "file_path", "pattern", "url", "question", "description"].lazy.compactMap { i[$0] ?? nil }.first + let text = relative(brief.map { jsString($0) } ?? "", root) + return text.count > 120 ? "\(text.prefix(120))…" : text +} + +private func toolTarget(_ input: (any Sendable)?, _ root: String) -> String { + guard let path = (input as? JSONDict)?["file_path"] as? String else { + return "" + } + return relative(path, root) +} + +private func relative(_ path: String, _ root: String) -> String { + !root.isEmpty && path.hasPrefix("\(root)/") ? String(path.dropFirst(root.count + 1)) : path +} + +private func resultText(_ content: (any Sendable)?) -> String { + if let s = content as? String { + return s + } + if let list = content as? [(any Sendable)?] { + return list.map { c in + if let d = c as? JSONDict, let text = d["text"] { + return jsString(text) + } + return compactJSON(c) + }.joined(separator: "\n") + } + return prettyJSON(content) +} + +private func compactJSON(_ v: (any Sendable)?) -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + guard let container = try? OpenAPIValueContainer(unvalidatedValue: v), let data = try? encoder.encode(container) else { + return String(describing: v) + } + return String(decoding: data, as: UTF8.self) +}