diff --git a/TODO.md b/TODO.md index 9194ac2..51ec458 100644 --- a/TODO.md +++ b/TODO.md @@ -36,8 +36,6 @@ # M8 — macOS client -- macOS client parity review - Side by side with the web client on the same server, window size and appearance, using disposable tasks: every tab, switcher, Inbox choice types, merge/discard dialogs, light/dark, narrow windows. Document build, install and storage in `docs/operations.md`. - Phone app A native client for the phone over the same API, sharing the macOS client's SwiftUI code where it can. Every surveyed tool has one (Claude, Codex, Cursor and Conductor apps, GitHub Mobile, Superset for iPhone); it needs its own layout, since two half-width panes don't fit a phone. - Drop the web app diff --git a/docs/macos.md b/docs/macos.md index 942057c..d10758d 100644 --- a/docs/macos.md +++ b/docs/macos.md @@ -58,7 +58,7 @@ The Swift files follow the TypeScript ones, so a change in one points at the sam | `terminal.ts` | `Terminal.swift` | The terminal view and its WebSocket | | `reviews.ts` | `Reviews.swift` | Review cards, finding diffs, Fix and Ignore | | `workflow.ts` | `Workflow.swift` | Stage graph and attempts | -| `project.ts`, `todolink.ts` | `Project.swift`, `TodoLink.swift` | Project tab and TODO links | +| `project.ts`, `todolink.ts` | `Project.swift`, `MarkdownView.swift`, `Store.swift` | Project tab and TODO links | | `status.ts`, `settings.ts` | `Status.swift`, `Settings.swift` | Left-pane tabs | | `switcher.ts` | `Switcher.swift` | ⌘K overlay, the shortcuts help and the Go actions (`runGo`) | | `drafts.ts` | `Drafts.swift` | Composer and answer drafts | @@ -82,7 +82,7 @@ Startup follows `main` in `app.ts`: `GET /api/admin/status`, then me, projects, The app logs through `os.Logger` with subsystem `run.fc.byre.mac`, as the web client logs to the console: `log show --last 10m --predicate 'subsystem == "run.fc.byre.mac"'` prints it. -There is no URL hash. The web client mirrors focus into `#/task/`; the app keeps focus in `Store` only, and in-app task links call `focusTask` directly. Focus stays shared through the server, so the app and a browser follow each other as two browser tabs do. +There is no URL hash. The web client mirrors focus into `#/task/`; the app keeps focus in `Store` only. In-app `#/` links go through `Store.route`, a port of `route` in `app.ts`: `#/task//` opens that tab, `#/task/` and `#/project/` keep the current tab, and anything else clears focus. Focus stays shared through the server, so the app and a browser follow each other as two browser tabs do. ## Window and layout @@ -107,11 +107,11 @@ Each view does what its TypeScript counterpart does, with the same text, refresh - **Changes, Files, Reviews, Workflow, Project, Status, Settings.** Ports of their files, including the pinned-snapshot stepper, open-diff memory, per-task place in Files, finding diffs with Fix and Ignore, nested subtasks, TODO Start and Discuss, rate-limit pace bars and lead notes. The Workflow tab draws its stage graph natively from the `WorkflowRun` (stages as boxes coloured by state, `after` as edges, laid out top to bottom by depth) with the layout and drawing code from "Diagrams", fed from the run's data rather than Mermaid text. - **Terminal.** A terminal view attached to `GET /api/tasks/{taskId}/terminal` with `URLSessionWebSocketTask`, following `terminal.ts`: binary input and output, `TerminalSize` JSON on resize, close code 4000 for `shell ended: ` with New shell, 1011 for a start failure, reconnect after the same delay while the task is active, one socket per open tab and selected task. Colours come from the palette as in the web client. Ctrl combinations typed in it go to the shell. - **Switcher.** The ⌘K overlay, also opened by clicking the task picker, centred below the picker: the same ordering (tasks with inbox items first, then least recently visited, the selected task last), the `name: title` project prefix, New task, Start in …, Start workflow… and its second step, projects, Escape stepping back, and the list scrolling to keep the highlighted row in view. Picking a task opens its Inbox when it has items, its unread summary when it has one, and its Transcript otherwise. -- **Merge and Discard.** The transcript head's Merge button is enabled once `GET /api/tasks/{taskId}/unmerged` reports commits or files, refetched 1.5 s after a tool result or turn end, and labelled and disabled as in `taskHead`. `confirmMerge` (`MergeDialog.swift`) is ported whole: the editable commit title (at most 72 characters, kept across a warning link's detour), co-authors, warnings that refetch on the task's snapshot and review events and link to the Reviews or Inbox tab, the parent-or-default-branch choice for subtasks, and Merge now / After review while the latest snapshot's review is running. It sits just below the Merge button, or centred when ⌘S opens it with the button off screen. Discard confirms below its button, then clears the focused task. +- **Merge and Discard.** The transcript head's Merge button is enabled once `GET /api/tasks/{taskId}/unmerged` reports commits or files, refetched 1.5 s after a tool result or turn end, and labelled and disabled as in `taskHead`. `confirmMerge` (`MergeDialog.swift`) is ported whole: the editable commit title (at most 72 characters, kept across a warning link's detour), co-authors, warnings that refetch on the task's snapshot and review events and link to the Reviews or Inbox tab, the parent-or-default-branch choice for subtasks, and Merge now / After review while the latest snapshot's review is running. It is centred in the right pane. Discard confirms below its button, then clears the focused task. ## Markdown -`Markdown.swift` ports `markdown.ts` line for line into a block tree (paragraph, heading, list, choice list, choice grid, quote, rule, table, code, diff, details, diagram, image), because byre's dialect (choice markers, `
` sections, `#/task/`, `#/todo/` and `#/suggest/` links, worktree image paths, `NO_REPLY`) matches no library. Inline content becomes an `AttributedString` with code, strong, emphasis and links. Links: `http` and `https` open in the default browser; `#/task/` focuses the task; `#/todo//` becomes the TODO link with its start and discuss actions or the task's state; `#/suggest/<project>/<title>/<brief>` becomes the same, its start creating the task with the brief and its discuss messaging the lead; anything else is plain text. +`Markdown.swift` ports `markdown.ts` line for line into a block tree (paragraph, heading, list, choice list, choice grid, quote, rule, table, code, diff, details, diagram, image), because byre's dialect (choice markers, `<details>` sections, `#/task/`, `#/todo/` and `#/suggest/` links, worktree image paths, `NO_REPLY`) matches no library. Inline content becomes an `AttributedString` with code, strong, emphasis and links. Links: `http` and `https` open in the default browser; `#/todo/<project>/<title>` becomes the TODO link with its start and discuss actions or the task's state; `#/suggest/<project>/<title>/<brief>` becomes the same, its start creating the task with the brief and its discuss messaging the lead; any other `#/` link goes through `Store.route` (see "Store and events"); anything else is plain text. The TODO actions are link text in the selectable run, with the web's tooltips; while one runs both lose their link and the clicked one reads "starting…" (or "sending…" for a suggestion's discuss). The web draws them as small bordered buttons. ## Diagrams @@ -153,7 +153,7 @@ Images load through the API: `GET /api/blobs/{id}`, `GET /api/tasks/{taskId}/ima ## Keyboard and menus -The app menu has About, Change Server…, Hide, Hide Others and Quit; Edit has the standard items, with Cut, Copy, Paste and Select All being the app's own commands so Paste can attach images (see "Gaps in SwiftUI"); a **Go** menu has Switch Task ⌘K, Project ⌘P, Lead ⌘L, Merge ⌘S and Keyboard Shortcuts ⌘/; View has Enter Full Screen; Window has the standard items. There is no File menu. The Go commands are disabled while an overlay is open, as `bindShortcuts` ignores them while a dialog is open. The Ctrl variants the web client also accepts (Ctrl-K, P, L, S and /) run the same actions from the key monitor in `Overlays`, except while the terminal's `TerminalView` is the first responder (`terminalFocused`), where they go to the shell. ⌘ combinations always reach the menu, the terminal included. Escape closes the open overlay. +The app menu has About, Change Server…, Hide, Hide Others and Quit; Edit has the standard items, with Cut, Copy, Paste and Select All being the app's own commands so Paste can attach images (see "Gaps in SwiftUI"); a **Go** menu has Switch Task ⌘K, Project ⌘P, Lead ⌘L, Merge ⌘S and Keyboard Shortcuts ⌘/; View has Enter Full Screen; Window has the standard items. There is no File menu. The Go commands are disabled while an alert or modal is open (`dialogOpen`), as `bindShortcuts` ignores them while a dialog is open; the switcher and the More menu don't block them, and ⌘L closes the switcher so the Lead composer gets the keys. The Ctrl variants the web client also accepts (Ctrl-K, P, L, S and /) run the same actions from the key monitor in `Overlays`, except while the terminal's `TerminalView` is the first responder (`terminalFocused`), where they go to the shell. ⌘ combinations always reach the menu, the terminal included. Escape closes the open overlay. ## Connection @@ -161,7 +161,7 @@ On first launch the window shows one field, Server URL (an `https` origin with n ## Persistence -`Persistence.swift` holds the app's state in SwiftData, synced through CloudKit to the private database of container `iCloud.run.fc.byre.mac`, so it follows the iCloud account to every Mac: `ServerRecord` (the server origin), `DraftRecord` (drafts, see "Drafts") and `VisitRecord` (one per task: when it was last selected, read by the task switcher). CloudKit has no unique constraints, so two Macs can each create a record for the same thing; readers take the newest `ServerRecord` and `DraftRecord` and the latest `VisitRecord` per task, and the next write keeps one and deletes the rest. The store opens at launch and the app exits if it can't, which is what an unsigned build without the iCloud entitlement does. 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`). +`Persistence.swift` holds the app's state in SwiftData, synced through CloudKit to the private database of container `iCloud.run.fc.byre.mac`, so it follows the iCloud account to every Mac: `ServerRecord` (the server origin), `DraftRecord` (drafts, see "Drafts") and `VisitRecord` (one per task: when it was last selected, read by the task switcher). CloudKit has no unique constraints, so two Macs can each create a record for the same thing; readers take the newest `ServerRecord` and `DraftRecord` and the latest `VisitRecord` per task, and the next write keeps one and deletes the rest. The store is `byre.store` in `run.fc.byre.mac` under Application Support (the app isn't sandboxed, so SwiftData's default `default.store` would be shared with every other unsandboxed app). It opens at launch and the app exits if it can't, which is what an unsigned build without the iCloud entitlement does. 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 @@ -192,6 +192,9 @@ Platform differences the copy can't avoid, each with what the web client does an | Clicking a transcript image | Opens the image in a new browser tab | Opens it in an in-window overlay | | Preview links (`expose_port`) | Open in a new browser tab | Open in the default browser | | Window frame | Browser chrome and tabs | One macOS window with a standard title bar | +| Merge dialog | Just below the Merge button | Centred in the right pane | +| TODO and suggestion link actions | Small bordered **start** and **discuss** buttons | Accent link text, so the line stays selectable | +| Pasted or dropped images | PNG, JPEG, GIF and WebP; anything else is refused | The same four, and any other image `NSBitmapImageRep` decodes, such as TIFF, is converted to PNG, its size checked after conversion | | Selecting transcript text | A drag selects across any number of messages | A drag selects within one run of text in one message, ending at a code block, table, quote, diagram or `<details>`; each message is copied on its own | ## Building and checking diff --git a/docs/operations.md b/docs/operations.md index 07d60b1..2e317d0 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. The app keeps its state in SwiftData synced through the iCloud private database of container `iCloud.run.fc.byre.mac` (the CloudKit console shows its records): the server origin, last task visits, and composer and answer drafts as JSON, one record per server and login (`docs/macos.md`, "Persistence"). 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. +- `mac/` is the macOS client; `docs/macos.md` is its design and says what exists, and "macOS app" below covers building, installing and its storage. It connects to the byre `https` origin typed into its connection screen (the same `tailscale serve` URL a browser uses). 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 @@ -111,6 +111,14 @@ After rebuilding: `go install ./cmd/byre && launchctl kickstart -k gui/$(id -u)/ Clients reach `tailscale serve` over HTTP/2; it proxies to byre over HTTP/1.1 and passes SSE through unbuffered. +## macOS app + +**Building.** `mac/bundle.sh`, from any directory, builds the release configuration with `swift build` and assembles `mac/.build/byre.app`: the `Byre` executable, `mac/Info.plist`, the `web/fonts/` woff2 files, an icon rendered from `web/favicon-tile.svg` with `rsvg-convert` and `iconutil` (cached as `mac/.build/byre.icns` until the SVG changes) and `mac/byre.provisionprofile`, then signs the bundle with the Mac's Apple Development certificate and `mac/byre.entitlements`. It prints the bundle's path. It needs Xcode selected (`xcode-select --print-path` prints `/Applications/Xcode.app/Contents/Developer`), `rsvg-convert` (Homebrew's `librsvg`), an Apple Development certificate for the team (`security find-identity -v -p codesigning` lists them) and a Mac registered in the profile (`security cms -D -i mac/byre.provisionprofile` prints `ProvisionedDevices`); `docs/macos.md`, "Stack", says how to regenerate the profile for a new Mac. Debug builds are refused by an `#error` in `ByreApp.swift`. The first build fetches the Swift packages pinned in `mac/Package.resolved` and compiles the generated API client; later builds recompile only the `Byre` target. The 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. + +**Installing.** The app runs from where it was built: `open mac/.build/byre.app` in the worktree that built it. Nothing copies it to `/Applications`. A new build replaces the bundle, so quit the running app first (`pkill -x Byre` quits every running copy, whichever worktree built it) and open the new one; step 4 of "Deploying" in `AGENTS.md` does this after a deploy. Every build shares the bundle identifier `run.fc.byre.mac` and the same store, so any build reconnects to the server the last one used. Closing the window leaves the app running for notifications; ⌘Q quits it. + +**Storage.** The app keeps three kinds of record in SwiftData (`mac/Sources/Byre/Persistence.swift`): the server origin, when each task was last selected, and composer and answer drafts as JSON, one record per server and login (`docs/macos.md`, "Persistence" and "Drafts"). The store is `~/Library/Application Support/run.fc.byre.mac/byre.store`, with its `-shm` and `-wal` files and CloudKit's working files beside it in that directory; the app isn't sandboxed, so it names its own file rather than SwiftData's default `default.store`, which every unsandboxed app shares. The store syncs through the iCloud private database of container `iCloud.run.fc.byre.mac`, in CloudKit's Development environment; the CloudKit console (icloud.developer.apple.com, the container, Development, Private Database, zone `com.apple.coredata.cloudkit.zone`) shows the records. Deleting the local files while the app isn't running makes the next launch download the records again from iCloud; deleting the records in the console as well starts from the connection screen. Pasted images stay in memory and are dropped on Change Server… and quit. The app logs through `os.Logger`: `log show --last 10m --predicate 'subsystem == "run.fc.byre.mac"'` prints it. + ## Previews An agent shows the human a server it runs with the `expose_port` MCP tool (`internal/agents/preview.go`): `name` labels it, `port` is where it listens. The agent starts the server itself, usually as a background command; byre doesn't supervise it, so it dies with the agent's `claude` process (a byre restart, compaction, the task closing) and the agent starts it again when asked. The call is refused when `port` is outside the task's block or nothing accepts a connection on `127.0.0.1:<port>`. Otherwise byre runs `tailscale serve --bg --https=<port> http://127.0.0.1:<port>` (`internal/tailscale`, which runs the macOS CLI at `/Applications/Tailscale.app/Contents/MacOS/Tailscale`, since byre's environment has no `tailscale` on its `PATH`, with `TAILSCALE_BE_CLI=1`, without which the app executable launched from the service exits 0 without running the subcommand), then reads `tailscale serve status --json` and fails the call if no `Web` host for that port is listed, so the server is at `https://<byre's ts.net name>:<port>/` with Tailscale's identity headers, and records a row in the `previews` table (task, name, port, time) with a `preview.exposed` event, which the transcript shows as a note linking the URL. Each port gets its own HTTPS port rather than a path under byre's, since path-prefix proxying breaks apps that use absolute paths. Exposing a name or port the task already has replaces that row, and a port it no longer uses is turned off with `tailscale serve --https=<port> off`. A server must listen on `127.0.0.1`: while `tailscale serve` holds a port, binding `0.0.0.0` on it fails with "address already in use". When the task is merged or discarded, byre turns off each of its ports (`Manager.Unexpose`); the rows stay. `GET /api/tasks/{taskId}/previews` lists a task's rows by name, and `tailscale serve status` shows what is served. diff --git a/mac/Sources/Byre/ByreApp.swift b/mac/Sources/Byre/ByreApp.swift index 7984bc2..2c046f4 100644 --- a/mac/Sources/Byre/ByreApp.swift +++ b/mac/Sources/Byre/ByreApp.swift @@ -87,6 +87,6 @@ struct ByreApp: App { } } .keyboardShortcut(KeyEquivalent(Character(key))) - .disabled(connection.store == nil || overlays.isOpen) + .disabled(connection.store == nil || overlays.dialogOpen) } } diff --git a/mac/Sources/Byre/Changes.swift b/mac/Sources/Byre/Changes.swift index 1b087cc..90bb682 100644 --- a/mac/Sources/Byre/Changes.swift +++ b/mac/Sources/Byre/Changes.swift @@ -316,7 +316,7 @@ private struct SnapWhere: View { HStack(spacing: 0) { Text("snapshot \(n)") if let snap { - Text(" · \(snap.trigger.rawValue) · \(snap.createdAt.formatted(Date.FormatStyle().hour(.twoDigits(amPM: .omitted)).minute(.twoDigits).second(.twoDigits)))") + Text(" · \(snap.trigger.rawValue) · \(clockText(snap.createdAt))") } Text(" · ") step(n - 1, "← #\(n - 1)", snaps) @@ -348,7 +348,7 @@ private struct ChangesList: View { } else { ScrollView { LazyVStack(alignment: .leading, spacing: 6) { - ForEach(c.files.sorted { $0.path.localizedStandardCompare($1.path) == .orderedAscending }, id: \.path) { f in + ForEach(c.files.sorted { $0.path.localizedCompare($1.path) == .orderedAscending }, id: \.path) { f in FileRow(model: model, changes: c, file: f) } } diff --git a/mac/Sources/Byre/Chat.swift b/mac/Sources/Byre/Chat.swift index 669285e..26a4e27 100644 --- a/mac/Sources/Byre/Chat.swift +++ b/mac/Sources/Byre/Chat.swift @@ -210,9 +210,10 @@ final class ChatModel { } store.jump = nil stuck = false - if i > 0, case .agent = rows[i - 1].kind { + switch i > 0 ? rows[i - 1].kind : nil { + case .agent, .subagentMessage: requestScroll(rows[i - 1].id, .top) - } else { + default: requestScroll(rows[i].id, .bottom) } } @@ -528,12 +529,15 @@ private struct TranscriptScroll: View { ForEach(model.builder.pending) { p in PendingView(pending: p) } - if running { - Scanner(color: accent) - } } } .scrollTargetLayout() + .overlay(alignment: .bottomLeading) { + if running && model.key != nil { + Scanner(color: accent) + .offset(y: 8) + } + } .padding(16) } .scrollPosition($position) @@ -555,7 +559,10 @@ private struct TranscriptScroll: View { scrollLog.notice("phase \(String(describing: old), privacy: .public) -> \(String(describing: phase), privacy: .public)") scrolling = phase.isScrolling } - .onChange(of: model.builder.pending.count) { + .onChange(of: model.builder.pending.last?.id) { old, new in + guard let new, new > old ?? 0 else { + return + } scrollLog.notice("pending scrollTo bottom") model.stuck = true position.scrollTo(edge: .bottom) @@ -631,6 +638,7 @@ struct DetailsBox<Summary: View, Content: View>: View { var boxed = true var nested = false var working = false + var status: (text: String, color: Color)? = nil @ViewBuilder let summary: Summary @ViewBuilder let content: Content @Environment(\.paneAccent) private var accent @@ -651,6 +659,12 @@ struct DetailsBox<Summary: View, Content: View>: View { .padding(.leading, 4) } Spacer(minLength: 0) + if let status { + Text(status.text) + .font(Fonts.font(12)) + .foregroundStyle(status.color) + .padding(.leading, 12) + } } .contentShape(Rectangle()) } @@ -738,8 +752,8 @@ struct RowView: View { 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) + Text(styled([("byre", 600, nil), (" ", 400, nil), (brief, 400, Palette.muted)])) + .fixedSize(horizontal: false, vertical: true) Spacer(minLength: 12) WhenText(when: when).font(Fonts.font(12)).foregroundStyle(Palette.muted) } content: { @@ -790,13 +804,9 @@ struct RowView: View { } case .subagent(let title, let description, let prompt): - DetailsBox(nested: nested, working: model.working(row)) { + DetailsBox(nested: nested, working: model.working(row), status: (row.status, row.statusState == .done ? Palette.muted : row.statusState == .error ? Palette.danger : Palette.accent)) { 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) @@ -1014,10 +1024,11 @@ private struct SnapshotRow: View { let taskId: String? @Environment(Store.self) private var store @Environment(Overlays.self) private var overlays + @Environment(\.paneAccent) private var accent @ViewState private var forking = false var body: some View { - let time = snap.createdAt.formatted(Date.FormatStyle().hour(.twoDigits(amPM: .omitted)).minute(.twoDigits).second(.twoDigits)) + let time = clockText(snap.createdAt) HStack(spacing: 8) { Rectangle().fill(Palette.border).frame(height: 1) Text("snapshot #\(snap.n)") @@ -1058,26 +1069,40 @@ private struct SnapshotRow: View { 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") + Text("review") + .font(Fonts.font(11, weight: n > 0 ? 700 : 400)) + .overlay(alignment: .topTrailing) { + if let review { + switch review.state { + case .running: + Spinner(color: accent).font(Fonts.font(10)).help("review in progress") + .fixedSize() + .offset(x: 13, y: -4) + case .failed: + EmptyView() + case .skipped: + Text("●").font(Fonts.font(11, weight: 700)).foregroundStyle(Palette.muted).help("skipped: no changes") + .fixedSize() + .offset(x: 13, y: -7) + 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") + .fixedSize() + .offset(x: 13, y: -7) + } else { + Text("\(n)") + .font(Fonts.font(10, weight: 700)) + .foregroundStyle(Palette.danger) + .frame(height: 14) + .padding(.horizontal, 5) + .background(Palette.dangerTint, in: Capsule()) + .help("\(n) open findings") + .fixedSize() + .offset(x: 15, y: -9) + } } } } - } } .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 1de4fff..e148de4 100644 --- a/mac/Sources/Byre/Composer.swift +++ b/mac/Sources/Byre/Composer.swift @@ -226,7 +226,7 @@ struct Thumbnails: View { Button("×") { drafts.remove(key, img.id) } - .buttonStyle(WebButtonStyle()) + .buttonStyle(ThumbRemoveStyle()) .help("Remove image") .offset(x: 6, y: -6) } @@ -237,6 +237,30 @@ struct Thumbnails: View { } } +private struct ThumbRemoveStyle: ButtonStyle { + func makeBody(configuration: ButtonStyleConfiguration) -> some View { + ThumbRemove(configuration: configuration) + } +} + +private struct ThumbRemove: View { + let configuration: ButtonStyleConfiguration + @ViewState private var hover = false + + var body: some View { + configuration.label + .font(Fonts.font(13)) + .foregroundStyle(Palette.text) + .padding(.horizontal, 6) + .frame(height: 18) + .padding(1) + .background(Palette.raised, in: RoundedRectangle(cornerRadius: 9)) + .overlay(RoundedRectangle(cornerRadius: 9).strokeBorder(hover ? Palette.muted : Palette.border)) + .contentShape(RoundedRectangle(cornerRadius: 9)) + .onHover { hover = $0 } + } +} + struct ComposerView: View { let chat: ChatModel let key: String diff --git a/mac/Sources/Byre/Dialogs.swift b/mac/Sources/Byre/Dialogs.swift index c41f2ff..ccaabb9 100644 --- a/mac/Sources/Byre/Dialogs.swift +++ b/mac/Sources/Byre/Dialogs.swift @@ -10,6 +10,7 @@ struct MenuEntry: Identifiable { struct OverlayMenu { let anchor: CGRect + let accent: Color let entries: [MenuEntry] } @@ -44,29 +45,42 @@ enum LeftTab { final class Overlays { var alerts: [OverlayAlert] = [] var image: NSImage? - var menu: OverlayMenu? + var menu: OverlayMenu? { + didSet { + menuHover = false + } + } var modal: OverlayModal? var switcher: SwitcherModel? var leftTab = LeftTab.lead var leadFocus = 0 @ObservationIgnored var pickerFrame: CGRect = .zero + @ObservationIgnored var menuHover = false var paneFrame: CGRect = .zero @ObservationIgnored private var monitor: Any? + @ObservationIgnored private var mouseMonitor: Any? var isOpen: Bool { !alerts.isEmpty || image != nil || menu != nil || modal != nil || switcher != nil } + var dialogOpen: Bool { + !alerts.isEmpty || modal != nil + } + func showAlert(_ text: String) { + menu = nil alerts.append(OverlayAlert(content: AnyView(ModalText(text: text)))) } func showAlert(_ content: some View) { + menu = nil alerts.append(OverlayAlert(content: AnyView(content))) } func showModal(_ content: some View, _ buttons: [ModalButton], anchor: CGRect? = nil, inPane: Bool = false, canSubmit: @escaping @MainActor () -> Bool = { true }) async -> String { modal?.done("cancel") + menu = nil return await withCheckedContinuation { cont in modal = OverlayModal(content: AnyView(content), buttons: buttons, anchor: anchor, inPane: inPane, canSubmit: canSubmit) { value in cont.resume(returning: value) @@ -103,7 +117,7 @@ final class Overlays { func closeTop() { if !alerts.isEmpty { - alerts.removeFirst() + alerts.removeLast() } else if modal != nil { answerModal("cancel") } else if image != nil { @@ -128,13 +142,21 @@ final class Overlays { } return taken ? nil : event } + mouseMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown]) { [weak self] event in + MainActor.assumeIsolated { + if let self, self.menu != nil, !self.menuHover { + self.menu = nil + } + } + return event + } } private func key(_ code: UInt16, _ flags: NSEvent.ModifierFlags, _ chars: String, _ ctrl: @MainActor (String) -> Bool) -> Bool { + if !dialogOpen && flags.contains(.control) && !flags.contains(.command) && goKeys.contains(chars) { + return ctrl(chars) + } if !isOpen { - if flags.contains(.control) && !flags.contains(.command) && goKeys.contains(chars) { - return ctrl(chars) - } return false } if code == 53 { @@ -145,7 +167,7 @@ final class Overlays { let marked = (NSApp.keyWindow?.firstResponder as? NSTextView)?.hasMarkedText() ?? false if !alerts.isEmpty { if enter { - alerts.removeFirst() + alerts.removeLast() return true } return false @@ -180,7 +202,11 @@ final class Overlays { if let monitor { NSEvent.removeMonitor(monitor) } + if let mouseMonitor { + NSEvent.removeMonitor(mouseMonitor) + } monitor = nil + mouseMonitor = nil } } @@ -208,27 +234,6 @@ struct OverlayHost: View { 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 switcher = overlays.switcher { Color.black.opacity(0.35) .contentShape(Rectangle()) @@ -252,22 +257,39 @@ struct OverlayHost: View { overlays.image = nil } } + if let menu = overlays.menu { + 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: overlayShadow, radius: 14, y: 8) + .environment(\.paneAccent, menu.accent) + .onHover { inside in + overlays.menuHover = inside + } + .fixedSize() + .offset(x: menu.anchor.minX, y: menu.anchor.maxY + 4) + } if let m = overlays.modal { Color.black.opacity(0.35) .contentShape(Rectangle()) - .onTapGesture { - overlays.answerModal("cancel") - } ModalPlacement(modal: m, pane: overlays.paneFrame) { value in overlays.answerModal(value) } } - if let alert = overlays.alerts.first { + if let alert = overlays.alerts.last { Color.black.opacity(0.35) .contentShape(Rectangle()) GeometryReader { g in AlertBox(alert: alert, maxText: max(60, g.size.height - 48 - 90)) { - overlays.alerts.removeFirst() + overlays.alerts.removeLast() } .frame(maxWidth: .infinity, maxHeight: .infinity) } @@ -399,7 +421,7 @@ private struct ModalBox: View { .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) + .shadow(color: overlayShadow, radius: 14, y: 8) } } @@ -427,7 +449,7 @@ private struct AlertBox: View { .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) + .shadow(color: overlayShadow, radius: 14, y: 8) .padding(24) } } diff --git a/mac/Sources/Byre/Files.swift b/mac/Sources/Byre/Files.swift index 6adb69c..6b72dad 100644 --- a/mac/Sources/Byre/Files.swift +++ b/mac/Sources/Byre/Files.swift @@ -277,15 +277,14 @@ private struct EntryRow: View { @ViewState private var hover = false var body: some View { - let row = HStack(spacing: 12) { - HStack(spacing: 0) { + let row = HStack(alignment: .top, spacing: 12) { + HStack(alignment: .top, spacing: 0) { Text(label) .foregroundStyle(kind == .other || ignored ? Palette.muted : kind == .dir ? Palette.accent : Palette.text) if symlink { Text(" →").foregroundStyle(Palette.muted) } } - .lineLimit(1) Spacer(minLength: 0) Text(meta) .font(Fonts.font(11)) diff --git a/mac/Sources/Byre/Format.swift b/mac/Sources/Byre/Format.swift index fc16b3c..43e3de9 100644 --- a/mac/Sources/Byre/Format.swift +++ b/mac/Sources/Byre/Format.swift @@ -52,12 +52,82 @@ func jsString(_ v: (any Sendable)??) -> String { } 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) + jsonText(v, indent: " ", depth: 0) +} + +func compactJSON(_ v: (any Sendable)?) -> String { + jsonText(v, indent: nil, depth: 0) +} + +private func jsonText(_ v: (any Sendable)?, indent: String?, depth: Int) -> String { + guard let v else { + return "null" } - return String(decoding: data, as: UTF8.self) + if let s = v as? String { + return jsonQuote(s) + } + if let b = v as? Bool { + return b ? "true" : "false" + } + if let i = v as? Int { + return String(i) + } + if let d = v as? Double { + return d.isFinite ? jsString(d) : "null" + } + let items: [String] + let open: String + let close: String + if let list = v as? [(any Sendable)?] { + items = list.map { jsonText($0, indent: indent, depth: depth + 1) } + open = "[" + close = "]" + } else if let d = v as? JSONDict { + let keys = d.keys.sorted { $0.utf8.lexicographicallyPrecedes($1.utf8) } + items = keys.map { "\(jsonQuote($0)):\(indent == nil ? "" : " ")\(jsonText(d[$0] ?? nil, indent: indent, depth: depth + 1))" } + open = "{" + close = "}" + } else { + return jsonQuote(String(describing: v)) + } + if items.isEmpty { + return open + close + } + guard let indent else { + return open + items.joined(separator: ",") + close + } + let inner = "\n" + String(repeating: indent, count: depth + 1) + return open + inner + items.joined(separator: "," + inner) + "\n" + String(repeating: indent, count: depth) + close +} + +private func jsonQuote(_ s: String) -> String { + var out = "\"" + for u in s.unicodeScalars { + switch u { + case "\"": + out += "\\\"" + case "\\": + out += "\\\\" + case "\u{08}": + out += "\\b" + case "\u{0C}": + out += "\\f" + case "\n": + out += "\\n" + case "\r": + out += "\\r" + case "\t": + out += "\\t" + default: + if u.value < 0x20 { + let hex = String(u.value, radix: 16) + out += "\\u" + String(repeating: "0", count: 4 - hex.count) + hex + } else { + out.unicodeScalars.append(u) + } + } + } + return out + "\"" } func ago(_ date: Date) -> String { @@ -78,6 +148,10 @@ func isoText(_ date: Date) -> String { date.formatted(Date.ISO8601FormatStyle(includingFractionalSeconds: true)) } +func clockText(_ d: Date) -> String { + d.formatted(Date.VerbatimFormatStyle(format: "\(hour: .twoDigits(clock: .twentyFourHour, hourCycle: .zeroBased)):\(minute: .twoDigits):\(second: .twoDigits)", timeZone: .current, calendar: .current)) +} + func trimmed(_ s: String) -> String { s.trimmingCharacters(in: .whitespacesAndNewlines) } @@ -490,3 +564,7 @@ struct FlowLayout: Layout { return rows } } + +let overlayShadow = Color(nsColor: NSColor(name: nil) { appearance in + appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua ? NSColor(srgbRed: 0, green: 0, blue: 0, alpha: 0.5) : NSColor(srgbRed: 16 / 255, green: 24 / 255, blue: 40 / 255, alpha: 0.18) +}) diff --git a/mac/Sources/Byre/Inbox.swift b/mac/Sources/Byre/Inbox.swift index a16f176..a815d49 100644 --- a/mac/Sources/Byre/Inbox.swift +++ b/mac/Sources/Byre/Inbox.swift @@ -88,6 +88,7 @@ private final class CardModel { let context: [MDBlock] let firstChoice: [String: Int] var busy = false + @ObservationIgnored var shown: Set<String> = [] init(_ item: InboxItem, _ store: Store, _ overlays: Overlays) { self.item = item @@ -144,6 +145,17 @@ private final class CardModel { store.drafts?.setCheckedAnswer(item.id, options.filter { now.contains($0) }) } + func pick(_ text: String) { + if busy { + return + } + if multiple { + toggle(text, !checked.contains(text)) + return + } + submit(InboxResponseCreate(itemId: item.id, _type: .option, options: [text])) + } + func submit(_ response: InboxResponseCreate, withImages: Bool = false) { if busy { return @@ -225,7 +237,7 @@ private struct InboxCard: View { } private func move(_ model: CardModel, _ by: Int) -> KeyPress.Result { - var stops = model.options + var stops = model.firstChoice.filter { model.shown.contains($0.key) }.sorted { $0.value < $1.value }.map(\.key) if !model.editorOpen { stops.append(otherStop) } @@ -240,6 +252,13 @@ private struct InboxCard: View { private struct CardBody: View { let model: CardModel var focus: FocusState<String?>.Binding + @ViewState private var wide = true + + private var contextCtx: MDContext { + MDContext(taskId: model.item.taskId, choice: { md, first in + AnyView(ChoiceBox(model: model, item: md, first: first, ctx: self.contextCtx, focus: focus)) + }, gridColumns: wide ? 4 : 2) + } var body: some View { let p = model.payload @@ -247,10 +266,9 @@ private struct CardBody: View { VStack(alignment: .leading, spacing: 0) { switch item.kind { case .question: - MarkdownView(blocks: model.context, ctx: MDContext(taskId: item.taskId, choice: { md in - AnyView(ChoiceBox(model: model, item: md, focus: focus)) - })) - .padding(.vertical, 10) + MarkdownView(blocks: model.context, ctx: contextCtx) + .onGeometryChange(for: Bool.self) { $0.size.width > 480 } action: { wide = $0 } + .padding(.vertical, 10) MarkdownView(blocks: parseMarkdown(jsString(p["question"] ?? "")), ctx: MDContext(taskId: item.taskId)) .padding(.top, 10) .padding(.bottom, 4) @@ -308,6 +326,8 @@ private struct WhyLine: View { private struct ChoiceBox: View { let model: CardModel let item: MDItem + let first: Bool + let ctx: MDContext var focus: FocusState<String?>.Binding @Environment(Store.self) private var store @ViewState private var hover = false @@ -321,7 +341,7 @@ private struct ChoiceBox: View { Text("•") VStack(alignment: .leading, spacing: 6) { InlineView(inlines: parseInline(item.raw), ctx: MDContext(taskId: model.item.taskId)) - ItemBody(item: item, ctx: MDContext(taskId: model.item.taskId)) + ItemBody(item: item, ctx: ctx) } } } @@ -333,6 +353,7 @@ private struct ChoiceBox: View { let lit = (hover && !model.multiple) || focused let hasDetail = !item.inline.isEmpty || !item.blocks.isEmpty let on = model.checked.contains(text) + let keys: Set<KeyEquivalent> = model.multiple ? [.space] : [.return, .space] return VStack(alignment: .leading, spacing: 0) { HStack(alignment: .firstTextBaseline, spacing: 10) { if model.multiple { @@ -342,29 +363,42 @@ private struct ChoiceBox: View { .tint(Palette.accent) .disabled(model.busy) } - InlineView(inlines: parseInline(text), ctx: MDContext(taskId: model.item.taskId)) - .font(Fonts.font(13, weight: 600)) + InlineView(inlines: parseInline(text), ctx: MDContext(taskId: model.item.taskId, weight: 600)) .frame(maxWidth: .infinity, alignment: .leading) - .allowsHitTesting(false) if rec { - Text("recommended").font(Fonts.font(11)).foregroundStyle(Palette.ok) + Text("recommended") + .font(Fonts.font(11)) + .foregroundStyle(Palette.ok) + .allowsHitTesting(false) } } .padding(.horizontal, 10) .padding(.vertical, 6) - .background(lit ? Palette.accentTint : rec ? Palette.ok.opacity(0.12) : Palette.surface) + .background { + (lit ? Palette.accentTint : rec ? Palette.ok.opacity(0.12) : Palette.surface) + .onTapGesture { + model.pick(text) + } + } if hasDetail { - ItemBody(item: MDItem(inline: item.inline, blocks: item.blocks), ctx: MDContext(taskId: model.item.taskId)) + ItemBody(item: MDItem(inline: item.inline, blocks: item.blocks), ctx: ctx) .padding(.horizontal, 10) .padding(.vertical, 8) - .allowsHitTesting(false) .overlay(alignment: .top) { Rectangle().fill(Palette.border).frame(height: 1) } } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .background(Palette.raised) + .background { + Palette.raised + .onTapGesture { + model.pick(text) + } + } + .environment(\.choiceTap, { [model] in + model.pick(text) + }) .clipShape(RoundedRectangle(cornerRadius: 6)) .overlay(RoundedRectangle(cornerRadius: 6).stroke(lit ? Palette.accent : rec ? Palette.ok.opacity(0.55) : Palette.border)) .overlay { @@ -377,26 +411,18 @@ private struct ChoiceBox: View { .focusable(!model.busy) .focusEffectDisabled() .focused(focus, equals: text) - .onTapGesture { - pick(text) - } - .onKeyPress(keys: [.return, .space]) { _ in - pick(text) + .onKeyPress(keys: keys) { _ in + model.pick(text) return .handled } .onHover { hover = $0 } - .padding(.top, 8) - } - - private func pick(_ text: String) { - if model.busy { - return + .onAppear { + model.shown.insert(text) } - if model.multiple { - model.toggle(text, !model.checked.contains(text)) - return + .onDisappear { + model.shown.remove(text) } - model.submit(InboxResponseCreate(itemId: model.item.id, _type: .option, options: [text])) + .padding(.top, first ? 0 : 6) } } @@ -613,9 +639,7 @@ private struct Attachments: View { } private func code(_ s: String) -> Text { - var a = AttributedString(s) - a.backgroundColor = Palette.surface - return Text(a) + Text(verbatim: s) } @ViewBuilder @@ -628,15 +652,18 @@ private struct Attachments: View { Text("diff \(code(a.ref ?? ""))").textSelection(.enabled) case .image: let src = (a.image?.isEmpty == false ? a.image : a.path) ?? "" + let caption = a.caption ?? "" if let source = worktreeImageSource(src, taskId) { ByreImage(source: source, src: src) + } else if !src.isEmpty { + InlineView(inlines: [.link(href: src, [.text(caption.isEmpty ? src : caption)])], ctx: MDContext(taskId: taskId)) } case .diagram: DiagramView(source: a.mermaid ?? "") case .task: let id = a.taskId ?? "" Button("task \(id)") { - store.focusTask(id) + store.route("#/task/\(id)") } .buttonStyle(.plain) .foregroundStyle(store.taskColor(id) ?? Palette.accent) diff --git a/mac/Sources/Byre/MarkdownView.swift b/mac/Sources/Byre/MarkdownView.swift index 5544840..0f13353 100644 --- a/mac/Sources/Byre/MarkdownView.swift +++ b/mac/Sources/Byre/MarkdownView.swift @@ -7,11 +7,15 @@ enum MDSegment { } enum AppLink { - static func task(_ id: String) -> URL { + static func task(_ id: String, tab: String? = nil) -> URL { + route("#/task/\(id)\(tab.map { "/\($0)" } ?? "")") + } + + static func route(_ hash: String) -> URL { var c = URLComponents() c.scheme = "byre-app" - c.host = "task" - c.queryItems = [URLQueryItem(name: "id", value: id)] + c.host = "route" + c.queryItems = [URLQueryItem(name: "hash", value: hash)] return c.url! } @@ -39,13 +43,13 @@ func openAppLink(_ url: URL, _ store: Store, _ overlays: Overlays) -> OpenURLAct } let q = Dictionary((c.queryItems ?? []).map { ($0.name, $0.value ?? "") }, uniquingKeysWith: { a, _ in a }) switch c.host { - case "task": - store.focusTask(q["id"] ?? "") + case "route": + store.route(q["hash"] ?? "") case "todo": store.startTodo(q["project"] ?? "", q["title"] ?? "", discuss: q["discuss"] == "1", overlays) case "suggest": if q["discuss"] == "1" { - store.discussSuggestion(q["title"] ?? "", brief: q["brief"] ?? "", overlays) + store.discussSuggestion(q["project"] ?? "", q["title"] ?? "", brief: q["brief"] ?? "", overlays) } else { store.startSuggestion(q["project"] ?? "", q["title"] ?? "", brief: q["brief"] ?? "", overlays) } @@ -55,6 +59,34 @@ func openAppLink(_ url: URL, _ store: Store, _ overlays: Overlays) -> OpenURLAct return .handled } +private struct ChoiceTapKey: EnvironmentKey { + static let defaultValue: (@MainActor @Sendable () -> Void)? = nil +} + +private struct CenterImagesKey: EnvironmentKey { + static let defaultValue = false +} + +extension EnvironmentValues { + var choiceTap: (@MainActor @Sendable () -> Void)? { + get { + self[ChoiceTapKey.self] + } + set { + self[ChoiceTapKey.self] = newValue + } + } + + var centerImages: Bool { + get { + self[CenterImagesKey.self] + } + set { + self[CenterImagesKey.self] = newValue + } + } +} + extension MDMark { var color: Color { switch self { @@ -85,6 +117,7 @@ struct InlineRenderer { let store: Store let size: CGFloat let taskId: String? + var weight: CGFloat = 400 private struct Style { var bold = false @@ -92,6 +125,7 @@ struct InlineRenderer { var link: URL? var color: Color? var small = false + var tip: String? } func segments(_ items: [MDInline]) -> [MDSegment] { @@ -112,8 +146,8 @@ struct InlineRenderer { 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) - a.mergeAttributes(try! AttributeContainer([.font: Fonts.ns(style.small ? 11 : size, weight: style.bold ? 700 : 400, italic: style.italic)], including: \.appKit)) + a.font = Fonts.font(style.small ? 11 : size, weight: style.bold ? 700 : weight, italic: style.italic) + a.mergeAttributes(try! AttributeContainer([.font: Fonts.ns(style.small ? 11 : size, weight: style.bold ? 700 : weight, italic: style.italic)], including: \.appKit)) if let link = style.link { a.link = link } @@ -121,6 +155,9 @@ struct InlineRenderer { a.foregroundColor = color a.appKit.foregroundColor = NSColor(color) } + if let tip = style.tip { + a.appKit.toolTip = tip + } return a } @@ -177,7 +214,7 @@ struct InlineRenderer { return } if let m = taskHref.exec(href) { - s.link = AppLink.task(m[1]!) + s.link = AppLink.route(href) s.color = store.taskColor(m[1]!) ?? Palette.accent render(inner, s, &out) return @@ -190,6 +227,12 @@ struct InlineRenderer { todo(m[1]!, title, brief, inner, style, &out) return } + if href.hasPrefix("#/") { + s.link = AppLink.route(href) + s.color = Palette.accent + render(inner, s, &out) + return + } render(inner, style, &out) } @@ -201,22 +244,40 @@ struct InlineRenderer { 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) + var dot = Style(link: AppLink.task(task.id), color: state == .running ? Palette.ok : Palette.muted, small: true, tip: state?.rawValue ?? "no session") append(run(state == .running ? "●" : "○", dot), &out) dot.color = Palette.muted + dot.tip = nil 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) + let busy = store.todoBusy["\(projectId)\n\(title)"] + func action(_ discuss: Bool, _ label: String, _ busyLabel: String, _ tip: String) { + let url = brief.map { AppLink.suggest(projectId, title, $0, discuss: discuss) } ?? AppLink.todo(projectId, title, discuss: discuss) + let mine = busy == (discuss ? "discuss" : "start") + let a = Style(link: busy == nil ? url : nil, color: busy == nil ? Palette.accent : Palette.muted, small: true, tip: tip) + append(run(mine ? busyLabel : label, a), &out) + } + if brief != nil { + action(false, "start", "starting…", "Start a task with the lead's brief") + append(run(" ", style), &out) + action(true, "discuss", "sending…", "Ask your lead to talk it over in chat") return } - let link = { (discuss: Bool) in brief.map { AppLink.suggest(projectId, title, $0, discuss: discuss) } ?? AppLink.todo(projectId, title, discuss: discuss) } - append(run("start", Style(link: link(false), color: Palette.accent, small: true)), &out) + action(false, "start", "starting…", "Start a task that gets straight to work") append(run(" ", style), &out) - append(run("discuss", Style(link: link(true), color: Palette.accent, small: true)), &out) + action(true, "discuss", "starting…", "Start a task that discusses it with you in chat before building anything") + } +} + +private func flowColor(_ s: NSMutableAttributedString, _ color: NSColor) { + let all = NSRange(location: 0, length: s.length) + unsafe s.enumerateAttribute(.foregroundColor, in: all) { value, range, _ in + if value == nil { + s.addAttribute(.foregroundColor, value: color, range: range) + } } } @@ -224,7 +285,9 @@ struct MDContext { var size: CGFloat = 13 var color: Color = Palette.text var taskId: String? - var choice: ((MDItem) -> AnyView)? + var choice: ((MDItem, Bool) -> AnyView)? + var gridColumns = 4 + var weight: CGFloat = 400 } private enum MDRun { @@ -356,7 +419,7 @@ private struct FlowBuilder { } private func inline(_ inlines: [MDInline], _ size: CGFloat) -> NSAttributedString { - let a = InlineRenderer(store: store, size: size, taskId: ctx.taskId).text(inlines) + let a = InlineRenderer(store: store, size: size, taskId: ctx.taskId, weight: ctx.weight).text(inlines) return try! NSAttributedString(a, including: \.appKit) } @@ -364,12 +427,7 @@ private struct FlowBuilder { let s = NSMutableAttributedString(attributedString: a) s.append(NSAttributedString(string: "\n", attributes: [.font: Fonts.ns(ctx.size)])) let all = NSRange(location: 0, length: s.length) - let color = NSColor(ctx.color) - unsafe s.enumerateAttribute(.foregroundColor, in: all) { value, range, _ in - if value == nil { - s.addAttribute(.foregroundColor, value: color, range: range) - } - } + flowColor(s, NSColor(ctx.color)) let lead = (s.string as NSString).range(of: "\n").location + 1 s.addAttribute(.paragraphStyle, value: style(first, head, 0), range: all) s.addAttribute(.paragraphStyle, value: style(first, head, gap), range: NSRange(location: 0, length: lead)) @@ -389,10 +447,37 @@ private struct FlowBuilder { } private final class FlowTextView: NSTextView { + var onTap: (@MainActor @Sendable () -> Void)? + override func resignFirstResponder() -> Bool { setSelectedRange(NSRange(location: 0, length: 0)) return super.resignFirstResponder() } + + override func mouseDown(with event: NSEvent) { + let onLink = link(at: convert(event.locationInWindow, from: nil)) + super.mouseDown(with: event) + if let onTap, !onLink, selectedRange().length == 0 { + onTap() + } + } + + private func link(at point: NSPoint) -> Bool { + guard let lm = unsafe layoutManager, let tc = unsafe textContainer, let ts = unsafe textStorage, ts.length > 0 else { + return false + } + let p = NSPoint(x: point.x - textContainerOrigin.x, y: point.y - textContainerOrigin.y) + let g = lm.glyphIndex(for: p, in: tc) + if !lm.boundingRect(forGlyphRange: NSRange(location: g, length: 1), in: tc).contains(p) { + return false + } + let i = lm.characterIndexForGlyph(at: g) + if i >= ts.length { + return false + } + let value = unsafe ts.attribute(.link, at: i, effectiveRange: nil) + return value != nil + } } private struct FlowText: NSViewRepresentable { @@ -415,6 +500,7 @@ private struct FlowText: NSViewRepresentable { func updateNSView(_ v: FlowTextView, context: Context) { context.coordinator.openURL = context.environment.openURL + v.onTap = context.environment.choiceTap if unsafe v.textStorage!.isEqual(to: text) { return } @@ -480,25 +566,45 @@ struct InlineView: View { let inlines: [MDInline] let ctx: MDContext @Environment(Store.self) private var store + @Environment(\.choiceTap) private var choiceTap + @Environment(\.centerImages) private var centerImages var body: some View { - let segs = InlineRenderer(store: store, size: ctx.size, taskId: ctx.taskId).segments(inlines) + let segs = InlineRenderer(store: store, size: ctx.size, taskId: ctx.taskId, weight: ctx.weight).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) + if choiceTap != nil { + FlowText(text: flowString(a)) + } else { + 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) + if centerImages { + ByreImage(source: source, src: src) + .frame(maxWidth: .infinity) + } else { + ByreImage(source: source, src: src) + } } } } } + + private func flowString(_ a: AttributedString) -> NSAttributedString { + let s = NSMutableAttributedString(attributedString: try! NSAttributedString(a, including: \.appKit)) + flowColor(s, NSColor(ctx.color)) + let p = NSMutableParagraphStyle() + p.lineSpacing = 3 + s.addAttribute(.paragraphStyle, value: p, range: NSRange(location: 0, length: s.length)) + return s + } } private struct BlockView: View { @@ -562,16 +668,17 @@ private struct ListBlock: View { var body: some View { if grid, let choice = ctx.choice { - LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8, alignment: .top), count: 4), spacing: 8) { + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8, alignment: .top), count: ctx.gridColumns), spacing: 8) { ForEach(Array(items.enumerated()), id: \.offset) { _, item in - choice(item) + choice(item, true) } } + .environment(\.centerImages, true) } 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) + choice(item, n == 0) } else { HStack(alignment: .firstTextBaseline, spacing: 6) { Text(verbatim: ordered ? "\(start + n)." : "•") @@ -606,21 +713,28 @@ struct ItemBody: View { private struct DiffBlock: View { let lines: [String] let size: CGFloat + @ViewState private var width: CGFloat = 0 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)) + ScrollView(.horizontal) { + StretchColumn(minWidth: width) { + ForEach(Array(lines.enumerated()), id: \.offset) { _, line in + Text(verbatim: line.isEmpty ? " " : line) + .font(Fonts.font(size)) + .foregroundStyle(line.hasPrefix("@@") || line.hasPrefix("+++") || line.hasPrefix("---") ? Palette.muted : Palette.text) + .textSelection(.enabled) + .fixedSize() + .padding(.horizontal, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(background(line)) + } } + .padding(.vertical, 6) } - .padding(.vertical, 6) + .scrollBounceBehavior(.basedOnSize) + .frame(maxWidth: .infinity, alignment: .leading) + .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { width = $0 } .background(Palette.surface, in: RoundedRectangle(cornerRadius: 6)) .overlay(RoundedRectangle(cornerRadius: 6).stroke(Palette.border)) } @@ -680,6 +794,14 @@ private struct TableBlock: View { let ctx: MDContext var body: some View { + ViewThatFits(in: .horizontal) { + grid.fixedSize(horizontal: true, vertical: false) + grid + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var grid: some View { Grid(alignment: .topLeading, horizontalSpacing: 0, verticalSpacing: 0) { GridRow { ForEach(Array(head.enumerated()), id: \.offset) { _, cell in @@ -696,7 +818,7 @@ private struct TableBlock: View { } } } - .overlay(Rectangle().stroke(Palette.border)) + .overlay(Rectangle().strokeBorder(Palette.border)) } private func tint(_ cell: [MDInline]) -> Color? { diff --git a/mac/Sources/Byre/Panes.swift b/mac/Sources/Byre/Panes.swift index 57834a3..13921de 100644 --- a/mac/Sources/Byre/Panes.swift +++ b/mac/Sources/Byre/Panes.swift @@ -4,7 +4,7 @@ import SwiftUI enum TabBadge { case none - case count(Int) + case count(Int, String) case spinner } @@ -16,8 +16,9 @@ struct TabBadgeView: View { switch badge { case .none: EmptyView() - case .count(let n): + case .count(let n, let help): Badge(count: n) + .help(help) case .spinner: Spinner(color: accent) .help("review in progress") @@ -109,7 +110,7 @@ struct PaneTabButton: View { TabBadgeView(badge: badge) } .font(Fonts.font(13, weight: attention ? 700 : 400)) - .foregroundStyle(attention ? Palette.warn : active || hover ? Palette.text : Palette.muted) + .foregroundStyle(hover ? Palette.text : attention ? Palette.warn : active ? Palette.text : Palette.muted) .padding(.horizontal, 10) .padding(.top, 8) .padding(.bottom, 6) @@ -149,6 +150,7 @@ private struct LeftPane: View { if !store.connected { Text("reconnecting…") .foregroundStyle(Palette.danger) + .fixedSize() } Circle() .fill(store.connected ? Palette.ok : Palette.danger) @@ -156,9 +158,9 @@ private struct LeftPane: View { .help(store.connected ? "live" : "reconnecting") Text(store.me?.displayName ?? "") .foregroundStyle(Palette.muted) + .truncationMode(.tail) } .lineLimit(1) - .fixedSize() } .padding(.horizontal, 12) .overlay(alignment: .bottom) { @@ -280,12 +282,7 @@ private struct TaskBar: View { 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") + PreviewLink(preview: p, url: previewURL(store.api.origin, p.port)) } } .frame(maxWidth: .infinity) @@ -340,9 +337,28 @@ private struct TaskBar: View { } } +private struct PreviewLink: View { + let preview: Components.Schemas.Preview + let url: URL + @Environment(\.paneAccent) private var accent + @ViewState private var hover = false + + var body: some View { + Link(destination: url) { + Text("\(preview.name) ↗") + .foregroundStyle(accent) + .underline(hover) + .lineLimit(1) + } + .help("Open \(preview.name) (port \(preview.port)) in a new window") + .onHover { hover = $0 } + } +} + private struct TabStrip: View { @Environment(Store.self) private var store @Environment(Overlays.self) private var overlays + @Environment(\.paneAccent) private var accent @ViewState private var widths: [TaskTab: CGFloat] = [:] @ViewState private var moreWidth: CGFloat = 0 @ViewState private var stripWidth: CGFloat = 0 @@ -363,7 +379,7 @@ private struct TabStrip: View { if t == .reviews && reviewing { return .spinner } - return count(t) > 0 ? .count(count(t)) : .none + return count(t) > 0 ? .count(count(t), t == .inbox ? "inbox items" : "open findings in the latest review") : .none } } @@ -427,8 +443,8 @@ private struct TabStrip: View { } } if !hidden.isEmpty { - moreButton(flagged.map(info.badge)) { - overlays.menu = OverlayMenu(anchor: moreFrame, entries: hidden.map { t in + MoreButton(badges: flagged.map(info.badge)) { + overlays.menu = OverlayMenu(anchor: moreFrame, accent: accent, entries: hidden.map { t in MenuEntry(id: t.rawValue, label: tabLabels[t]!, badge: info.badge(t)) { store.focusTab(t) } @@ -455,7 +471,7 @@ private struct TabStrip: View { widths[t] = w } } - moreButton([]) {} + MoreButton(badges: []) {} .onGeometryChange(for: CGFloat.self) { g in g.size.width } action: { w in @@ -474,8 +490,14 @@ private struct TabStrip: View { Rectangle().fill(Palette.border).frame(height: 1) } } +} - private func moreButton(_ badges: [TabBadge], _ action: @escaping () -> Void) -> some View { +private struct MoreButton: View { + let badges: [TabBadge] + let action: () -> Void + @ViewState private var hover = false + + var body: some View { Button(action: action) { HStack(spacing: 6) { Text("More ▾") @@ -484,7 +506,7 @@ private struct TabStrip: View { } } .font(Fonts.font(13, weight: badges.isEmpty ? 400 : 700)) - .foregroundStyle(badges.isEmpty ? Palette.muted : Palette.warn) + .foregroundStyle(hover ? Palette.text : badges.isEmpty ? Palette.muted : Palette.warn) .padding(.horizontal, 10) .padding(.top, 8) .padding(.bottom, 6) @@ -497,6 +519,7 @@ private struct TabStrip: View { .buttonStyle(.plain) .fixedSize() .help("More tabs") + .onHover { hover = $0 } } } diff --git a/mac/Sources/Byre/Persistence.swift b/mac/Sources/Byre/Persistence.swift index 648ecca..52592ea 100644 --- a/mac/Sources/Byre/Persistence.swift +++ b/mac/Sources/Byre/Persistence.swift @@ -38,8 +38,10 @@ final class VisitRecord { private let container: ModelContainer = { let schema = Schema([ServerRecord.self, DraftRecord.self, VisitRecord.self]) + let dir = URL.applicationSupportDirectory.appending(path: "run.fc.byre.mac", directoryHint: .isDirectory) do { - return try ModelContainer(for: schema, configurations: ModelConfiguration(schema: schema, cloudKitDatabase: .private("iCloud.run.fc.byre.mac"))) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return try ModelContainer(for: schema, configurations: ModelConfiguration(schema: schema, url: dir.appending(path: "byre.store"), cloudKitDatabase: .private("iCloud.run.fc.byre.mac"))) } catch { fatalError("open persistent store: \(error)") } diff --git a/mac/Sources/Byre/Project.swift b/mac/Sources/Byre/Project.swift index eaef8f2..49c9956 100644 --- a/mac/Sources/Byre/Project.swift +++ b/mac/Sources/Byre/Project.swift @@ -146,7 +146,7 @@ struct HoverRow<Content: View>: View { HStack(spacing: 10) { content } - .padding(.leading, 10 + indent) + .padding(.leading, max(0, 10 + indent)) .padding(.trailing, 10) .padding(.vertical, 6) .frame(maxWidth: .infinity, alignment: .leading) @@ -226,10 +226,11 @@ private struct TodoBlock: View { Text(item.section) .font(Fonts.font(12, weight: 700)) .foregroundStyle(Palette.muted) - .padding(.top, 10) + .padding(.top, i == 0 ? 10 : 2) .padding(.bottom, 4) } TodoRow(project: project, item: item, task: active.first { $0.todoItem == item.title }) + .padding(.bottom, i == t.items.count - 1 || t.items[i + 1].section != item.section ? 8 : 0) } } } diff --git a/mac/Sources/Byre/Reviews.swift b/mac/Sources/Byre/Reviews.swift index ee7a19b..d0f573f 100644 --- a/mac/Sources/Byre/Reviews.swift +++ b/mac/Sources/Byre/Reviews.swift @@ -157,6 +157,7 @@ struct ReviewsView: View { VStack(alignment: .leading, spacing: 12) { if let shown { ReviewCard(review: shown, taskId: taskId) + .id(shown.id) } else { Muted(text: "No review of snapshot #\(n) yet.") } @@ -200,6 +201,9 @@ struct ReviewCard: View { Spacer(minLength: 0) Text(ago(review.createdAt)) .help(isoText(review.createdAt)) + if review.state == .running { + StopReviewButton(reviewId: review.id) + } } .font(Fonts.font(11)) .foregroundStyle(Palette.muted) @@ -220,6 +224,30 @@ struct ReviewCard: View { } } +private struct StopReviewButton: View { + let reviewId: String + @Environment(Store.self) private var store + @Environment(Overlays.self) private var overlays + @ViewState private var busy = false + + var body: some View { + Button("Stop") { + busy = true + Task { + do { + _ = try await store.api.client.stopReview(path: .init(reviewId: reviewId)).ok + } catch { + busy = false + overlays.showAlert(errorText(error)) + } + } + } + .buttonStyle(WebButtonStyle(danger: true)) + .disabled(busy) + .help("End the reviewer and record this review as failed") + } +} + private struct ReviewResult: View { let review: Review let taskId: String? @@ -338,6 +366,7 @@ private struct FindingDiff: View { } } .task(id: "\(taskId)/\(n)/\(path)/\(line)/\(end)") { + failure = nil do { let patch = try await SnapshotPatches.patch(store.api, taskId, n, path) let lines = patch.map { linesAround($0, line, end) } ?? [] diff --git a/mac/Sources/Byre/Settings.swift b/mac/Sources/Byre/Settings.swift index 690a95d..61dfac4 100644 --- a/mac/Sources/Byre/Settings.swift +++ b/mac/Sources/Byre/Settings.swift @@ -150,16 +150,27 @@ private struct LeadNotes: View { let groups = [("", "Every project")] + store.projects.map { ($0.id, $0.name) } let current = groups.contains { $0.0 == project } ? project : "" let shown = list.filter { ($0.projectId ?? "") == current } - Picker("", selection: Binding(get: { current }, set: { v in - project = v - notesProject = v - })) { + let label = { (g: (String, String)) in "\(g.1) (\(list.filter { ($0.projectId ?? "") == g.0 }.count))" } + Menu { ForEach(groups, id: \.0) { g in - Text("\(g.1) (\(list.filter { ($0.projectId ?? "") == g.0 }.count))").tag(g.0) + Button(label(g)) { + project = g.0 + notesProject = g.0 + } } + } label: { + HStack(spacing: 8) { + Text(label(groups.first { $0.0 == current } ?? groups[0])) + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 9, weight: .semibold)) + } + .foregroundStyle(Palette.text) + .modifier(FieldStyle()) + .contentShape(Rectangle()) } - .labelsHidden() - .pickerStyle(.menu) + .menuStyle(.button) + .buttonStyle(.plain) + .menuIndicator(.hidden) .fixedSize() .help("Which notes to show") .padding(.bottom, 8) diff --git a/mac/Sources/Byre/Status.swift b/mac/Sources/Byre/Status.swift index be04d90..0914f23 100644 --- a/mac/Sources/Byre/Status.swift +++ b/mac/Sources/Byre/Status.swift @@ -352,17 +352,22 @@ private struct PaceBar: View { let title = diff > 0 ? "\(diff) points ahead of an even pace" : diff < 0 ? "\(-diff) points behind an even pace" : "on an even pace" GeometryReader { g in ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 2).fill(Palette.surface) Rectangle() - .fill((used > elapsed ? Palette.warn : Palette.ok).opacity(0.55)) + .fill(Palette.raised) + .overlay((used > elapsed ? Palette.warn : Palette.ok).opacity(0.55)) .frame(width: g.size.width * min(1, used)) - RoundedRectangle(cornerRadius: 2).stroke(Palette.border) Rectangle() .fill(Palette.text) .frame(width: 2, height: g.size.height + 6) .offset(x: g.size.width * elapsed - 1) } } + .padding(1) + .background { + RoundedRectangle(cornerRadius: 2) + .fill(Palette.surface) + .overlay(RoundedRectangle(cornerRadius: 2).strokeBorder(Palette.border)) + } .frame(minWidth: 80, maxWidth: .infinity) .frame(height: 10) .help(title) diff --git a/mac/Sources/Byre/Store.swift b/mac/Sources/Byre/Store.swift index 72df1ae..850ccac 100644 --- a/mac/Sources/Byre/Store.swift +++ b/mac/Sources/Byre/Store.swift @@ -6,6 +6,7 @@ let taskTabs: [TaskTab] = [.transcript, .changes, .files, .terminal, .inbox, .re private let historyPage = 1000 private let leadPage = 50 +private let routeHash = Re(#"^#/(task|project)/(\w+)(?:/(\w+))?$"#) struct TaskState { enum Kind { @@ -44,7 +45,7 @@ final class Store { var composerFocus: String? var summaryReads: [String: Int64] = [:] private(set) var leadMore = false - var todoStarting: Set<String> = [] + var todoBusy: [String: String] = [:] private(set) var drafts: Drafts? let images = ImageDrafts() let events = Listeners<ByreEvent>() @@ -76,39 +77,48 @@ final class Store { drafts!.prune(self) } + var todoStarting: Set<String> { + Set(todoBusy.keys) + } + func startTodo(_ projectId: String, _ title: String, discuss: Bool, _ overlays: Overlays) { let key = "\(projectId)\n\(title)" - if todoStarting.contains(key) { + if todoBusy[key] != nil { return } - todoStarting.insert(key) + todoBusy[key] = discuss ? "discuss" : "start" 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) + todoBusy[key] = nil } } func startSuggestion(_ projectId: String, _ title: String, brief: String, _ overlays: Overlays) { let key = "\(projectId)\n\(title)" - if todoStarting.contains(key) { + if todoBusy[key] != nil { return } - todoStarting.insert(key) + todoBusy[key] = "start" Task { do { _ = try await api.client.createTask(path: .init(projectId: projectId), body: .json(.init(title: title, prompt: brief, model: models[0]))).created } catch { overlays.showAlert(errorText(error)) } - todoStarting.remove(key) + todoBusy[key] = nil } } - func discussSuggestion(_ title: String, brief: String, _ overlays: Overlays) { + func discussSuggestion(_ projectId: String, _ title: String, brief: String, _ overlays: Overlays) { + let key = "\(projectId)\n\(title)" + if todoBusy[key] != nil { + return + } + todoBusy[key] = "discuss" let lead = currentLead() let live = lead != nil && lead!.state != .completed Task { @@ -117,6 +127,7 @@ final class Store { } catch { overlays.showAlert(errorText(error)) } + todoBusy[key] = nil } } @@ -310,6 +321,28 @@ final class Store { } } + func route(_ hash: String) { + let m = routeHash.exec(hash) + let kind = m?[1] ?? nil + let id = m?[2] ?? nil + let name = m?[3] ?? nil + let tab = taskTabs.first { $0.rawValue == name } + if kind == "task", let id { + if id != focus.taskId || (tab != nil && tab != focus.tab) { + spawn("set focus") { + try await self.setFocus(taskId: id, tab: tab) + } + } + return + } + let projectId = kind == "project" ? id : nil + if focus.taskId != nil || projectId != focus.projectId { + spawn("set focus") { + try await self.setFocus(taskId: .some(nil), projectId: .some(projectId)) + } + } + } + private func loadHistory(_ taskId: String, _ feed: Feed) async throws { var got: [ByreEvent] = [] while true { diff --git a/mac/Sources/Byre/Switcher.swift b/mac/Sources/Byre/Switcher.swift index 19e861c..6a376f0 100644 --- a/mac/Sources/Byre/Switcher.swift +++ b/mac/Sources/Byre/Switcher.swift @@ -8,12 +8,14 @@ let goKeys: Set<String> = ["k", "p", "l", "s", "/"] @MainActor func runGo(_ key: String, _ store: Store, _ overlays: Overlays) { + overlays.image = nil switch key { case "k": overlays.openSwitcher(store) case "p": store.focusTab(.project) case "l": + overlays.switcher?.close() overlays.leftTab = .lead overlays.leadFocus += 1 case "s": @@ -380,7 +382,7 @@ private struct SwitcherBox: View { .padding(10) .background(Palette.raised, in: RoundedRectangle(cornerRadius: 10)) .overlay(RoundedRectangle(cornerRadius: 10).stroke(Palette.border)) - .shadow(color: .black.opacity(0.18), radius: 14, y: 8) + .shadow(color: overlayShadow, radius: 14, y: 8) .onAppear { DispatchQueue.main.async { logFocus("switcher before focus") @@ -406,7 +408,8 @@ private struct SwitcherRowView: View { case .task(let project, let task, let count): let ts = store.taskState(task) let color = taskPalette[task.color] - StateMark(state: ts, color: color) + StateDot(filled: ts.kind == .working || ts.kind == .merging ? Palette.ok : ts.kind == .answer ? Palette.warn : ts.kind == .error ? Palette.danger : nil) + .help(ts.title) Text(taskTitle(project, task, color)) .lineLimit(1) .truncationMode(.tail) @@ -429,32 +432,43 @@ private struct SwitcherRowView: View { StateDot(filled: nil) Text(p.name) .foregroundStyle(Palette.muted) + .lineLimit(1) + .truncationMode(.tail) .frame(maxWidth: .infinity, alignment: .leading) state("project") case .newTask(let p, let title): Text(created([("New task in ", nil), (p.name, Palette.muted), (": \(title)", nil)])) .foregroundStyle(Palette.accent) + .lineLimit(1) + .truncationMode(.tail) .frame(maxWidth: .infinity, alignment: .leading) state(models[0]) .help("Model for the first message") case .startTask(let p, let title): Text(created([("Start in ", nil), (p.name, Palette.muted), (": \(title)", nil)])) .foregroundStyle(Palette.accent) + .lineLimit(1) + .truncationMode(.tail) .frame(maxWidth: .infinity, alignment: .leading) state(models[0]) .help("The title is the agent's first message") case .startWorkflow(let p, let title): Text(created([("Start workflow… in ", nil), (p.name, Palette.muted), (": \(title)", nil)])) .foregroundStyle(Palette.accent) + .lineLimit(1) + .truncationMode(.tail) .frame(maxWidth: .infinity, alignment: .leading) state("workflow") case .workflow(let p, let title, let w): VStack(alignment: .leading, spacing: 0) { Text(workflowTitle(p, title, w)) + .lineLimit(1) + .truncationMode(.tail) if !w.description.isEmpty { Text(w.description) .foregroundStyle(Palette.muted) - .fixedSize(horizontal: false, vertical: true) + .lineLimit(1) + .truncationMode(.tail) } } .foregroundStyle(Palette.accent) diff --git a/mac/Sources/Byre/Terminal.swift b/mac/Sources/Byre/Terminal.swift index 541af29..513d14e 100644 --- a/mac/Sources/Byre/Terminal.swift +++ b/mac/Sources/Byre/Terminal.swift @@ -78,6 +78,7 @@ struct TerminalHost: NSViewRepresentable { func makeNSView(context: Context) -> TerminalView { let t = TerminalView(frame: NSRect(x: 0, y: 0, width: 800, height: 400)) t.font = Fonts.ct(13) as NSFont + t.changeScrollback(5000) t.terminalDelegate = context.coordinator context.coordinator.view = t DispatchQueue.main.async { diff --git a/mac/Sources/Byre/TranscriptBuilder.swift b/mac/Sources/Byre/TranscriptBuilder.swift index e8d9454..68404a2 100644 --- a/mac/Sources/Byre/TranscriptBuilder.swift +++ b/mac/Sources/Byre/TranscriptBuilder.swift @@ -780,12 +780,3 @@ private func resultText(_ content: (any Sendable)?) -> String { } 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) -} diff --git a/mac/Sources/Byre/Workflow.swift b/mac/Sources/Byre/Workflow.swift index 934d8c8..da5e2b7 100644 --- a/mac/Sources/Byre/Workflow.swift +++ b/mac/Sources/Byre/Workflow.swift @@ -75,9 +75,7 @@ struct WorkflowView: View { let task = store.selectedTaskId.flatMap { store.findTask($0) } VStack(spacing: 0) { if let task, let runId = task.workflowRun { - if !error.isEmpty { - EmptyText(text: error, error: true) - } else if let run, run.run == runId { + if let run, run.run == runId { HStack(alignment: .firstTextBaseline, spacing: 10) { Text(run.name).font(Fonts.font(13, weight: 700)) Text(run.title).foregroundStyle(Palette.muted) @@ -90,22 +88,28 @@ struct WorkflowView: View { VStack(alignment: .leading, spacing: 12) { FlowchartCanvas(layout: layoutFlowchart(stageGraph(run))) .frame(maxWidth: .infinity, alignment: .leading) - if !trimmed(run.brief).isEmpty { - DetailsBox(boxed: false) { - Text("Brief").foregroundStyle(Palette.muted) - } content: { - MarkdownView(blocks: parseMarkdown(run.brief), ctx: MDContext(taskId: task.id)) - .padding(.top, 6) + if !error.isEmpty { + EmptyText(text: error, error: true) + } else { + if !trimmed(run.brief).isEmpty { + DetailsBox(boxed: false) { + Text("Brief").foregroundStyle(Palette.muted) + } content: { + MarkdownView(blocks: parseMarkdown(run.brief), ctx: MDContext(taskId: task.id)) + .padding(.top, 6) + } + } + ForEach(run.stages, id: \.name) { s in + StageCard(run: run, stage: s) } - } - ForEach(run.stages, id: \.name) { s in - StageCard(run: run, stage: s) } } .padding(.horizontal, 16) .padding(.top, 8) .padding(.bottom, 16) } + } else if !error.isEmpty { + EmptyText(text: error, error: true) } else { EmptyText(text: "Loading…") } diff --git a/web/style.css b/web/style.css index d8d52c5..3af0097 100644 --- a/web/style.css +++ b/web/style.css @@ -201,10 +201,12 @@ textarea { align-items: center; gap: 10px; margin-left: auto; + min-width: 0; white-space: nowrap; } .connection { + flex-shrink: 0; width: 8px; height: 8px; border-radius: 50%; @@ -219,6 +221,7 @@ textarea { } .connection-label { + flex-shrink: 0; color: var(--danger); } @@ -227,6 +230,9 @@ textarea { } .me { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; color: var(--muted); }