diff --git a/.claude/skills/bloom-dev-build/SKILL.md b/.claude/skills/bloom-dev-build/SKILL.md index 9222090cc..5de5f7a03 100644 --- a/.claude/skills/bloom-dev-build/SKILL.md +++ b/.claude/skills/bloom-dev-build/SKILL.md @@ -41,9 +41,9 @@ changes merely to try them locally. Fast mode cannot be combined with a revision `xcode-select -p` and `xcrun swift --version` when setup is uncertain. Signing is ad hoc by default; no release certificate or notarisation credentials are needed. Running App Intents through Shortcuts requires a real signing identity. -3. Run the selected command. Only restart the dev app when authorised. `make dev-fast` and - `make dev` both quit and relaunch the dev copy. `--no-launch` still replaces the installed bundle; - an already running copy does not switch to the new code until restarted. +3. Run the selected command. Bloom Dev may be restarted as needed, as described in `CLAUDE.md`. + `make dev-fast` and `make dev` both quit and relaunch the dev copy. `--no-launch` still replaces + the installed bundle; an already running copy does not switch to the new code until restarted. 4. Verify the bundle at the path printed by the script. With `--no-install`, use that build path; otherwise use `~/Applications/Bloom Dev.app`. Check its `Contents/Info.plist` and signature: - `CFBundleIdentifier` is `be.spatie.bloom.dev`. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7f935ff46..41136950c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -107,6 +107,9 @@ jobs: - name: Check the house rules run: ./Tools/house-rules.sh + - name: Check the browser client script + run: node Tools/check-local-web-client.mjs + # And the rules that are the language's rather than ours, which is the other half. # `.swiftlint.yml` is tuned rather than default: the head of that file has the counts every # disabled rule produced and why each one is off. diff --git a/CLAUDE.md b/CLAUDE.md index 5421c981f..ca36ccd57 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -324,16 +324,17 @@ have to, and names the file and line. The owner is sitting at this Mac, working, while you run. Anything that takes focus, moves the pointer, opens or closes a window, launches or quits an application, or puts a window in front of what he is reading is an interruption to a person, not a step in a task. **Ask before doing any of -it.** +it, except for restarting Bloom Dev as described below.** Needs permission every time: launching an app in a way that activates it (`open -g` does not, and is the one to reach for), `NSApp.activate` or anything that makes a process frontmost, quitting or -signalling any application, sending a keystroke or a click or any synthetic event, capturing or -recording the **display**, changing a system setting including the appearance, and opening a -browser or an editor. +signalling an application other than Bloom Dev, sending a keystroke or a click or any synthetic +event, capturing or recording the **display**, changing a system setting including the appearance, +and opening a browser or an editor. Needs no permission: offscreen rendering, anything headless, and capturing **your own** window by -its window id after opening it with `open -g`. +its window id after opening it with `open -g`. Agents may quit and relaunch Bloom Dev as needed for +development. Relaunch it with `open -g` so it does not take focus. If a piece of work cannot be verified without one of these, say so and stop. An honest "I could not photograph this" is a better outcome than an interruption, and it is what several agents have @@ -356,7 +357,7 @@ existing dev data, and restart only the dev copy. Fast mode cannot be combined w For agent verification without installation or launch, use `./Tools/dev-build.sh --fast --no-install`. To install without restarting, use -`./Tools/dev-build.sh --fast --no-launch`. Restarting the dev app still needs authorisation. +`./Tools/dev-build.sh --fast --no-launch`. See `.claude/skills/bloom-dev-build/SKILL.md` for the full workflow. **Bloom Dev has its own identity:** bundle id `be.spatie.bloom.dev`, and with it its own diff --git a/README.md b/README.md index 0f19bee4d..65c252d9b 100644 --- a/README.md +++ b/README.md @@ -40,11 +40,42 @@ account. Agent CLIs use their own authentication and provider connections. Workspaces live under `~/bloom/workspaces.noindex` by default. The `.noindex` suffix keeps Spotlight from indexing every worktree. Bloom also supports multiple chats in one workspace, reusable quick -prompts and subagents that work in the same branch. **Ask Bloom** is a separate conversation area -for questions that do not need a worktree. - -Useful shortcuts include Cmd+N for a new workspace, Cmd+P to search files in the current workspace -and Cmd+T for a new conversation. See [the menu reference](docs/MENUS.md) for the full list. +prompts and subagents that work in the same branch. Conversation workspaces appear below projects +in the sidebar and need no project, Git repository or worktree. + +Useful shortcuts include Cmd+N for a new workspace, Cmd+J for a project-free conversation and +Cmd+P to search files in the current project workspace. See [the menu reference](docs/MENUS.md) +for the full list. + +### Browser access (alpha) + +Browser access is in alpha and may change or be removed at any time. Bloom can show conversations +in a browser while the Mac app is running. The browser can send +prompts, create projects and workspaces, and inspect transcripts and file references. Some Mac +features, including native diff review and terminal panes, are not available there yet. + +On the same Mac, open **Settings > Browser Access**, enable **Allow browser access**, then choose +**Open in Browser**. Bloom opens a one-use link, so you do not need to type an access code. Bloom Dev +also accepts direct connections to `http://127.0.0.1:53544` on the host Mac without a code. + +To open Bloom from a phone or another computer through Tailscale: + +1. Install [Tailscale](https://tailscale.com/docs/install) on both devices and sign in to the same + tailnet. Keep Bloom running on the Mac. +2. In Bloom's Browser Access settings, enable access and expand **Manual connection**. Note the + port in the address and use **Copy Code** for the access code. +3. In Terminal on the Mac, run `tailscale serve --bg PORT`, replacing `PORT` with the port from + Bloom. For example, Bloom Dev uses `tailscale serve --bg 53544`. +4. Tailscale prints a private HTTPS address such as `https://your-mac.your-tailnet.ts.net`. Open + it on the other device and enter Bloom's access code. [Tailscale Serve](https://tailscale.com/docs/features/tailscale-serve) + keeps this address within your tailnet. + +The hostname names your Mac in Tailscale, not a separate Bloom account. You can [rename the Mac in +Tailscale](https://tailscale.com/docs/concepts/machine-names) if you want a shorter hostname. The +regular Bloom app chooses a new browser port and access code when it restarts. If the port changes, +run `tailscale serve --bg PORT` again with the new port and use the new access code. Bloom Dev keeps +port 53544, but its remote connections still require the code. To stop remote access, run +`tailscale serve off` on the Mac or turn off browser access in Bloom. ### Repository settings diff --git a/RELEASING.md b/RELEASING.md index 84bb5f254..7724fe699 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -73,9 +73,9 @@ you ticked the box on. Either one puts the item on Sparkle's `beta` channel, which nobody sees unless the app asks for that channel. The version in the bundle comes from the tag, not from `Resources/Info.plist`. -The build number is 2132 plus the number of commits reachable from the tag. The -offset preserves update order after the repository history was reset. Sparkle -compares that number, which is why the workflow checks out with full history. +The build number is the number of commits reachable from the tag, including the +restored release history. Sparkle compares that number, which is why the workflow +checks out with full history. To retry a release, prefer rerunning its original release-event workflow with `gh run rerun `. This preserves the GitHub prerelease flag and asset attachment step. diff --git a/Sources/Bloom/BloomApp.swift b/Sources/Bloom/BloomApp.swift index 3a062179a..e536a264d 100644 --- a/Sources/Bloom/BloomApp.swift +++ b/Sources/Bloom/BloomApp.swift @@ -83,6 +83,7 @@ struct BloomApp: App { // resize. It is how somebody finally watches the transcript go blank. See `ComposerProbe`. if ComposerProbe.isRequested { ComposerProbe.schedule() } if TranscriptLayoutProbe.isRequested { TranscriptLayoutProbe.schedule() } + if TranscriptAppendProbe.isRequested { TranscriptAppendProbe.schedule() } if CommentFocusProbe.isRequested { CommentFocusProbe.schedule() } if MessageArrivalProbe.isRequested { MessageArrivalProbe.schedule() } if ArchiveFailureProbe.isRequested { ArchiveFailureProbe.schedule() } diff --git a/Sources/Bloom/Design/ComposerPickerGallery.swift b/Sources/Bloom/Design/ComposerPickerGallery.swift index ea6a1a1a0..55ab28ca6 100644 --- a/Sources/Bloom/Design/ComposerPickerGallery.swift +++ b/Sources/Bloom/Design/ComposerPickerGallery.swift @@ -66,7 +66,7 @@ struct ComposerPickerGallery: View { VStack(alignment: .leading, spacing: Metrics.pane) { panel( - "Ask Bloom, where the footnote is the one thing left in it", + "A conversation, where the footnote is the one thing left in it", options: permissionOptions(on: .claudeCode), selection: PermissionMode.auto.rawValue, heading: "Permission mode", diff --git a/Sources/Bloom/Design/Confirmation.swift b/Sources/Bloom/Design/Confirmation.swift index ac8f5c6b8..ced5c68d5 100644 --- a/Sources/Bloom/Design/Confirmation.swift +++ b/Sources/Bloom/Design/Confirmation.swift @@ -12,6 +12,8 @@ struct ConfirmationSheet: View { let confirmation: Confirmation let onConfirm: () -> Void let onCancel: () -> Void + /// Shown only when the question has a third answer, such as trying a failed step again. + var onAlternative: (() -> Void)? /// Which button the keyboard is on. It starts on the safe one, and `Keys` says why that is /// not a detail. @@ -61,6 +63,7 @@ struct ConfirmationSheet: View { case .standard: VStack(spacing: Layout.betweenButtons) { confirmButton + alternativeButton cancelButton } .padding(.horizontal, Layout.buttonInset) @@ -102,6 +105,17 @@ struct ConfirmationSheet: View { .focused($focus, equals: .confirm) } + @ViewBuilder + private var alternativeButton: some View { + if let label = confirmation.alternativeLabel, let onAlternative { + Button { onAlternative() } label: { + Text(label).frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .controlSize(.large) + } + } + /// The answer that changes nothing, and the one Escape is wired to. private var cancelButton: some View { Button { onCancel() } label: { @@ -249,6 +263,8 @@ struct Confirmation: Equatable, Sendable { var cancelLabel: String var tone: ConfirmationTone = .destructive var layout: ConfirmationLayout = .standard + /// A third answer between confirming and cancelling. Drawn by the standard layout only. + var alternativeLabel: String? } enum ConfirmationLayout: Equatable, Sendable { @@ -301,6 +317,7 @@ extension View { _ item: Binding, _ question: @escaping (Item) -> Confirmation, onConfirm: @escaping (Item) -> Void, + onAlternative: ((Item) -> Void)? = nil, onCancel: @escaping () -> Void = {} ) -> some View { sheet(isPresented: item.isPresent()) { @@ -314,6 +331,12 @@ extension View { onCancel: { item.wrappedValue = nil onCancel() + }, + onAlternative: onAlternative.map { alternative in + { + item.wrappedValue = nil + alternative(value) + } } ) } diff --git a/Sources/Bloom/Design/JumpProbe.swift b/Sources/Bloom/Design/JumpProbe.swift index c0c236da7..3e314afe8 100644 --- a/Sources/Bloom/Design/JumpProbe.swift +++ b/Sources/Bloom/Design/JumpProbe.swift @@ -8,14 +8,9 @@ import BloomCore /// /// The sixth of the family, and the only one that is not about frame times. The others ask what a /// gesture COST; this one asks whether a gesture DID WHAT IT SAID, because the bug it was written -/// for was not slow, it was wrong. The jump pill scrolled one frame, that frame posted -/// `boundsDidChange`, the coordinator read its own write as the reader taking hold, and the glide -/// stopped itself at nothing. With Reduce Motion on it worked, because that path jumps rather than -/// glides, which is exactly the kind of fault that survives a hand test: whoever checks it once, -/// in the wrong accessibility setting, comes away satisfied. -/// -/// `TranscriptModel.submit` bumps the same counter, so "the chat does not scroll down when I say -/// something" was the same bug wearing different clothes. Both are covered here. +/// for was not slow, it was wrong. The jump pill once scrolled one frame, that frame posted +/// `boundsDidChange`, and the glide stopped short. The pill now places the table at its end +/// immediately, and this probe checks that the placement remains correct as heights change. /// /// Bloom --jump-probe /tmp/jump.json --jump-workspace /// [--jump-settle 2500] [--window-size 1440x900] @@ -42,10 +37,8 @@ enum JumpProbe { /// How long a jump is given before it is judged, in milliseconds. /// - /// Generous on purpose. The glide is a display link over a fixed duration, and the standing - /// instruction it hands over to is re-asserted on the next run loop turn and again on every - /// height correction, so the honest question is where the view came to rest rather than where - /// it was a frame after the request. + /// Generous on purpose. The standing instruction is re-asserted on the next run loop turn and + /// again on every height correction, so this checks that the instant placement stays put. private static var settle: Duration { .milliseconds(ProbeHarness.count("--jump-settle", or: 2_500)) } diff --git a/Sources/Bloom/Design/MenuProbe.swift b/Sources/Bloom/Design/MenuProbe.swift index f5ce67473..d3f99c150 100644 --- a/Sources/Bloom/Design/MenuProbe.swift +++ b/Sources/Bloom/Design/MenuProbe.swift @@ -264,6 +264,7 @@ enum MenuProbe { NSHostingMenu(rootView: SidebarFilterMenuItems( filter: .constant(.all), showsHiddenProjects: .constant(true), + showsEmptyProjects: .constant(true), hiddenCount: 2, grouping: .constant(SidebarGrouping.fallback.rawValue) )) diff --git a/Sources/Bloom/Design/MessageArrivalEffect.swift b/Sources/Bloom/Design/MessageArrivalEffect.swift index 37d62e220..882e2e9b9 100644 --- a/Sources/Bloom/Design/MessageArrivalEffect.swift +++ b/Sources/Bloom/Design/MessageArrivalEffect.swift @@ -7,34 +7,49 @@ extension View { } } -/// Time belongs to the message, not the hosting cell. This survives the instant-to-saved handoff -/// and ignores the table's animation-free layout transaction. Only these drawing attributes tick; -/// the supplied content is not rebuilt or re-parsed by the timeline closure. +/// Fades a reply in as it starts to stream. Time belongs to the message, not the hosting cell, so +/// a new view for an arrival already under way picks up where it is and survives the handoff from +/// the streaming slot to the stored row. +/// +/// One structure and one explicit animation. This was an `if let` around a +/// `TimelineView(.animation)`, which could rebuild the whole reply on its second token while its +/// markdown `VStack` was growing, and crashed inside SwiftUI's stack layout. Explicit, as in +/// `ArrivingRow`, because the cell root clears implicit animation. private struct MessageArrivalEffect: ViewModifier { let arrival: MessageArrival? @Environment(\.accessibilityReduceMotion) private var reduceMotion - @State private var finishedArrival: MessageArrival? + + /// Taken once, when this view is made. Reduce Motion is applied in `settle`, because the + /// environment cannot be read here. + @State private var opacity: Double + + init(arrival: MessageArrival?) { + self.arrival = arrival + _opacity = State(initialValue: Self.opacity(of: arrival, at: Date())) + } func body(content: Content) -> some View { - // Sent bubbles enter through the composer's occlusion as the transcript scrolls. - // A second fade, scale and offset on the row made that one movement look disconnected. - if let arrival, arrival.style == .reply { - let finished = finishedArrival == arrival - TimelineView(.animation(paused: finished || reduceMotion)) { context in - let pose = finished ? .settled : arrival.pose(at: context.date, reduceMotion: reduceMotion) - content - .opacity(pose.opacity) - .scaleEffect(pose.scale, anchor: .bottomTrailing) - .offset(y: pose.rise) - } - .task(id: arrival) { - let remaining = arrival.remaining(at: Date()) - if remaining > 0 { try? await Task.sleep(for: .seconds(remaining)) } - guard !Task.isCancelled else { return } - finishedArrival = arrival - } - } else { - content + content + .opacity(opacity) + .onChange(of: arrival, initial: true) { _, arrival in settle(arrival) } + } + + private func settle(_ arrival: MessageArrival?) { + // Text the reader can already see is not faded again. + guard opacity < 1 else { return } + let remaining = arrival?.remaining(at: Date()) ?? 0 + guard arrival?.style == .reply, !reduceMotion, remaining > 0 else { + opacity = 1 + return } + // The cubic ease out `MessageArrival.pose` describes. + withAnimation(.timingCurve(0.33, 1, 0.68, 1, duration: remaining)) { opacity = 1 } + } + + /// Only a reply fades. Sent bubbles already move in behind the composer, and a second fade + /// made that look disconnected. + private static func opacity(of arrival: MessageArrival?, at date: Date) -> Double { + guard let arrival, arrival.style == .reply else { return 1 } + return arrival.pose(at: date, reduceMotion: false).opacity } } diff --git a/Sources/Bloom/Design/SidebarIndentGallery.swift b/Sources/Bloom/Design/SidebarIndentGallery.swift index e71e556de..84d32c54b 100644 --- a/Sources/Bloom/Design/SidebarIndentGallery.swift +++ b/Sources/Bloom/Design/SidebarIndentGallery.swift @@ -117,6 +117,7 @@ struct SidebarIndentGallery: View { private func header(count: Int) -> some View { RepoHeaderRow( repo: Self.repo, + isFirstProject: true, hasUnreadWork: count > 0, workspaceCount: count, onCreateWorkspace: { _ in } diff --git a/Sources/Bloom/Design/SwitchProbe.swift b/Sources/Bloom/Design/SwitchProbe.swift index 8488677f4..2f99212ed 100644 --- a/Sources/Bloom/Design/SwitchProbe.swift +++ b/Sources/Bloom/Design/SwitchProbe.swift @@ -81,6 +81,7 @@ enum SwitchProbe { let ticker = Ticker(view: contentView) ticker.start() SwitchTrace.isEnabled = true + harness.markStarted() var runs: [JSONValue] = [] @@ -117,6 +118,19 @@ enum SwitchProbe { } } + // A focused performance run ends here. The position, rapid-switch and background-update + // checks below answer separate correctness questions and extend a timed run by minutes. + if ProbeHarness.isPresent("--switch-timing-only") { + SwitchTrace.isEnabled = false + ticker.stop() + harness.write(.object([ + "driver": .string(driver), + "order": .strings(order.map(\.rawValue)), + "runs": .array(runs), + ].merging(harness.conditions(window: window)) { mine, _ in mine })) + exit(0) + } + // Whether a reader who was NOT at the live end is put back where they were, which is the // half of `TranscriptResume` the runs above cannot reach: a probe that only ever sits at // the end measures the flag, never the anchor. @@ -154,6 +168,7 @@ enum SwitchProbe { return [:] } ticker.beginRun() + let cpuBefore = ProbeHarness.mainThreadCPUSeconds() // Which pane's layout the freeze is in. `FrameProbe` uses the same counters for the same // reason: "the window stood still for 300ms" is not an answer until it says which of the // two columns stood still. @@ -203,6 +218,7 @@ enum SwitchProbe { "panePasses": .map(PaneLayoutTiming.timeline()), "transcriptHold": .map(TranscriptHoldCensus.summary()), "worstFrameMs": .number(ticker.intervalsMs.max() ?? 0), + "mainThreadCpuMs": .number((ProbeHarness.mainThreadCPUSeconds() - cpuBefore) * 1_000), ] } diff --git a/Sources/Bloom/Design/TranscriptAppendProbe.swift b/Sources/Bloom/Design/TranscriptAppendProbe.swift new file mode 100644 index 000000000..df7e98844 --- /dev/null +++ b/Sources/Bloom/Design/TranscriptAppendProbe.swift @@ -0,0 +1,85 @@ +import AppKit +import BloomCore +import QuartzCore +import SwiftUI + +/// Profiles the actual transcript view while stored rows arrive into an already long session. +/// The window and messages exist only in memory, so the probe has no workspace or display to touch. +@MainActor +enum TranscriptAppendProbe { + private static let harness = ProbeHarness(subject: "transcript-append") + static var isRequested: Bool { harness.isRequested } + + static func schedule() { Task { @MainActor in await run() } } + + private static func run() async { + let app = AppModel() + let workspace = Workspace( + repoID: RepoID(UUID().uuidString), name: "Append probe", branch: "probe", + path: "/nonexistent-bloom-append-probe", baseBranch: "main" + ) + let session = Session(workspaceID: workspace.id, title: "Append probe", lastReadSeq: Int.max) + let transcript = TranscriptModel(session: session, workspace: workspace, app: app) + let initial = ProbeHarness.count("--append-rows", or: 5_000) + let arrivals = ProbeHarness.count("--append-count", or: 200) + let payload = Data("{}".utf8) + func message(_ seq: Int) -> Message { + Message( + id: Int64(seq + 1), sessionID: session.id, seq: seq, + kind: seq.isMultiple(of: 20) ? .result : .assistantText, payload: payload + ) + } + transcript.seedRowsForProbe((0.. 0 else { + harness.fail("transcript table did not render") + } + let startingTableRows = table.numberOfRows + + harness.markStarted() + let started = CACurrentMediaTime() + let cpuStarted = ProbeHarness.mainThreadCPUSeconds() + for offset in 0.. TranscriptTable { TranscriptTable( entries: entries(phase: phase), session: SessionID("send-probe"), controller: controller, + heightMemory: nil, scale: 1, rowEnvironment: environment, onGeometryChange: { _ in }, onSettled: {}, onLiveScrollChange: { follower.isPaused = $0 }, onContentWillChange: { follower.nudge() } ) diff --git a/Sources/Bloom/Design/WelcomeLayoutProbe.swift b/Sources/Bloom/Design/WelcomeLayoutProbe.swift index 33a0bde91..a08dc3703 100644 --- a/Sources/Bloom/Design/WelcomeLayoutProbe.swift +++ b/Sources/Bloom/Design/WelcomeLayoutProbe.swift @@ -40,7 +40,7 @@ enum WelcomeLayoutProbe { window.isReleasedWhenClosed = false window.titleVisibility = .hidden window.titlebarAppearsTransparent = true - window.contentViewController = host + window.contentViewController = WelcomeContentController(host: host) await settle(window) let greetingHeight = window.frame.height diff --git a/Sources/Bloom/State/AppModel+Archive.swift b/Sources/Bloom/State/AppModel+Archive.swift index d09d4abcc..f6a816f44 100644 --- a/Sources/Bloom/State/AppModel+Archive.swift +++ b/Sources/Bloom/State/AppModel+Archive.swift @@ -1,3 +1,4 @@ +import Foundation import BloomCore /// Archiving a workspace, undoing that, reading what has been archived, and bringing one back. @@ -15,6 +16,21 @@ import BloomCore /// `forgetWorkspace` live in `AppModel.swift`, next to the properties they are the only writer /// of, and they are what this file reaches for instead of writing those lists itself. +/// An archive that stopped on its script, with everything needed to run it again without the +/// script. Carries the arguments rather than reading them back out of the model, for the reason +/// `confirmArchive` gives: a dismissal clears whatever the model was holding. +struct ScriptFailureRequest: Identifiable, Equatable { + let id = UUID() + let workspace: Workspace + let deleteBranch: Bool? + let force: Bool + let report: WorkspaceSafetyReport? + let hazards: ArchiveHazards + let failure: ArchiveScriptFailure + + static func == (lhs: Self, rhs: Self) -> Bool { lhs.id == rhs.id } +} + extension AppModel { /// Every archived workspace with what it still holds, and whether its branch is still here. @@ -339,6 +355,57 @@ extension AppModel { ) } + /// Offers the way out of a failed archive script, or says so in an alert where nobody is + /// sitting in front of a dialog (an archive an agent asked for). + private func reportScriptFailure( + _ error: WorkspaceError, + workspace: Workspace, + deleteBranch: Bool?, + force: Bool, + report: WorkspaceSafetyReport?, + hazards: ArchiveHazards, + allowsConfirmation: Bool + ) -> WorkspaceArchiveOutcome { + guard let failure = ArchiveScriptFailure(error, workspaceName: workspace.name) else { + return .refused(error.readableMessage) + } + if allowsConfirmation { + pendingScriptFailure = ScriptFailureRequest( + workspace: workspace, deleteBranch: deleteBranch, force: force, + report: report, hazards: hazards, failure: failure + ) + } else { + alert = BloomAlert(title: failure.title, message: failure.message) + } + return .refused("The archive script failed. Its worktree and branch were kept. Check Bloom's dialog for the script output.") + } + + /// The owner has read the script's failure and archived without it. Takes the request as an + /// argument for the reason `confirmArchive` does. + func archiveAnyway(_ request: ScriptFailureRequest) async { + await rerunArchive(request, skipArchiveScript: true) + } + + /// Runs the script again, for a failure that was the machine's rather than the script's, such + /// as Docker not running yet. A second failure raises the same dialog again. + func retryArchive(_ request: ScriptFailureRequest) async { + await rerunArchive(request, skipArchiveScript: false) + } + + private func rerunArchive(_ request: ScriptFailureRequest, skipArchiveScript: Bool) async { + guard let repo = repo(for: request.workspace) else { return } + await performArchive( + request.workspace, + repo: repo, + deleteBranch: request.deleteBranch, + force: request.force, + report: request.report, + hazards: request.hazards, + skipArchiveScript: skipArchiveScript, + presentConfirmation: nil + ) + } + func checkArchive(_ request: ArchiveRequest) async -> ArchiveRequest { var checked = request guard let manager, let repo = repo(for: request.workspace) else { @@ -384,6 +451,7 @@ extension AppModel { force: Bool, report: WorkspaceSafetyReport?, hazards: ArchiveHazards, + skipArchiveScript: Bool = false, allowsConfirmation: Bool = true, presentConfirmation: ((ArchiveRequest) -> Void)? ) async -> WorkspaceArchiveOutcome { @@ -446,7 +514,8 @@ extension AppModel { repo: repo, deleteBranch: deleteBranch, force: force, - isPullRequestMerged: hazards.isPullRequestMerged + isPullRequestMerged: hazards.isPullRequestMerged, + skipArchiveScript: skipArchiveScript ) // Asked again, and the same question, for a window that arrived back on this // workspace while git worked. Nothing in the app does that today, because the row is @@ -488,25 +557,13 @@ extension AppModel { } catch let error as WorkspaceError { await undoOptimisticArchive(workspace) switch error { - case .archiveScriptFailed(let status, let output): + case .archiveScriptFailed, .archiveScriptIncomplete: Log.archive.error( - "the archive script for \(workspace.name, privacy: .public) exited \(status), so nothing was removed" + "the archive script for \(workspace.name, privacy: .public) did not succeed, so nothing was removed" ) - // Worth its own wording: the manager stops before removing anything, so the user - // needs to hear that the worktree is still there rather than fear the worst. It - // does not claim the workspace is untouched, because the agent went first: see - // the teardown above. - // - // Titled without the workspace name. Names here are whole sentences, and a title - // built from one wraps to three lines of bold text that reads as the warning - // itself. The name goes in the message, which has room for it. - alert = BloomAlert( - title: "The archive script failed", - message: "\u{201C}\(workspace.name)\u{201D} is still here: its worktree and " - + "its branch are untouched. Any agent it was running has been stopped.\n\n" - + "The script exited with status \(status).\n\n" - // The tail is where a script says why it gave up. - + String(output.trimmingCharacters(in: .whitespacesAndNewlines).suffix(1_000)) + return reportScriptFailure( + error, workspace: workspace, deleteBranch: deleteBranch, force: force, + report: report, hazards: hazards, allowsConfirmation: allowsConfirmation ) case .unsafeToArchive(let fresh): Log.archive.notice( @@ -533,7 +590,6 @@ extension AppModel { ) return .refused(await reportArchiveFailure(error, workspace: workspace)) } - return .refused("The archive script failed. Its worktree and branch were kept. Check Bloom's alert for the script output.") } catch { await undoOptimisticArchive(workspace) Log.archive.error( @@ -678,26 +734,48 @@ extension AppModel { /// a worktree from a menu item, with no safety report in front of it, which is the one thing /// this app is careful never to do. func restore(_ workspace: Workspace) async { - guard let manager else { return } - guard let repo = repo(for: workspace) else { - alert = BloomAlert( - title: "Could not bring \(workspace.name) back", - message: "Its project is no longer in Bloom, so there is no repository to cut a " - + "worktree from. Add the project again and try once more." - ) - return + switch await restoreWorkspaceForBridge(workspace) { + case .restored: + selection = .workspace(workspace.id) + case .refused(let reason): + alert = BloomAlert(title: "Could not bring \(workspace.name) back", message: reason) } - guard !restoring.contains(workspace.id) else { return } + } + /// The bridge and Restore button share the rebuild. An MCP call must not move the window + /// away from what the owner is reading, so only the button's caller selects the result. + func restoreWorkspaceForBridge(_ workspace: Workspace) async -> WorkspaceRestoreToolOutcome { + guard let manager, let store else { + return .refused("Bloom is still starting up. Try again in a moment.") + } + guard !restoring.contains(workspace.id) else { + return .refused("This workspace is already being restored. Check workspace_list shortly.") + } restoring.insert(workspace.id) defer { restoring.remove(workspace.id) } + let current: Workspace + do { + guard let found = try await store.workspace(id: workspace.id) else { + return .refused("This workspace is no longer in Bloom. Refresh workspace_list.") + } + current = found + } catch { + return .refused("Bloom could not read this workspace. Try again shortly.") + } + guard current.state == .archived else { + return .refused("This workspace is active already. Refresh workspace_list.") + } + guard let repo = repo(for: current) else { + return .refused("Its project is no longer in Bloom, so there is no repository to cut a " + + "worktree from. Add the project again and try once more.") + } + do { - let outcome = try await manager.restore(workspace: workspace, repo: repo) + let outcome = try await manager.restore(workspace: current, repo: repo) // The other half of the pair: this workspace has left the archived list. invalidateArchived() await reload() - selection = .workspace(outcome.workspace.id) Log.archive.info("restored \(workspace.name, privacy: .public)") if let from = outcome.relocatedFrom { @@ -711,6 +789,7 @@ extension AppModel { dismissal: .untilDismissed ) } + return .restored(path: outcome.workspace.path, relocatedFrom: outcome.relocatedFrom) } catch { Log.archive.error( "could not restore \(workspace.name, privacy: .public): \(error.readableMessage, privacy: .public)" @@ -726,10 +805,7 @@ extension AppModel { project: repo.name, projectPath: repo.path ) - alert = BloomAlert( - title: "Could not bring \(workspace.name) back", - message: trouble.sentence - ) + return .refused(trouble.sentence) } } } diff --git a/Sources/Bloom/State/AppModel+BrowserLocation.swift b/Sources/Bloom/State/AppModel+BrowserLocation.swift index a88601793..9c420d213 100644 --- a/Sources/Bloom/State/AppModel+BrowserLocation.swift +++ b/Sources/Bloom/State/AppModel+BrowserLocation.swift @@ -5,13 +5,12 @@ import BloomCore /// existing Add to Chat meaning everywhere else. extension AppModel { var browserLocationTarget: String? { - guard !isAskInFront, let workspace = selectedModel else { return nil } + guard let workspace = selectedModel else { return nil } return visibleBrowserTarget(in: workspace) } /// The browser in the selected tab. var browserReloadTarget: String? { - guard !isAskInFront else { return nil } guard let workspace = selectedModel else { return nil } return visibleBrowserTarget(in: workspace) } diff --git a/Sources/Bloom/State/AppModel+LastModelChoice.swift b/Sources/Bloom/State/AppModel+LastModelChoice.swift new file mode 100644 index 000000000..1221a2751 --- /dev/null +++ b/Sources/Bloom/State/AppModel+LastModelChoice.swift @@ -0,0 +1,15 @@ +import BloomCore +import Foundation + +/// Remembering the model somebody picked, so the next workspace opens on it. See `LastModelChoice` +/// for where it ranks against Settings and a default preset. +extension AppModel { + /// Called by every footer when its controls change. Only a move of the model, the effort or + /// the backend is written: the footer also reports fast mode, the output style and the + /// permission mode, and none of those is a model choice. + func rememberModelChoice(from old: ComposerControls, to new: ComposerControls) { + let choice = LastModelChoice(new) + guard choice != LastModelChoice(old), let store else { return } + Task { try? await choice.save(to: store) } + } +} diff --git a/Sources/Bloom/State/AppModel+LocalWeb.swift b/Sources/Bloom/State/AppModel+LocalWeb.swift new file mode 100644 index 000000000..30d118796 --- /dev/null +++ b/Sources/Bloom/State/AppModel+LocalWeb.swift @@ -0,0 +1,606 @@ +import AppKit +import Foundation +import BloomCore + +/// Browser access to this running Bloom instance. The listener owns no runner or database; every +/// prompt uses the same transcript model as the Mac window, and all reads use its Store actor. +extension AppModel { + func setLocalWebEnabled(_ enabled: Bool) { + guard localWebEnabled != enabled else { return } + localWebEnabled = enabled + UserDefaults.standard.set(enabled, forKey: "localWeb.enabled") + if enabled { + startLocalWebIfEnabled() + } else { + let unsent = localWebUploads.values.flatMap { $0 } + localWebUploads.removeAll() + Task.detached(priority: .utility) { + for upload in unsent { AttachmentFiles.discard(upload.attachment, workspace: upload.root) } + } + localWebServer?.stop() + localWebServer = nil + localWebAddress = nil + localWebToken = nil + localWebConnectTicket = nil + localWebError = nil + } + } + + func startLocalWebIfEnabled() { + guard isLoaded, localWebEnabled, localWebServer == nil, store != nil else { return } + localWebError = nil + localWebConnectTicket = nil + let token = BridgeRegistry.randomToken() + do { + let server = try LocalWebServer( + port: LocalWebDevelopment.enabled ? LocalWebDevelopment.port : nil, + handler: { [weak self] request in + guard let self else { return .text(503, "Bloom is closing") } + return await self.localWebResponse(to: request, token: token) + }, + onReady: { [weak self] port in + Task { @MainActor [weak self] in + guard let self, self.localWebToken == token else { return } + self.localWebAddress = "http://127.0.0.1:\(port)" + } + }, + onFailure: { [weak self] message in + Task { @MainActor [weak self] in + guard let self, self.localWebToken == token else { return } + self.localWebError = message + self.localWebAddress = nil + } + } + ) + localWebToken = token + localWebServer = server + server.start() + } catch { + localWebError = error.localizedDescription + } + } + + /// A short-lived link for a browser opened on this Mac. The URL carries only a one-use ticket; + /// the long-lived access code is returned to that page after the ticket is consumed. + func localWebBrowserURL() -> URL? { + guard localWebEnabled, let address = localWebAddress, + var components = URLComponents(string: address) else { return nil } + let ticket = BridgeRegistry.randomToken() + localWebConnectTicket = (ticket, Date().addingTimeInterval(60)) + components.fragment = "connect=\(ticket)" + return components.url + } + + func openLocalWebInBrowser() { + guard let url = localWebBrowserURL() else { return } + NSWorkspace.shared.open(url) + } + + private func localWebResponse(to request: LocalWebServer.Request, token: String) async -> LocalWebServer.Response { + guard localWebEnabled, localWebToken == token else { return .text(503, "Browser access is off") } + if request.method == "GET", request.path == "/" { + let nonce = BridgeRegistry.randomToken() + return .init( + contentType: "text/html; charset=utf-8", + body: Data(LocalWebPage.html(nonce: nonce, devBrowser: LocalWebDevelopment.enabled).utf8), + contentSecurityPolicy: "default-src 'none'; script-src 'nonce-\(nonce)'; " + + "connect-src 'self'; style-src 'unsafe-inline'; img-src 'self' data:; " + + "base-uri 'none'; form-action 'self'" + ) + } + if request.method == "GET", request.path == "/assets/bloom-mark.svg" { + guard let url = Bundle.main.url(forResource: "BloomMark", withExtension: "svg"), + let data = try? Data(contentsOf: url) else { return .text(404, "Bloom mark unavailable") } + return .init(contentType: "image/svg+xml", body: data) + } + if request.method == "POST", request.path == "/api/dev-connect" { + guard LocalWebDevelopment.enabled, LocalWebDevelopment.accepts(request) else { + return .text(404, "Not found") + } + return json(WebConnectResponse(token: token)) + } + if request.method == "POST", request.path == "/api/connect" { + guard let input: WebConnectRequest = decode(request), + let pending = localWebConnectTicket, + pending.value == input.ticket, pending.expiresAt > Date() else { + return .text(401, "This browser link has expired. Open it again from Bloom Settings") + } + localWebConnectTicket = nil + return json(WebConnectResponse(token: token)) + } + guard request.headers["authorization"] == "Bearer \(token)" else { + return .text(401, "Enter the access code from Bloom Settings") + } + guard let store else { return .text(503, "Bloom is loading") } + let parts = request.path.split(separator: "/").map(String.init) + do { + if request.method == "GET", parts == ["api", "projects"] { + return json(try await store.repos().filter { !$0.hidden }.map { + WebProject(id: $0.id, name: $0.name, path: $0.path, + branch: $0.defaultBranch, accent: $0.accent) + }) + } + if request.method == "POST", parts == ["api", "projects"] { + guard let input: WebProjectRequest = decode(request) else { return .text(400, "Enter a project path") } + if let name = input.name, let location = input.location { + let facts = NewProjectStarter.inspect(name: name, location: location) + switch ProjectTargetVerdict.of(facts) { + case .create, .adopt: break + case .add: return .text(400, "This is already a repository. Use Add existing project.") + case .track: return .text(400, "This folder has files. Use the Mac app's Start Tracking flow to review its first commit.") + case .refuse: return .text(400, "Bloom cannot create a project at this location.") + } + do { + let created = try await NewProjectStarter.create(at: facts.path) + let repo = try await WorkspaceManager(store: store).addRepository(at: created.path) + return json(WebProject(id: repo.id, name: repo.name, path: repo.path, + branch: repo.defaultBranch, accent: repo.accent)) + } catch { + return .text(400, error.localizedDescription) + } + } + return await webTool("project_add", arguments: ["path": .string(input.path ?? "")], store: store) + } + if request.method == "POST", parts == ["api", "workspaces"] { + guard let input: WebWorkspaceRequest = decode(request), !input.prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return .text(400, "Enter a task for the new workspace") + } + var arguments: [String: JSONValue] = ["project": .string(input.project), "prompt": .string(input.prompt)] + if let name = input.name, !name.isEmpty { arguments["name"] = .string(name) } + if let branch = input.baseBranch, !branch.isEmpty { arguments["base_branch"] = .string(branch) } + return await webTool("workspace_start", arguments: arguments, store: store) + } + if request.method == "GET", parts == ["api", "conversations"] { + return json(try await store.sessionsWithoutWorkspace().map { + WebChat(id: $0.id, title: ChatListTool.title(of: $0), agent: $0.agentKind.rawValue, + model: $0.model, effort: $0.effort, state: $0.state.rawValue) + }) + } + if request.method == "POST", parts == ["api", "conversations"] { + await ask.newConversation() + guard let session = ask.session else { return .text(503, "Bloom could not create the conversation") } + return json(WebChat(id: session.id, title: ChatListTool.title(of: session), + agent: session.agentKind.rawValue, model: session.model, + effort: session.effort, state: session.state.rawValue)) + } + if request.method == "POST", parts.count == 4, parts[0] == "api", + parts[1] == "workspaces", parts[3] == "chats" { + guard let input: WebChatRequest = decode(request) else { return .text(400, "Invalid chat request") } + var arguments: [String: JSONValue] = ["workspace": .string(parts[2]), "terminal_chat": .bool(false)] + if let title = input.title, !title.isEmpty { arguments["title"] = .string(title) } + return await webTool("chat_create", arguments: arguments, store: store) + } + if request.method == "GET", parts.count == 4, parts[0] == "api", + parts[1] == "workspaces", parts[3] == "changes" { + guard let workspace = try await store.workspace(id: WorkspaceID(parts[2])), workspace.state == .active else { + return .text(404, "Workspace not found") + } + let files = try await Git.changedFiles(worktree: workspace.path, base: workspace.baseBranch) + return json(files.map { + WebChangedFile(path: $0.path, change: $0.change.rawValue, + additions: $0.additions, deletions: $0.deletions) + }) + } + if request.method == "GET", parts.count == 4, parts[0] == "api", + parts[1] == "workspaces", parts[3] == "files" { + guard let workspace = try await store.workspace(id: WorkspaceID(parts[2])), workspace.state == .active else { + return .text(404, "Workspace not found") + } + return webFileResponse(target: request.target, root: workspace.path) + } + if request.method == "GET", parts.count == 4, parts[0] == "api", + parts[1] == "chats", parts[3] == "files" { + guard let session = try await store.session(id: SessionID(parts[2])), session.archivedAt == nil else { + return .text(404, "Chat not found") + } + let root: String + if let workspaceID = session.workspaceID, + let workspace = try await store.workspace(id: workspaceID), workspace.state == .active { + root = workspace.path + } else if session.workspaceID == nil { + let saved = try await store.setting(AskTabs.directoryKey(session.id)) + guard let directory = AskTabs.prepareDirectory(saved ?? "", databasePath: store.path) else { + return .text(404, "Conversation folder not found") + } + root = directory + } else { return .text(404, "Chat not found") } + return webFileResponse(target: request.target, root: root) + } + if request.method == "POST", parts.count == 4, parts[0] == "api", + parts[1] == "chats", parts[3] == "attachments" { + let sessionID = SessionID(parts[2]) + guard let session = try await store.session(id: sessionID), session.archivedAt == nil, + request.headers["content-type"]?.hasPrefix("application/octet-stream") == true, + let named = request.headers["x-bloom-filename"]?.removingPercentEncoding, + !named.isEmpty, named.utf8.count <= 512, + !named.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains), + !request.body.isEmpty, request.body.count <= 25 * 1_024 * 1_024 else { + return .text(400, "Choose a file up to 25 MB") + } + let root: String + if let workspaceID = session.workspaceID, + let workspace = try await store.workspace(id: workspaceID), workspace.state == .active { + root = workspace.path + } else if session.workspaceID == nil { + await ask.open() + await ask.select(sessionID) + guard let directory = ask.paneState(for: sessionID)?.directory else { + return .text(404, "Conversation folder not found") + } + root = directory + } else { return .text(404, "Chat not found") } + let filename = PromptAttachments.safeFilename(named) + let bytes = request.body + do { + let attachment = try await Task.detached(priority: .userInitiated) { + let directory = FileManager.default.temporaryDirectory + .appending(path: "bloom-web-upload-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let source = directory.appending(path: filename) + try bytes.write(to: source, options: .atomic) + return try AttachmentFiles.attach(.file(source), workspace: root) + }.value + localWebUploads[sessionID, default: []].append( + LocalWebUpload(attachment: attachment, root: root) + ) + return json(WebUploadedAttachment( + path: attachment.path, name: attachment.filename, + token: AttachmentDraft.token(for: attachment.path) + )) + } catch { + return .text(400, error.readableMessage) + } + } + if request.method == "POST", parts.count == 5, parts[0] == "api", + parts[1] == "chats", parts[3] == "attachments", parts[4] == "remove" { + guard let input: WebRemoveAttachment = decode(request) else { return .text(400, "Name an attachment") } + let sessionID = SessionID(parts[2]) + var uploads = localWebUploads[sessionID] ?? [] + guard let index = uploads.firstIndex(where: { $0.attachment.path == input.path }) else { + return .text(404, "Attachment not found") + } + let removed = uploads.remove(at: index) + localWebUploads[sessionID] = uploads + await Task.detached(priority: .utility) { + AttachmentFiles.discard(removed.attachment, workspace: removed.root) + }.value + return json(WebAccepted(accepted: true)) + } + if request.method == "POST", parts.count == 5, parts[0] == "api", + parts[1] == "chats", parts[3] == "permissions" { + guard let input: WebPermissionAnswer = decode(request), + let session = try await store.session(id: SessionID(parts[2])), session.archivedAt == nil else { + return .text(400, "Permission request not found") + } + let transcript: TranscriptModel + if let workspaceID = session.workspaceID, + let workspace = try await store.workspace(id: workspaceID), workspace.state == .active { + let model = model(for: workspace) + if !model.hasReadSessions { await model.reloadSessions() } + guard let current = model.sessions.first(where: { $0.id == session.id }) else { + return .text(404, "Chat not found") + } + transcript = model.transcript(for: current) + } else if session.workspaceID == nil { + await ask.open() + await ask.select(session.id) + guard let current = ask.transcript(for: session.id) else { return .text(404, "Chat not found") } + transcript = current + } else { return .text(404, "Chat not found") } + await transcript.load() + guard transcript.isRunning, + let pending = transcript.pendingPermissionAsks.first(where: { $0.requestID == parts[4] }) else { + return .text(404, "Permission request is no longer pending") + } + guard let decision = webPermissionDecision(input, for: pending) else { + return .text(400, "This answer does not match the pending request") + } + await transcript.answer(requestID: parts[4], decision: decision) + return json(WebAccepted(accepted: true)) + } + if request.method == "GET", parts == ["api", "workspaces"] { + let workspaces = try await store.workspaces() + let repos = Dictionary(uniqueKeysWithValues: try await store.repos() + .filter { !$0.hidden }.map { ($0.id, $0.name) }) + return json(workspaces.filter { repos[$0.repoID] != nil }.map { workspace in + WebWorkspace( + id: workspace.id, name: workspace.name, + project: repos[workspace.repoID] ?? "Project", projectID: workspace.repoID, + branch: workspace.branch + ) + }) + } + if request.method == "GET", parts.count == 4, parts[0] == "api", + parts[1] == "workspaces", parts[3] == "chats" { + let workspaceID = WorkspaceID(parts[2]) + guard let workspace = try await store.workspace(id: workspaceID), workspace.state == .active else { + return .text(404, "Workspace not found") + } + let sessions = try await store.sessions(workspaceID: workspaceID) + return json(sessions.filter { $0.parentSessionID == nil }.map { session in + WebChat( + id: session.id, title: ChatListTool.title(of: session), + agent: session.agentKind.rawValue, model: session.model, + effort: session.effort, state: session.state.rawValue + ) + }) + } + if parts.count == 4, parts[0] == "api", parts[1] == "chats", parts[3] == "composer" { + return await localWebComposerResponse(request, sessionID: SessionID(parts[2]), store: store) + } + if parts.count == 4, parts[0] == "api", parts[1] == "chats", parts[3] == "quick-prompts" { + return await localWebQuickPromptsResponse(request, sessionID: SessionID(parts[2]), store: store) + } + if request.method == "POST", parts.count == 4, parts[0] == "api", + parts[1] == "chats", parts[3] == "side-question" { + return await localWebSideQuestionResponse(request, sessionID: SessionID(parts[2]), store: store) + } + if parts.count == 4, parts[0] == "api", parts[1] == "chats", parts[3] == "messages" { + let sessionID = SessionID(parts[2]) + guard let session = try await store.session(id: sessionID), + session.archivedAt == nil else { + return .text(404, "Chat not found") + } + let workspaceID = session.workspaceID + let workspace: Workspace? + if let workspaceID { + workspace = try await store.workspace(id: workspaceID) + } else { + workspace = nil + } + guard workspaceID == nil || workspace?.state == .active else { return .text(404, "Chat not found") } + if request.method == "GET" { + let components = URLComponents(string: "http://localhost\(request.target)") + let after = components?.queryItems?.first(where: { $0.name == "after" })?.value.flatMap(Int.init) + let before = components?.queryItems?.first(where: { $0.name == "before" })?.value.flatMap(Int.init) + let fetched = if let before { + try await store.messages(sessionID: sessionID, beforeSeq: before, limit: 201) + } else if let after { + try await store.messages(sessionID: sessionID, afterSeq: after, limit: 200) + } else { + try await store.recentMessages(sessionID: sessionID, limit: 201) + } + let hasMoreEarlier = (before != nil || after == nil) && fetched.count > 200 + let messages = hasMoreEarlier ? Array(fetched.dropFirst()) : fetched + let highest = messages.last?.seq ?? after ?? -1 + let first = messages.first?.seq ?? before ?? -1 + let streaming = if let workspaceID { + existingModel(for: workspaceID)?.existingTranscript(for: sessionID)?.streamingText ?? "" + } else { ask.transcript(for: sessionID)?.streamingText ?? "" } + let decisions = try await store.permissionAskDecisions(sessionID: sessionID) + let savedDirectory = workspace == nil + ? try await store.setting(AskTabs.directoryKey(sessionID)) : nil + let worktree = workspace?.path + ?? AskTabs.prepareDirectory(savedDirectory ?? "", databasePath: store.path) + ?? "" + // Provider decoding and Markdown highlighting can walk large rows. Keep that + // work off the main actor that also drives the Mac transcript. + let (rows, streamingHTML) = await Task.detached(priority: .utility) { + ( + messages.compactMap { TranscriptClientRow.present($0, worktree: worktree) }, + MarkdownHTML.render(streaming, imagesAsAltText: true).html + ) + }.value + return json(WebMessages( + schemaVersion: 1, messages: rows, firstSeq: first, lastSeq: highest, + hasMoreEarlier: hasMoreEarlier, streaming: streaming, + streamingHTML: streamingHTML, decisions: decisions + )) + } + if request.method == "POST" { + guard request.headers["content-type"]?.hasPrefix("application/json") == true, + let prompt = try? JSONDecoder().decode(WebPrompt.self, from: request.body), + !prompt.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + prompt.text.count <= 32_000 else { return .text(400, "Enter a prompt up to 32,000 characters") } + if let question = SideConversation.question(in: prompt.text) { + return await localWebSideQuestion(question, sessionID: sessionID, store: store) + } + let sent: Bool + if let workspace { + let model = model(for: workspace) + if !model.hasReadSessions { await model.reloadSessions() } + guard let current = model.sessions.first(where: { $0.id == sessionID }) else { + return .text(404, "Chat not found") + } + sent = await model.transcript(for: current).submit(prompt.text) + } else { + await ask.open() + await ask.select(sessionID) + sent = await ask.transcript(for: sessionID)?.submit(prompt.text) == true + } + if sent { + let named = Set(AttachmentDraft.parse(prompt.text).paths) + localWebUploads[sessionID]?.removeAll { named.contains($0.attachment.path) } + } + return sent ? json(WebAccepted(accepted: true)) : .text(503, "Bloom could not send this prompt") + } + } + return .text(404, "Not found") + } catch { + return .text(500, "Bloom could not complete this request") + } + } + + private func json(_ value: T) -> LocalWebServer.Response { + guard let data = try? JSONEncoder().encode(value) else { return .text(500, "Could not encode response") } + return .init(body: data) + } + + private func webFileResponse(target: String, root: String) -> LocalWebServer.Response { + let components = URLComponents(string: "http://localhost\(target)") + guard let path = components?.queryItems?.first(where: { $0.name == "path" })?.value, + !path.isEmpty, !path.hasPrefix("/"), !path.split(separator: "/").contains(".."), + let url = ContainedPath.resolve(URL(filePath: root).appending(path: path), inside: URL(filePath: root)), + let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]), + values.isRegularFile == true, let size = values.fileSize else { + return .text(404, "File not found inside this conversation") + } + let imageTypes = ["png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", + "gif": "image/gif", "webp": "image/webp"] + if let mime = imageTypes[url.pathExtension.lowercased()], size <= 5_242_880, + let data = try? Data(contentsOf: url) { + return json(WebFile(path: path, content: nil, + imageDataURL: "data:\(mime);base64,\(data.base64EncodedString())")) + } + guard size <= 1_048_576, let content = try? String(contentsOf: url, encoding: .utf8), + !content.contains("\0") else { return .text(404, "Preview unavailable for this file") } + return json(WebFile(path: path, content: content, imageDataURL: nil)) + } + + private func decode(_ request: LocalWebServer.Request) -> T? { + guard request.headers["content-type"]?.hasPrefix("application/json") == true else { return nil } + return try? JSONDecoder().decode(T.self, from: request.body) + } + + private func webPermissionDecision(_ input: WebPermissionAnswer, for ask: PermissionAsk) -> PermissionDecision? { + switch input.decision { + case "allow_once" where !ask.isQuestion && !ask.isPlanApproval: + return .allow(scope: .once) + case "deny" where !ask.isQuestion && !ask.isPlanApproval: + return .deny(message: "", endsTurn: false) + case "approve_plan" where ask.isPlanApproval: + let mode = input.mode.flatMap(PermissionMode.init(rawValue:)) + ?? PlanApproval.implementationMode(ask.implementationMode ?? .acceptEdits) + guard input.mode == nil || PermissionMode(rawValue: input.mode ?? "") != nil, + PlanApproval.modes.contains(mode) else { return nil } + return .approvePlan(mode: mode) + case "keep_planning" where ask.isPlanApproval: + return .deny(message: PlanApproval.keepPlanningMessage, endsTurn: false) + case "answer" where ask.isQuestion: + let questions = AgentQuestionnaire.questions(in: ask.input) + let choices = input.choices ?? [:] + let other = input.other ?? [:] + let ids = Set(questions.map(\.id)) + guard Set(choices.keys).isSubset(of: ids), Set(other.keys).isSubset(of: ids) else { return nil } + var draft = AgentQuestionDraft() + for question in questions { + let selected = choices[question.id] ?? [] + guard question.multiSelect || selected.count <= 1, + Set(selected).count == selected.count, + selected.allSatisfy({ label in question.options.contains { $0.label == label } }) else { + return nil + } + for label in selected { draft.toggle(label, on: question) } + if let written = other[question.id]?.trimmingCharacters(in: .whitespacesAndNewlines), + !written.isEmpty { + guard question.allowsOther, written.count <= 4_000 else { return nil } + draft.writeOther(on: question) + draft.other[question.id] = written + } + } + guard draft.isComplete(questions) else { return nil } + return .answer(input: AgentQuestionnaire.answered(ask.input, answers: draft.answers(to: questions))) + default: + return nil + } + } + + private func webTool(_ name: String, arguments: [String: JSONValue], store: Store) async -> LocalWebServer.Response { + guard let handler = bridgeToolbox().handler(named: name, for: .owner) else { return .text(404, "Command unavailable") } + let result = await handler.call(MCPRequest(id: .integer(1), method: "tools/call", params: .object(arguments)), as: .owner, store: store) + if result.isError { return .text(400, result.text) } + return .init(body: Data(result.text.utf8)) + } +} + +/// Bloom Dev is a separate app and database. Its local browser may connect without a copied +/// code, while another host or a reverse proxy still follows the ordinary access-code path. +private enum LocalWebDevelopment { + static let port: UInt16 = 53_544 + + static var enabled: Bool { Bundle.main.bundleIdentifier == Store.devBundleIdentifier } + + static func accepts(_ request: LocalWebServer.Request) -> Bool { + let localOrigin = "http://127.0.0.1:\(port)" + return request.headers["host"] == "127.0.0.1:\(port)" + && request.headers["origin"] == localOrigin + && request.headers["forwarded"] == nil + && request.headers["x-forwarded-for"] == nil + && request.headers["x-forwarded-host"] == nil + && request.headers["x-real-ip"] == nil + && request.body.isEmpty + } +} + +struct LocalWebUpload: Sendable { + let attachment: PromptAttachment + let root: String +} + +private struct WebProject: Encodable { + let id: RepoID + let name: String + let path: String + let branch: String + let accent: String +} + +private struct WebConnectRequest: Decodable { let ticket: String } +private struct WebConnectResponse: Encodable { let token: String } + +private struct WebProjectRequest: Decodable { + let path: String? + let name: String? + let location: String? +} + +private struct WebWorkspaceRequest: Decodable { + let project: String + let prompt: String + let name: String? + let baseBranch: String? +} + +private struct WebChatRequest: Decodable { let title: String? } +private struct WebUploadedAttachment: Encodable { let path: String; let name: String; let token: String } +private struct WebRemoveAttachment: Decodable { let path: String } +private struct WebPermissionAnswer: Decodable { + let decision: String + let choices: [String: [String]]? + let other: [String: String]? + let mode: String? +} +private struct WebFile: Encodable { let path: String; let content: String?; let imageDataURL: String? } +private struct WebChangedFile: Encodable { + let path: String + let change: String + let additions: Int + let deletions: Int +} + +private struct WebWorkspace: Encodable { + let id: WorkspaceID + let name: String + let project: String + let projectID: RepoID + let branch: String +} + +private struct WebChat: Encodable { + let id: SessionID + let title: String + let agent: String + let model: String + let effort: String + let state: String +} + +private struct WebPrompt: Decodable { + let text: String +} + +private struct WebAccepted: Encodable { + let accepted: Bool +} + +private struct WebMessages: Encodable { + let schemaVersion: Int + let messages: [TranscriptClientRow] + let firstSeq: Int + let lastSeq: Int + let hasMoreEarlier: Bool + let streaming: String + let streamingHTML: String + let decisions: [String: String] +} diff --git a/Sources/Bloom/State/AppModel+LocalWebComposer.swift b/Sources/Bloom/State/AppModel+LocalWebComposer.swift new file mode 100644 index 000000000..86cad72e5 --- /dev/null +++ b/Sources/Bloom/State/AppModel+LocalWebComposer.swift @@ -0,0 +1,446 @@ +import BloomCore +import Foundation + +/// The browser reads and changes the same composer state as the Mac footer. Agent choices stay +/// on `ComposerControls`, while the session and its extra settings remain in Bloom's Store. +extension AppModel { + func localWebComposerResponse( + _ request: LocalWebServer.Request, sessionID: SessionID, store: Store + ) async -> LocalWebServer.Response { + do { + guard let context = try await webComposerContext(sessionID, store: store) else { + return .text(404, "Chat not found") + } + await ComposerPlanningSupport.shared.refresh(from: store) + if request.method == "GET" { + return webComposerJSON(await webComposerSnapshot(context, store: store)) + } + guard request.method == "POST", + request.headers["content-type"]?.hasPrefix("application/json") == true, + let change = try? JSONDecoder().decode(WebComposerChange.self, from: request.body) else { + return .text(400, "Invalid composer change") + } + return await webChangeComposer(change, context: context, store: store) + } catch { + return .text(500, "Bloom could not read this chat's composer") + } + } + + private func webComposerContext(_ id: SessionID, store: Store) async throws -> WebComposerContext? { + guard let stored = try await store.session(id: id), stored.archivedAt == nil else { return nil } + if let workspaceID = stored.workspaceID { + guard let workspace = try await store.workspace(id: workspaceID), workspace.state == .active else { + return nil + } + let model = model(for: workspace) + if !model.hasReadSessions { await model.reloadSessions() } + guard let session = model.sessions.first(where: { $0.id == id }) else { return nil } + let transcript = model.transcript(for: session) + await transcript.load() + return WebComposerContext(session: transcript.session, workspace: workspace, + model: model, transcript: transcript) + } + await ask.open() + if ask.transcript(for: id) == nil { await ask.select(id) } + guard let transcript = ask.transcript(for: id) else { return nil } + await transcript.load() + return WebComposerContext(session: transcript.session, workspace: nil, + model: nil, transcript: transcript) + } + + private func webComposerControls(_ context: WebComposerContext, store: Store) async throws -> ComposerControls { + let id = context.session.id + let fast = try await store.setting(ComposerControls.fastModeKey(sessionID: id)) == "1" + let codexFast = CodexSpeed.override(stored: try await store.setting(CodexSpeed.key(sessionID: id))) + let style = try await store.setting(ComposerControls.outputStyleKey(sessionID: id)) + ?? OutputStyle.defaultName + let window = CodexContextWindow.normalised(try await store.setting( + ComposerControls.contextWindowKey(sessionID: id) + )) + return ComposerControls(session: context.session, isFastMode: fast, outputStyle: style, + codexContextWindow: window, codexFastMode: codexFast) + } + + private func webComposerSnapshot(_ context: WebComposerContext, store: Store) async -> WebComposerSnapshot { + let controls = (try? await webComposerControls(context, store: store)) + ?? ComposerControls(session: context.session, isFastMode: false, outputStyle: OutputStyle.defaultName) + let catalog = ComposerModelCatalog.shared + await catalog.loadReady() + let presets = await ModelPresetList.load(from: store) + let styleCatalog = ComposerOutputStyleCatalog.shared(for: context.workspace?.path) + if controls.offersOutputStyle { await styleCatalog.refreshIfStale(project: context.workspace?.path) } + let speed: CodexSpeed? = if controls.agentKind == .codex { + try? await CodexSpeed.read( + cwd: context.workspace?.path ?? AgentScratchDirectory.current(), modelID: controls.model + ) + } else { nil } + let modelSections = catalog.sections(includingCurrent: controls.model, on: controls.agentKind) + let efforts = ComposerOption.adding([controls.effort], to: catalog.efforts( + for: controls.agentKind, model: controls.model + )) + return WebComposerSnapshot( + agent: controls.agentKind.rawValue, model: controls.model, effort: controls.effort, + permissionMode: controls.permissionMode.rawValue, + interactionMode: controls.interactionMode.rawValue, + fastMode: controls.isFast(codexSpeed: speed), + fastAvailable: controls.fastModeAvailability( + codexSpeed: speed, codexSpeedFailed: speed == nil + ) == .available, + outputStyle: controls.outputStyle, contextWindow: controls.codexContextWindow, + models: modelSections.map { section in + WebComposerModelSection(agent: section.kind.rawValue, title: section.title, + options: section.options.map(WebComposerOption.init)) + }, + efforts: efforts.map(WebComposerOption.init), + permissionModes: controls.permissionModeChoices.map { + WebComposerOption(value: $0.mode.rawValue, label: $0.label, detail: $0.summary) + }, + outputStyles: controls.offersOutputStyle + ? styleCatalog.options(includingCurrent: controls.outputStyle).map(WebComposerOption.init) : [], + contextWindows: controls.offersContextWindow + ? CodexContextWindow.options(including: controls.codexContextWindow).map { + WebComposerWindow(tokens: $0, label: CodexContextWindow.label(for: $0)) + } : [], + presets: presets.presets.map { WebComposerPreset(key: $0.id.rawValue, name: $0.name) }, + matchedPreset: presets.matching(controls)?.id.rawValue, + offersInteractionMode: controls.offersInteractionMode, + planAvailable: ComposerPlanningSupport.shared.isAvailable, + contextUsed: context.transcript.contextUsage?.used, + contextLimit: context.transcript.contextUsage?.limit, + canSideQuestion: context.workspace != nil && context.session.sideConversationParentID == nil + ) + } + + private func webChangeComposer( + _ change: WebComposerChange, context: WebComposerContext, store: Store + ) async -> LocalWebServer.Response { + guard let current = try? await webComposerControls(context, store: store) else { + return .text(503, "Composer settings are unavailable") + } + var chosen = current + let catalog = ComposerModelCatalog.shared + await catalog.loadReady() + switch change.field { + case "preset": + let list = await ModelPresetList.load(from: store) + guard let key = change.value, + let preset = list.presets.first(where: { $0.id.rawValue == key }) else { + return .text(400, "Choose a saved preset") + } + chosen = chosen.applying(preset) + chosen.effort = catalog.resolvedEffort(chosen.effort, for: chosen.agentKind, model: chosen.model) + if chosen.interactionMode == .plan, !ComposerPlanningSupport.shared.isAvailable { + return .text(400, "Planning is unavailable for this chat") + } + case "model": + let sections = catalog.sections(includingCurrent: current.model, on: current.agentKind) + guard let section = sections.first(where: { $0.options.contains(where: { $0.id == change.value }) }), + let value = change.value else { return .text(400, "Choose an available model") } + chosen.model = value + chosen.agentKind = section.kind + chosen.effort = catalog.resolvedEffort(chosen.effort, for: section.kind, model: value) + case "effort": + guard let value = change.value, + ComposerOption.adding([current.effort], to: catalog.efforts( + for: current.agentKind, model: current.model + )).contains(where: { $0.id == value }) else { return .text(400, "Choose an available effort") } + chosen.effort = value + case "permissionMode": + guard let value = change.value.flatMap(PermissionMode.init(rawValue:)), + chosen.availablePermissionModes.contains(value) else { + return .text(400, "Choose an available permission mode") + } + chosen.permissionMode = value + case "interactionMode": + guard let value = change.value.flatMap(InteractionMode.init(rawValue:)), + value.nearest(on: chosen.agentKind) == value, + value != .plan || ComposerPlanningSupport.shared.isAvailable else { + return .text(400, "This work mode is unavailable") + } + chosen.interactionMode = value + case "fastMode": + guard let enabled = change.enabled, + chosen.agentKind == .claudeCode || chosen.agentKind == .codex else { + return .text(400, "Fast mode is unavailable") + } + if chosen.agentKind == .codex { + guard let speed = try? await CodexSpeed.read( + cwd: context.workspace?.path ?? AgentScratchDirectory.current(), modelID: chosen.model + ), speed.supportsFast else { return .text(400, "Fast mode is unavailable for this model") } + } + chosen = chosen.settingFastMode(enabled) + case "outputStyle": + guard chosen.offersOutputStyle, let value = change.value else { + return .text(400, "Output styles are unavailable") + } + let styles = ComposerOutputStyleCatalog.shared(for: context.workspace?.path) + await styles.refreshIfStale(project: context.workspace?.path) + guard styles.options(includingCurrent: current.outputStyle).contains(where: { $0.id == value }) else { + return .text(400, "Choose an available output style") + } + chosen.outputStyle = value + case "contextWindow": + guard chosen.offersContextWindow, let tokens = change.tokens, + CodexContextWindow.options(including: current.codexContextWindow).contains(tokens) else { + return .text(400, "Choose an available context window") + } + chosen.codexContextWindow = tokens + default: + return .text(400, "Unknown composer setting") + } + guard chosen != current else { + return webComposerJSON(WebComposerChangeResult(chatID: context.session.id, forked: false)) + } + let spoken = BackendChange.hasSpoken( + rowCount: context.transcript.rows.count, + agentSessionID: context.session.agentSessionID, + isTranscriptLoaded: context.transcript.isLoaded + ) + if case .fork(let kind) = BackendChange.decide( + from: context.session.agentKind, to: chosen.agentKind, hasSpoken: spoken + ) { + if let model = context.model { + guard let made = await model.createSession( + title: BackendChange.forkedTitle(context.session.title, to: kind), + controls: chosen, draft: change.draft ?? "" + ) else { return .text(503, "Could not open the new chat") } + return webComposerJSON(WebComposerChangeResult(chatID: made.id, forked: true)) + } + await ask.startFresh(controls: chosen, draft: change.draft ?? "") + guard let made = ask.session, made.id != context.session.id else { + return .text(503, "Could not open the new conversation") + } + return webComposerJSON(WebComposerChangeResult(chatID: made.id, forked: true)) + } + do { + try await store.saveComposerControls(chosen, sessionID: context.session.id) + await context.transcript.updatePreferences( + model: chosen.model, effort: chosen.effort, permissionMode: chosen.permissionMode, + interactionMode: chosen.interactionMode, agentKind: chosen.agentKind + ) + if let model = context.model { await model.reloadSessions() } + return webComposerJSON(WebComposerChangeResult(chatID: context.session.id, forked: false)) + } catch { + return .text(500, "Could not save composer settings") + } + } + + func localWebQuickPromptsResponse( + _ request: LocalWebServer.Request, sessionID: SessionID, store: Store + ) async -> LocalWebServer.Response { + do { + guard let context = try await webComposerContext(sessionID, store: store) else { + return .text(404, "Chat not found") + } + let personal = try await store.seedQuickPrompts() + await context.model?.reloadSettings() + let project = context.model?.settings.quickPrompts ?? [] + if request.method == "GET" { + return webComposerJSON( + personal.map { WebQuickPrompt(source: "personal", key: $0.id.rawValue, + name: $0.name, preview: $0.preview, + sendsImmediately: $0.sendsImmediately, + opensNewChat: $0.opensNewChat) } + + project.map { WebQuickPrompt(source: "project", key: $0.id, + name: $0.name, preview: $0.preview, + sendsImmediately: false, + opensNewChat: $0.opensNewChat) } + ) + } + guard request.method == "POST", + request.headers["content-type"]?.hasPrefix("application/json") == true, + let input = try? JSONDecoder().decode(WebQuickPromptApply.self, from: request.body), + input.draft.count <= 32_000 else { return .text(400, "Invalid quick prompt request") } + let words: String + let title: String + let delivery: QuickPromptDelivery + if input.source == "personal", let prompt = personal.first(where: { $0.id.rawValue == input.key }) { + words = prompt.text + title = prompt.name.isEmpty ? prompt.preview : prompt.name + delivery = QuickPromptDelivery.decided( + for: prompt, canSend: true, canOpenNewChat: context.model != nil + ) + } else if input.source == "project", let prompt = project.first(where: { $0.id == input.key }) { + words = prompt.text + title = prompt.chatTitle + delivery = prompt.delivery(canOpenNewChat: context.model != nil) + } else { return .text(404, "Quick prompt not found") } + let inserted = QuickPromptInsertion.inserting(words, into: input.draft, at: input.caret) + switch delivery { + case .compose: + return webComposerJSON(WebQuickPromptResult( + draft: inserted.text, caret: inserted.caret, sent: false, chatID: nil + )) + case .send: + guard await context.transcript.submit(inserted.text) else { + return .text(503, "Could not send the quick prompt") + } + settleWebUploads(in: inserted.text, sessionID: sessionID) + return webComposerJSON(WebQuickPromptResult(draft: "", caret: 0, sent: true, chatID: nil)) + case .composeInNewChat, .sendInNewChat: + guard let model = context.model, + let made = await model.createSession( + title: title, draft: delivery.sends ? "" : words + ) else { return .text(503, "Could not open the new chat") } + if delivery.sends { + guard await model.transcript(for: made).submit(words) else { + return .text(503, "Could not send the quick prompt") + } + } + return webComposerJSON(WebQuickPromptResult( + draft: delivery.sends ? "" : words, + caret: delivery.sends ? 0 : (words as NSString).length, + sent: delivery.sends, chatID: made.id + )) + } + } catch { + return .text(500, "Bloom could not load quick prompts") + } + } + + func localWebSideQuestionResponse( + _ request: LocalWebServer.Request, sessionID: SessionID, store: Store + ) async -> LocalWebServer.Response { + guard request.headers["content-type"]?.hasPrefix("application/json") == true, + let input = try? JSONDecoder().decode(WebSideQuestion.self, from: request.body), + input.question.count <= 32_000 else { return .text(400, "Enter a shorter side question") } + return await localWebSideQuestion(input.question, sessionID: sessionID, store: store) + } + + func localWebSideQuestion( + _ question: String, sessionID: SessionID, store: Store + ) async -> LocalWebServer.Response { + do { + guard let context = try await webComposerContext(sessionID, store: store), + let model = context.model, context.session.sideConversationParentID == nil else { + return .text(404, "Side questions need a workspace chat") + } + let child = try await store.openSideConversation( + parentID: sessionID, streamingText: context.transcript.streamingText + ) + await model.reloadSessions() + let transcript = model.transcript(for: child) + await transcript.load() + let body = question.trimmingCharacters(in: .whitespacesAndNewlines) + if !body.isEmpty, await transcript.submit(body) == false { + return .text(503, "Could not send the side question") + } + return webComposerJSON(WebComposerChangeResult(chatID: child.id, forked: true)) + } catch { + return .text(500, "Could not open the side question") + } + } + + private func settleWebUploads(in text: String, sessionID: SessionID) { + let named = Set(AttachmentDraft.parse(text).paths) + localWebUploads[sessionID]?.removeAll { named.contains($0.attachment.path) } + } + + private func webComposerJSON(_ value: T) -> LocalWebServer.Response { + guard let body = try? JSONEncoder().encode(value) else { return .text(500, "Could not encode composer") } + return .init(body: body) + } +} + +private struct WebComposerContext { + let session: Session + let workspace: Workspace? + let model: WorkspaceModel? + let transcript: TranscriptModel +} + +private struct WebComposerChange: Decodable { + let field: String + let value: String? + let enabled: Bool? + let tokens: Int? + let draft: String? +} + +private struct WebComposerChangeResult: Encodable { + let chatID: SessionID + let forked: Bool +} + +private struct WebComposerOption: Encodable { + let value: String + let label: String + let detail: String? + + init(value: String, label: String, detail: String? = nil) { + self.value = value + self.label = label + self.detail = detail + } + + init(_ option: ComposerOption) { + self.init(value: option.id, label: option.label, detail: option.detail) + } +} + +private struct WebComposerModelSection: Encodable { + let agent: String + let title: String + let options: [WebComposerOption] +} + +private struct WebComposerWindow: Encodable { + let tokens: Int + let label: String +} + +private struct WebComposerSnapshot: Encodable { + let agent: String + let model: String + let effort: String + let permissionMode: String + let interactionMode: String + let fastMode: Bool + let fastAvailable: Bool + let outputStyle: String + let contextWindow: Int + let models: [WebComposerModelSection] + let efforts: [WebComposerOption] + let permissionModes: [WebComposerOption] + let outputStyles: [WebComposerOption] + let contextWindows: [WebComposerWindow] + let presets: [WebComposerPreset] + let matchedPreset: String? + let offersInteractionMode: Bool + let planAvailable: Bool + let contextUsed: Int? + let contextLimit: Int? + let canSideQuestion: Bool +} + +private struct WebComposerPreset: Encodable { + let key: String + let name: String +} + +private struct WebQuickPrompt: Encodable { + let source: String + let key: String + let name: String + let preview: String + let sendsImmediately: Bool + let opensNewChat: Bool +} + +private struct WebQuickPromptApply: Decodable { + let source: String + let key: String + let draft: String + let caret: Int +} + +private struct WebQuickPromptResult: Encodable { + let draft: String + let caret: Int + let sent: Bool + let chatID: SessionID? +} + +private struct WebSideQuestion: Decodable { + let question: String +} diff --git a/Sources/Bloom/State/AppModel+TabCycle.swift b/Sources/Bloom/State/AppModel+TabCycle.swift index e47aa8cb0..c174bfbaf 100644 --- a/Sources/Bloom/State/AppModel+TabCycle.swift +++ b/Sources/Bloom/State/AppModel+TabCycle.swift @@ -8,12 +8,6 @@ import BloomCore /// next is `TabCycle` in the core. extension AppModel { func cycleCentreTab(by offset: Int) { - if isAskInFront { - if let next = TabCycle.next(from: ask.selectedID, in: ask.sessions.map(\.id), offset: offset) { - Task { await ask.select(next) } - } - return - } guard let workspace = selectedModel else { return } WorkspaceTabsStore.shared.selectNextTab(offset: offset, in: workspace) } diff --git a/Sources/Bloom/State/AppModel+TerminalBridge.swift b/Sources/Bloom/State/AppModel+TerminalBridge.swift index 27351b6f3..98c4e4b49 100644 --- a/Sources/Bloom/State/AppModel+TerminalBridge.swift +++ b/Sources/Bloom/State/AppModel+TerminalBridge.swift @@ -51,9 +51,7 @@ extension AppModel { for: TerminalTab( id: TerminalTabID(tab.id), workspaceID: workspaceID, title: tab.title ), - workspace: model.workspace, - repo: model.repo, - port: model.port, + owner: .workspace(model.workspace, model.repo, port: model.port), directory: tab.directory ) } diff --git a/Sources/Bloom/State/AppModel+WorkspaceBridge.swift b/Sources/Bloom/State/AppModel+WorkspaceBridge.swift index 18e597cf2..1c07f2a12 100644 --- a/Sources/Bloom/State/AppModel+WorkspaceBridge.swift +++ b/Sources/Bloom/State/AppModel+WorkspaceBridge.swift @@ -124,6 +124,10 @@ extension AppModel { guard let self else { return .refused("Bloom is still starting up.") } return await self.archiveWorkspaceForBridge(order) }, + WorkspaceRestoreTool { [weak self] workspace in + guard let self else { return .refused("Bloom is still starting up.") } + return await self.restoreWorkspaceForBridge(workspace) + }, WorkspaceMergeTool { [weak self] workspace, pullRequest, method in guard let self else { return .refused("Bloom is still starting up. Try again in a moment.") diff --git a/Sources/Bloom/State/AppModel+Workspaces.swift b/Sources/Bloom/State/AppModel+Workspaces.swift index 023a7ce98..29ed40d80 100644 --- a/Sources/Bloom/State/AppModel+Workspaces.swift +++ b/Sources/Bloom/State/AppModel+Workspaces.swift @@ -321,10 +321,17 @@ extension AppModel { func resolvedControls(for repo: Repo?) async throws -> ComposerControls { guard let store else { return ComposerControls() } - let appDefaults = await AppDefaults.loadForNewSessions(from: store) - let repoSettings = await Task.detached(priority: .userInitiated) { + let appDefaults = await AppDefaults.loadForNewSessions( + from: store, models: ComposerModelCatalog.shared.models + ) + let loadedSettings = await Task.detached(priority: .userInitiated) { repo.map { SettingsLoader.load(repo: $0.path) } ?? RepoSettings() }.value + let repoSettings = if let repo { + try await store.applyingProjectAgentDefaults(to: loadedSettings, for: repo.id) + } else { + loadedSettings + } // The Codex list as this window last fetched it, which may well be empty here: nothing // waits for a fetch to start a workspace. Empty costs only the effort fallback, because // the backend the Models screen recorded is read without any list at all. See diff --git a/Sources/Bloom/State/AppModel.swift b/Sources/Bloom/State/AppModel.swift index b82391097..aa252ab69 100644 --- a/Sources/Bloom/State/AppModel.swift +++ b/Sources/Bloom/State/AppModel.swift @@ -54,6 +54,15 @@ final class AppModel { /// start one. @ObservationIgnored private(set) var bridge: BridgeServer? + /// Optional browser access is local to this app instance and disabled until explicitly enabled. + @ObservationIgnored var localWebServer: LocalWebServer? + var localWebEnabled = UserDefaults.standard.bool(forKey: "localWeb.enabled") + var localWebAddress: String? + var localWebToken: String? + var localWebError: String? + @ObservationIgnored var localWebConnectTicket: (value: String, expiresAt: Date)? + @ObservationIgnored var localWebUploads: [SessionID: [LocalWebUpload]] = [:] + private(set) var repos: [Repo] = [] private(set) var workspaces: [Workspace] = [] { didSet { updateInspectorPresentation() } @@ -80,6 +89,7 @@ final class AppModel { // sidebar row can arrive here again, and without this guard it writes defaults, // rebuilds the subagent rows and starts another settings read for no state change. guard newValue != storedSelection else { return } + highlightedSidebarSelection = nil if let id = newValue.workspaceID, id != storedSelection.workspaceID { SwitchTrace.begin(workspaceID: id) } @@ -114,6 +124,30 @@ final class AppModel { private var storedSelection: SidebarSelection = .home + /// The row the sidebar has highlighted while its display-link activation is pending. + /// Menu shortcuts must follow that row, not the workspace the centre column is still showing. + private var highlightedSidebarSelection: SidebarSelection? + + func highlightSidebarSelection(_ selection: SidebarSelection?) { + guard let selection, selection != storedSelection else { + highlightedSidebarSelection = nil + return + } + if let id = selection.workspaceID, + let workspace = workspaces.first(where: { $0.id == id }) { + _ = model(for: workspace) + } + highlightedSidebarSelection = selection + } + + /// The model used by menu commands during the sidebar's brief selection animation. + var shortcutModel: WorkspaceModel? { + guard let highlightedSidebarSelection else { return selectedModel } + guard let id = highlightedSidebarSelection.workspaceID, + workspaces.contains(where: { $0.id == id }) else { return nil } + return workspaceModels[id] + } + /// The current workspace column and the one immediately before it, in stable structural order. /// /// Unlike `workspaceModels`, this is observed because `DetailColumn` uses membership to keep @@ -245,6 +279,9 @@ final class AppModel { /// Non-nil while an archive is waiting for the user to confirm that the work it would destroy /// really is expendable. RootView presents the confirmation from this. var pendingArchive: ArchiveRequest? + /// Non-nil after an archive script failed, while the owner decides between keeping the + /// workspace and archiving it without the script. RootView presents it. + var pendingScriptFailure: ScriptFailureRequest? /// The transcript half of a search, one row per workspace. Held here rather than in `HomeView` /// for the same reason `homeFilter` is: the pane is destroyed and rebuilt every time the /// selection leaves Home, and a result list that had to be fetched again on the way back would @@ -567,6 +604,7 @@ final class AppModel { // jumps to the workspace. restoreLastSelection() isLoaded = true + startLocalWebIfEnabled() let blocking = Int(Date().timeIntervalSince(began) * 1000) Log.launch.info("window usable after \(blocking, privacy: .public)ms") Log.launchStep("loaded") @@ -702,6 +740,8 @@ final class AppModel { // held in memory precisely so nothing outlives the launch that minted it. bridge?.stop() bridge = nil + localWebServer?.stop() + localWebServer = nil let models = Array(workspaceModels.values) // Signal every agent first, so the SIGTERM escalations all run at the same time rather than @@ -798,6 +838,9 @@ final class AppModel { for workspace in workspaces { CenterTabStore.shared.load(workspaceID: workspace.id) if let existing = workspaceModels[workspace.id], existing.workspace != workspace { + if existing.workspace.path != workspace.path { + existing.invalidateFileTree(keepListing: false) + } existing.workspace = workspace } } @@ -1045,7 +1088,7 @@ final class AppModel { await model.refreshChanges(.quiet) } - /// What the file system watcher saw, turned into workspaces the next tick has to ask git about. + /// What the file system watcher saw, turned into workspaces whose diff and file tree changed. /// /// Paths rather than ids come back, because a watcher has no idea what a workspace is. A path /// that matches nothing is dropped, which is what happens for the moment between a worktree @@ -1054,6 +1097,7 @@ final class AppModel { let changed = workspaces.filter { paths.contains($0.path) }.map(\.id) guard !changed.isEmpty else { return } diffInvalidations.record(Set(changed)) + for id in changed { workspaceModels[id]?.invalidateFileTree() } } /// - Returns: the workspaces this pass actually asked git about. diff --git a/Sources/Bloom/State/AskModel.swift b/Sources/Bloom/State/AskModel.swift index 3e931ee04..b21eaedcd 100644 --- a/Sources/Bloom/State/AskModel.swift +++ b/Sources/Bloom/State/AskModel.swift @@ -9,33 +9,54 @@ final class AskModel { private(set) var sessions: [Session] = [] private(set) var selectedID: SessionID? private(set) var transcripts: [SessionID: TranscriptModel] = [:] + private(set) var paneStates: [SessionID: ConversationPaneState] = [:] private(set) var trouble: String? + private(set) var archiveRevision = 0 + private(set) var pinnedConversations: Set = [] + private(set) var unreadConversations: Set = [] + private(set) var conversationColours: [SessionID: String] = [:] var closingID: SessionID? private unowned let app: AppModel private var isChanging = false private var hasOpened = false - private var settledModes: Set = [] + private var openingTask: Task? private var loading: Set = [] + private var sessionsWithHistory: Set = [] var transcript: TranscriptModel? { selectedID.flatMap { transcripts[$0] } } + func transcript(for id: SessionID) -> TranscriptModel? { transcripts[id] } + func paneState(for id: SessionID) -> ConversationPaneState? { paneStates[id] } var session: Session? { transcript?.session ?? sessions.first { $0.id == selectedID } } init(app: AppModel) { self.app = app } func open() async { - guard !isChanging, let store = app.store else { return } - isChanging = true - defer { isChanging = false } + if let openingTask { + await openingTask.value + return + } if hasOpened { if transcript == nil, let selectedID { await select(selectedID) } return } + guard !isChanging, let store = app.store else { return } + isChanging = true + let task = Task { [weak self] in + guard let self else { return } + await self.loadInitial(from: store) + } + openingTask = task + await task.value + openingTask = nil + isChanging = false + } + + private func loadInitial(from store: Store) async { do { sessions = try await store.sessionsWithoutWorkspace() - if sessions.isEmpty { - let directory = try await newDirectory() - sessions = [try await store.createAskConversation(directory: directory)] - } + sessionsWithHistory = try await store.projectFreeSessionsWithHistory() + unreadConversations = try await store.unreadProjectFreeConversationIDs() + await loadMarks(from: store) let saved = try await store.setting(AskTabs.selectionKey) hasOpened = true if let id = AskTabs.selection(saved: saved, sessions: sessions) { await select(id) } @@ -43,12 +64,16 @@ final class AskModel { } func select(_ id: SessionID) async { - guard let store = app.store, var chat = sessions.first(where: { $0.id == id }) else { return } + guard let store = app.store, let chat = sessions.first(where: { $0.id == id }) else { return } selectedID = id + // `open()` restores a saved selection even when the window is showing a workspace. + // Only a conversation actually on screen has been read. + if app.selection == .conversation(id) { unreadConversations.remove(id) } trouble = nil do { try await store.setSetting(AskTabs.selectionKey, id.rawValue) - if transcripts[id] != nil || loading.contains(id) { return } + if transcripts[id] != nil, paneStates[id] != nil { return } + if loading.contains(id) { return } loading.insert(id) defer { loading.remove(id) } // Old conversations predate directory preferences and retain their original cwd. @@ -56,13 +81,10 @@ final class AskModel { guard let directory = AskTabs.prepareDirectory(saved ?? "", databasePath: store.path) else { throw AskDirectoryError.unavailable(saved ?? AskConversation.directory(besideDatabaseAt: store.path)) } - if let mode = AskConversation.modeOnOpening( - stored: chat.permissionMode, isFirstOpenSinceLaunch: !settledModes.contains(id) - ) { - try await store.updateSessionPreferences(id: id, permissionMode: mode) - chat.permissionMode = mode + if paneStates[id] == nil { + paneStates[id] = ConversationPaneState(sessionID: id, directory: directory) } - settledModes.insert(id) + if transcripts[id] != nil { return } let model = TranscriptModel(askSession: chat, directory: directory, app: app) transcripts[id] = model await model.load() @@ -92,11 +114,102 @@ final class AskModel { } catch { report(error, title: "Could not start a new conversation") } } + /// A persistent conversation started from the New Workspace sheet. + /// Its directory is beside this copy's database, independent of project settings and Git. + @discardableResult + func startWorkspaceConversation( + prompt: String, controls: ComposerControls, staged: StagedAttachments? + ) async -> Session? { + await open() + guard !isChanging, let store = app.store else { return nil } + let stagedPaths = staged?.attachments.map(\.path) ?? [] + let spoken = WorkspaceStartAttachments.spoken(prompt, staged: stagedPaths) + guard !spoken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil } + isChanging = true + defer { isChanging = false } + do { + let id = SessionID.new() + guard let directory = AskConversation.prepareWorkspaceDirectory( + besideDatabaseAt: store.path, id: id + ) else { + throw AskDirectoryError.unavailable( + AskConversation.workspaceDirectory(besideDatabaseAt: store.path, id: id) + ) + } + let made = try await store.createAskConversation( + directory: directory, controls: controls, + title: Git.title(from: spoken, maxLength: 40), id: id + ) + sessions.append(made) + hasOpened = true + await select(made.id) + let arrived: Set = staged.map { + WorkspaceStartAttachments.adopt(stagedPaths, from: $0.directory, into: directory) + } ?? [] + let opening = WorkspaceStartAttachments.opening( + prompt, staged: stagedPaths, arrived: arrived, isChatWorkspace: true + ) + app.selection = .conversation(made.id) + guard let opening, await transcripts[made.id]?.submit(opening) == true else { + throw AskDirectoryError.couldNotSend + } + return made + } catch { + report(error, title: "Could not create the conversation") + return nil + } + } + + func archivedConversations() async -> [Session] { + guard let store = app.store else { return [] } + let archived = (try? await store.sessionsWithoutWorkspace(includeArchived: true)) ?? [] + let used = (try? await store.projectFreeSessionsWithHistory(includeArchived: true)) ?? [] + return archived.filter { used.contains($0.id) || !AskConversation.isBlank($0) } + } + + func restore(_ id: SessionID) async { + guard let store = app.store else { return } + do { + guard let restored = try await store.restoreConversation(id: id) else { return } + sessions.append(restored) + sessionsWithHistory.insert(id) + archiveRevision &+= 1 + await select(id) + app.selection = .conversation(id) + } catch { report(error, title: "Could not restore the conversation") } + } + + func deleteArchived(_ id: SessionID) async { + guard let store = app.store else { return } + do { + let directory = try await store.setting(AskTabs.directoryKey(id)) + guard try await store.deleteArchivedConversation(id: id) else { return } + paneStates.removeValue(forKey: id)?.discard() + ConversationPaneState.discardStoredLayout(for: id) + pinnedConversations.remove(id) + conversationColours[id] = nil + archiveRevision &+= 1 + if app.selection == .archivedConversation(id) { app.selection = .home } + let owned = AskConversation.workspaceDirectory(besideDatabaseAt: store.path, id: id) + if directory == owned { + // A fresh conversation can inherit the previous one's directory. Keep its + // files while any other conversation still points there. + if let directories = try? await store.settings(prefix: "ask.directory."), + !directories.contains(where: { $0.value == owned }) { + try? await Task.detached(priority: .utility) { + try FileManager.default.removeItem(atPath: owned) + }.value + } + } + } catch { report(error, title: "Could not delete the conversation") } + } + /// Explicit fresh-start composer actions replace only their tab, carrying its directory. func startFresh(controls: ComposerControls? = nil, draft: String = "") async { guard !isChanging, let store = app.store, let current = session else { return } isChanging = true defer { isChanging = false } + var preparedDirectory: String? do { let carried: ComposerControls if let controls { @@ -113,13 +226,32 @@ final class AskModel { carried = ComposerControls(session: current, isFastMode: fast, outputStyle: style, codexContextWindow: window, codexFastMode: codexFastMode) } - let made = try await store.replaceAskConversation(id: current.id, controls: carried, draft: draft) + let nextID = SessionID.new() + let currentDirectory = try await store.setting(AskTabs.directoryKey(current.id)) + let owned = AskConversation.workspaceDirectory( + besideDatabaseAt: store.path, id: current.id + ) + if currentDirectory == owned { + guard let directory = AskConversation.prepareWorkspaceDirectory( + besideDatabaseAt: store.path, id: nextID + ) else { throw AskDirectoryError.unavailable(owned) } + preparedDirectory = directory + } + let made = try await store.replaceAskConversation( + id: current.id, controls: carried, draft: draft, + nextID: nextID, replacementDirectory: preparedDirectory + ) + preparedDirectory = nil transcripts.removeValue(forKey: current.id)?.teardown() + paneStates.removeValue(forKey: current.id)?.discard() app.bridge?.retire(sessionID: current.id) + unreadConversations.remove(current.id) if let index = sessions.firstIndex(where: { $0.id == current.id }) { sessions[index] = made } - settledModes.insert(made.id) await select(made.id) - } catch { report(error, title: "Could not start a new conversation") } + } catch { + if let preparedDirectory { try? FileManager.default.removeItem(atPath: preparedDirectory) } + report(error, title: "Could not start a new conversation") + } } func requestClose(_ id: SessionID) { @@ -136,6 +268,8 @@ final class AskModel { if isBlank(only) { return } if selectedID != id { await select(id) } await startFresh() + archiveRevision &+= 1 + if app.selection == .conversation(id) { app.selection = .home } return } guard !isChanging, sessions.count > 1, let store = app.store else { return } @@ -144,9 +278,13 @@ final class AskModel { do { let next = try await store.closeAskConversation(id: id, selected: selectedID) transcripts.removeValue(forKey: id)?.teardown() + paneStates.removeValue(forKey: id)?.suspend() app.bridge?.retire(sessionID: id) sessions.removeAll { $0.id == id } + unreadConversations.remove(id) + archiveRevision &+= 1 if let next { await select(next) } + if app.selection == .conversation(id) { app.selection = .home } } catch { report(error, title: "Could not close the conversation") } } @@ -164,9 +302,66 @@ final class AskModel { /// The conversations the rail lists: everything but the blank ones. See `AskConversation.isBlank`. var listedSessions: [Session] { sessions.filter { !isBlank($0) } } + var workspaceConversations: [Session] { + ConversationListing.ordered(listedSessions, pinned: pinnedConversations) + } + + func togglePin(_ id: SessionID) async { + guard let store = app.store else { return } + let isPinned = pinnedConversations.contains(id) + do { + try await store.setSetting(ConversationListing.pinnedKey(id), isPinned ? nil : "1") + if isPinned { + pinnedConversations.remove(id) + } else { + pinnedConversations.insert(id) + } + } catch { report(error, title: "Could not change the pin") } + } + + func setColour(_ colour: String?, for id: SessionID) async { + guard let store = app.store else { return } + do { + try await store.setSetting(ConversationListing.colourKey(id), colour) + conversationColours[id] = colour + } catch { report(error, title: "Could not change the colour") } + } + + func reorderConversations(from offsets: IndexSet, to destination: Int) async { + guard let store = app.store else { return } + let before = workspaceConversations + guard let after = ConversationListing.reordered( + before, pinned: pinnedConversations, from: offsets, to: destination + ) else { + app.notice = BloomNotice(message: "Pinned conversations stay at the top.") + return + } + let storageOrder = after.reversed().map(\.id) + do { + try await store.reorderSessions(ids: storageOrder) + for (order, id) in storageOrder.enumerated() { + if let index = sessions.firstIndex(where: { $0.id == id }) { + sessions[index].sortOrder = order + } + } + } catch { report(error, title: "Could not reorder conversations") } + } + + private func loadMarks(from store: Store) async { + let pins = (try? await store.settings(prefix: "conversation.pinned.")) ?? [] + pinnedConversations = Set(pins.filter { $0.value == "1" }.map { + SessionID(String($0.key.dropFirst("conversation.pinned.".count))) + }) + let colours = (try? await store.settings(prefix: "conversation.colour.")) ?? [] + conversationColours = Dictionary(uniqueKeysWithValues: colours.map { + (SessionID(String($0.key.dropFirst("conversation.colour.".count))), $0.value) + }) + } + /// Blank by its row, unless its transcript already has something in it, which covers the /// moment between a message going out and the title it earns being written. func isBlank(_ chat: Session) -> Bool { + if sessionsWithHistory.contains(chat.id) { return false } if transcripts[chat.id]?.hasNothingToShow == false { return false } return AskConversation.isBlank(transcripts[chat.id]?.session ?? chat) } @@ -184,14 +379,24 @@ final class AskModel { transcripts[id]?.isRunning == true || transcripts[id]?.subagents.isWorking == true } - /// The mark one conversation's row wears in the panel's rail. See `AskPanelLayout.status`. - func status(for id: SessionID) -> WorkspaceStatus? { - AskPanelLayout.status( + /// The mark one conversation's row wears in the sidebar. + func status(for id: SessionID) -> WorkspaceStatus { + ConversationListing.status( isRunning: isRunning(id), - isAwaitingPermission: transcripts[id]?.isAwaitingPermission == true + isAwaitingPermission: transcripts[id]?.isAwaitingPermission == true, + isUnread: unreadConversations.contains(id) ) } + func noteTurnFinished(_ id: SessionID, wasViewed: Bool) { + guard sessions.contains(where: { $0.id == id }) else { return } + if wasViewed { + unreadConversations.remove(id) + } else { + unreadConversations.insert(id) + } + } + var isRunning: Bool { transcripts.keys.contains { isRunning($0) } } var isAwaitingPermission: Bool { transcripts.values.contains { $0.isAwaitingPermission } } @@ -214,10 +419,12 @@ final class AskModel { private enum AskDirectoryError: LocalizedError { case unavailable(String) + case couldNotSend var errorDescription: String? { switch self { case .unavailable(let path): "The working directory could not be opened: \(path)" + case .couldNotSend: "The conversation was saved, but its first message could not be sent." } } } diff --git a/Sources/Bloom/State/Bridges.swift b/Sources/Bloom/State/Bridges.swift index 86e3c56c5..83eac602e 100644 --- a/Sources/Bloom/State/Bridges.swift +++ b/Sources/Bloom/State/Bridges.swift @@ -5,9 +5,12 @@ import BloomCore /// The one place the app layer touches `GitHub`. Keeping it behind a single adapter means a /// change to the gh wrapper's signature is a one-file fix rather than a sweep through views. /// -/// Opening a pull request, pushing a branch and merging are turns sent to the workspace's agent. -/// Marking a draft ready for review needs no interpretation, so it runs directly through gh. +/// Pull requests can be prepared by an agent or directly through git and gh. Direct merges and +/// marking a draft ready also use the GitHub CLI without involving an agent. enum GitHubBridge { + static func createPullRequestDirectly(for workspace: Workspace) async throws -> PullRequest { + try await DirectPullRequestCreation.create(for: workspace) + } static func readPullRequest(for workspace: Workspace, maxAge: Duration = .zero) async -> PullRequestRead { let availability = await GitHubAvailability.shared.check() if availability == .notInstalled { @@ -42,6 +45,12 @@ enum GitHubBridge { try await GitHub.markReadyForReview(pullRequest, worktree: worktree) } + static func mergePullRequest( + _ pullRequest: PullRequest, base: String, method: GitHub.MergeMethod, worktree: String + ) async throws -> DirectMergeOutcome { + try await GitHub.mergePullRequest(pullRequest, into: base, method: method, worktree: worktree) + } + /// Nil when GitHub refused this token the check runs. A failed read is still an empty list, /// as it always was, because `try?` would flatten it into that nil. static func checks(for workspace: Workspace) async -> [CheckRun]? { diff --git a/Sources/Bloom/State/ConversationPaneState.swift b/Sources/Bloom/State/ConversationPaneState.swift new file mode 100644 index 000000000..1da4f5733 --- /dev/null +++ b/Sources/Bloom/State/ConversationPaneState.swift @@ -0,0 +1,136 @@ +import Foundation +import Observation +import BloomCore + +/// Live browser and terminal panes around one project-free conversation. The value in +/// `ConversationLayout` is saved per conversation; native views stay here across tab switches. +@MainActor +@Observable +final class ConversationPaneState { + let sessionID: SessionID + let directory: String + private(set) var layout: ConversationLayout + private(set) var browsers: [ConversationPaneID: BrowserSession] = [:] + + init(sessionID: SessionID, directory: String) { + self.sessionID = sessionID + self.directory = directory + let saved = UserDefaults.standard.data(forKey: Self.key(sessionID)) + .flatMap { try? JSONDecoder().decode(ConversationLayout.self, from: $0) } + if let saved, saved.isValid { + layout = saved + } else { + layout = ConversationLayout() + } + for tab in layout.tabs { + for pane in tab.panes.values where pane.kind == .terminal { + TerminalSessionStore.shared.reopenPanes(of: pane.id.rawValue) + } + } + } + + static func key(_ id: SessionID) -> String { "conversation.panes.\(id.rawValue)" } + + func open(_ kind: ConversationLayout.Kind, url: String = "") { + _ = layout.open(kind, url: url) + save() + } + + func split(tabID: ConversationPaneID, paneID: ConversationPaneID, axis: SplitAxis, kind: ConversationLayout.Kind) { + _ = layout.split(tabID: tabID, paneID: paneID, axis: axis, opening: kind) + save() + } + + func closePane(tabID: ConversationPaneID, paneID: ConversationPaneID) { + guard let removed = layout.closePane(tabID: tabID, paneID: paneID) else { return } + retire(removed) + save() + } + + func closeTab(_ id: ConversationPaneID) { + let removed = layout.closeTab(id) + guard !removed.isEmpty else { return } + for pane in removed { retire(pane) } + save() + } + + func select(_ id: ConversationPaneID) { + layout.select(id) + save() + } + + func focus(_ pane: ConversationPaneID, in tab: ConversationPaneID) { + layout.focus(pane, in: tab) + save() + } + + func setRatio(_ ratio: Double, at path: [Int], in tab: ConversationPaneID) { + layout.setRatio(ratio, at: path, in: tab) + } + + func persistRatio() { save() } + + func rename(_ title: String, tabID: ConversationPaneID) { + layout.rename(title, tabID: tabID) + save() + } + + func updatePage(_ page: BrowserTabTitle.BrowserPage, paneID: ConversationPaneID, tabID: ConversationPaneID) { + guard let before = layout.tab(tabID)?.pane(paneID)?.url, + before != page.address else { return } + layout.setURL(page.address, paneID: paneID, in: tabID) + save() + } + + /// A browser is created only when its pane is first drawn. Restoring five tabs must not fetch + /// five pages before the reader has selected one. + func prepareBrowser(_ pane: ConversationLayout.Pane) { + guard pane.kind == .browser, browsers[pane.id] == nil else { return } + browsers[pane.id] = BrowserSession(url: pane.url, root: directory) + } + + func browser(for id: ConversationPaneID) -> BrowserSession? { browsers[id] } + + /// Archiving stops processes and pages without forgetting the layout. Restoring the + /// conversation brings its tabs back in the same arrangement. + func suspend() { + for tab in layout.tabs { + for pane in tab.panes.values where pane.kind == .terminal { + TerminalSessionStore.shared.closePanes(of: pane.id.rawValue, preserveLayout: true) + } + } + for browser in browsers.values { browser.stop() } + browsers.removeAll() + } + + func discard() { + suspend() + Self.discardStoredLayout(for: sessionID) + } + + static func discardStoredLayout(for id: SessionID) { + let storageKey = key(id) + let layout = UserDefaults.standard.data(forKey: storageKey) + .flatMap { try? JSONDecoder().decode(ConversationLayout.self, from: $0) } + for tab in layout?.tabs ?? [] { + for pane in tab.panes.values where pane.kind == .terminal { + TerminalSplitStore.shared.discard(ownerID: pane.id.rawValue) + } + } + UserDefaults.standard.removeObject(forKey: storageKey) + } + + private func retire(_ pane: ConversationLayout.Pane) { + switch pane.kind { + case .chat: break + case .terminal: TerminalSessionStore.shared.closePanes(of: pane.id.rawValue) + case .browser: + browsers.removeValue(forKey: pane.id)?.stop() + } + } + + private func save() { + guard let data = try? JSONEncoder().encode(layout) else { return } + UserDefaults.standard.set(data, forKey: Self.key(sessionID)) + } +} diff --git a/Sources/Bloom/State/RunScriptLauncher.swift b/Sources/Bloom/State/RunScriptLauncher.swift index 81da6ef35..4bc5e4524 100644 --- a/Sources/Bloom/State/RunScriptLauncher.swift +++ b/Sources/Bloom/State/RunScriptLauncher.swift @@ -151,9 +151,7 @@ final class RunScriptLauncher { private func fork(_ tab: CenterTab, in model: WorkspaceModel) { _ = sessions.terminal( for: TerminalTab(id: TerminalTabID(tab.id), workspaceID: model.workspace.id, title: tab.title), - workspace: model.workspace, - repo: model.repo, - port: model.port, + owner: .workspace(model.workspace, model.repo, port: model.port), directory: tab.directory ) } diff --git a/Sources/Bloom/State/TranscriptModel.swift b/Sources/Bloom/State/TranscriptModel.swift index e96a68228..4153eb448 100644 --- a/Sources/Bloom/State/TranscriptModel.swift +++ b/Sources/Bloom/State/TranscriptModel.swift @@ -143,6 +143,23 @@ final class TranscriptModel { @ObservationIgnored private var foldCache = TranscriptFoldCache() @ObservationIgnored private var turnFoldCache = TranscriptTurnFoldCache() @ObservationIgnored private var questionIndex = PinnedQuestionIndex() + /// A tab switch destroys its table, but it should not discard rows already measured at this + /// pane's width. Kept per pane because two split panes can draw this chat at different widths. + /// Old split pane ids are expendable, so the cache is bounded even after many rearrangements. + private let heightMemories: NSCache = { + let cache = NSCache() + cache.countLimit = 8 + return cache + }() + + func heightMemory(for pane: String) -> TranscriptHeightMemory { + let key = pane as NSString + if let remembered = heightMemories.object(forKey: key) { return remembered } + let remembered = TranscriptHeightMemory() + heightMemories.setObject(remembered, forKey: key) + return remembered + } + /// Tool calls that arrived moments ago, by call id, which fold before their result is back. /// See `TranscriptFold.freshCall`. Told through `presentationRevision`, like the cache above. @ObservationIgnored private var freshCalls: Set = [] @@ -437,6 +454,28 @@ final class TranscriptModel { await loader.run { [self] in await read(from: store) } } + /// Reconcile persisted rows while this pane is visible. The runner's event pump normally + /// presents them first, but a missed presentation notification must not leave a saved answer + /// invisible until Bloom is restarted. Store changes are coalesced and this reader caps its + /// own reads while a provider writes many rows in a turn. + func watchStoredRows(when shouldRead: @escaping @MainActor () -> Bool) async { + guard let store else { return } + var changes = store.changes(of: [.messages]).makeAsyncIterator() + await load() + guard !Task.isCancelled else { return } + if shouldRead() { await appendLatestMessages() } + while await changes.next() != nil { + do { try await Task.sleep(for: .milliseconds(500)) } catch { return } + guard !Task.isCancelled else { return } + if shouldRead() { await appendLatestMessages() } + } + } + + func reconcileStoredRows() async { + await load() + await appendLatestMessages() + } + /// The read itself, reached only through `loader`. /// /// The rows are built into a list off to one side and put on the model in one assignment at the @@ -453,6 +492,11 @@ final class TranscriptModel { // rows and at most a handful of them are questions. let decisions = (try? await store.permissionAskDecisions(sessionID: session.id)) ?? [:] + // These reads do not depend on the row fold. Start them while that fold runs off the main + // actor, so opening a long conversation does not wait for three more store round trips + // after its rows are ready. + async let opening = loadOpening(from: store) + // Off the main actor, and not for tidiness. The fold below is the one place in the app // that JSON-parses a whole session in one go: `ParentProbe.parentToolUseID` materialises // every message's payload to read one top level key, `ToolResultSummary.decode` does it @@ -505,13 +549,20 @@ final class TranscriptModel { SwitchTrace.mark("transcript.rows.built", workspace: workspace?.id) SwitchTrace.markOnScreen("transcript.rows.built", workspace: workspace?.id) + await opening + isLoaded = true + } + + private func loadOpening(from store: Store) async { draft = (try? await store.draft(sessionID: session.id)) ?? "" + SwitchTrace.mark("transcript.draft.loaded", workspace: workspace?.id) // Read, and deliberately not drained. A message queued before the last quit must not // start a paid turn on a Mac nobody is sitting at, so it is shown as pending and goes with // the owner's next message. See `DeliveryHold.none`. await refreshQueue() + SwitchTrace.mark("transcript.queue.loaded", workspace: workspace?.id) await history.load(store: store, sessionID: session.id) - isLoaded = true + SwitchTrace.mark("transcript.checkpoints.loaded", workspace: workspace?.id) } /// Folds a stored message into the row list, pairing tool results onto their tool call. @@ -739,22 +790,9 @@ final class TranscriptModel { sending = delivery } - // And the list is taken to it, on the same frame, for the same reason the bubble is drawn - // before anything is awaited: what somebody just asked for should be the thing they are - // looking at. - // - // The bug this closes was Merge. The button composes a turn and sends it down this path, - // the transcript did not follow, and the request landed below the fold with the jump pill - // floating over the top of it: the one thing the owner had just triggered was the one - // thing off screen. It was never only Merge. Create pull request, Push, the continuation - // after a merge, a review sent with comments and a reply typed into a banner all arrive - // here, and a message typed into the composer while the reader was scrolled up did the - // same. One queue in, one rule about where the list sits. - // - // `jumpToLiveEnd` rather than a scroll of its own: it is the jump pill's own request, so - // this reuses the display-link glide in `TranscriptListView.goToLiveEnd`, which already - // asks `TranscriptMotion` whether Reduce Motion is on and jumps instead when it is. - jumpToLiveEnd() + // Sending does not move a reader who is looking at older messages. The visible delivery + // still appears at the live end, and the jump pill remains the explicit way to reach it. + // A reader already at the end keeps following through the table's existing end hold. do { _ = try await store.enqueueDelivery(delivery, clearingDraftMatching: submittedDraft, sourcePlan: sourcePlan) @@ -1191,7 +1229,7 @@ final class TranscriptModel { // bubble claiming to have been said to an agent that was never built. It takes a database // that never opened, so it is close to impossible, and a message of the owner's // disappearing without a word is not a thing to leave resting on that. - guard let runner = ensureRunner() else { + guard let runner = await ensureRunner() else { await abandon(delivery, saying: "Bloom could not open an agent for this chat.") return false } @@ -1472,7 +1510,7 @@ final class TranscriptModel { /// nil forever if the open in `AppModel.bootstrap` throws while `isLoaded` is set true, so a /// second caller of this would take the app down. There is no reason for the guarantee to be /// somewhere other than here. - private func ensureRunner() -> (any SessionRunner)? { + private func ensureRunner() async -> (any SessionRunner)? { guard !usesInteractiveTerminal else { return nil } guard !isWorkspaceArchiving else { return nil } guard let store else { return nil } @@ -1494,13 +1532,20 @@ final class TranscriptModel { // minted for that workspace and the role its origin says; Ask Bloom gets the owner's own, // which is the same door the owner's terminal comes in through and the reason every owner // tool works here without one of them being written twice. + let profile: AgentAccountProfile? + do { + profile = try await store.accountForLaunch(session: session) + } catch { + return nil + } let bridge = workspace.map { app.bridge?.register(session: session, workspace: $0) } ?? app.bridge?.register(askSession: session) let runner = Self.makeRunner( session: session, workspacePath: cwd, store: store, - bridge: bridge + bridge: bridge, + accountHome: profile.map(store.agentAccountHome) ) self.runner = runner runnerPreferences = preferences @@ -1526,7 +1571,8 @@ final class TranscriptModel { session: Session, workspacePath: String, store: Store, - bridge: BridgeHandle? = nil + bridge: BridgeHandle? = nil, + accountHome: String? = nil ) -> any SessionRunner { switch session.agentKind { case .codex: @@ -1534,14 +1580,16 @@ final class TranscriptModel { workspacePath: workspacePath, session: session, store: store, - bridge: bridge?.attachment + bridge: bridge?.attachment, + accountHome: accountHome ) case .grok: return GrokRunner( workspacePath: workspacePath, session: session, store: store, - bridge: bridge?.attachment + bridge: bridge?.attachment, + accountHome: accountHome ) // Cursor and OpenCode have no runner, and `AgentKind.canRunWorkspaces` is what stops a // chat ever being on one. A chat that somehow is falls back to Claude Code rather than @@ -1552,7 +1600,8 @@ final class TranscriptModel { workspacePath: workspacePath, session: session, store: store, - mcpConfigPath: bridge?.mcpConfigPath + mcpConfigPath: bridge?.mcpConfigPath, + accountHome: accountHome ) } } @@ -1730,6 +1779,17 @@ final class TranscriptModel { await handle(event) } + /// The append profiler needs the same row mutation as a stored arrival, without a database or + /// runner. Its synthetic messages never leave the disposable probe process. + func seedRowsForProbe(_ messages: [Message]) { + rows = Self.rows(from: messages) + presentationRevision += 1 + } + + func appendRowForProbe(_ message: Message) { + absorb(message) + } + private func handle(_ event: AgentEvent) async { switch event { case .initialized: @@ -1923,6 +1983,10 @@ final class TranscriptModel { statusLabel = isRunning ? "Working" : nil } await refreshSession() + // An answer releases the question hold. Claude Code and Codex can receive the + // messages queued behind it during the same turn, so this is a queue trigger too. + // The runner emits this only after writing the answer to the provider. + if !isAwaitingPermission { await drain() } case .rateLimit(let raw): // Both backends converge on this one event, and until now it landed here and stopped. @@ -2252,9 +2316,13 @@ final class TranscriptModel { private func notifyFinished(result: AgentResult) async { guard let store else { return } - // Every line below is about a worktree: an unread mark on a sidebar row, a workspace model - // to refresh, a banner naming where the turn finished. A chat with none is a chat the - // owner is looking at, so there is nothing to mark and nobody to tell. + if workspace == nil { + let wasViewed = app.selection == .conversation(session.id) + if wasViewed { await markAllRead() } + app.ask.noteTurnFinished(session.id, wasViewed: wasViewed) + return + } + // The remaining work is about a worktree: its unread mark, model and notification. guard let workspace, let workspaceNow else { return } try? await store.touch( workspaceID: workspace.id, unread: app.selection.workspaceID != workspace.id diff --git a/Sources/Bloom/State/WorkspaceModel+Chats.swift b/Sources/Bloom/State/WorkspaceModel+Chats.swift index 4f4e5575f..dffdac155 100644 --- a/Sources/Bloom/State/WorkspaceModel+Chats.swift +++ b/Sources/Bloom/State/WorkspaceModel+Chats.swift @@ -25,7 +25,7 @@ extension WorkspaceModel { if let sessionID = tab.agentSessionID, pendingCLILaunches.contains(sessionID) { TerminalView( tab: TerminalTab(id: TerminalTabID(tab.id), workspaceID: workspace.id, title: tab.title), - workspace: workspace, repo: repo, port: port, + owner: .workspace(workspace, repo, port: port), output: (sessions.first { $0.id == sessionID }?.agentKind ?? .claudeCode) .interactiveSetupOutput(prompt: pendingCLIPrompts[sessionID], log: setupOutput) ) diff --git a/Sources/Bloom/State/WorkspaceModel.swift b/Sources/Bloom/State/WorkspaceModel.swift index f3aa2a53b..904b55292 100644 --- a/Sources/Bloom/State/WorkspaceModel.swift +++ b/Sources/Bloom/State/WorkspaceModel.swift @@ -217,6 +217,8 @@ final class WorkspaceModel { /// the same repository are two copies of this property and the store is the one answer between /// them. See `MergeMethodChoice`, which holds the scope argument and the key. private(set) var mergeMethod = MergeMethodChoice.fallback + private(set) var mergeExecution = MergeExecutionChoice.fallback + @ObservationIgnored private var mergeExecutionSaveTask: Task? /// What the last press on the pull request strip left to say, drawn at the top of the /// inspector column. /// @@ -537,10 +539,16 @@ final class WorkspaceModel { usesCLI suppliedCLIChoice: Bool? ) async -> PaneContent? { guard let store, let repo = app.repo(for: workspace) else { return nil } - let defaults = await AppDefaults.loadForNewSessions(from: store) + let defaults = await AppDefaults.loadForNewSessions( + from: store, models: ComposerModelCatalog.shared.models + ) + let loadedSettings = SettingsLoader.load(repo: workspace.path) + let repoSettings = (try? await store.applyingProjectAgentDefaults( + to: loadedSettings, for: repo.id + )) ?? loadedSettings let controls = suppliedControls ?? ComposerControls( defaults: ComposerDefaults.resolve( - repo: SettingsLoader.load(repo: workspace.path), app: defaults, + repo: repoSettings, app: defaults, models: ComposerModelCatalog.shared.models ), isFastMode: defaults.fastMode, outputStyle: defaults.outputStyle, @@ -1310,7 +1318,7 @@ final class WorkspaceModel { if let cliSession, let prompt, !prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { cliDelivery = try? await store?.enqueueDelivery(Delivery(targetSessionID: cliSession.id, body: prompt)) if let terminal = CenterTabStore.shared.terminal(for: cliSession.id, in: workspace.id), - let command = prepareCLICommand(for: cliSession, prompt: prompt) { + let command = await prepareCLICommand(for: cliSession, prompt: prompt) { try? await store?.setSetting(TerminalCommandMemory.key(paneID: terminal.id), command) } } else { @@ -1445,23 +1453,25 @@ final class WorkspaceModel { guard !Task.isCancelled, let terminal = CenterTabStore.shared.terminal(for: cliSession.id, in: workspace.id), sessions.contains(where: { $0.id == cliSession.id }), - let command = prepareCLICommand(for: cliSession, prompt: prompt) else { return } + let command = await prepareCLICommand(for: cliSession, prompt: prompt) else { return } try? FileManager.default.removeItem(at: AgentKind.interactiveStatusURL(sessionID: cliSession.id)) let terminals = TerminalSessionStore.shared terminals.useStore(store) terminals.run(command, inPaneID: terminal.id) _ = terminals.terminal( for: TerminalTab(id: TerminalTabID(terminal.id), workspaceID: workspace.id, title: terminal.title), - workspace: workspace, repo: repo, port: port, directory: terminal.directory + owner: .workspace(workspace, repo, port: port), directory: terminal.directory ) pendingCLILaunches.remove(cliSession.id) pendingCLIPrompts[cliSession.id] = nil } - private func prepareCLICommand(for session: Session, prompt: String) -> String? { + private func prepareCLICommand(for session: Session, prompt: String) async -> String? { do { + let profile = try await store?.accountForLaunch(session: session) return try TerminalSessionStore.shared.prepareCLICommand( - for: session, workspace: workspace, prompt: prompt + for: session, workspace: workspace, prompt: prompt, + accountHome: profile.flatMap { store?.agentAccountHome($0) } ) } catch { app.alert = BloomAlert(title: "Could not launch the agent", message: error.readableMessage) @@ -2178,7 +2188,7 @@ final class WorkspaceModel { // MARK: - The worktree listing - /// Every directory's children, for the All files tab, built once per workspace per launch. + /// Every directory's children, for the All files tab, cached until the worktree changes. /// /// It lives here rather than in the view for two reasons, and the second one is a bug rather /// than a cost. The cost: `git ls-files` on a large worktree is a subprocess and tens of @@ -2190,15 +2200,34 @@ final class WorkspaceModel { /// Whether the listing has been read at all, so the tab can tell "nothing tracked" apart from /// "nobody has looked yet". private(set) var hasReadFileTree = false + private(set) var fileTreeRevision = 0 + private var fileTreeLoadedRevision = -1 private var fileTreeTask: Task? - /// - Parameter force: read it again even though it has been read. For a refresh the user asked - /// for; an arrival never forces, which is the whole point. + func invalidateFileTree(keepListing: Bool = true) { + guard hasReadFileTree || fileTreeTask != nil else { return } + if !keepListing { + fileTree = [:] + hasReadFileTree = false + } + fileTreeRevision += 1 + } + + /// - Parameter force: read it again even though the current revision has been read, for a + /// refresh the user asked for. File system changes advance the revision instead. func refreshFileTree(force: Bool = false) async { - if hasReadFileTree, !force { return } - if let fileTreeTask, !force { return await fileTreeTask.value } + guard !Task.isCancelled else { return } + if hasReadFileTree, fileTreeLoadedRevision == fileTreeRevision, !force { return } + if let fileTreeTask, !force { + await fileTreeTask.value + if !Task.isCancelled, fileTreeLoadedRevision != fileTreeRevision { + await refreshFileTree() + } + return + } fileTreeTask?.cancel() + let revision = fileTreeRevision let worktree = workspace.path let task = Task { [weak self] in let index = await Task.detached(priority: .userInitiated) { @@ -2212,15 +2241,31 @@ final class WorkspaceModel { let deleted = try? await Shell.run( "git", ["ls-files", "--deleted"], cwd: worktree, timeout: .seconds(30) ) + // All files includes ignored build output and local configuration as well as + // tracked and ordinary untracked files. Git still omits its own internal .git + // directory, which is not a worktree file. + let ignored = try? await Shell.run( + "git", ["ls-files", "--others", "--ignored", "--exclude-standard"], + cwd: worktree, timeout: .seconds(30) + ) + let directories = FileTreeDirectoryListing.paths(in: worktree) // Indexed off the main thread as well as read there. Forty thousand paths turned // into a dictionary is not a subprocess, but it is not free either, and the main // thread is what the switch is waiting on. let missing = Set(deleted?.lines ?? []) - return FileTreeNode.index((result?.lines ?? []).filter { !missing.contains($0) }) + return FileTreeNode.index( + ((result?.lines ?? []) + (ignored?.lines ?? [])).filter { !missing.contains($0) } + + directories + ) }.value guard let self, !Task.isCancelled else { return } + guard workspace.path == worktree else { + fileTreeTask = nil + return + } if fileTree != index { fileTree = index } hasReadFileTree = true + fileTreeLoadedRevision = revision fileTreeTask = nil } fileTreeTask = task @@ -2375,6 +2420,23 @@ final class WorkspaceModel { await MergeMethodChoice.save(method, repoID: workspace.repoID, to: store) } + func loadMergeExecution() async { + await mergeExecutionSaveTask?.value + guard let store else { return } + mergeExecution = await MergeExecutionChoice.load(repoID: workspace.repoID, from: store) + } + + func chooseMergeExecution(_ choice: MergeExecution) { + mergeExecution = choice + guard let store else { return } + let previous = mergeExecutionSaveTask + let repoID = workspace.repoID + mergeExecutionSaveTask = Task { + await previous?.value + await MergeExecutionChoice.save(choice, repoID: repoID, to: store) + } + } + /// - Parameter maxAge: how old a cached answer may be. Zero always asks GitHub. func refreshPullRequest(maxAge: Duration = .zero) async { pullRequestTask?.cancel() @@ -2470,6 +2532,16 @@ final class WorkspaceModel { return nil } + func createPullRequestDirectly() async throws -> PullRequest { + guard !isRunning else { + throw GitHubError("Wait for the agent to finish before committing directly.") + } + let created = try await GitHubBridge.createPullRequestDirectly(for: workspace) + await refreshChanges() + await refreshPullRequest() + return created + } + /// Asks this workspace's agent to commit what is outstanding and push the branch. /// /// The agent rather than Bloom, and the reasoning is the same one that put pull request @@ -2556,6 +2628,16 @@ final class WorkspaceModel { return nil } + func mergePullRequestDirectly( + _ pullRequest: PullRequest, method: GitHub.MergeMethod + ) async throws -> DirectMergeOutcome { + let outcome = try await GitHubBridge.mergePullRequest( + pullRequest, base: workspace.baseBranch, method: method, worktree: workspace.path + ) + await refreshPullRequest() + return outcome + } + /// One of Bloom's own turns about landing a branch, with Bloom's rules under it and the /// project's own instructions attached when it has any. /// diff --git a/Sources/Bloom/System/LocalWebClientScript.swift b/Sources/Bloom/System/LocalWebClientScript.swift new file mode 100644 index 000000000..62ce0a96f --- /dev/null +++ b/Sources/Bloom/System/LocalWebClientScript.swift @@ -0,0 +1,996 @@ +import Foundation + +/// Browser behaviour stays separate from the HTML and theme. Provider payloads never enter here; +/// this script renders the versioned rows the host has already interpreted. +enum LocalWebClientScript { + static let javascript = #""" + const $ = id => document.getElementById(id); + const localDev = document.documentElement.dataset.devBrowser === 'true' + && window.location.hostname === '127.0.0.1'; + let token = sessionStorage.getItem('bloom-access') || ''; + let workspace = null, chat = null, lastSeq = null, earliestSeq = null, poll = null, fetching = false; + let projectRows = [], workspaceRows = []; + const shown = new Set(), tools = new Map(), permissions = new Map(), messageHistory = new Map(); + const drafts = new Map(JSON.parse(sessionStorage.getItem('bloom-drafts') || '[]')); + const uploadsByChat = new Map( + JSON.parse(sessionStorage.getItem('bloom-uploads') || '[]').map(([id, entries]) => [id, new Map(entries)]) + ); + let uploading = 0, sending = false, quickApplying = false; + let composerState = null, quickPromptRows = []; + + function saveComposer() { + sessionStorage.setItem('bloom-drafts', JSON.stringify([...drafts])); + sessionStorage.setItem('bloom-uploads', JSON.stringify( + [...uploadsByChat].map(([id, entries]) => [id, [...entries]]) + )); + } + + async function api(path, options = {}) { + const response = await fetch(path, { + ...options, + headers: { Authorization: `Bearer ${token}`, ...(options.headers || {}) }, + cache: 'no-store' + }); + if (response.status === 401) { + sessionStorage.removeItem('bloom-access'); + if (!localDev) $('login').hidden = false; + throw Error('Access code was not accepted.'); + } + if (!response.ok) throw Error(await response.text()); + return response.json(); + } + + function empty(title, detail) { + const box = document.createElement('div'); box.className = 'empty'; + const heading = document.createElement('strong'); heading.textContent = title; + box.append(heading, document.createTextNode(detail)); $('messages').replaceChildren(box); + } + + function earlierButton() { + const button = document.createElement('button'); + button.id = 'load-earlier'; button.className = 'load-earlier'; button.type = 'button'; + button.textContent = 'Load earlier messages'; button.hidden = true; + button.addEventListener('click', () => loadEarlier().catch(showError)); + return button; + } + + function showError(error) { + $('status').textContent = error.message || 'Connection lost'; + $('status').style.color = 'var(--negative)'; + } + + function rememberDraft() { + if (chat) { drafts.set(chat, $('prompt').value); saveComposer(); } + } + + function renderUploads() { + const container = $('uploads'); container.replaceChildren(); + for (const upload of uploadsByChat.get(chat)?.values() || []) { + const chip = document.createElement('span'); chip.className = 'upload-chip'; + const label = document.createElement('span'); label.textContent = upload.name; + const remove = document.createElement('button'); remove.type = 'button'; + remove.title = `Remove ${upload.name}`; remove.textContent = '×'; + remove.addEventListener('click', () => removeUpload(upload)); + chip.append(label, remove); container.append(chip); + } + } + + function insertUpload(token, at) { + const field = $('prompt'); const current = field.value; + const offset = Math.min(at, current.length); + const before = current.slice(0, offset), after = current.slice(offset); + const lead = before && !/\s$/.test(before) ? ' ' : ''; + const trail = after && /^\s/.test(after) ? '' : ' '; + field.value = before + lead + token + trail + after; + field.selectionStart = field.selectionEnd = offset + lead.length + token.length + trail.length; + drafts.set(chat, field.value); saveComposer(); field.focus(); + return field.selectionStart; + } + + async function uploadFiles(files) { + if (!chat || !files.length) return; + const current = chat; let offset = $('prompt').selectionStart; + uploading += 1; $('send').disabled = true; $('attach-file').disabled = true; + $('hint').textContent = 'Attaching files…'; + try { + for (const file of files) { + if (file.size > 25 * 1024 * 1024) throw Error(`${file.name} is over the 25 MB browser limit.`); + const added = await api(`/api/chats/${encodeURIComponent(current)}/attachments`, { + method:'POST', headers:{ 'Content-Type':'application/octet-stream', + 'X-Bloom-Filename':encodeURIComponent(file.name || 'attachment') }, body:file + }); + let held = uploadsByChat.get(current); + if (!held) { held = new Map(); uploadsByChat.set(current, held); } + held.set(added.path, added); + if (chat === current) { offset = insertUpload(added.token, offset); renderUploads(); } + else { drafts.set(current, `${drafts.get(current) || ''} ${added.token}`.trim()); saveComposer(); } + } + } catch(error) { showError(error); } + finally { + uploading -= 1; $('hint').textContent = ''; + $('attach-file').disabled = !chat || uploading > 0; + $('send').disabled = !chat || uploading > 0 || sending; + } + } + + async function removeUpload(upload) { + if (!chat) return; + const current = chat; + try { + await api(`/api/chats/${encodeURIComponent(current)}/attachments/remove`, { + method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({path:upload.path}) + }); + uploadsByChat.get(current)?.delete(upload.path); + if (chat === current) { + $('prompt').value = $('prompt').value.split(upload.token).join(''); + drafts.set(current, $('prompt').value); renderUploads(); + } + saveComposer(); + } catch(error) { showError(error); } + } + + function composerLabel(state) { + const model = state.models.flatMap(section => section.options).find(item => item.value === state.model); + const effort = state.efforts.find(item => item.value === state.effort); + return `${model?.label || state.model} · ${effort?.label || state.effort}`; + } + + function renderContext() { + const detail = $('context-detail'); detail.replaceChildren(); + if (!composerState?.contextLimit) return; + const used = composerState.contextUsed; + const limit = composerState.contextLimit; + const percent = Math.round(100 * used / limit); + const heading = document.createElement('div'); heading.className = 'context-row'; + heading.innerHTML = 'Context window'; + const value = document.createElement('span'); value.textContent = `${percent}%`; heading.append(value); + const track = document.createElement('div'); track.className = 'context-track'; + const fill = document.createElement('span'); fill.style.width = `${Math.min(percent, 100)}%`; track.append(fill); + const numbers = document.createElement('div'); numbers.className = 'context-row'; + const label = document.createElement('span'); label.textContent = 'In context'; + const amount = document.createElement('span'); amount.textContent = `${used.toLocaleString()} of ${limit.toLocaleString()} tokens`; + numbers.append(label, amount); detail.append(heading, track, numbers); + const note = document.createElement('small'); note.textContent = 'Detailed allocation is not reported by the agent.'; + detail.append(note); + } + + function settingSelect(label, field, options, current, note = '') { + const row = document.createElement('label'); row.textContent = label; + const select = document.createElement('select'); select.dataset.field = field; + for (const item of options) { + if (item.options) { + const group = document.createElement('optgroup'); group.label = item.title; + for (const choice of item.options) { + const option = document.createElement('option'); option.value = choice.value; + option.textContent = choice.label; option.title = choice.detail || ''; + group.append(option); + } + select.append(group); + } else { + const option = document.createElement('option'); + option.value = String(item.value ?? item.tokens); + option.textContent = item.label; option.title = item.detail || ''; + select.append(option); + } + } + select.value = String(current); row.append(select); + if (note) { const help = document.createElement('small'); help.textContent = note; row.append(help); } + return row; + } + + function renderAgentFields() { + const fields = $('agent-fields'); fields.replaceChildren(); + if (!composerState) return; + const state = composerState; + if (state.presets.length) fields.append(settingSelect('Preset', 'preset', [ + {value:'',label:'Custom'}, ...state.presets.map(item => ({value:item.key,label:item.name})) + ], state.matchedPreset || '')); + fields.append(settingSelect('Model', 'model', state.models, state.model)); + fields.append(settingSelect('Reasoning effort', 'effort', state.efforts, state.effort)); + fields.append(settingSelect('Permissions', 'permissionMode', state.permissionModes, state.permissionMode)); + if (state.offersInteractionMode) fields.append(settingSelect('Work mode', 'interactionMode', [ + {value:'build',label:'Build'}, + ...(state.planAvailable || state.interactionMode === 'plan' ? [{value:'plan',label:'Plan'}] : []) + ], state.interactionMode)); + if (state.outputStyles.length) fields.append(settingSelect( + 'Output style', 'outputStyle', state.outputStyles, state.outputStyle + )); + if (state.contextWindows.length) fields.append(settingSelect( + 'Context window', 'contextWindow', state.contextWindows, state.contextWindow, + 'Changing this restarts Codex for this chat. Session permissions are asked again.' + )); + } + + function renderComposer() { + if (!composerState) return; + $('chat-settings').textContent = composerLabel(composerState); + $('agent-settings').title = `Agent settings: ${composerLabel(composerState)}`; + $('fast-mode').hidden = !composerState.fastAvailable; + $('fast-mode').setAttribute('aria-pressed', String(composerState.fastMode)); + $('context-usage').hidden = !composerState.contextLimit; + if (composerState.contextLimit) { + const percent = Math.round(100 * composerState.contextUsed / composerState.contextLimit); + $('context-usage').textContent = `${percent}%`; + $('context-usage').title = `${percent}% of context window used`; + } + $('side-question').hidden = !composerState.canSideQuestion; + if ($('agent-dialog').open) renderAgentFields(); + if ($('context-dialog').open) renderContext(); + } + + async function loadComposer(current = chat) { + if (!current) return; + const state = await api(`/api/chats/${encodeURIComponent(current)}/composer`); + if (chat !== current) return; + composerState = state; renderComposer(); + } + + async function followNewChat(id, draft = '') { + drafts.set(id, draft); saveComposer(); + if (workspace) sessionStorage.setItem('bloom-chat', id); + else { workspace = null; chat = null; sessionStorage.setItem('bloom-conversation', id); } + await loadWorkspaces(); + } + + async function changeComposer(field, value) { + if (!chat || !composerState) return; + const current = chat; + const body = {field, draft:$('prompt').value}; + if (field === 'fastMode') body.enabled = value; + else if (field === 'contextWindow') body.tokens = Number(value); + else body.value = value; + $('agent-fields').querySelectorAll('select').forEach(item => item.disabled = true); + $('fast-mode').disabled = true; + try { + const result = await api(`/api/chats/${encodeURIComponent(current)}/composer`, { + method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body) + }); + if (result.forked) { + $('agent-dialog').close(); + await followNewChat(result.chatID, $('prompt').value); + } else await loadComposer(current); + } catch(error) { showError(error); await loadComposer(current); } + finally { + $('agent-fields').querySelectorAll('select').forEach(item => item.disabled = false); + $('fast-mode').disabled = false; + } + } + + function renderQuickPrompts() { + const query = $('quick-search').value.trim().toLowerCase(); + const list = $('quick-list'); list.replaceChildren(); + for (const prompt of quickPromptRows.filter(item => + `${item.name} ${item.preview}`.toLowerCase().includes(query) + )) { + const button = document.createElement('button'); button.type = 'button'; + const name = document.createElement('strong'); name.textContent = prompt.name || prompt.preview; + const preview = document.createElement('small'); preview.textContent = prompt.preview; + button.append(name, preview); + button.addEventListener('click', () => applyQuickPrompt(prompt)); list.append(button); + } + if (!list.childElementCount) { + const empty = document.createElement('div'); empty.className = 'quick-empty'; + empty.textContent = 'No matching quick prompts.'; list.append(empty); + } + } + + async function applyQuickPrompt(prompt) { + if (!chat || quickApplying) return; + const current = chat; + quickApplying = true; + $('quick-list').querySelectorAll('button').forEach(button => button.disabled = true); + try { + const result = await api(`/api/chats/${encodeURIComponent(current)}/quick-prompts`, { + method:'POST', headers:{'Content-Type':'application/json'}, + body:JSON.stringify({source:prompt.source,key:prompt.key, + draft:$('prompt').value,caret:$('prompt').selectionStart}) + }); + $('quick-dialog').close(); + if (result.chatID) { await followNewChat(result.chatID, result.draft); return; } + if (chat !== current) return; + $('prompt').value = result.draft; + $('prompt').selectionStart = $('prompt').selectionEnd = result.caret; + rememberDraft(); $('prompt').focus(); + if (result.sent) await refresh(); + } catch(error) { showError(error); } + finally { + quickApplying = false; + if ($('quick-dialog').open) $('quick-list').querySelectorAll('button').forEach(button => button.disabled = false); + } + } + + function collapseSidebar() { + $('sidebar').classList.remove('expanded'); + $('mobile-sidebar').setAttribute('aria-expanded', 'false'); + } + + async function loadWorkspaces() { + const [projects, rows, conversations] = await Promise.all([ + api('/api/projects'), api('/api/workspaces'), api('/api/conversations') + ]); + projectRows.splice(0, projectRows.length, ...projects); + workspaceRows.splice(0, workspaceRows.length, ...rows); + const container = $('workspaces'); container.replaceChildren(); + for (const project of projects) { + const heading = document.createElement('div'); heading.className = 'project-heading'; + const icon = document.createElement('span'); icon.className = 'project-icon'; + icon.textContent = project.name.slice(0, 1).toUpperCase(); + if (/^[0-9a-fA-F]{6}$/.test(project.accent)) icon.style.backgroundColor = `#${project.accent}`; + const name = document.createElement('span'); name.textContent = project.name; + const add = document.createElement('button'); add.type = 'button'; add.textContent = '+'; + add.title = `New workspace in ${project.name}`; + add.addEventListener('click', () => showCreate('workspace', project)); + heading.append(icon, name, add); container.append(heading); + for (const row of rows.filter(item => item.projectID === project.id)) { + const button = document.createElement('button'); button.className = 'workspace'; + button.type = 'button'; button.dataset.workspace = row.id; + const mark = document.createElement('span'); mark.className = 'mark'; + if (/^[0-9a-fA-F]{6}$/.test(project.accent)) mark.style.backgroundColor = `#${project.accent}`; + const name = document.createElement('span'); name.className = 'name'; name.textContent = row.name; + const branch = document.createElement('span'); branch.className = 'branch'; branch.textContent = row.branch; + button.append(mark, name, branch); + button.addEventListener('click', () => selectWorkspace(row)); container.append(button); + } + } + const conversationList = $('conversations'); conversationList.replaceChildren(); + for (const entry of conversations) { + const button = document.createElement('button'); button.className = 'conversation'; + button.type = 'button'; button.dataset.conversation = entry.id; + button.textContent = entry.title; + button.addEventListener('click', () => selectConversation(entry)); conversationList.append(button); + } + const savedWorkspace = workspace || sessionStorage.getItem('bloom-workspace'); + const savedConversation = !workspace && (chat || sessionStorage.getItem('bloom-conversation')); + const savedChat = sessionStorage.getItem('bloom-chat'); + if (savedWorkspace && rows.some(row => row.id === savedWorkspace)) { + await selectWorkspace(rows.find(row => row.id === savedWorkspace), savedChat); + } else if (savedConversation && conversations.some(row => row.id === savedConversation)) { + await selectConversation(conversations.find(row => row.id === savedConversation)); + } else if (projects.length && rows.length) { + const first = projects.map(project => rows.find(row => row.projectID === project.id)).find(Boolean); + await selectWorkspace(first || rows[0]); + } else if (conversations.length) await selectConversation(conversations[0]); + else { $('new-chat').hidden = true; empty('Start in Bloom', 'Create a project or conversation from the sidebar.'); } + } + + async function selectWorkspace(row, preferredChat = null) { + rememberDraft(); + workspace = row.id; chat = null; lastSeq = null; earliestSeq = null; + collapseSidebar(); + $('inspector').classList.remove('requested'); $('toggle-inspector').hidden = false; + sessionStorage.setItem('bloom-workspace', row.id); sessionStorage.removeItem('bloom-conversation'); + $('new-chat').hidden = false; + shown.clear(); tools.clear(); permissions.clear(); messageHistory.clear(); clearInterval(poll); poll = null; + $('title').textContent = row.name; $('subtitle').textContent = `${row.project} · ${row.branch}`; + $('messages').replaceChildren(); $('prompt').disabled = true; $('send').disabled = true; + $('attach-file').disabled = true; $('uploads').replaceChildren(); + document.querySelectorAll('.workspace').forEach(button => { + button.classList.toggle('selected', button.dataset.workspace === row.id); + }); + document.querySelectorAll('.conversation').forEach(button => button.classList.remove('selected')); + const rows = await api(`/api/workspaces/${encodeURIComponent(row.id)}/chats`); + if (workspace !== row.id) return; + const container = $('chats'); container.replaceChildren(); + container.hidden = rows.length <= 1; + for (const entry of rows) { + const button = document.createElement('button'); button.className = 'chat-tab'; + button.type = 'button'; button.dataset.chat = entry.id; button.textContent = entry.title; + button.title = `${entry.title} · ${entry.agent}`; + button.addEventListener('click', () => selectChat(entry)); container.append(button); + } + if (rows.length) await selectChat(rows.find(entry => entry.id === preferredChat) || rows[0]); + else empty('No conversations yet', 'Use the plus button above to create a chat.'); + loadChanges(row.id).catch(showError); + } + + async function selectConversation(entry) { + rememberDraft(); + workspace = null; chat = null; lastSeq = null; earliestSeq = null; + collapseSidebar(); + sessionStorage.setItem('bloom-conversation', entry.id); sessionStorage.removeItem('bloom-workspace'); + $('new-chat').hidden = true; + shown.clear(); tools.clear(); permissions.clear(); messageHistory.clear(); clearInterval(poll); poll = null; + $('chats').replaceChildren(); + $('chats').hidden = true; + $('inspector').classList.remove('open', 'requested'); $('toggle-inspector').hidden = true; + document.querySelectorAll('.workspace').forEach(button => button.classList.remove('selected')); + document.querySelectorAll('.conversation').forEach(button => { + button.classList.toggle('selected', button.dataset.conversation === entry.id); + }); + await selectChat(entry); + } + + async function selectChat(entry) { + rememberDraft(); + chat = entry.id; lastSeq = null; earliestSeq = null; + composerState = null; + if (workspace) sessionStorage.setItem('bloom-chat', entry.id); + shown.clear(); tools.clear(); permissions.clear(); messageHistory.clear(); clearInterval(poll); + $('title').textContent = entry.title; $('subtitle').textContent = `${entry.agent} · ${entry.state}`; + $('messages').replaceChildren(earlierButton()); $('prompt').disabled = false; + $('prompt').value = drafts.get(entry.id) || ''; + $('send').disabled = uploading > 0 || sending; $('attach-file').disabled = uploading > 0; + $('agent-settings').disabled = false; $('quick-prompts').disabled = false; + $('fast-mode').hidden = true; $('context-usage').hidden = true; $('side-question').hidden = true; + renderUploads(); + $('chat-settings').textContent = [entry.model || entry.agent, entry.effort].filter(Boolean).join(' · '); + $('hint').textContent = ''; + document.querySelectorAll('.chat-tab').forEach(button => { + button.classList.toggle('selected', button.dataset.chat === entry.id); + }); + await refresh(); + loadComposer(entry.id).catch(showError); + poll = setInterval(() => refresh().catch(showError), 1200); + } + + function prose(row) { + const body = document.createElement('div'); body.className = 'prose'; + // The host renders this with MarkdownHTML, which escapes markup and filters addresses. + if (row.html !== null && row.html !== undefined) body.innerHTML = row.html; + else body.textContent = row.text; + if (chat && row.files?.length) { + linkReferences(body, row.files); + for (const anchor of body.querySelectorAll('a[href]')) { + const file = row.files.find(item => item.label === anchor.textContent); + if (!file) continue; + anchor.removeAttribute('href'); anchor.classList.add('file-link'); + anchor.tabIndex = 0; anchor.setAttribute('role', 'button'); + anchor.addEventListener('click', () => openFile(file)); + anchor.addEventListener('keydown', event => { if (event.key === 'Enter') openFile(file); }); + } + } + return body; + } + + function linkReferences(body, files) { + const byLabel = new Map(files.map(file => [file.label, file])); + const labels = [...byLabel.keys()].sort((a, b) => b.length - a.length); + const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT); + const nodes = []; while (walker.nextNode()) nodes.push(walker.currentNode); + for (const node of nodes) { + if (node.parentElement.closest('a,pre')) continue; + const text = node.textContent; let position = 0; const fragment = document.createDocumentFragment(); + while (position < text.length) { + let hit = null, index = text.length; + for (const label of labels) { + const found = text.indexOf(label, position); + if (found >= 0 && found < index) { hit = label; index = found; } + } + if (!hit) break; + fragment.append(document.createTextNode(text.slice(position, index))); + const file = byLabel.get(hit); const link = document.createElement('button'); + link.className = 'file-link'; link.type = 'button'; link.textContent = hit; + link.addEventListener('click', () => openFile(file)); fragment.append(link); + position = index + hit.length; + } + if (position) { fragment.append(document.createTextNode(text.slice(position))); node.replaceWith(fragment); } + } + } + + async function openFile(file) { + if (!chat) return; + $('inspector').classList.add('open', 'requested'); $('file-title').textContent = file.path; + $('file-content').textContent = 'Loading file…'; + try { + const result = await api(`/api/chats/${encodeURIComponent(chat)}/files?path=${encodeURIComponent(file.path)}`); + const back = document.createElement('button'); back.className = 'back-to-changes'; + back.type = 'button'; back.textContent = workspace ? '‹ Changes' : '‹ Close file'; + back.addEventListener('click', () => workspace + ? loadChanges(workspace).catch(showError) + : $('inspector').classList.remove('open', 'requested')); + if (result.imageDataURL) { + const picture = document.createElement('img'); picture.className = 'file-image'; + picture.src = result.imageDataURL; picture.alt = file.path; + $('file-content').replaceChildren(back, picture); + return; + } + const pre = document.createElement('pre'); + const lines = (result.content || '').split('\n'); + lines.forEach((line, index) => { + const row = document.createElement('div'); row.className = 'file-line'; + if (index + 1 === file.line) row.classList.add('focused'); + const number = document.createElement('span'); number.textContent = index + 1; + row.append(number, document.createTextNode(line)); pre.append(row); + }); + $('file-content').replaceChildren(back, pre); + $('file-content').querySelector('.focused')?.scrollIntoView({ block:'center' }); + } catch(error) { $('file-content').textContent = error.message; } + } + + async function loadChanges(id) { + let files; + try { files = await api(`/api/workspaces/${encodeURIComponent(id)}/changes`); } + catch(error) { + if (workspace === id) { + $('inspector').classList.add('open'); $('file-title').textContent = 'Changes'; + $('file-content').textContent = 'Bloom could not read changes for this workspace.'; + } + return; + } + if (workspace !== id) return; + $('inspector').classList.add('open'); $('file-title').textContent = `Changes (${files.length})`; + const list = document.createElement('div'); list.className = 'changes-list'; + if (!files.length) { + const empty = document.createElement('div'); empty.className = 'inspector-empty'; + const icon = document.createElement('span'); icon.className = 'inspector-empty-icon'; + icon.setAttribute('aria-hidden', 'true'); icon.textContent = '✓'; + const title = document.createElement('strong'); title.textContent = 'No changes yet'; + const detail = document.createElement('span'); + detail.textContent = 'Nothing in this workspace differs from its base branch.'; + empty.append(icon, title, detail); $('file-content').replaceChildren(empty); + return; + } + for (const file of files) { + const button = document.createElement('button'); button.type = 'button'; button.className = 'change-file'; + const status = document.createElement('span'); status.textContent = file.change; + const name = document.createElement('span'); name.textContent = file.path; name.title = file.path; + const count = document.createElement('small'); count.textContent = `+${file.additions} −${file.deletions}`; + button.append(status, name, count); button.addEventListener('click', () => openFile({path:file.path,line:1})); + list.append(button); + } + $('file-content').replaceChildren(list); + } + + function activity(row, glyph) { + const line = document.createElement('div'); line.className = 'activity-line'; + const mark = document.createElement('span'); mark.className = 'glyph'; mark.textContent = glyph; + const title = document.createElement('span'); title.className = 'activity-title'; title.textContent = row.title; + const detail = document.createElement('span'); detail.className = 'activity-detail'; + detail.textContent = row.detail; + line.append(mark, title, detail); return line; + } + + function toolGlyph(row) { + if (/shell|command|terminal/i.test(row.title)) return '›'; + if (/read|search|find/i.test(row.title)) return '⌕'; + if (/write|edit|patch/i.test(row.title)) return '✎'; + return '◦'; + } + + function addResult(row) { + const target = tools.get(row.refID); + if (target) { + const output = target.querySelector('.tool-output'); + output.textContent = row.text || (row.isError ? 'Failed' : 'Completed'); + output.classList.toggle('failure', row.isError); + if (row.isError) target.dataset.tint = 'negative'; + return; + } + const orphan = document.createElement('article'); orphan.className = 'entry tool'; + const details = document.createElement('details'); const summary = document.createElement('summary'); + summary.setAttribute('role', 'button'); summary.setAttribute('aria-label', row.title); + summary.append(activity(row, row.isError ? '!' : '✓')); + const output = document.createElement('div'); output.className = 'tool-output'; + output.textContent = row.text; details.append(summary, output); orphan.append(details); + $('messages').append(orphan); + } + + function addChips(element, labels, files = []) { + if (!labels?.length) return; + const chips = document.createElement('span'); chips.className = 'chip-list'; + for (const label of labels) { + const file = chat && files.find(item => item.label === label); + const chip = document.createElement(file ? 'button' : 'span'); chip.className = 'chip'; + chip.textContent = label; chips.append(chip); + if (file) { chip.type = 'button'; chip.addEventListener('click', () => openFile(file)); } + } + element.append(chips); + } + + async function answerPermission(row, payload, actions, note, outcome) { + actions.querySelectorAll('button,input').forEach(item => item.disabled = true); + try { + await api(`/api/chats/${encodeURIComponent(chat)}/permissions/${encodeURIComponent(row.requestID)}`, { + method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(payload) + }); + note.textContent = outcome; actions.remove(); + } catch(error) { + showError(error); + actions.querySelectorAll('button,input').forEach(item => item.disabled = false); + } + } + + function questionForm(row, note) { + const form = document.createElement('form'); form.className = 'question-form'; + for (const [index, question] of row.questions.entries()) { + const group = document.createElement('fieldset'); group.dataset.question = question.answerKey; + const heading = document.createElement('legend'); + if (question.header) { + const header = document.createElement('span'); header.className = 'question-header'; + header.textContent = question.header; heading.append(header); + } + const questionText = document.createElement('span'); questionText.textContent = question.text; + heading.append(questionText); + group.append(heading); + if (question.multiSelect) { + const hint = document.createElement('small'); hint.className = 'question-hint'; + hint.textContent = 'Choose as many as apply.'; group.append(hint); + } + for (const option of question.options) { + const label = document.createElement('label'); label.className = 'question-option'; + const input = document.createElement('input'); input.className = 'option-input'; + input.type = question.multiSelect ? 'checkbox' : 'radio'; + input.name = `question-${row.seq}-${index}`; input.value = option.label; + const words = document.createElement('span'); words.textContent = option.label; + if (option.detail) { + const detail = document.createElement('small'); detail.textContent = option.detail; words.append(detail); + } + input.addEventListener('change', () => { + const other = group.querySelector('.other-answer'); if (other) other.value = ''; + updateSend(); + }); + label.append(input, words); group.append(label); + if (option.preview) { + const preview = document.createElement('pre'); preview.textContent = option.preview; group.append(preview); + } + } + if (question.allowsOther || !question.options.length) { + const label = document.createElement('label'); label.className = 'question-other'; + label.textContent = question.options.length ? 'Other answer' : 'Your answer'; + const input = document.createElement('input'); input.className = 'other-answer'; + input.type = question.isSecret ? 'password' : 'text'; + input.autocomplete = 'off'; + input.addEventListener('input', () => { + if (input.value.trim()) group.querySelectorAll('.option-input').forEach(choice => choice.checked = false); + updateSend(); + }); + label.append(input); group.append(label); + } + form.append(group); + } + const send = document.createElement('button'); send.className = 'primary'; + send.textContent = 'Send answer'; send.disabled = true; form.append(send); + function updateSend() { + send.disabled = !Array.from(form.querySelectorAll('fieldset')).every(group => + !!group.querySelector('.option-input:checked') || !!group.querySelector('.other-answer')?.value.trim() + ); + } + form.addEventListener('submit', event => { + event.preventDefault(); + const choices = {}, other = {}; + for (const [index, question] of row.questions.entries()) { + const group = form.querySelectorAll('fieldset')[index]; + choices[question.answerKey] = Array.from(group.querySelectorAll('.option-input:checked')).map(item => item.value); + other[question.answerKey] = group.querySelector('.other-answer')?.value || ''; + } + answerPermission(row, {decision:'answer', choices, other}, form, note, 'Answered'); + }); + return form; + } + + function appendMessage(row, temporary = false) { + const key = `${row.seq}:${row.kind}`; + if (!temporary && shown.has(key)) return; + if (!temporary) shown.add(key); + if (row.kind === 'turnEnd') return; + if (row.kind === 'toolResult') { addResult(row); return; } + const element = document.createElement('article'); element.className = `entry ${row.kind}`; + if (temporary) element.id = 'streaming'; + if (row.parentToolUseID) element.classList.add('nested'); + if (['user', 'assistant', 'crew', 'plan'].includes(row.kind)) { + if (row.kind === 'crew' || row.kind === 'plan') { + const title = document.createElement('div'); title.className = 'card-title'; + title.textContent = row.title; element.append(title); + } + element.append(prose(row)); addChips(element, row.chips, row.files); + } else if (row.kind === 'tool') { + element.dataset.tint = row.tint; + const details = document.createElement('details'); const summary = document.createElement('summary'); + summary.setAttribute('role', 'button'); + summary.setAttribute('aria-label', `${row.title} ${row.detail}`.trim()); + summary.append(activity(row, toolGlyph(row))); + const output = document.createElement('div'); output.className = 'tool-output'; + output.textContent = 'Waiting for result'; + details.append(summary, output); element.append(details); addChips(element, row.chips, row.files); + if (row.refID) tools.set(row.refID, element); + } else if (row.kind === 'thinking') { + const details = document.createElement('details'); const summary = document.createElement('summary'); + summary.setAttribute('role', 'button'); summary.setAttribute('aria-label', 'Thinking'); + summary.append(activity(row, '◌')); + const body = document.createElement('div'); body.className = 'thinking-body'; body.textContent = row.text; + details.append(summary, body); element.append(details); + } else if (row.kind === 'permission' || row.kind === 'error') { + const title = document.createElement('div'); title.className = 'card-title'; title.textContent = row.title; + const body = document.createElement('div'); body.textContent = row.questions?.length ? '' : row.text; + const note = document.createElement('div'); note.className = 'card-note'; + note.textContent = row.kind === 'permission' ? (row.canAnswer ? '' : 'Answer this in Bloom on the host Mac.') : row.detail; + element.append(title, body, note); if (row.requestID) permissions.set(row.requestID, note); + if (row.kind === 'permission' && row.canAnswer && row.requestID) { + if (row.questions?.length) { + element.append(questionForm(row, note)); + } else { + const actions = document.createElement('div'); actions.className = 'permission-actions'; + const options = row.planMode + ? [['Approve and implement','approve_plan','Approved'], ['Keep planning','keep_planning','Still planning']] + : [['Allow once','allow_once','Allowed once'], ['Deny','deny','Denied']]; + if (row.planMode) note.textContent = `Implementation permissions: ${row.planMode}`; + for (const [label, decision, outcome] of options) { + const button = document.createElement('button'); button.type = 'button'; button.textContent = label; + if (decision === 'allow_once' || decision === 'approve_plan') button.className = 'primary'; + button.addEventListener('click', () => answerPermission( + row, {decision, mode:row.planMode}, actions, note, outcome + )); + actions.append(button); + } + element.append(actions); + } + } + } else { + element.append(activity(row, row.kind === 'session' ? '◉' : '•')); + if (row.text) { + const text = document.createElement('div'); text.className = 'card-note'; + text.textContent = row.text; element.append(text); + } + } + $('messages').append(element); + } + + function updateDecisions(decisions) { + for (const [request, decision] of Object.entries(decisions || {})) { + const note = permissions.get(request); + if (note) { + note.textContent = `Answered: ${decision}`; + note.parentElement.querySelector('.permission-actions')?.remove(); + note.parentElement.querySelector('.question-form')?.remove(); + } + } + } + + async function loadEarlier() { + if (!chat || fetching || earliestSeq === null || earliestSeq <= 0) return; + fetching = true; + const current = chat; + const pane = $('messages'); const oldHeight = pane.scrollHeight; const oldTop = pane.scrollTop; + const button = $('load-earlier'); button.disabled = true; + try { + const data = await api(`/api/chats/${encodeURIComponent(current)}/messages?before=${earliestSeq}`); + if (chat !== current) return; + if (data.schemaVersion !== 1) throw Error('Bloom was updated. Reload this page.'); + data.messages.forEach(row => messageHistory.set(row.seq, row)); + earliestSeq = data.firstSeq; button.hidden = !data.hasMoreEarlier; + shown.clear(); tools.clear(); permissions.clear(); + pane.replaceChildren(button); + Array.from(messageHistory.values()).sort((a, b) => a.seq - b.seq).forEach(row => appendMessage(row)); + updateDecisions(data.decisions); + pane.scrollTop = oldTop + pane.scrollHeight - oldHeight; + } finally { button.disabled = false; fetching = false; } + } + + async function refresh() { + if (!chat || fetching) return; + fetching = true; + const current = chat; + const initial = lastSeq === null; + const path = `/api/chats/${encodeURIComponent(current)}/messages${lastSeq === null ? '' : '?after=' + lastSeq}`; + try { + const data = await api(path); if (chat !== current) return; + if (data.schemaVersion !== 1) throw Error('Bloom was updated. Reload this page.'); + const pane = $('messages'); + const follow = pane.scrollHeight - pane.scrollTop - pane.clientHeight < 100; + $('streaming')?.remove(); + data.messages.forEach(row => { messageHistory.set(row.seq, row); appendMessage(row); }); + if (data.messages.some(row => row.kind === 'turnEnd')) loadComposer(current).catch(showError); + if (initial) { + earliestSeq = data.firstSeq; + $('load-earlier').hidden = !data.hasMoreEarlier; + } + lastSeq = Math.max(lastSeq ?? -1, data.lastSeq); + updateDecisions(data.decisions); + if (data.streaming) appendMessage({ seq:-1, kind:'assistant', text:data.streaming, html:data.streamingHTML }, true); + if (follow) pane.scrollTop = pane.scrollHeight; + $('status').textContent = ''; $('status').style.color = ''; + } finally { fetching = false; } + } + + let createKind = null, createProject = null; + function showCreate(kind, project = null) { + createKind = kind; createProject = project; + const titles = { project:'New project', addProject:'Add existing project', workspace:`New workspace in ${project?.name || ''}`, chat:'New chat' }; + $('create-title').textContent = titles[kind]; $('create-error').textContent = ''; + const fields = $('create-fields'); fields.replaceChildren(); + function field(name, title, placeholder, multiline = false) { + const label = document.createElement('label'); label.textContent = title; + const input = document.createElement(multiline ? 'textarea' : 'input'); + input.name = name; input.placeholder = placeholder; + input.required = name === 'path' || name === 'location' || name === 'name' && kind === 'project' || name === 'prompt'; + label.append(input); fields.append(label); + } + if (kind === 'project') { field('name', 'Project name', 'my-project'); field('location', 'Parent folder', '~/Developer'); } + if (kind === 'addProject') field('path', 'Git repository folder', '~/Developer/my-project'); + if (kind === 'workspace') { + field('name', 'Workspace name (optional)', 'Describe the task'); + field('baseBranch', 'Base branch (optional)', project.branch); + field('prompt', 'What should the agent do?', 'Describe the work to do', true); + } + if (kind === 'chat') field('title', 'Chat title (optional)', 'New chat'); + $('confirm-create').textContent = kind === 'addProject' ? 'Add project' : 'Create'; + $('create-dialog').showModal(); fields.querySelector('input,textarea')?.focus(); + } + + $('new-project').addEventListener('click', () => showCreate('project')); + $('mobile-sidebar').addEventListener('click', () => { + const open = $('sidebar').classList.toggle('expanded'); + $('mobile-sidebar').setAttribute('aria-expanded', String(open)); + }); + $('add-project').addEventListener('click', () => showCreate('addProject')); + $('new-chat').addEventListener('click', () => { if (workspace) showCreate('chat'); }); + $('attach-file').addEventListener('click', () => $('file-picker').click()); + document.querySelectorAll('[data-close]').forEach(button => { + button.addEventListener('click', () => $(button.dataset.close).close()); + }); + $('agent-settings').addEventListener('click', () => { + if (!chat) return; + $('agent-dialog').showModal(); + $('agent-fields').textContent = composerState ? '' : 'Loading settings…'; + if (composerState) renderAgentFields(); + else loadComposer().catch(showError); + }); + $('agent-fields').addEventListener('change', event => { + if (event.target.matches('select[data-field]')) { + if (event.target.dataset.field === 'preset' && !event.target.value) return; + changeComposer(event.target.dataset.field, event.target.value); + } + }); + $('fast-mode').addEventListener('click', () => { + changeComposer('fastMode', !composerState?.fastMode); + }); + $('context-usage').addEventListener('click', () => { + renderContext(); $('context-dialog').showModal(); + }); + $('quick-prompts').addEventListener('click', async () => { + if (!chat) return; + const current = chat; + $('quick-search').value = ''; + $('quick-list').textContent = 'Loading quick prompts…'; + $('quick-dialog').showModal(); + try { + const rows = await api(`/api/chats/${encodeURIComponent(current)}/quick-prompts`); + if (chat !== current || !$('quick-dialog').open) return; + quickPromptRows = rows; renderQuickPrompts(); $('quick-search').focus(); + } catch(error) { showError(error); } + }); + $('quick-search').addEventListener('input', renderQuickPrompts); + $('side-question').addEventListener('click', () => { + $('side-prompt').value = ''; $('side-dialog').showModal(); $('side-prompt').focus(); + }); + $('side-prompt').addEventListener('keydown', event => { + if (event.key !== 'Enter' || event.shiftKey || event.isComposing) return; + event.preventDefault(); + if (!event.repeat) $('side-form').requestSubmit(); + }); + $('side-form').addEventListener('submit', async event => { + event.preventDefault(); if (!chat) return; + const current = chat; + $('send-side-question').disabled = true; + try { + const result = await api(`/api/chats/${encodeURIComponent(current)}/side-question`, { + method:'POST', headers:{'Content-Type':'application/json'}, + body:JSON.stringify({question:$('side-prompt').value}) + }); + $('side-dialog').close(); await followNewChat(result.chatID); + } catch(error) { showError(error); } + finally { $('send-side-question').disabled = false; } + }); + $('file-picker').addEventListener('change', event => { + uploadFiles(Array.from(event.target.files || [])); event.target.value = ''; + }); + $('prompt').addEventListener('input', () => rememberDraft()); + $('prompt').addEventListener('keydown', event => { + if (event.key !== 'Enter' || event.shiftKey || event.isComposing) return; + event.preventDefault(); + if (!event.repeat) $('compose').requestSubmit(); + }); + $('prompt').addEventListener('drop', event => { + if (!event.dataTransfer?.files.length) return; + event.preventDefault(); uploadFiles(Array.from(event.dataTransfer.files)); + }); + $('prompt').addEventListener('paste', event => { + if (!event.clipboardData?.files.length) return; + event.preventDefault(); uploadFiles(Array.from(event.clipboardData.files)); + }); + $('toggle-inspector').addEventListener('click', () => { + const inspector = $('inspector'); + if (inspector.classList.contains('requested') || window.innerWidth > 700 && inspector.classList.contains('open')) { + inspector.classList.remove('open', 'requested'); + } else { inspector.classList.add('open', 'requested'); } + }); + $('new-conversation').addEventListener('click', async () => { + try { + const entry = await api('/api/conversations', { method:'POST', headers:{'Content-Type':'application/json'}, body:'{}' }); + await loadWorkspaces(); await selectConversation(entry); + } catch(error) { showError(error); } + }); + $('cancel-create').addEventListener('click', () => $('create-dialog').close()); + $('close-file').addEventListener('click', () => $('inspector').classList.remove('open', 'requested')); + $('create-form').addEventListener('submit', async event => { + event.preventDefault(); + const values = Object.fromEntries(new FormData(event.currentTarget)); + let path, body; + if (createKind === 'project') { path = '/api/projects'; body = { name:values.name, location:values.location }; } + else if (createKind === 'addProject') { path = '/api/projects'; body = { path:values.path }; } + else { path = createKind === 'workspace' ? '/api/workspaces' : `/api/workspaces/${encodeURIComponent(workspace)}/chats`; + body = createKind === 'workspace' ? { project:createProject.path, name:values.name, baseBranch:values.baseBranch, prompt:values.prompt } : { title:values.title }; } + $('confirm-create').disabled = true; $('create-error').textContent = ''; + try { + const targetWorkspace = workspace; + const made = await api(path, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body) }); + $('create-dialog').close(); await loadWorkspaces(); + if (createKind === 'workspace') { + const row = workspaceRows.find(item => item.id === made.workspace_id); + if (row) await selectWorkspace(row); + } else if (createKind === 'chat') { + const row = workspaceRows.find(item => item.id === targetWorkspace); + if (row) { await selectWorkspace(row); await selectChat({ id:made.chat_id, title:made.title, agent:made.agent, model:made.model, effort:made.effort, state:'idle' }); } + } + } catch(error) { $('create-error').textContent = error.message; } + finally { $('confirm-create').disabled = false; } + }); + + $('compose').addEventListener('submit', async event => { + event.preventDefault(); const text = $('prompt').value.trim(); + if (!chat || !text || uploading || sending) return; + const current = chat; + sending = true; + $('send').disabled = true; + try { + const result = await api(`/api/chats/${encodeURIComponent(current)}/messages`, { + method:'POST', headers:{ 'Content-Type':'application/json' }, body:JSON.stringify({ text }) + }); + if (result.chatID) { + drafts.set(current, ''); $('prompt').value = ''; saveComposer(); + await followNewChat(result.chatID); return; + } + for (const [path, upload] of uploadsByChat.get(current) || []) { + if (text.includes(upload.token)) uploadsByChat.get(current).delete(path); + } + drafts.set(current, ''); saveComposer(); + if (chat === current) { $('prompt').value = ''; renderUploads(); await refresh(); } + } catch(error) { showError(error); } + finally { sending = false; $('send').disabled = !chat || uploading > 0; } + }); + + $('login-form').addEventListener('submit', async event => { + event.preventDefault(); token = $('code').value.trim(); $('login-error').textContent = ''; + try { await loadWorkspaces(); sessionStorage.setItem('bloom-access', token); $('login').hidden = true; } + catch(error) { $('login-error').textContent = error.message; } + }); + + async function connectDevelopmentBrowser() { + try { + const response = await fetch('/api/dev-connect', { method:'POST', cache:'no-store' }); + if (!response.ok) throw Error(await response.text()); + token = (await response.json()).token; + sessionStorage.setItem('bloom-access', token); + await loadWorkspaces(); $('login').hidden = true; + } catch(error) { + $('login').hidden = false; + $('login-error').textContent = error.message || 'Could not connect to Bloom Dev.'; + } + } + + const connectTicket = new URLSearchParams(window.location.hash.slice(1)).get('connect'); + if (connectTicket) { + window.history.replaceState(null, '', window.location.pathname + window.location.search); + fetch('/api/connect', { + method:'POST', headers:{'Content-Type':'application/json'}, + body:JSON.stringify({ticket:connectTicket}), cache:'no-store' + }).then(async response => { + if (!response.ok) throw Error(await response.text()); + return response.json(); + }).then(async result => { + token = result.token; + sessionStorage.setItem('bloom-access', token); + await loadWorkspaces(); $('login').hidden = true; + }).catch(error => { + if (localDev) connectDevelopmentBrowser(); + else { $('login').hidden = false; $('login-error').textContent = error.message; } + }); + } else if (token) loadWorkspaces().then(() => { $('login').hidden = true; }) + .catch(error => { + if (localDev) connectDevelopmentBrowser(); + else { $('login').hidden = false; $('login-error').textContent = error.message; } + }); + else if (localDev) connectDevelopmentBrowser(); + else $('login').hidden = false; + """# +} diff --git a/Sources/Bloom/System/LocalWebPage.swift b/Sources/Bloom/System/LocalWebPage.swift new file mode 100644 index 000000000..e4cc33b54 --- /dev/null +++ b/Sources/Bloom/System/LocalWebPage.swift @@ -0,0 +1,146 @@ +import Foundation + +/// The browser shell is small. The host supplies semantic transcript rows, and the CSS draws +/// them using the selected Bloom theme. No transcript payload is interpolated into this page. +enum LocalWebPage { + @MainActor static func html(nonce: String, devBrowser: Bool) -> String { + """ + + + + + + + Bloom + + + + +
+ +
+
+

Choose a workspace

Work stays on the host Mac

+ + + Local +
+ +
+
+
+
Choose a conversationIts transcript will appear here.
+
+
+
+ +
+
+
+ + +
+
+ + + + + +
+
+ +
+
+
+
+ +
+
+
+ +
+

Create

+
+ +
+ + +
+
+
+ +

Agent settings

+
+
+ +

Quick prompts

+ +
+
+ +

Context

+
+
+ +
+

Ask a side question

+

This question gets a snapshot of the current conversation and runs separately.

+ +
+
+
+ + + + + """ + } +} diff --git a/Sources/Bloom/System/LocalWebServer.swift b/Sources/Bloom/System/LocalWebServer.swift new file mode 100644 index 000000000..fd405b352 --- /dev/null +++ b/Sources/Bloom/System/LocalWebServer.swift @@ -0,0 +1,192 @@ +import Foundation +import Network + +/// A loopback-only HTTP listener for Bloom's optional browser client. Tailscale Serve can proxy +/// this port later without making Bloom listen on a public interface. +final class LocalWebServer: @unchecked Sendable { + struct Request: Sendable { + let method: String + let target: String + let headers: [String: String] + let body: Data + + var path: String { String(target.prefix(while: { $0 != "?" })) } + } + + struct Response: Sendable { + let status: Int + let contentType: String + let body: Data + let contentSecurityPolicy: String? + + init( + _ status: Int = 200, contentType: String = "application/json; charset=utf-8", + body: Data, contentSecurityPolicy: String? = nil + ) { + self.status = status + self.contentType = contentType + self.body = body + self.contentSecurityPolicy = contentSecurityPolicy + } + + static func text(_ status: Int, _ message: String) -> Self { + Response(status, contentType: "text/plain; charset=utf-8", body: Data(message.utf8)) + } + + var bytes: Data { + let reason: String = switch status { + case 200: "OK" + case 202: "Accepted" + case 400: "Bad Request" + case 401: "Unauthorized" + case 404: "Not Found" + case 405: "Method Not Allowed" + case 413: "Content Too Large" + case 503: "Service Unavailable" + default: "Internal Server Error" + } + var headers = [ + "HTTP/1.1 \(status) \(reason)", + "Content-Type: \(contentType)", + "Content-Length: \(body.count)", + "Cache-Control: no-store", + "X-Content-Type-Options: nosniff", + "Referrer-Policy: no-referrer", + "Connection: close", + ] + if let contentSecurityPolicy { headers.append("Content-Security-Policy: \(contentSecurityPolicy)") } + let head = (headers + ["", ""]).joined(separator: "\r\n") + return Data(head.utf8) + body + } + } + + private enum Parsed { + case waiting + case invalid(Int) + case ready(Request) + } + + /// A browser upload is held in memory until its whole body arrives. Keep the buffer bounded, + /// and keep ordinary JSON calls at their original, much smaller limit. + private static let maximumUploadBytes = 25 * 1_024 * 1_024 + private static let maximumHeaderBytes = 16 * 1_024 + private static let maximumRequestBytes = maximumUploadBytes + maximumHeaderBytes + + /// The connection's queue is its only writer. A reference avoids copying every earlier chunk + /// each time another part of a large attachment arrives. + private final class RequestBuffer: @unchecked Sendable { + var bytes = Data() + } + private let listener: NWListener + private let queue = DispatchQueue(label: "be.spatie.bloom.local-web", qos: .userInitiated) + private let handler: @Sendable (Request) async -> Response + private let onReady: @Sendable (UInt16) -> Void + private let onFailure: @Sendable (String) -> Void + + init( + port: UInt16? = nil, + handler: @escaping @Sendable (Request) async -> Response, + onReady: @escaping @Sendable (UInt16) -> Void, + onFailure: @escaping @Sendable (String) -> Void + ) throws { + let parameters = NWParameters.tcp + let listenerPort = port.flatMap(NWEndpoint.Port.init(rawValue:)) ?? .any + parameters.requiredLocalEndpoint = .hostPort(host: "127.0.0.1", port: listenerPort) + listener = try NWListener(using: parameters) + self.handler = handler + self.onReady = onReady + self.onFailure = onFailure + } + + func start() { + listener.stateUpdateHandler = { [weak self] state in + guard let self else { return } + switch state { + case .ready: + if let port = self.listener.port { self.onReady(port.rawValue) } + case .failed(let error): + self.onFailure(error.localizedDescription) + default: + break + } + } + listener.newConnectionHandler = { [weak self] connection in + guard let self else { + connection.cancel() + return + } + connection.start(queue: self.queue) + self.read(connection, buffer: RequestBuffer()) + } + listener.start(queue: queue) + } + + func stop() { + listener.cancel() + } + + private func read(_ connection: NWConnection, buffer: RequestBuffer) { + connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1_024) { [weak self] chunk, _, complete, error in + guard let self else { + connection.cancel() + return + } + if let chunk { buffer.bytes.append(chunk) } + guard error == nil, buffer.bytes.count <= Self.maximumRequestBytes else { + self.send(.text(413, "Request too large"), on: connection) + return + } + switch Self.parse(buffer.bytes) { + case .waiting where !complete: + self.read(connection, buffer: buffer) + case .waiting: + self.send(.text(400, "Bad request"), on: connection) + case .invalid(let status): + self.send(.text(status, status == 413 ? "Request too large" : "Bad request"), on: connection) + case .ready(let request): + Task { self.send(await self.handler(request), on: connection) } + } + } + } + + private func send(_ response: Response, on connection: NWConnection) { + connection.send(content: response.bytes, completion: .contentProcessed { _ in + connection.cancel() + }) + } + + private static func parse(_ bytes: Data) -> Parsed { + let divider = Data("\r\n\r\n".utf8) + guard let boundary = bytes.range(of: divider) else { + return bytes.count > maximumHeaderBytes ? .invalid(413) : .waiting + } + guard boundary.lowerBound <= maximumHeaderBytes else { return .invalid(413) } + guard let head = String(data: bytes[..= 0 else { return .invalid(400) } + guard length <= maximumBody else { return .invalid(413) } + let bodyStart = boundary.upperBound + guard bytes.count - bodyStart >= length else { return .waiting } + return .ready(Request( + method: String(parts[0]), target: String(parts[1]), headers: headers, + body: bytes[bodyStart..<(bodyStart + length)] + )) + } +} diff --git a/Sources/Bloom/System/LocalWebStyle.swift b/Sources/Bloom/System/LocalWebStyle.swift new file mode 100644 index 000000000..c48a46433 --- /dev/null +++ b/Sources/Bloom/System/LocalWebStyle.swift @@ -0,0 +1,257 @@ +import Foundation +import BloomCore + +/// CSS uses the same colour pairs and spacing scale as Bloom's Mac views. Layout adapts at phone +/// widths, while the transcript's visual language stays the same. +enum LocalWebStyle { + @MainActor static var css: String { + let theme = ColourThemePreference.shared.choice.surfaces + let appearance = UserDefaults.standard.string(forKey: "appearance") ?? "system" + let light = palette(theme, dark: false) + let dark = palette(theme, dark: true) + let systemDark = appearance == "system" ? "@media (prefers-color-scheme: dark) { :root { \(dark) } }" : "" + let chosenDark = appearance == "dark" ? ":root { \(dark) }" : "" + let preference = ColourThemePreference.shared + let face: String + switch preference.chatFont { + case "system": face = "-apple-system,BlinkMacSystemFont,sans-serif" + case "reading": face = "'New York',Georgia,serif" + default: + let name = preference.chatFont.replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "'", with: "\\'") + face = "'\(name)',-apple-system,BlinkMacSystemFont,sans-serif" + } + let typography = ":root { --chat-font:\(face); --chat-size:\(13 * preference.chatTextSize.scale)px; --chat-leading:\(preference.chatLineHeight.ratio); --sidebar-width:\(Metrics.sidebarWidth)px; --inspector-width:\(Metrics.inspectorWidth)px; --row-height:\(Metrics.rowHeight)px; --corner:\(Metrics.corner)px; }" + return ":root { \(light) } \(systemDark) \(chosenDark) \(typography) \(layout)" + } + + private static func palette(_ theme: ThemeSurfaces, dark: Bool) -> String { + func hex(_ pair: PaletteInk.Pair) -> String { + String(format: "#%06X", pair.member(dark: dark)) + } + return """ + color-scheme:\(dark ? "dark" : "light"); + --surface:\(hex(theme.surface)); --raised:\(hex(theme.raised)); + --sunken:\(hex(theme.sunken)); --sidebar:\(hex(theme.sidebar)); + --selected:\(hex(theme.selected)); --border:\(hex(theme.border)); + --accent:\(hex(PaletteInk.accent)); --accent-fill:\(hex(PaletteInk.accentFill)); + --negative:\(hex(PaletteInk.negative)); --warning:\(hex(PaletteInk.warning)); + --tertiary:\(hex(PaletteInk.textTertiary)); + --text:\(dark ? "#E7F0F3" : "#1D3039"); + --secondary:\(dark ? "#A7BAC4" : "#53646D"); + """ + } + + private static let layout = #""" + * { box-sizing:border-box; } + body { margin:0; background:var(--surface); color:var(--text); font:13px/1.48 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; -webkit-font-smoothing:antialiased; } + button,input,textarea { font:inherit; } + button { cursor:pointer; transition:background-color .14s ease,color .14s ease,box-shadow .14s ease; } + button:focus-visible,input:focus-visible,textarea:focus-visible,summary:focus-visible { outline:2px solid var(--accent); outline-offset:2px; } + ::selection { background:var(--selected); color:var(--text); } + .shell { height:100dvh; min-height:0; display:grid; grid-template-columns:var(--sidebar-width) minmax(0,1fr); } + .sidebar { min-width:0; min-height:0; background:var(--sidebar); border-right:1px solid var(--border); display:flex; flex-direction:column; } + .sidebar-head { height:53px; flex:none; display:flex; align-items:center; justify-content:space-between; gap:9px; padding:0 16px; } + .sidebar-head strong { font-size:11px; font-weight:650; color:var(--secondary); letter-spacing:.015em; } + .sidebar-head button { display:none; width:30px; height:30px; place-items:center; border:0; border-radius:6px; background:transparent; color:var(--secondary); } + .sidebar-head svg,.top svg,.composer-bar svg { width:18px; height:18px; fill:none; stroke:currentColor; stroke-width:1.7; stroke-linecap:round; stroke-linejoin:round; } + .workspaces { flex:1; min-height:0; overflow:auto; padding:8px 8px 16px; scrollbar-width:thin; } + .sidebar-actions { display:flex; gap:5px; padding:9px 10px 0; } + .sidebar-actions button,.conversation-head button,.project-heading button,.top > button,.inspector-head button { border:0; border-radius:5px; background:transparent; color:var(--secondary); } + .sidebar-actions button { min-height:27px; padding:4px 8px; background:var(--sunken); font-size:11px; } + .sidebar-actions #new-project { background:var(--selected); color:var(--text); font-weight:600; } + .sidebar-actions #new-project span { font-size:15px; line-height:0; vertical-align:-1px; } + .sidebar-actions button:hover,.conversation-head button:hover,.project-heading button:hover,.top > button:hover,.inspector-head button:hover { background:var(--selected); } + .project-heading { display:flex; align-items:center; gap:7px; margin:13px 9px 4px; color:var(--text); font-size:11px; font-weight:650; } + .project-heading > span:nth-child(2) { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } + .project-icon { width:16px; height:16px; flex:none; display:grid; place-items:center; border-radius:5px; background:var(--accent-fill); color:white; font-size:9px; font-weight:700; } + .project-heading button { font-size:16px; line-height:1; } + .workspace { display:flex; align-items:center; gap:8px; width:100%; min-height:var(--row-height); padding:3px 8px; border:0; border-radius:var(--corner); text-align:left; background:transparent; color:var(--text); font-size:11px; } + .workspace:hover,.chat-tab:hover { background:var(--sunken); } + .workspace.selected,.chat-tab.selected { background:var(--selected); font-weight:600; } + .workspace .mark { width:6px; height:6px; margin:0 5px; flex:none; border-radius:50%; background:var(--accent-fill); } + .workspace .name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } + .workspace .branch { display:none; } + .conversation-head { display:flex; align-items:center; justify-content:space-between; padding:8px 16px 3px; color:var(--tertiary); font-size:10px; text-transform:uppercase; letter-spacing:.045em; } + .conversation-head button { width:23px; height:23px; font-size:17px; line-height:1; } + .conversations { max-height:25vh; min-height:36px; overflow:auto; padding:3px 8px 8px; } + .conversation { display:block; width:100%; border:0; border-radius:6px; padding:5px 9px; text-align:left; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; background:transparent; color:var(--text); font-size:11px; } + .conversation:hover { background:var(--sunken); } .conversation.selected { background:var(--selected); } + .stage { display:flex; flex-direction:column; min-width:0; height:100dvh; background:var(--surface); } + .top { min-height:53px; padding:7px 20px 7px 24px; display:flex; align-items:center; gap:6px; border-bottom:1px solid var(--border); } + .top > div { flex:1; min-width:0; } + .top > button { width:29px; height:29px; display:grid; place-items:center; font-size:19px; line-height:1; } + .top h1 { margin:0; font-size:12px; font-weight:650; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } + .top p { margin:1px 0 0; color:var(--tertiary); font-size:10px; } + .status { color:var(--accent); font-size:11px; white-space:nowrap; } + .status:empty { display:none; } + .status::before { content:""; display:inline-block; width:6px; height:6px; margin-right:6px; border-radius:50%; background:currentColor; vertical-align:1px; } + .tabs { display:flex; align-items:stretch; gap:3px; min-height:34px; padding:0 15px; overflow-x:auto; border-bottom:1px solid var(--border); scrollbar-width:thin; } + .tabs[hidden] { display:none; } + .chat-tab { position:relative; max-width:230px; padding:6px 13px; border:0; border-radius:6px 6px 0 0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; background:transparent; color:var(--secondary); } + .chat-tab.selected { color:var(--text); font-weight:600; } + .chat-tab.selected::after { content:""; position:absolute; left:8px; right:8px; bottom:0; height:2px; border-radius:2px; background:var(--accent-fill); } + .content { position:relative; flex:1; display:flex; min-height:0; } + .center-pane { flex:1; display:flex; flex-direction:column; min-width:0; min-height:0; } + .messages { flex:1; display:flex; flex-direction:column; gap:3px; min-width:0; overflow:auto; padding:26px max(24px,calc((100% - 790px)/2)) 20px; scrollbar-width:thin; } + .inspector { display:none; width:var(--inspector-width); flex:none; border-left:1px solid var(--border); background:var(--raised); overflow:hidden; } + .inspector.open { display:flex; flex-direction:column; } + .inspector-head { min-height:43px; display:flex; align-items:center; justify-content:space-between; gap:8px; padding:7px 14px; border-bottom:1px solid var(--border); } + .inspector-head strong { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:11px; font-weight:650; } + .inspector-head button { font-size:18px; } + .file-content { flex:1; overflow:auto; padding:12px; color:var(--secondary); font-size:11px; } + .inspector-empty { min-height:100%; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:5px; padding:24px; text-align:center; color:var(--tertiary); } + .inspector-empty-icon { width:38px; height:38px; display:grid; place-items:center; margin-bottom:10px; border:2px solid var(--border); border-radius:50%; color:var(--tertiary); font-size:23px; line-height:1; } + .inspector-empty strong { color:var(--secondary); font-size:12px; font-weight:650; } + .inspector-empty span:last-child { max-width:220px; line-height:1.4; } + .file-content pre { margin:0; font:11px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; } + .file-image { display:block; max-width:100%; height:auto; margin:auto; } + .file-line { white-space:pre; } .file-line > span { display:inline-block; width:35px; margin-right:8px; text-align:right; color:var(--tertiary); user-select:none; } + .file-line.focused { background:var(--selected); color:var(--text); } + .changes-list { display:flex; flex-direction:column; gap:2px; } + .change-file { display:grid; grid-template-columns:14px minmax(0,1fr) auto; gap:7px; align-items:center; width:100%; padding:5px 4px; border:0; border-radius:5px; text-align:left; background:transparent; color:var(--text); font-size:11px; } + .change-file:hover { background:var(--selected); } + .change-file > span:nth-child(2) { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } + .change-file > span:first-child { color:var(--accent); font:10px ui-monospace,SFMono-Regular,Menlo,monospace; } + .change-file small { color:var(--tertiary); font-size:10px; } + .back-to-changes { margin:0 0 9px; border:0; background:transparent; color:var(--accent); font-size:11px; } + .empty { max-width:380px; margin:auto; text-align:center; color:var(--tertiary); } + .empty strong { display:block; margin-bottom:4px; color:var(--text); font-size:14px; } + .load-earlier { align-self:center; margin:0 0 16px; padding:5px 10px; border:1px solid var(--border); border-radius:6px; background:var(--raised); color:var(--secondary); font-size:11px; } + .load-earlier[hidden] { display:none; } + .entry { min-width:0; flex:none; } + .entry.user { align-self:flex-end; max-width:min(78%,560px); margin:15px 0 12px; padding:8px 11px; border-radius:11px 11px 4px 11px; background:var(--accent-fill); color:white; } + .entry.user .prose { white-space:pre-wrap; } + .entry.user .chip { color:white; border-color:#ffffff70; background:#ffffff1f; } + .entry.assistant { max-width:820px; margin:17px 0 8px; padding:0 4px; font:var(--chat-size)/var(--chat-leading) var(--chat-font); } + .entry.user .prose,.entry.crew .prose,.entry.plan .prose { font:var(--chat-size)/var(--chat-leading) var(--chat-font); } + .file-link { border:0; border-bottom:1px dotted currentColor; padding:0; background:transparent; color:var(--accent); cursor:pointer; font:inherit; } + .entry.user .file-link { color:white; } + .entry.nested { margin-left:20px; border-left:2px solid var(--border); padding-left:12px; } + .entry.tool,.entry.thinking,.entry.session,.entry.notice { color:var(--secondary); font-size:12px; } + .activity-line { display:grid; grid-template-columns:16px minmax(85px,176px) minmax(0,1fr); align-items:baseline; gap:8px; min-height:25px; padding:4px 6px; border-radius:5px; } + .glyph { width:16px; text-align:center; color:var(--tertiary); font-size:12px; } + .tool[data-tint=positive] .glyph { color:var(--accent); } + .tool[data-tint=negative] .glyph,.entry.error .glyph { color:var(--negative); } + .tool[data-tint=warning] .glyph,.entry.permission .glyph { color:var(--warning); } + .activity-title { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-weight:520; } + .activity-detail { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; min-width:0; color:var(--tertiary); } + details > summary { list-style:none; cursor:pointer; } + details > summary::-webkit-details-marker { display:none; } + details > summary:hover { background:var(--sunken); border-radius:5px; } + details > summary:focus-visible { border-radius:5px; } + .tool-output,.thinking-body { max-height:340px; margin:3px 8px 12px 30px; padding:10px 12px; overflow:auto; border-left:2px solid var(--border); border-radius:0 5px 5px 0; background:var(--sunken); white-space:pre-wrap; overflow-wrap:anywhere; font:11px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; } + .tool-output.failure { border-left-color:var(--negative); } + .chip-list { display:inline-flex; flex-wrap:wrap; gap:4px; margin:5px 0 0 30px; } + .chip { display:inline-block; padding:1px 5px; border:1px solid var(--border); border-radius:5px; color:var(--secondary); font:10px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace; } + .entry.permission,.entry.error,.entry.crew,.entry.plan { max-width:820px; margin:10px 4px; padding:10px 13px; border:1px solid var(--border); border-radius:7px; } + .entry.permission { background:var(--sunken); } + .permission-actions { display:flex; gap:7px; margin-top:9px; } + .permission-actions button:not(.primary) { border:1px solid var(--border); border-radius:var(--corner); padding:6px 11px; background:var(--raised); color:var(--text); font-size:11px; } + .question-form { display:flex; flex-direction:column; align-items:flex-start; gap:12px; margin-top:10px; } + .question-form fieldset { width:100%; margin:0; padding:0; border:0; } + .question-form legend { margin-bottom:7px; font-weight:600; } + .question-header { display:block; margin-bottom:4px; color:var(--accent); font-size:10px; font-weight:650; letter-spacing:.05em; text-transform:uppercase; } + .question-hint { display:block; margin:-3px 0 7px; color:var(--tertiary); } + .question-option { display:flex; align-items:flex-start; gap:8px; padding:7px 9px; border:1px solid var(--border); border-radius:var(--corner); background:var(--raised); cursor:pointer; } + .question-option + .question-option { margin-top:5px; } + .question-option input { margin:2px 0 0; accent-color:var(--accent); } + .question-option small { display:block; margin-top:2px; color:var(--secondary); } + .question-form pre { padding:8px; overflow:auto; background:var(--surface); font:11px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; } + .question-other { display:block; margin-top:8px; color:var(--secondary); font-size:11px; } + .question-other input { display:block; width:100%; margin-top:4px; padding:8px; border:1px solid var(--border); border-radius:var(--corner); background:var(--surface); color:var(--text); } + .card-title { margin-bottom:4px; font-size:12px; font-weight:650; } + .card-note { margin-top:5px; color:var(--tertiary); font-size:11px; } + .composer { flex:none; padding:8px max(24px,calc((100% - 820px)/2)) 14px; background:var(--surface); } + .composer form { display:flex; flex-direction:column; gap:3px; padding:7px 8px 6px; border:1px solid var(--border); border-radius:13px; background:var(--raised); box-shadow:0 2px 10px #0000000c,0 10px 28px #00000008; transition:border-color .14s ease,box-shadow .14s ease; } + .composer form:focus-within { border-color:var(--accent); box-shadow:0 0 0 2px color-mix(in srgb,var(--accent) 12%,transparent),0 6px 20px #00000012; } + .composer textarea { width:100%; min-height:32px; max-height:180px; padding:5px 7px; border:0; outline:0; resize:vertical; background:transparent; color:var(--text); } + .composer textarea:focus-visible { outline:0; } + .composer textarea::placeholder { color:var(--tertiary); opacity:1; } + .composer-bar { display:flex; align-items:center; justify-content:space-between; gap:8px; padding:0 2px 0 4px; color:var(--tertiary); font-size:10px; } + .composer-bar > div { display:flex; align-items:center; gap:3px; min-width:0; } + .composer-bar [hidden] { display:none !important; } + .composer-bar button:not(.primary) { min-width:27px; height:27px; display:inline-flex; align-items:center; justify-content:center; gap:5px; padding:0 5px; border:0; border-radius:6px; background:transparent; color:var(--secondary); } + .composer-bar button:not(.primary):hover { background:var(--sunken); color:var(--text); } + .composer-bar button:not(.primary):disabled { opacity:.5; cursor:default; } + #agent-settings { max-width:min(250px,37vw); } + #agent-settings svg { flex:none; } + #chat-settings { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:11px; } + .settings-chevron { flex:none; font-size:13px; line-height:1; } + #fast-mode[aria-pressed="true"] { background:color-mix(in srgb,var(--warning) 16%,transparent); color:var(--warning); } + #context-usage { font-size:11px; font-variant-numeric:tabular-nums; } + #attach-file { flex:none; } + .upload-list:empty { display:none; } + .upload-list { display:flex; flex-wrap:wrap; gap:5px; padding:0 8px 4px; } + .upload-chip { display:flex; align-items:center; gap:5px; padding:2px 5px; border:1px solid var(--border); border-radius:5px; background:var(--sunken); color:var(--secondary); font:11px ui-monospace,SFMono-Regular,Menlo,monospace; } + .upload-chip button { border:0; background:transparent; color:var(--secondary); font-size:13px; } + .composer-bar .primary { width:29px; height:29px; display:grid; place-items:center; padding:0; border-radius:50%; } + #send svg { width:17px; height:17px; stroke-width:2.2; } + .primary { padding:8px 13px; border:0; border-radius:7px; background:var(--accent-fill); color:white; font-size:12px; font-weight:650; } + .primary:hover:not(:disabled) { filter:brightness(1.1); } + .primary:disabled { opacity:.5; cursor:default; } + .hint { margin:6px 3px 0; color:var(--tertiary); font-size:10px; } + .hint:empty { display:none; } + .prose { overflow-wrap:anywhere; } + .prose > :first-child { margin-top:0; } + .prose > :last-child { margin-bottom:0; } + .prose h1,.prose h2,.prose h3 { margin:1.25em 0 .45em; font-size:1.16em; line-height:1.3; } + .prose h1 { font-size:1.28em; } + .prose p,.prose ul,.prose ol,.prose blockquote,.prose pre,.prose table { margin:.5em 0 .85em; } + .prose ul,.prose ol { padding-left:1.7em; } + .prose blockquote { padding-left:12px; border-left:2px solid var(--border); color:var(--secondary); } + .prose pre { padding:11px 13px; overflow:auto; border:1px solid var(--border); border-radius:6px; background:var(--sunken); font:11px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; } + .prose :not(pre) > code { padding:1px 3px; border-radius:3px; background:var(--sunken); font:11px ui-monospace,SFMono-Regular,Menlo,monospace; } + .prose table { display:block; overflow-x:auto; border-collapse:collapse; } + .prose td,.prose th { padding:5px 8px; border:1px solid var(--border); } + .prose a { color:var(--accent); } + .entry.user .prose a { color:white; } + .prose img { max-width:100%; } + .login { position:fixed; inset:0; z-index:10; display:grid; place-items:center; padding:24px; background:var(--sidebar); } + .login[hidden] { display:none; } + .login-card { width:min(100%,410px); padding:30px; border:1px solid var(--border); border-radius:15px; background:var(--surface); box-shadow:0 16px 45px #00000018; } + .login-mark { display:block; width:46px; height:46px; margin:0 0 16px -5px; object-fit:contain; } + .login-card h2 { margin:0 0 5px; font-size:19px; letter-spacing:-.02em; } + .login-card p { margin:0 0 23px; color:var(--secondary); } + .login-card label { display:block; margin-bottom:6px; font-size:11px; font-weight:650; } + .login-card input { width:100%; margin-bottom:11px; padding:10px; border:1px solid var(--border); border-radius:7px; background:var(--sunken); color:var(--text); } + .login-card .primary { width:100%; } + .login-error { min-height:18px; margin-top:10px; color:var(--negative); font-size:11px; } + dialog { width:min(94vw,480px); border:1px solid var(--border); border-radius:10px; padding:22px; background:var(--surface); color:var(--text); box-shadow:0 16px 55px #0005; } + dialog::backdrop { background:#0008; } + dialog h2 { margin:0 0 18px; font-size:16px; } + #create-fields label { display:block; margin:12px 0; color:var(--secondary); font-size:11px; } + #create-fields input,#create-fields textarea { display:block; width:100%; margin-top:5px; padding:9px; border:1px solid var(--border); border-radius:6px; outline-color:var(--accent); background:var(--sunken); color:var(--text); font-size:13px; } + #create-fields textarea { min-height:110px; resize:vertical; } + #create-error { min-height:1em; color:var(--negative); font-size:11px; } + .dialog-actions { display:flex; justify-content:flex-end; gap:8px; } + .dialog-actions button:not(.primary) { border:1px solid var(--border); border-radius:6px; background:var(--raised); color:var(--text); padding:7px 12px; } + .composer-dialog { width:min(94vw,380px); max-height:min(80dvh,680px); overflow:auto; padding:17px; } + .composer-dialog-head { display:flex; align-items:center; gap:12px; margin-bottom:13px; } + .composer-dialog-head h2 { flex:1; margin:0; font-size:14px; } + .composer-dialog-head button { width:27px; height:27px; border:0; border-radius:5px; background:transparent; color:var(--secondary); font-size:18px; } + .composer-dialog-head button:hover { background:var(--sunken); } + .agent-fields { display:flex; flex-direction:column; gap:11px; } + .agent-fields label { display:flex; flex-direction:column; gap:4px; color:var(--secondary); font-size:11px; font-weight:600; } + .agent-fields select { width:100%; padding:7px 9px; border:1px solid var(--border); border-radius:6px; background:var(--sunken); color:var(--text); font-size:12px; } + .agent-fields small { color:var(--tertiary); font-weight:400; line-height:1.4; } + .composer-dialog p { margin:0 0 12px; color:var(--secondary); font-size:11px; } + .composer-dialog textarea,.composer-dialog input[type="search"] { width:100%; padding:9px; border:1px solid var(--border); border-radius:7px; background:var(--sunken); color:var(--text); } + .composer-dialog textarea { min-height:92px; margin-bottom:13px; resize:vertical; } + .quick-list { display:flex; flex-direction:column; gap:3px; margin-top:9px; } + .quick-list button { display:flex; flex-direction:column; gap:2px; width:100%; padding:8px; border:0; border-radius:6px; text-align:left; background:transparent; color:var(--text); } + .quick-list button:hover { background:var(--sunken); } + .quick-list small { color:var(--tertiary); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } + .quick-list .quick-empty { padding:10px; color:var(--tertiary); } + .context-detail { display:flex; flex-direction:column; gap:9px; color:var(--secondary); font-size:12px; } + .context-detail .context-row { display:flex; justify-content:space-between; gap:12px; } + .context-track { height:6px; overflow:hidden; border-radius:6px; background:var(--selected); } + .context-track > span { display:block; height:100%; background:var(--accent); } + @media(max-width:1100px) { .inspector.open { position:absolute; z-index:2; right:0; top:0; bottom:0; box-shadow:-8px 0 20px #0003; } } + @media(max-width:700px) { .shell { display:flex; flex-direction:column; } .sidebar { flex:none; height:32dvh; max-height:32dvh; border-right:0; border-bottom:1px solid var(--border); } .sidebar-head { height:43px; flex:none; } .sidebar-actions { padding:4px 8px; } .workspaces { display:block; min-height:40px; padding:2px 8px; overflow:auto; } .project-heading { margin:7px 5px 2px; } .workspace .branch { display:none; } .conversation-head { padding:4px 12px 2px; } .conversations { display:flex; gap:4px; min-height:34px; padding:3px 8px; overflow-x:auto; } .conversation { width:auto; max-width:180px; flex:none; } .stage { height:68dvh; min-height:350px; } .top { min-height:41px; padding:5px 13px; } .top p { display:none; } .tabs { min-height:33px; padding:0 7px; } .chat-tab { padding:5px 10px; } .messages { padding:15px 12px; } .inspector.open { width:min(92vw,360px); } .entry.user { max-width:90%; } .activity-line { grid-template-columns:16px minmax(65px,110px) minmax(0,1fr); } .composer { padding:6px 10px max(9px,env(safe-area-inset-bottom)); } } + @media(max-width:700px) { .sidebar { height:43px; } .sidebar.expanded { height:32dvh; } .sidebar-head { justify-content:space-between; } .sidebar-head button { display:grid; } .sidebar:not(.expanded) .sidebar-actions,.sidebar:not(.expanded) .workspaces,.sidebar:not(.expanded) .conversation-head,.sidebar:not(.expanded) .conversations { display:none; } .stage { flex:1; height:auto; min-height:0; } } + @media(max-width:700px) { .inspector.open:not(.requested) { display:none; } } + @media(max-width:540px) { #chat-settings,.settings-chevron { display:none; } #agent-settings { width:27px; padding:0; } #context-usage { display:none !important; } .composer-bar > div { gap:2px; } } + @media(prefers-reduced-motion:reduce) { button,.composer form { transition:none; } } + """# +} diff --git a/Sources/Bloom/Views/Archive/ArchiveConfirmationPopover.swift b/Sources/Bloom/Views/Archive/ArchiveConfirmationPopover.swift index d2060473b..1cd22ffb9 100644 --- a/Sources/Bloom/Views/Archive/ArchiveConfirmationPopover.swift +++ b/Sources/Bloom/Views/Archive/ArchiveConfirmationPopover.swift @@ -10,6 +10,7 @@ struct ArchiveConfirmationPopover: View { @Environment(AppModel.self) private var app @State private var checkedRequest: ArchiveRequest? + @State private var uncheckedRevealRequest: UUID? @FocusState private var focusedButton: ButtonFocus? private enum ButtonFocus: Hashable { case cancel, archive } @@ -19,6 +20,10 @@ struct ArchiveConfirmationPopover: View { return checkedRequest } + private var isWaitingForCheck: Bool { + currentRequest.isChecking && uncheckedRevealRequest != request.id + } + var body: some View { VStack(alignment: .leading, spacing: 0) { HStack(spacing: 10) { @@ -59,16 +64,16 @@ struct ArchiveConfirmationPopover: View { .focused($focusedButton, equals: .cancel) .keyboardShortcut(.cancelAction) Button { - guard canConfirm else { return } + guard canConfirm, !isWaitingForCheck else { return } onConfirm(currentRequest) } label: { - Text(currentRequest.confirmLabel) + Text(isWaitingForCheck ? "Checking..." : currentRequest.confirmLabel) .frame(width: 196) } .buttonStyle(.borderedProminent) .tint(tint) .focused($focusedButton, equals: .archive) - .disabled(!canConfirm) + .disabled(!canConfirm || isWaitingForCheck) } } .environment(\.backgroundProminence, .standard) @@ -84,6 +89,14 @@ struct ArchiveConfirmationPopover: View { guard !Task.isCancelled else { return } checkedRequest = checked } + .task(id: request.id) { + guard request.isChecking else { return } + // Brief checks should never flash "Archive anyway". A slow check still offers the + // existing explicit choice to archive before git has answered. + try? await Task.sleep(for: .seconds(1)) + guard !Task.isCancelled, currentRequest.isChecking else { return } + uncheckedRevealRequest = request.id + } } } diff --git a/Sources/Bloom/Views/Archive/ArchivedConversationView.swift b/Sources/Bloom/Views/Archive/ArchivedConversationView.swift new file mode 100644 index 000000000..7af9e066e --- /dev/null +++ b/Sources/Bloom/Views/Archive/ArchivedConversationView.swift @@ -0,0 +1,57 @@ +import SwiftUI +import BloomCore + +/// The saved transcript of a conversation that has no project or worktree. +struct ArchivedConversationView: View { + let sessionID: SessionID + + @Environment(AppModel.self) private var app + @State private var transcript: TranscriptModel? + @State private var title = "Conversation" + @State private var isConfirmingDelete = false + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: Metrics.spacingWide) { + Text(title) + .font(Typo.title) + .lineLimit(1) + Spacer(minLength: 0) + Button("Restore") { Task { await app.ask.restore(sessionID) } } + Button("Delete…", role: .destructive) { isConfirmingDelete = true } + } + .padding(Metrics.gutter) + .background(Palette.sidebar) + Hairline() + TranscriptView(transcript: transcript) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .task(id: sessionID) { await load() } + .confirmationDialog( + "Delete this conversation?", + isPresented: $isConfirmingDelete, + titleVisibility: .visible + ) { + Button("Delete Conversation", role: .destructive) { + Task { await app.ask.deleteArchived(sessionID) } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("The transcript will be removed. Files in its folder are removed when no other conversation uses them.") + } + } + + private func load() async { + guard let store = app.store, + let session = try? await store.session(id: sessionID), + session.archivedAt != nil else { return } + title = session.title + let saved = try? await store.setting(AskTabs.directoryKey(sessionID)) + guard let directory = AskTabs.prepareDirectory(saved ?? "", databasePath: store.path) else { + return + } + let model = TranscriptModel(askSession: session, directory: directory, app: app) + transcript = model + await model.load() + } +} diff --git a/Sources/Bloom/Views/Ask/AskConversationContent.swift b/Sources/Bloom/Views/Ask/AskConversationContent.swift index dc1b2bef4..1caf1f5f9 100644 --- a/Sources/Bloom/Views/Ask/AskConversationContent.swift +++ b/Sources/Bloom/Views/Ask/AskConversationContent.swift @@ -1,11 +1,14 @@ import SwiftUI import BloomCore -/// The selected Ask conversation, its transcript and its composer, with nothing around them. -/// -/// What the panel puts beside it, the rail of conversations and a header, is `AskPanelView`'s. +/// A project-free conversation in the main detail column. struct AskConversationContent: View { @Environment(AppModel.self) private var app + var sessionID: SessionID? + + init(sessionID: SessionID? = nil) { + self.sessionID = sessionID + } private var textSize: ChatTextSize { ColourThemePreference.shared.chatTextSize } private var chatFontID: String { ColourThemePreference.shared.chatFont } @@ -21,7 +24,7 @@ struct AskConversationContent: View { message: trouble ) .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let transcript = app.ask.transcript { + } else if let transcript = displayedTranscript { AskConversationView(transcript: transcript) .id(transcript.session.id) } else { @@ -36,15 +39,17 @@ struct AskConversationContent: View { .environment(\.chatFont, ChatFont(rawValue: chatFontID)) .environment(\.chatLineHeight, lineHeight) // Not in a body: `open()` writes observed state and can create a session row. - .task { await app.ask.open() } + .task(id: sessionID) { + await app.ask.open() + if let sessionID { await app.ask.select(sessionID) } + } // A popover rather than a sheet over the window, like the app's other destructive - // questions. It hangs off the conversation rather than a control, because it is raised - // both from a tab's close button in the rail and from Cmd+W in the menu bar, and neither - // of those is a view this one can reach. + // questions. It hangs off the conversation rather than a control, because archive can + // be requested from the model while a turn is still running. .popover(isPresented: $ask.closingID.isPresent(), arrowEdge: .top) { ConfirmationPopover( - title: "Stop and close this conversation?", - confirmLabel: "Stop and Close", + title: "Stop and archive this conversation?", + confirmLabel: "Stop and Archive", tint: Palette.negative, onConfirm: { // Read before clearing: clearing is what dismisses the popover. @@ -58,6 +63,11 @@ struct AskConversationContent: View { } } } + + private var displayedTranscript: TranscriptModel? { + if let sessionID { return app.ask.transcript(for: sessionID) } + return app.ask.transcript + } } private struct AskConversationView: View { diff --git a/Sources/Bloom/Views/Ask/AskConversationRail.swift b/Sources/Bloom/Views/Ask/AskConversationRail.swift deleted file mode 100644 index 672af1d6b..000000000 --- a/Sources/Bloom/Views/Ask/AskConversationRail.swift +++ /dev/null @@ -1,89 +0,0 @@ -import SwiftUI -import BloomCore - -/// Every open Ask conversation, down the leading edge of the panel, newest first. -/// -/// Rows rather than tabs, because a strip of titles across a card this size truncates every one -/// of them after a word, so here they -/// are rows: the title in full, and the mark the sidebar's own rows wear when a turn is running or -/// waiting on you, so a conversation left running behind another one still says so. -struct AskConversationRail: View { - @Environment(AppModel.self) private var app - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - // No heading. The panel is already what you opened Ask Bloom to see, so its name over - // the rail and over the conversation said the same thing twice. New stays. - HStack(spacing: Metrics.spacing) { - Spacer(minLength: Metrics.spacingSmall) - - Button { Task { await app.ask.newConversation() } } label: { - Label("New conversation", systemImage: "plus") - .labelStyle(.iconOnly) - .frame(width: Metrics.rowHeight, height: Metrics.rowHeight) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .foregroundStyle(Palette.textSecondary) - .help("New Ask Bloom conversation") - } - .padding(.leading, Metrics.gutter) - .padding(.trailing, Metrics.spacingSmall) - .frame(height: Metrics.barHeight + Metrics.spacingWide) - - ScrollView { - LazyVStack(spacing: Metrics.spacingHair) { - ForEach(AskPanelLayout.railOrder(app.ask.listedSessions), id: \.id) { chat in - AskConversationRailRow( - title: app.ask.title(for: chat), - status: app.ask.status(for: chat.id), - isSelected: app.ask.selectedID == chat.id, - onSelect: { Task { await app.ask.select(chat.id) } }, - onClose: { app.ask.requestClose(chat.id) } - ) - } - } - .padding(.horizontal, Metrics.spacingSmall) - .padding(.bottom, Metrics.spacingSmall) - } - } - .frame(maxHeight: .infinity, alignment: .top) - .background(Palette.surfaceSunken) - } -} - -private struct AskConversationRailRow: View { - var title: String - var status: WorkspaceStatus? - var isSelected: Bool - var onSelect: () -> Void - var onClose: () -> Void - - var body: some View { - Button(action: onSelect) { - HStack(spacing: Metrics.spacing) { - Text(title) - .font(Typo.label) - .foregroundStyle(Palette.textPrimary) - .lineLimit(1) - .truncationMode(.tail) - - Spacer(minLength: Metrics.spacingSmall) - - if let status { - WorkspaceStatusGlyph(status: status) - } - } - .padding(.horizontal, Metrics.spacingWide) - .frame(height: Metrics.rowHeight) - .contentShape(Rectangle()) - .hoverPlate(cornerRadius: Metrics.corner, isActive: isSelected) - } - .buttonStyle(.plain) - .contextMenu { - Button("Close Conversation", action: onClose) - } - .accessibilityValue(status?.label ?? "") - .accessibilityAddTraits(isSelected ? .isSelected : []) - } -} diff --git a/Sources/Bloom/Views/Ask/AskPanelModel.swift b/Sources/Bloom/Views/Ask/AskPanelModel.swift deleted file mode 100644 index b62596249..000000000 --- a/Sources/Bloom/Views/Ask/AskPanelModel.swift +++ /dev/null @@ -1,39 +0,0 @@ -import Observation -import BloomCore - -/// Whether the Ask Bloom panel is in front of the window. -/// -/// Shared rather than held by a view, for the reason `SearchPanelModel` is: the panel is drawn by -/// a hosting view over the window's frame view, and that host's hit test has to ask whether there -/// is anything up without being a SwiftUI view that could read an environment. -@MainActor -@Observable -final class AskPanelModel { - static let shared = AskPanelModel() - - private(set) var isOpen = false - - private init() {} - - func open(app: AppModel) { - SearchPanelModel.shared.close(app: app) - isOpen = true - } - - func close() { - guard isOpen else { return } - isOpen = false - } - - func toggle(app: AppModel) { - if isOpen { close() } else { open(app: app) } - } -} - -extension AppModel { - /// Whether the tab commands (New Tab, Close Tab, the tab cycle and Go to Tab) are about Ask - /// Bloom's conversations rather than the selected workspace's tabs: the panel is in front of - /// the window. With it up, Command-W closing a tab of the workspace hidden behind it would be - /// closing something nobody is looking at. - var isAskInFront: Bool { AskPanelModel.shared.isOpen } -} diff --git a/Sources/Bloom/Views/Ask/AskPanelView.swift b/Sources/Bloom/Views/Ask/AskPanelView.swift deleted file mode 100644 index 600bb70f4..000000000 --- a/Sources/Bloom/Views/Ask/AskPanelView.swift +++ /dev/null @@ -1,95 +0,0 @@ -import SwiftUI -import BloomCore - -/// Ask Bloom as a card in front of the window: the conversations down the side, the selected one -/// beside them. -/// -/// Drawn by `SearchPanelWindowOverlay`, on the same ground and behind the same dim as the search -/// panel, for the reason given at the head of that file. See `AskPanelLayout` for why Ask is a -/// panel at all. -/// -/// **Opaque, where the search card is glass.** The search card is a short list of rows. This one -/// holds a transcript, and a transcript over regular glass carries whatever is behind the card -/// into every line of it. -struct AskPanelView: View { - let app: AppModel - var width: CGFloat - var height: CGFloat - - private static let corner: CGFloat = 12 - - var body: some View { - HStack(spacing: 0) { - // No rail until there is a conversation to list. An empty column with only a `+` in it - // offered a new conversation beside the one already waiting to be typed into. - if !app.ask.listedSessions.isEmpty { - AskConversationRail() - .frame(width: AskPanelLayout.railWidth) - - Hairline(axis: .vertical) - } - - VStack(spacing: 0) { - header - Hairline() - AskConversationContent() - } - .background(Palette.windowBackground) - } - .frame(width: width, height: height) - .clipShape(RoundedRectangle(cornerRadius: Self.corner)) - .overlay { - RoundedRectangle(cornerRadius: Self.corner) - .strokeBorder(Palette.border, lineWidth: Metrics.outline) - } - .elevation(.lifted) - // Escape reaches here only when nothing inside wanted it: a composer with a menu open, or - // a turn to interrupt, answers it first. - .onExitCommand { AskPanelModel.shared.close() } - // Going anywhere else in the window is leaving the panel. The sidebar stays usable under - // the dim only through the keyboard and the menu bar, and both move the selection. - .onChange(of: app.selection) { _, _ in AskPanelModel.shared.close() } - } - - private var header: some View { - HStack(spacing: Metrics.spacingSmall) { - Text(title) - .font(Typo.labelEmphasis) - .foregroundStyle(Palette.textPrimary) - .lineLimit(1) - .truncationMode(.tail) - - Spacer(minLength: Metrics.spacing) - - headerButton("Close", systemImage: "xmark") { - AskPanelModel.shared.close() - } - } - .padding(.leading, Metrics.gutter) - .padding(.trailing, Metrics.spacingSmall) - .frame(height: Metrics.barHeight + Metrics.spacingWide) - } - - /// The conversation's own name, and nothing for one that has not earned one yet, rather than - /// "Ask Bloom" over a panel that is already Ask Bloom. - private var title: String { - guard let session = app.ask.session, !app.ask.isBlank(session) else { return "" } - let title = app.ask.title(for: session) - return title == AskConversation.title ? "" : title - } - - private func headerButton( - _ title: String, systemImage: String, action: @escaping () -> Void - ) -> some View { - Button(action: action) { - Label(title, systemImage: systemImage) - .labelStyle(.iconOnly) - .font(Typo.label) - .frame(width: Metrics.rowHeight, height: Metrics.rowHeight) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .foregroundStyle(Palette.textSecondary) - .help(title) - } -} diff --git a/Sources/Bloom/Views/Ask/ConversationBrowserPane.swift b/Sources/Bloom/Views/Ask/ConversationBrowserPane.swift new file mode 100644 index 000000000..4f8bb9b5b --- /dev/null +++ b/Sources/Bloom/Views/Ask/ConversationBrowserPane.swift @@ -0,0 +1,109 @@ +import AppKit +import SwiftUI +import BloomCore + +/// A browser in a project-free conversation. It keeps the same BrowserSession and web view across +/// tab switches, while its last address is part of the conversation's saved pane layout. +struct ConversationBrowserPane: View { + let state: ConversationPaneState + let tabID: ConversationPaneID + let pane: ConversationLayout.Pane + let isSplit: Bool + + @Environment(AppModel.self) private var app + @State private var address = "" + @FocusState private var addressFocused: Bool + + private var session: BrowserSession? { state.browser(for: pane.id) } + + var body: some View { + Group { + if let session { + VStack(spacing: 0) { + HStack(spacing: Metrics.spacingSmall) { + Button("Back", systemImage: "chevron.left") { session.goBack() } + .disabled(!session.canGoBack) + Button("Forward", systemImage: "chevron.right") { session.goForward() } + .disabled(!session.canGoForward) + TextField("Address", text: $address) + .textFieldStyle(.roundedBorder) + .focused($addressFocused) + .onSubmit { + session.load(address) + addressFocused = false + } + Button(session.isLoading ? "Stop" : "Reload", + systemImage: session.isLoading ? "xmark" : "arrow.clockwise") { + if session.isLoading { + session.webView.stopLoading() + } else { + session.reload() + } + } + } + .buttonStyle(.plain) + .padding(.horizontal, Metrics.spacingWide) + .frame(height: Metrics.barHeight) + Hairline() + ZStack { + BrowserViewportView( + session: session, + paneMenu: hostedMenu, + host: BrowserPaneHost( + openTab: { state.open(.browser, url: $0.absoluteString) }, + report: { app.alert = BloomAlert(title: $0.title, message: $0.message) } + ) + ) + if let failure = session.failure { + EmptyStateView( + glyph: "exclamationmark.triangle", + title: failure.title, + message: failure.message, + actionTitle: "Try again", + action: { session.reload() } + ) + .background(Palette.surface) + } else if session.currentURL == nil { + EmptyStateView( + glyph: "globe", + title: "No page yet", + message: "Type an address above." + ) + .background(Palette.surface) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .onChange(of: session.page, initial: true) { _, page in + state.updatePage(page, paneID: pane.id, tabID: tabID) + if !addressFocused { address = session.displayAddress } + } + } else { + LoadingView("Opening a browser") + .task(id: pane.id) { state.prepareBrowser(pane) } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Palette.surface) + } + + private func hostedMenu() -> NSMenu { + NSHostingMenu(rootView: CenterPaneMenu( + isSplit: isSplit, + split: { axis, kind in + state.split(tabID: tabID, paneID: pane.id, axis: axis, kind: kind.conversationKind) + }, + close: { state.closePane(tabID: tabID, paneID: pane.id) } + )) + } +} + +extension PaneKind { + var conversationKind: ConversationLayout.Kind { + switch self { + case .chat: .chat + case .terminal: .terminal + case .browser: .browser + } + } +} diff --git a/Sources/Bloom/Views/Ask/ConversationColumnView.swift b/Sources/Bloom/Views/Ask/ConversationColumnView.swift new file mode 100644 index 000000000..341276444 --- /dev/null +++ b/Sources/Bloom/Views/Ask/ConversationColumnView.swift @@ -0,0 +1,175 @@ +import SwiftUI +import BloomCore + +/// The chat and tool panes of one project-free conversation. A conversation uses its own directory +/// for shells and local browser pages, while its tabs and split ratios belong to its session id. +struct ConversationColumnView: View { + let sessionID: SessionID + + @Environment(AppModel.self) private var app + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var renamingID: ConversationPaneID? + @Namespace private var selection + + private var state: ConversationPaneState? { app.ask.paneState(for: sessionID) } + + var body: some View { + Group { + if let state { + let tabs = state.layout.tabs + VStack(spacing: 0) { + if tabs.count > 1 { + tabStrip(tabs, state: state) + .transition(.opacity) + } + ConversationPanesView(state: state, sessionID: sessionID) + } + .animation(reduceMotion ? nil : Motion.pane, value: tabs.count > 1) + } else { + Color.clear + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Palette.windowBackground) + .task(id: sessionID) { + await app.ask.open() + await app.ask.select(sessionID) + } + } + + private func tabStrip(_ tabs: [ConversationLayout.Tab], state: ConversationPaneState) -> some View { + TabStrip(tabCount: tabs.count, selection: AnyHashable(state.layout.selectedTabID)) { + EmptyView() + } tabs: { + HStack(spacing: 0) { + ForEach(tabs) { tab in + TabItemView( + title: title(of: tab), + icon: .symbol(symbol(of: tab)), + isActive: state.layout.selectedTabID == tab.id, + isRenaming: renamingID == tab.id, + editableTitle: title(of: tab), + canClose: tab.id != ConversationLayout.chatID, + closeTitle: title(of: tab), + onSelect: { state.select(tab.id) }, + onStartRename: { renamingID = tab.id }, + onCommitRename: { title in + if tab.id == ConversationLayout.chatID { + Task { await app.ask.rename(sessionID, title: title) } + } else { + state.rename(title, tabID: tab.id) + } + renamingID = nil + }, + onCancelRename: { renamingID = nil }, + onClose: { state.closeTab(tab.id) }, + namespace: selection + ) + .id(tab.id) + } + } + } append: { + EmptyView() + } trailing: { + EmptyView() + } + } + + private func title(of tab: ConversationLayout.Tab) -> String { + guard tab.id == ConversationLayout.chatID else { return tab.title } + return app.ask.sessions.first(where: { $0.id == sessionID }).map(app.ask.title(for:)) ?? tab.title + } + + private func symbol(of tab: ConversationLayout.Tab) -> String { + switch tab.panes[ConversationPaneID(tab.layout.focus)]?.kind ?? .chat { + case .chat: PaneGlyph.chat + case .terminal: PaneGlyph.terminal + case .browser: PaneGlyph.browser + } + } +} + +private struct ConversationPanesView: View { + let state: ConversationPaneState + let sessionID: SessionID + + var body: some View { + let tab = state.layout.selectedTab + let layout = tab?.layout ?? SplitLayout(pane: ConversationLayout.chatID.rawValue) + + GeometryReader { proxy in + let geometry = layout.geometry(in: proxy.size, dividerThickness: CentrePaneMeasure.dividerThickness) + ZStack(alignment: .topLeading) { + if let tab, proxy.size.width > 1, proxy.size.height > 1 { + ForEach(geometry.panes, id: \.pane) { item in + if let pane = tab.pane(ConversationPaneID(item.pane)) { + ConversationPaneView( + state: state, sessionID: sessionID, tabID: tab.id, + pane: pane, isSplit: layout.paneCount > 1 + ) + .frame(width: item.frame.width, height: item.frame.height) + .clipped() + .position(x: item.frame.midX, y: item.frame.midY) + } + } + ForEach(geometry.dividers, id: \.path) { divider in + SplitPaneDivider( + axis: divider.axis, + ratio: divider.ratio, + span: divider.span, + length: divider.axis == .horizontal + ? divider.frame.height : divider.frame.width, + color: Palette.border, + onChange: { state.setRatio($0, at: divider.path, in: tab.id) }, + onChangeEnded: { state.persistRatio() } + ) + .position(x: divider.frame.midX, y: divider.frame.midY) + } + } + } + } + .background(Palette.windowBackground) + } +} + +private struct ConversationPaneView: View { + let state: ConversationPaneState + let sessionID: SessionID + let tabID: ConversationPaneID + let pane: ConversationLayout.Pane + let isSplit: Bool + + var body: some View { + Group { + switch pane.kind { + case .chat: + AskConversationContent(sessionID: sessionID) + case .terminal: + TerminalSplitView( + ownerID: pane.id.rawValue, + owner: .conversation(sessionID, directory: state.directory), + onCloseTab: { state.closePane(tabID: tabID, paneID: pane.id) }, + splitColumn: { axis, kind in + state.split(tabID: tabID, paneID: pane.id, axis: axis, kind: kind.conversationKind) + }, + terminalLabel: pane.title + ) + case .browser: + ConversationBrowserPane(state: state, tabID: tabID, pane: pane, isSplit: isSplit) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .simultaneousGesture(TapGesture().onEnded { state.focus(pane.id, in: tabID) }) + .contextMenu { + if pane.kind != .terminal && pane.kind != .browser { + CenterPaneMenu( + isSplit: isSplit, + split: { axis, kind in + state.split(tabID: tabID, paneID: pane.id, axis: axis, kind: kind.conversationKind) + }, + close: { state.closePane(tabID: tabID, paneID: pane.id) } + ) + } + } + } +} diff --git a/Sources/Bloom/Views/Ask/ConversationNewTabMenu.swift b/Sources/Bloom/Views/Ask/ConversationNewTabMenu.swift new file mode 100644 index 000000000..a70411aa0 --- /dev/null +++ b/Sources/Bloom/Views/Ask/ConversationNewTabMenu.swift @@ -0,0 +1,23 @@ +import SwiftUI +import BloomCore + +/// New tools in a project-free conversation use the same tab strip as project tools. +struct ConversationNewTabMenu: View { + let state: ConversationPaneState + + var body: some View { + Menu { + Button(PaneKind.terminal.title, systemImage: PaneKind.terminal.symbol) { + state.open(.terminal) + } + Button(PaneKind.browser.title, systemImage: PaneKind.browser.symbol) { + state.open(.browser) + } + } label: { + Label("New Tab", systemImage: "plus") + } + .menuIndicator(.hidden) + .accessibilityLabel("New Tab") + .help("New tab in this conversation") + } +} diff --git a/Sources/Bloom/Views/Center/Attachments/FileMediaView.swift b/Sources/Bloom/Views/Center/Attachments/FileMediaView.swift index 2d5d8c10d..17135aa7a 100644 --- a/Sources/Bloom/Views/Center/Attachments/FileMediaView.swift +++ b/Sources/Bloom/Views/Center/Attachments/FileMediaView.swift @@ -28,12 +28,17 @@ struct FileMediaView: View { Hairline() GeometryReader { proxy in - AttachmentPreview( - url: url, - maxWidth: max(proxy.size.width - Metrics.pane * 2, 1), - maxHeight: max(proxy.size.height - Metrics.pane * 2, 1) - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) + if Self.isImage(path: path) { + Button { ImageLightbox.open(url) } label: { + preview(in: proxy.size) + } + .buttonStyle(.plain) + .help("View \(filename) at full size") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + preview(in: proxy.size) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } } } .background(Palette.surface) @@ -66,6 +71,18 @@ struct FileMediaView: View { private var filename: String { (path as NSString).lastPathComponent } + private func preview(in size: CGSize) -> some View { + AttachmentPreview( + url: url, + maxWidth: max(size.width - Metrics.pane * 2, 1), + maxHeight: max(size.height - Metrics.pane * 2, 1) + ) + } + + static func isImage(path: String) -> Bool { + UTType(filenameExtension: (path as NSString).pathExtension)?.conforms(to: .image) == true + } + /// Whether a path is one of these rather than one for `FilePreview`. /// /// Asked of the extension rather than of the bytes, so nothing is read twice to find out. A diff --git a/Sources/Bloom/Views/Center/Browser/BrowserScrollObserver.swift b/Sources/Bloom/Views/Center/Browser/BrowserScrollObserver.swift new file mode 100644 index 000000000..6216850c6 --- /dev/null +++ b/Sources/Bloom/Views/Center/Browser/BrowserScrollObserver.swift @@ -0,0 +1,42 @@ +import WebKit + +/// Reports scroll position at most a few times per second, plus once after scrolling stops. The +/// toolbar uses it to refresh a thin picture of the page edge without snapshotting every frame. +@MainActor +final class BrowserScrollObserver: NSObject, WKScriptMessageHandler { + static let handlerName = "bloomPageScrolled" + + static let script = """ + (() => { + let previous; + let lastReport = 0; + let trailing; + const report = () => { + const offset = Math.max(0, window.scrollY); + if (offset === previous) return; + previous = offset; + window.webkit.messageHandlers.bloomPageScrolled.postMessage(offset); + }; + window.addEventListener('scroll', () => { + const now = performance.now(); + if (now - lastReport >= 120) { + lastReport = now; + report(); + } + clearTimeout(trailing); + trailing = setTimeout(report, 120); + }, { passive: true }); + window.addEventListener('pageshow', report); + report(); + })(); + """ + + weak var owner: BrowserSession? + + func userContentController( + _ userContentController: WKUserContentController, didReceive message: WKScriptMessage + ) { + guard let offset = (message.body as? NSNumber)?.doubleValue else { return } + owner?.setPageScrollOffset(offset) + } +} diff --git a/Sources/Bloom/Views/Center/Browser/BrowserSession+Agent.swift b/Sources/Bloom/Views/Center/Browser/BrowserSession+Agent.swift index 0adcd977c..5b0e98045 100644 --- a/Sources/Bloom/Views/Center/Browser/BrowserSession+Agent.swift +++ b/Sources/Bloom/Views/Center/Browser/BrowserSession+Agent.swift @@ -197,7 +197,9 @@ extension BrowserSession { /// handlers strongly, and a closed tab should not be keeping a listener alive. func stopListeningToConsole() { guard consoleLog.isListening else { return } - webView.configuration.userContentController.removeAllScriptMessageHandlers() + webView.configuration.userContentController.removeScriptMessageHandler( + forName: BrowserConsoleScript.handlerName, contentWorld: .page + ) } } diff --git a/Sources/Bloom/Views/Center/Browser/BrowserSession.swift b/Sources/Bloom/Views/Center/Browser/BrowserSession.swift index 6a894c58a..5f70bc155 100644 --- a/Sources/Bloom/Views/Center/Browser/BrowserSession.swift +++ b/Sources/Bloom/Views/Center/Browser/BrowserSession.swift @@ -39,6 +39,10 @@ final class BrowserSession { private(set) var canGoBack = false private(set) var canGoForward = false private(set) var isLoading = false + private(set) var isPageScrolled = false + private(set) var scrollEdgeImage: NSImage? + @ObservationIgnored private var scrollEdgeCapture: Task? + @ObservationIgnored private var scrollEdgeNeedsCapture = false /// How far the fetch in flight has got, rounded to hundredths. /// /// Rounded because WebKit reports this continuously and every write redraws the pane. A @@ -68,6 +72,7 @@ final class BrowserSession { @ObservationIgnored private let navigation = NavigationObserver() @ObservationIgnored private let ui = BrowserUIObserver() + @ObservationIgnored private let scrollObserver = BrowserScrollObserver() /// What this pane can ask the window around it for, which the session cannot do itself. Set by /// the pane drawing it, on every update. See `BrowserPaneHost`. @@ -137,7 +142,19 @@ final class BrowserSession { Self.preferInspectorDocked() let configuration = WKWebViewConfiguration() Self.enableDeveloperExtras(on: configuration.preferences) + configuration.userContentController.add( + scrollObserver, contentWorld: .defaultClient, name: BrowserScrollObserver.handlerName + ) + configuration.userContentController.addUserScript( + WKUserScript( + source: BrowserScrollObserver.script, + injectionTime: .atDocumentStart, + forMainFrameOnly: true, + in: .defaultClient + ) + ) webView = BrowserPageWebView(frame: .zero, configuration: configuration) + Self.keepPageFromTakingKeyboard(webView) webView.allowsBackForwardNavigationGestures = true pageView.attach(webView) // A dev server is the whole point of this tab, and one that is still booting answers with @@ -145,6 +162,7 @@ final class BrowserSession { // page keeps that moment from flashing white in a dark window. webView.underPageBackgroundColor = NSColor(Palette.surface) navigation.owner = self + scrollObserver.owner = self webView.navigationDelegate = navigation ui.owner = self webView.uiDelegate = ui @@ -184,6 +202,45 @@ final class BrowserSession { load(url) } + func setPageScrollOffset(_ offset: Double) { + guard offset > 0 else { + isPageScrolled = false + scrollEdgeImage = nil + scrollEdgeNeedsCapture = false + return + } + isPageScrolled = true + captureScrollEdge() + } + + /// WebKit does not expose a live material surface for a toolbar outside its view. A narrow + /// snapshot gives the bar the colours passing immediately below it without moving the page + /// behind the controls and hiding the page's first line at rest. + func captureScrollEdge() { + guard isPageScrolled, webView.window != nil, + webView.bounds.width > 0, webView.bounds.height > 0 else { return } + guard scrollEdgeCapture == nil else { + scrollEdgeNeedsCapture = true + return + } + scrollEdgeCapture = Task { [weak self] in + guard let self else { return } + repeat { + scrollEdgeNeedsCapture = false + let configuration = WKSnapshotConfiguration() + configuration.rect = CGRect( + x: 0, y: 0, width: webView.bounds.width, + height: min(Metrics.barHeight, webView.bounds.height) + ) + if let image = try? await webView.takeSnapshot(configuration: configuration), + isPageScrolled { + scrollEdgeImage = image + } + } while scrollEdgeNeedsCapture && isPageScrolled + scrollEdgeCapture = nil + } + } + /// Points the tab at whatever the user typed. Anything that cannot be read as an address is /// ignored rather than handed to a search engine: this field is for the dev server next door, /// and shipping a half-typed line off to a third party is not what it is for. @@ -664,6 +721,15 @@ final class BrowserSession { preferences.setValue(true, forKey: "developerExtrasEnabled") } + /// Page `focus()`, which every agent click and fill triggers, made WebKit take the window's + /// keyboard from the composer. Refusing in `becomeFirstResponder` is too late: the composer + /// has already resigned. Private setting, reached by KVC as in `enableDeveloperExtras`. + private static func keepPageFromTakingKeyboard(_ webView: WKWebView) { + guard webView.responds(to: NSSelectorFromString("_setShouldSuppressFirstResponderChanges:")) + else { return } + webView.setValue(true, forKey: "shouldSuppressFirstResponderChanges") + } + /// The key WebKit remembers the inspector's last attachment in, and it is WebKit's own: it /// writes it into whichever application's defaults the inspected web view belongs to. private static let startsAttachedKey = diff --git a/Sources/Bloom/Views/Center/Browser/BrowserTabView.swift b/Sources/Bloom/Views/Center/Browser/BrowserTabView.swift index af3549a2b..6b3699ffb 100644 --- a/Sources/Bloom/Views/Center/Browser/BrowserTabView.swift +++ b/Sources/Bloom/Views/Center/Browser/BrowserTabView.swift @@ -199,6 +199,8 @@ struct BrowserTabView: View { loadProgress: session.loadProgress, isCapturing: isCapturing || isSelectingRegion ), + isPageScrolled: session.isPageScrolled, + scrollEdgeImage: session.scrollEdgeImage, address: $address, addressFocus: $isAddressFocused, locationFocusRequest: locationFocusRequest, @@ -219,6 +221,8 @@ struct BrowserTabView: View { submit: { session.load(address) isAddressFocused = false + // The page can no longer take it itself. See `keepPageFromTakingKeyboard`. + session.webView.window?.makeFirstResponder(session.webView) } ) } diff --git a/Sources/Bloom/Views/Center/Browser/BrowserToolbarView.swift b/Sources/Bloom/Views/Center/Browser/BrowserToolbarView.swift index f510734c3..ab5460aa9 100644 --- a/Sources/Bloom/Views/Center/Browser/BrowserToolbarView.swift +++ b/Sources/Bloom/Views/Center/Browser/BrowserToolbarView.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppKit import BloomCore /// One bare glyph in the browser pane's bar. @@ -40,16 +41,17 @@ struct BrowserToolbarButton: View { /// no stock component for any of it, and `BrowserToolbar` says why: the toolbar, the joined pair /// and the search item are all `NSWindow`'s, and this is a pane inside a split inside a tab. /// -/// **The pair and the field are real glass.** It was refused once on the grounds that glass -/// samples an arbitrary web page, and that was wrong: `BrowserTabView` is a `VStack(spacing: 0)`, -/// so this bar sits above the page rather than over it. What the two shapes sample is the bar's -/// own `surfaceSunken`, which is ours. +/// **The pair and the field are real glass.** The page stays below the bar so its first lines are +/// never hidden. Once it scrolls, a blurred reflection of its top edge colours this bar. /// /// Camera and Share use the same circular treatment. Their glyphs still use `.accessoryBar` for /// the native hover and pressed states. The camera is on that side because with Reload inside the /// field, what is left on the right is the pair that takes this page elsewhere. struct BrowserToolbarView: View { var toolbar: BrowserToolbar + var isPageScrolled = false + var scrollEdgeImage: NSImage? + @Environment(\.colorScheme) private var colorScheme /// What the field shows, which is not where the page is. See `BrowserTabView.address`. @Binding var address: String /// The field's focus state when a caller wants to drive it. @@ -101,11 +103,22 @@ struct BrowserToolbarView: View { } .padding(.horizontal, Metrics.spacingSmall) .frame(height: Metrics.barHeight) - // The bar itself is a colour and not glass. What is behind it is `Palette.surface`, one - // flat fill, so the material would resolve to a colour Bloom already has a name for, and - // it would lift the ground the two shapes above sample. Its bottom edge also meets the - // page's top edge, where a ground that moves reads as a seam rather than as material. - .background(Palette.surfaceSunken) + .background { + if isPageScrolled, let scrollEdgeImage { + GeometryReader { geometry in + Image(nsImage: scrollEdgeImage) + .resizable() + .scaledToFill() + .frame(width: geometry.size.width, height: geometry.size.height) + .scaleEffect(y: -1) + .blur(radius: 10) + .overlay(Palette.surfaceSunken.opacity(colorScheme == .dark ? 0.8 : 0.58)) + .clipped() + } + } else { + Palette.surfaceSunken + } + } } /// Navigation stays together, including the action that refreshes the current page. diff --git a/Sources/Bloom/Views/Center/Browser/BrowserWebView.swift b/Sources/Bloom/Views/Center/Browser/BrowserWebView.swift index 7a06ae158..8573cf15c 100644 --- a/Sources/Bloom/Views/Center/Browser/BrowserWebView.swift +++ b/Sources/Bloom/Views/Center/Browser/BrowserWebView.swift @@ -188,5 +188,8 @@ struct BrowserWebView: NSViewRepresentable { session.webView.underPageBackgroundColor = NSColor(Palette.surface) session.webView.paneMenu = paneMenu session.host = host + if session.isPageScrolled && session.scrollEdgeImage == nil { + session.captureScrollEdge() + } } } diff --git a/Sources/Bloom/Views/Center/Composer/ComposerModelCatalog.swift b/Sources/Bloom/Views/Center/Composer/ComposerModelCatalog.swift index 8ddecff57..d4065a347 100644 --- a/Sources/Bloom/Views/Center/Composer/ComposerModelCatalog.swift +++ b/Sources/Bloom/Views/Center/Composer/ComposerModelCatalog.swift @@ -61,6 +61,13 @@ final class ComposerModelCatalog { } } + /// Browser settings need a complete model menu in one response, not a snapshot taken while + /// the same catalogue is still fetching the backend lists. + func loadReady() async { + load() + while let task = loadTask { await task.value } + } + func refresh() { loadTask?.cancel() loadGeneration = UUID() diff --git a/Sources/Bloom/Views/Center/Composer/ComposerOption.swift b/Sources/Bloom/Views/Center/Composer/ComposerOption.swift index fec5b110e..17da739d6 100644 --- a/Sources/Bloom/Views/Center/Composer/ComposerOption.swift +++ b/Sources/Bloom/Views/Center/Composer/ComposerOption.swift @@ -18,6 +18,7 @@ struct ComposerOption: Identifiable, Hashable { ComposerOption(id: "fable", label: "Fable 5.1"), ComposerOption(id: "opus", label: "Opus 5.5"), ComposerOption(id: "sonnet", label: "Sonnet 5"), + ComposerOption(id: "claude-sonnet-5-5", label: "Sonnet 5.5"), ComposerOption(id: "haiku", label: "Haiku 4.5"), ] diff --git a/Sources/Bloom/Views/Center/Composer/ComposerPrompt.swift b/Sources/Bloom/Views/Center/Composer/ComposerPrompt.swift index 4f47bbe3d..905a519b9 100644 --- a/Sources/Bloom/Views/Center/Composer/ComposerPrompt.swift +++ b/Sources/Bloom/Views/Center/Composer/ComposerPrompt.swift @@ -241,6 +241,7 @@ struct ComposerPrompt: View { // and then draw them under every session the window visited afterwards. See // `CentrePaneMeasure.soloPane`. .task(id: attachmentKey) { + TypeToComposer.register(editor, showing: attachmentKey) PromptAttachmentStore.shared.load(sessionID: attachmentKey) adoptAttachmentsKeptBesideTheDraft() applyCaptureDraft() diff --git a/Sources/Bloom/Views/Center/Composer/ComposerTextEditor.swift b/Sources/Bloom/Views/Center/Composer/ComposerTextEditor.swift index 12c919849..28f440495 100644 --- a/Sources/Bloom/Views/Center/Composer/ComposerTextEditor.swift +++ b/Sources/Bloom/Views/Center/Composer/ComposerTextEditor.swift @@ -217,6 +217,7 @@ struct ComposerTextEditor: NSViewRepresentable { textView.setAccessibilityLabel(accessibilityLabel) handle?.textView = textView + handle?.coordinator = context.coordinator let scrollView = NSScrollView() scrollView.documentView = textView @@ -231,6 +232,7 @@ struct ComposerTextEditor: NSViewRepresentable { guard let textView = scrollView.documentView as? ComposerTextView else { return } context.coordinator.parent = self handle?.textView = textView + handle?.coordinator = context.coordinator let theme = ColourThemePreference.shared.choice if let previous = context.coordinator.lastTheme, previous != theme { textView.needsDisplay = true @@ -520,6 +522,17 @@ struct ComposerTextEditor: NSViewRepresentable { @MainActor final class ComposerEditorHandle { fileprivate weak var textView: ComposerTextView? + fileprivate weak var coordinator: ComposerTextEditor.Coordinator? + + /// The binding is set first: the deferred focus report would let a redraw in between read + /// "not focused" and take the keyboard back. + func takeKeyboard(in window: NSWindow) -> Bool { + guard let textView, let coordinator, textView.window === window, + !textView.isHiddenOrHasHiddenAncestor else { return false } + coordinator.parent.isFocused = true + textView.setSelectedRange(NSRange(location: (textView.string as NSString).length, length: 0)) + return window.makeFirstResponder(textView) + } /// Writes files into the draft at `range`, measured in the draft's own units. /// diff --git a/Sources/Bloom/Views/Center/Composer/ComposerView.swift b/Sources/Bloom/Views/Center/Composer/ComposerView.swift index 2c47eaa68..5875df45b 100644 --- a/Sources/Bloom/Views/Center/Composer/ComposerView.swift +++ b/Sources/Bloom/Views/Center/Composer/ComposerView.swift @@ -309,6 +309,7 @@ struct ComposerView: View { /// Writes the footer's choices back where a conversation keeps them: the four that are columns /// go on the session row, and fast mode and the output style go in the store's key value table. private func apply(controls new: ComposerControls) { + app.rememberModelChoice(from: controls, to: new) if new.codexFastMode != codexFastMode { codexFastMode = new.codexFastMode @@ -783,7 +784,9 @@ struct ComposerView: View { let wasPrepared = (try? await store.setting(appliedKey)) == "1" guard !wasPrepared, transcript.session.agentSessionID == nil else { return } - let appDefaults = await AppDefaults.loadForNewSessions(from: store) + let appDefaults = await AppDefaults.loadForNewSessions( + from: store, models: ComposerModelCatalog.shared.models + ) // Off the main actor because it reads up to six files from disk. var repoSettings = RepoSettings() @@ -792,12 +795,14 @@ struct ComposerView: View { repoSettings = await Task.detached(priority: .utility) { SettingsLoader.load(repo: path) }.value + repoSettings = (try? await store.applyingProjectAgentDefaults( + to: repoSettings, for: repo.id + )) ?? repoSettings } guard !Task.isCancelled else { return } - // A chat with no worktree does not inherit the owner's permission mode, and that is the - // whole of decision two: the default is Full access, and this is the one conversation in - // Bloom that sits above every project. See `ComposerDefaults.resolve`. + // Ask Bloom uses the same permission preference as workspace chats. Its working directory + // and the permission menu explain the wider reach a mode may have without a worktree. let resolved = ComposerDefaults.resolve( repo: repoSettings, app: appDefaults, diff --git a/Sources/Bloom/Views/Center/Panes/ReviewPaneView.swift b/Sources/Bloom/Views/Center/Panes/ReviewPaneView.swift index 9f1cea1f9..9344b759f 100644 --- a/Sources/Bloom/Views/Center/Panes/ReviewPaneView.swift +++ b/Sources/Bloom/Views/Center/Panes/ReviewPaneView.swift @@ -126,6 +126,16 @@ struct ReviewPaneView: View { navigationRevision: tab.reviewNavigationRevision ) .id(model.workspace.id) + } else if isPresent, !model.diffScope.isHistorical, + DocumentPreview.kind(path: tab.path) == .svg { + model.filePane(path: tab.path) + .id(tab.path) + } else if isPresent, !model.diffScope.isHistorical, FileMediaView.isImage(path: tab.path) { + FileMediaView( + worktree: Self.isAbsolute(tab.path) ? "/" : model.workspace.path, + path: Self.isAbsolute(tab.path) ? String(tab.path.dropFirst()) : tab.path + ) + .id(tab.path) } else if let changed { // A path can exist in several workspaces. Include the workspace so switching // checkouts cannot reuse another workspace's diff, selection or expanded context. diff --git a/Sources/Bloom/Views/Center/Panes/ToolPaneView.swift b/Sources/Bloom/Views/Center/Panes/ToolPaneView.swift index b9cccc82c..fc1674b92 100644 --- a/Sources/Bloom/Views/Center/Panes/ToolPaneView.swift +++ b/Sources/Bloom/Views/Center/Panes/ToolPaneView.swift @@ -50,9 +50,7 @@ struct ToolPaneView: View { if readyTabID == tab.id { TerminalSplitView( ownerID: tab.id, - workspace: model.workspace, - repo: model.repo, - port: model.port, + owner: .workspace(model.workspace, model.repo, port: model.port), directory: tab.directory, runScript: runScript, onCloseTab: { @@ -146,6 +144,8 @@ struct ToolPaneView: View { private func prepareTerminal() async { TerminalSessionStore.shared.useStore(model.store) await model.ensurePort() + guard !Task.isCancelled else { return } readyTabID = tab.id + TerminalSplitStore.shared.requestKeyboard(in: tab.id) } } diff --git a/Sources/Bloom/Views/Chrome/App/BloomAppDelegate.swift b/Sources/Bloom/Views/Chrome/App/BloomAppDelegate.swift index 472259ff9..4552abf7f 100644 --- a/Sources/Bloom/Views/Chrome/App/BloomAppDelegate.swift +++ b/Sources/Bloom/Views/Chrome/App/BloomAppDelegate.swift @@ -45,6 +45,8 @@ final class BloomAppDelegate: NSObject, NSApplicationDelegate, UNUserNotificatio TabCycleShortcut.attach(model) // A selected passage in the current transcript answers plain R by moving into the reply. SelectionToChat.attachReplyShortcut() + // Typing with nothing focused goes to the chat composer. + TypeToComposer.attach(model) // The suppression rule needs to know which workspace the window is showing, and this is // the first moment there is a window to ask. NotificationService.shared.attach(model) diff --git a/Sources/Bloom/Views/Chrome/App/BloomCommands.swift b/Sources/Bloom/Views/Chrome/App/BloomCommands.swift index 54df8cf1d..d3561da15 100644 --- a/Sources/Bloom/Views/Chrome/App/BloomCommands.swift +++ b/Sources/Bloom/Views/Chrome/App/BloomCommands.swift @@ -5,6 +5,8 @@ import BloomCore /// Keeps keyboard-driven navigation available even when focus is inside a transcript or terminal. @MainActor struct BloomCommands: Commands { + /// The sidebar highlights a new row before activating its centre column. Command actions + /// read `shortcutModel` so a key pressed in that interval goes to the highlighted worktree. private let model: AppModel /// Read rather than queried, so the size items grey and ungrey as the sizes and the focus move. @@ -84,7 +86,6 @@ struct BloomCommands: Commands { // what the main window has selected. See `RootView.presentCreateWorkspace`. NotificationCenter.default.post(name: .bloomNewWorkspace, object: nil) } - .disabled(model.repos.isEmpty) // Directly under New Workspace, because it starts one, and at the top level of File // rather than nowhere. Opening a workspace on somebody else's pull request was a whole @@ -115,14 +116,10 @@ struct BloomCommands: Commands { } .disabled(projectSettingsRepo == nil) - // Reached from the menu as well as from the toolbar glyph that appears while Ask Bloom - // is open. See the note beside this item in `MenuBarCatalogue`: the toolbar button is - // a control you have to be looking at Ask Bloom to see, so it could not answer - // somebody wondering whether the conversation can be started over at all. - // - // The menu and toolbar both append a conversation and preserve the open tabs. - MenuCommand(.newAskConversation) { - NotificationCenter.default.post(name: .bloomNewAskConversation, object: nil) + // Available even before a project is added. The create sheet opens on Conversation + // in that case, and the File menu gives it a direct action as well. + MenuCommand(.newConversation) { + NotificationCenter.default.post(name: .bloomNewConversationWorkspace, object: nil) } MenuCommand(.searchFiles) { @@ -131,14 +128,10 @@ struct BloomCommands: Commands { .disabled(model.selectedWorkspace == nil) MenuCommand(.newSession) { - if model.isAskInFront { - Task { await model.ask.newConversation() } - return - } - guard let workspace = model.selectedModel else { return } + guard let workspace = model.shortcutModel else { return } NewPane.open(.chat, in: workspace) { WorkspaceTabsStore.shared.select($0, in: workspace) } } - .disabled(model.selectedModel == nil && !model.isAskInFront) + .disabled(model.shortcutModel == nil) // The other four things that open a tab in the workspace's centre column, which until // now existed only as key equivalents on hidden buttons inside `SessionTabsView`. The @@ -162,10 +155,10 @@ struct BloomCommands: Commands { Divider() MenuCommand(.newTerminalTab) { openPane(.terminal) } - .disabled(model.selectedModel == nil) + .disabled(model.shortcutModel == nil) MenuCommand(.newBrowserTab) { openBrowserPane() } - .disabled(model.selectedModel == nil) + .disabled(model.shortcutModel == nil) // The same key both ways, as the hidden button had it: show me the change, or give me // the conversation back. Enabled on any selected workspace rather than only on one @@ -173,16 +166,16 @@ struct BloomCommands: Commands { // the way back out of a review and a workspace can have a review open with nothing // left in it. MenuCommand(.showChanges) { - guard let workspace = model.selectedModel else { return } + guard let workspace = model.shortcutModel else { return } FileReview.toggle(in: workspace) } - .disabled(model.selectedModel == nil) + .disabled(model.shortcutModel == nil) MenuCommand(.reviewAllFiles) { - guard let workspace = model.selectedModel else { return } + guard let workspace = model.shortcutModel else { return } FileReview.openAll(in: workspace) } - .disabled(model.selectedModel == nil) + .disabled(model.shortcutModel == nil) // Shift+Cmd+N, which nothing in Bloom held. It is the initial of the thing, which is // what the other three in this group are, and that pattern is the only reason a set @@ -190,10 +183,10 @@ struct BloomCommands: Commands { // needing a workspace, because an empty note is exactly what somebody opening this is // about to fix. MenuCommand(.showNotes) { - guard let workspace = model.selectedModel else { return } + guard let workspace = model.shortcutModel else { return } WorkspaceNotes.open(in: workspace) } - .disabled(model.selectedModel == nil) + .disabled(model.shortcutModel == nil) Divider() @@ -224,7 +217,7 @@ struct BloomCommands: Commands { } // Scoped to the main window as well as to there being a tab, because this item holds // Cmd+W for the whole app. See `MainWindowFocus`. - .disabled(isMainWindowFocused != true || (model.isAskInFront ? !canCloseAskTab : closableTab == nil)) + .disabled(isMainWindowFocused != true || closableTab == nil) Divider() @@ -234,6 +227,11 @@ struct BloomCommands: Commands { MenuCommand(.startProject) { NotificationCenter.default.post(name: .bloomNewProject, object: nil) } + + Divider() + + MenuCommand(.openWebClient) { model.openLocalWebInBrowser() } + .disabled(!model.localWebEnabled || model.localWebAddress == nil) } // The item this file used to say did not exist. Two views bind Cmd+S (the project settings @@ -361,7 +359,7 @@ struct BloomCommands: Commands { splitMenu(.splitDown, axis: .vertical, symbol: PaneSymbol.splitDown) MenuCommand(.closePane, symbol: PaneSymbol.closePane) { closeCentrePane() } - .disabled(model.selectedModel == nil) + .disabled(model.shortcutModel == nil) // The two a terminal tab's shell tree can do and nothing else in the column can. They // were on the AppKit menu a shell returns from `menu(for:)` and nowhere a reader can @@ -406,13 +404,13 @@ struct BloomCommands: Commands { // fires, so it has to be one or the other. Here it is the menu, which greys itself out // when there is nothing to step through and says the keys out loud. MenuCommand(.fileBack) { - guard let workspace = model.selectedModel else { return } + guard let workspace = model.shortcutModel else { return } SourceNavigation.shared.move(-1, in: workspace) } .disabled(fileHistory?.canGoBack != true) MenuCommand(.fileForward) { - guard let workspace = model.selectedModel else { return } + guard let workspace = model.shortcutModel else { return } SourceNavigation.shared.move(1, in: workspace) } .disabled(fileHistory?.canGoForward != true) @@ -435,10 +433,10 @@ struct BloomCommands: Commands { } MenuCommand(.toggleInspector) { - guard model.selectedModel != nil else { return } + guard model.shortcutModel != nil else { return } model.isInspectorVisible.toggle() } - .disabled(model.selectedModel == nil) + .disabled(model.shortcutModel == nil) Divider() @@ -462,20 +460,16 @@ struct BloomCommands: Commands { // AppKit finds first would silently have killed the other. Cmd+Shift+H is what Home is // bound to in Finder and in Safari anyway, so this is where it should always have been. MenuCommand(.goToHome) { - AskPanelModel.shared.close() + model.homeFilter.scope = .all model.selection = .home } - .disabled(model.selection == .home) + .disabled(model.selection == .home && model.homeFilter.scope == .all) // The title and the key come from the table, like every other row in the bar. Written // here as a `Button` with its own string, it was a title nothing could check and a // keylessness nothing could compare against the rest of the bar, which is what // `MenuCommand`'s header calls the whole point of it. The argument for no key is on // the row itself now. - // Opens and closes the panel rather than going anywhere. - MenuCommand(.goToAsk) { - AskPanelModel.shared.toggle(app: model) - } } // Zoom, at the foot of the View menu, where Safari, Preview and Notes all keep it, and @@ -680,7 +674,7 @@ struct BloomCommands: Commands { /// `SetupRunAlert`. @ViewBuilder private var setupItem: some View { - if let workspace = model.selectedModel, let offer = workspace.setupRunOffer { + if let workspace = model.shortcutModel, let offer = workspace.setupRunOffer { Button(offer.title) { SetupRunAlert.shared.ask(workspace) } .disabled(!offer.isEnabled) } @@ -699,7 +693,7 @@ struct BloomCommands: Commands { /// submenu teaches nothing. See `WorkspaceModel.refreshSettings` for when the list is read. @ViewBuilder private var runScriptsMenu: some View { - if let workspace = model.selectedModel, !workspace.settings.runScripts.isEmpty { + if let workspace = model.shortcutModel, !workspace.settings.runScripts.isEmpty { MenuCommandGroup(.runScripts) { ForEach(workspace.settings.runScripts) { script in Button(script.name) { run(script, in: workspace) } @@ -755,7 +749,7 @@ struct BloomCommands: Commands { splitRow(action, axis: axis, kind: kind) } } - .disabled(model.selectedModel == nil) + .disabled(model.shortcutModel == nil) } /// One kind, carrying the key when it is the kind this pane is already showing. @@ -780,7 +774,7 @@ struct BloomCommands: Commands { /// `WorkspaceTabsStore` and `CenterTabStore` are both `@Observable`, so selecting another tab /// moves the key on its own, the way `zoom` greys itself above. private var sameAgainKind: PaneKind? { - guard let workspace = model.selectedModel else { return nil } + guard let workspace = model.shortcutModel else { return nil } let tabs = WorkspaceTabsStore.shared guard let tab = tabs.selectedTab(in: workspace) else { return nil } return PaneDuplicate.sameAgainKind( @@ -791,7 +785,7 @@ struct BloomCommands: Commands { /// The same call `CenterPaneView` makes for the same row of its own menu, so the two cannot /// end up splitting differently. private func splitCentre(_ axis: SplitAxis, opening kind: PaneKind) { - guard let workspace = model.selectedModel else { return } + guard let workspace = model.shortcutModel else { return } let tabs = WorkspaceTabsStore.shared guard let tab = tabs.selectedTab(in: workspace) else { return } let pane = tabs.focusedPane(of: tab) @@ -808,7 +802,7 @@ struct BloomCommands: Commands { /// A shell tree is the only thing in the centre column that has panes of its own to zoom or to /// step focus around, which is why these two items are the terminal's rather than the column's. private var terminalOwnerID: String? { - guard let workspace = model.selectedModel else { return nil } + guard let workspace = model.shortcutModel else { return nil } let tabs = WorkspaceTabsStore.shared guard let tab = tabs.selectedTab(in: workspace), case .tool(let id) = tabs.content(of: tabs.focusedPane(of: tab), in: tab), @@ -849,8 +843,7 @@ struct BloomCommands: Commands { /// Greyed on a strip with nothing to move to, rather than on no workspace at all, which is /// the same rule Split Right follows two items above. private var canCycleCentreTabs: Bool { - if model.isAskInFront { return model.ask.sessions.count > 1 } - guard let workspace = model.selectedModel else { return false } + guard let workspace = model.shortcutModel else { return false } return WorkspaceTabsStore.shared.entries(in: workspace).count > 1 } @@ -872,20 +865,7 @@ struct BloomCommands: Commands { /// `TabCycle.numbered` in the core. @ViewBuilder private var goToTabMenu: some View { - if model.isAskInFront { - MenuCommandGroup(.goToTab) { - ForEach(TabCycle.numbered(model.ask.sessions.map(\.id))) { entry in - if let chat = model.ask.sessions.first(where: { $0.id == entry.tab }) { - let button = Button(model.ask.title(for: chat)) { - Task { await model.ask.select(chat.id) } - } - if let ordinal = entry.ordinal { - button.keyboardShortcut(KeyEquivalent(Character("\(ordinal)")), modifiers: .command) - } else { button } - } - } - } - } else if let workspace = model.selectedModel { + if let workspace = model.shortcutModel { let entries = WorkspaceTabsStore.shared.entries(in: workspace) MenuCommandGroup(.goToTab) { ForEach(TabCycle.numbered(entries), id: \.tab) { entry in @@ -913,7 +893,7 @@ struct BloomCommands: Commands { /// What Cmd+W would close: the tab in front, or the pane of it the keyboard is in. See /// `TabClosure`, which is the rule and which the tests hold. private var closableTab: PaneContent? { - guard let workspace = model.selectedModel else { return nil } + guard let workspace = model.shortcutModel else { return nil } return closableTab(of: workspace) } @@ -922,7 +902,7 @@ struct BloomCommands: Commands { /// either would be a label the next reopen throws away. See `TabRenaming` in the core, which is /// also what the tab's own menu asks. private var renamableTab: PaneContent? { - guard let workspace = model.selectedModel else { return nil } + guard let workspace = model.shortcutModel else { return nil } return renamableTab(of: workspace) } @@ -948,18 +928,8 @@ struct BloomCommands: Commands { NotificationCenter.default.post(name: .bloomRenameTab, object: nil) } - /// Closing a conversation still goes through `CloseSessionAlert`, which asks when there is - /// something to lose by it and never asks when there is not. That is the whole of what the old - /// Close Session did, so nothing about a chat closes more quietly than it used to. - /// The last one closes too, and is replaced by a blank conversation. See `AskModel.close`. - private var canCloseAskTab: Bool { model.isAskInFront && model.ask.selectedID != nil } - private func closeSelectedTab() { - if model.isAskInFront { - if canCloseAskTab, let id = model.ask.selectedID { model.ask.requestClose(id) } - return - } - guard let workspace = model.selectedModel, let target = closableTab else { return } + guard let workspace = model.shortcutModel, let target = closableTab else { return } switch target { case .chat(let id): guard let session = workspace.sessions.first(where: { $0.id == id }) else { return } @@ -974,7 +944,7 @@ struct BloomCommands: Commands { // MARK: - Walking a review private var fileHistory: SourceHistory? { - guard let workspace = model.selectedModel, + guard let workspace = model.shortcutModel, let tab = WorkspaceTabsStore.shared.selectedTab(in: workspace), case let .tool(id) = WorkspaceTabsStore.shared.content( of: WorkspaceTabsStore.shared.focusedPane(of: tab), in: tab), @@ -986,19 +956,19 @@ struct BloomCommands: Commands { /// Greyed when there is no review open or nothing changed in the worktree, which is the state /// the two hidden buttons expressed by not existing. private var canStepChangedFiles: Bool { - guard let workspace = model.selectedModel, !workspace.changedFiles.isEmpty else { + guard let workspace = model.shortcutModel, !workspace.changedFiles.isEmpty else { return false } return CenterTabStore.shared.review(for: workspace.workspace.id) != nil } private func stepChangedFile(_ delta: Int) { - guard let workspace = model.selectedModel else { return } + guard let workspace = model.shortcutModel else { return } FileReview.step(delta, in: workspace) } private func closeCentrePane() { - guard let workspace = model.selectedModel else { return } + guard let workspace = model.shortcutModel else { return } let tabs = WorkspaceTabsStore.shared guard let tab = tabs.selectedTab(in: workspace) else { return } tabs.close( @@ -1008,7 +978,7 @@ struct BloomCommands: Commands { /// A terminal in a new tab, placed and selected exactly as the tab strip's own `+` does it. private func openPane(_ kind: PaneKind) { - guard let workspace = model.selectedModel else { return } + guard let workspace = model.shortcutModel else { return } NewPane.open(kind, in: workspace) { WorkspaceTabsStore.shared.select($0, in: workspace) } @@ -1017,7 +987,7 @@ struct BloomCommands: Commands { /// A browser on the workspace's own dev server, which is what the `+` opens and what a split /// does not: this is the route that knows where that is. See `SessionTabsView.newBrowser`. private func openBrowserPane() { - guard let workspace = model.selectedModel else { return } + guard let workspace = model.shortcutModel else { return } Task { let address = await workspace.browserAddress() NewPane.open(.browser, in: workspace, url: address) { diff --git a/Sources/Bloom/Views/Chrome/App/TypeToComposer.swift b/Sources/Bloom/Views/Chrome/App/TypeToComposer.swift new file mode 100644 index 000000000..68a3650bf --- /dev/null +++ b/Sources/Bloom/Views/Chrome/App/TypeToComposer.swift @@ -0,0 +1,82 @@ +import AppKit +import WebKit +import BloomCore + +/// Typing or pasting with nothing taking it goes to the focused chat's composer. The event is +/// handed back rather than replayed, so dead keys and input methods still work. +@MainActor +enum TypeToComposer { + private static var monitor: Any? + private static weak var model: AppModel? + private static let editors = NSMapTable( + keyOptions: .weakMemory, valueOptions: .strongMemory + ) + + static func attach(_ model: AppModel) { + self.model = model + guard monitor == nil else { return } + monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in + // File review handles Option+V after local monitors run. Moving focus first would + // make its shortcut think somebody is typing and leave the file unmarked. + if ViewedShortcutHostView.isViewedShortcut(event) { return event } + let characters = event.characters?.isEmpty == false + ? event.characters : event.charactersIgnoringModifiers + let modifiers = modifiers(of: event) + MainActor.assumeIsolated { + guard ComposerTypeAhead.belongsToComposer(characters: characters, modifiers: modifiers) else { return } + redirect() + } + return event + } + } + + /// Keyed by session: one composer is reused across workspaces, and the previous column stays + /// laid out out of sight. + static func register(_ editor: ComposerEditorHandle, showing sessionKey: String) { + editors.setObject(sessionKey as NSString, forKey: editor) + } + + private static func redirect() { + guard let window = NSApp.keyWindow, + window.canBecomeMain, !window.isSheet, window.attachedSheet == nil, + !takesKeys(window.firstResponder), + let sessionKey = focusedChat() else { return } + for case let editor as ComposerEditorHandle in editors.keyEnumerator() + where editors.object(forKey: editor) as String? == sessionKey { + if editor.takeKeyboard(in: window) { return } + } + } + + private static func focusedChat() -> String? { + guard let workspace = model?.selectedModel else { return nil } + let tabs = WorkspaceTabsStore.shared + guard let tab = tabs.selectedTab(in: workspace), + case .chat(let sessionID) = tabs.content(of: tabs.focusedPane(of: tab), in: tab) + else { return nil } + return sessionID.rawValue + } + + /// A selected passage keeps its keys too, because R quotes it into the reply. + private static func takesKeys(_ responder: NSResponder?) -> Bool { + var current = responder + while let each = current { + if let text = each as? NSTextView, text.isEditable || text.selectedRange().length > 0 { + return true + } + if each is BloomTerminalView || each is WKWebView + || each is ListKeyboardHostView || each is MenuKeyHostView { return true } + current = each.nextResponder + } + return false + } + + private static func modifiers(of event: NSEvent) -> MenuShortcut.Modifiers { + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + var modifiers: MenuShortcut.Modifiers = [] + if flags.contains(.command) { modifiers.insert(.command) } + if flags.contains(.shift) { modifiers.insert(.shift) } + if flags.contains(.option) { modifiers.insert(.option) } + if flags.contains(.control) { modifiers.insert(.control) } + return modifiers + } +} diff --git a/Sources/Bloom/Views/Chrome/Search/SearchPanelModel.swift b/Sources/Bloom/Views/Chrome/Search/SearchPanelModel.swift index 6a6a890a8..ad5cb2741 100644 --- a/Sources/Bloom/Views/Chrome/Search/SearchPanelModel.swift +++ b/Sources/Bloom/Views/Chrome/Search/SearchPanelModel.swift @@ -43,8 +43,6 @@ final class SearchPanelModel { func openFiles(app: AppModel) { guard let workspace = app.selectedWorkspace else { return } - // One card in front of the window at a time. They share the ground they are drawn on. - AskPanelModel.shared.close() if isOpen, files?.workspace.id == workspace.id { selectAllToken &+= 1 return @@ -57,7 +55,6 @@ final class SearchPanelModel { /// - Parameter scope: the chip to open on. Shift+Cmd+F opens on Transcripts, which is what that /// key has always meant, and Cmd+K opens on Everything. func open(scope: HomeScope = .all, app: AppModel) { - AskPanelModel.shared.close() if files != nil { close(app: app) } if isOpen { // The platform's rule for pressing a find key at an open find: select what is there, diff --git a/Sources/Bloom/Views/Chrome/Search/SearchPanelOverlay.swift b/Sources/Bloom/Views/Chrome/Search/SearchPanelOverlay.swift index 198276731..9dbc88de5 100644 --- a/Sources/Bloom/Views/Chrome/Search/SearchPanelOverlay.swift +++ b/Sources/Bloom/Views/Chrome/Search/SearchPanelOverlay.swift @@ -6,8 +6,8 @@ import BloomCore /// /// **The panel is not drawn from here any more.** It used to be an `.overlay` on the window's /// split view, which is why it dimmed the centre column and left the inspector, the title bar and -/// everything in them alone. It is an `NSHostingView` over the whole window now, installed once -/// and left in place, and `SearchPanelWindowOverlay` is where the whole of that decision is +/// everything in them alone. It is an `NSHostingView` over the whole window while search is open, +/// and `SearchPanelWindowOverlay` is where the whole of that decision is /// written down. What is left here is the installation and the wiring, which need a view. /// /// **The window is dimmed and it does not move.** Spotlight and Raycast float over the screen; @@ -25,19 +25,19 @@ struct SearchPanelOverlay: ViewModifier { func body(content: Content) -> some View { content .background(WindowAccessor(window: $window)) - .onChange(of: window, initial: true) { _, _ in install() } + .onChange(of: window, initial: true) { _, _ in syncHost() } // Home's list must not take the keyboard off a field somebody is typing in, and this // is the flag it reads. The panel's field is a field in the window exactly as the // toolbar's was. See `HomeListKeyboard`. - .onChange(of: panel.isOpen) { _, open in - app.isSearchFieldFocused = open || AskPanelModel.shared.isOpen - if open { raise() } + .onChange(of: panel.isOpen, initial: true) { _, open in + app.isSearchFieldFocused = open + syncHost() } - // Ask Bloom's panel is drawn by the same host. Home's list must leave the keyboard - // alone while it is up for the same reason: its composer is a field somebody types in. - .onChange(of: AskPanelModel.shared.isOpen) { _, open in - app.isSearchFieldFocused = open || panel.isOpen - if open { raise() } + .onDisappear { + // The host is a sibling of SwiftUI's content view, so SwiftUI cannot remove it + // when this modifier leaves the window. + host?.removeFromSuperview() + host = nil } // The list is rebuilt when its inputs move rather than while drawing, which is the // same rule Home's `rebuild` keeps and for the same reason: it runs over every @@ -52,17 +52,26 @@ struct SearchPanelOverlay: ViewModifier { .onChange(of: app.waitingWorkspaceIDs) { _, _ in panel.rebuild(app: app) } } - /// Adds the overlay to the window's frame view, above the content view and above the title - /// bar, once. + /// Keeps the overlay in the window only while search is open. /// /// `contentView.superview` rather than the content view itself. The content view of a SwiftUI /// scene is a hosting view whose subviews SwiftUI owns and reorders; a sibling of it is /// nobody's to reorder, and it is also the only ground that reaches over the toolbar and the - /// title bar accessory. The autoresizing mask is what keeps it the size of the window through - /// a resize, a zoom and a full screen, and `SearchPanelOverlayHost` measures the traffic - /// lights off each of those moves. - private func install() { - guard host == nil, let window, let frame = window.contentView?.superview else { return } + /// title bar accessory. Removing the host when search closes also removes its full-window hit + /// target. If its rendered state and AppKit hit testing disagree, an invisible host cannot + /// keep blocking controls throughout the window. + /// The autoresizing mask keeps the host sized through resize, zoom and full screen changes. + private func syncHost() { + guard panel.isOpen, let window, let frame = window.contentView?.superview else { + host?.removeFromSuperview() + host = nil + return + } + if let host, host.superview === frame { + frame.addSubview(host, positioned: .above, relativeTo: nil) + return + } + host?.removeFromSuperview() let view = SearchPanelOverlayHost( rootView: SearchPanelWindowOverlay(app: app, panel: panel) ) @@ -71,17 +80,6 @@ struct SearchPanelOverlay: ViewModifier { frame.addSubview(view, positioned: .above, relativeTo: nil) host = view } - - /// Puts it back on top when the panel opens. - /// - /// Not belt and braces. The frame view is AppKit's own and it adds views to it: a toolbar - /// arriving, a title bar accessory being added or a full screen transition all touch that - /// subview list, and any of them can end up above a view added before them. Asking once, on - /// the one event that matters, costs nothing and cannot be got wrong later. - private func raise() { - guard let host, let frame = host.superview else { return } - frame.addSubview(host, positioned: .above, relativeTo: nil) - } } extension View { diff --git a/Sources/Bloom/Views/Chrome/Search/SearchPanelWindowOverlay.swift b/Sources/Bloom/Views/Chrome/Search/SearchPanelWindowOverlay.swift index 309021a1a..26e8c4708 100644 --- a/Sources/Bloom/Views/Chrome/Search/SearchPanelWindowOverlay.swift +++ b/Sources/Bloom/Views/Chrome/Search/SearchPanelWindowOverlay.swift @@ -62,8 +62,8 @@ import BloomCore /// The cost is one gesture: a press in the title bar while the panel is up dismisses rather than /// starting a window drag, because the drag is a mouse-down and the dismissal takes it. The next /// press drags the window normally, which is the same bargain every other click outside makes. -/// Nothing is swallowed while the panel is closed: the host hit tests to nothing at all then, so -/// the window under it behaves exactly as it did before this file existed. +/// Nothing is swallowed while the panel is closed: the host is removed, and its hit test also +/// answers nothing during the closing transition. struct SearchPanelWindowOverlay: View { let app: AppModel let panel: SearchPanelModel @@ -72,25 +72,16 @@ struct SearchPanelWindowOverlay: View { private var geometry: SearchPanelWindowGeometry { .shared } - /// Ask Bloom's panel shares this ground. It is the other card that has to be in front of the - /// whole window rather than one pane of it, and the two never show at once: opening either - /// closes the other. See `AskPanelModel`. - private var ask: AskPanelModel { .shared } - var body: some View { GeometryReader { proxy in ZStack(alignment: .top) { if panel.isOpen { dim card(inWindow: proxy.size.width) - } else if ask.isOpen { - dim - askCard(in: proxy.size) } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .animation(reduceMotion ? nil : Motion.pane, value: panel.isOpen) - .animation(reduceMotion ? nil : Motion.pane, value: ask.isOpen) } // A hosting view starts a new SwiftUI environment, so the model has to be handed back in. // `TitleBarStrip` does the same, one accessory along, for the same reason. @@ -110,7 +101,6 @@ struct SearchPanelWindowOverlay: View { .contentShape(Rectangle()) .onTapGesture { panel.close(app: app) - ask.close() } .accessibilityHidden(true) .transition(.opacity) @@ -139,20 +129,6 @@ struct SearchPanelWindowOverlay: View { .transition(.opacity) } - /// Centred in the window below the title bar, rather than hung from the top as the search card - /// is: it is tall, and a tall card hung from the top leaves its composer at the foot of the - /// window with nothing under it. - private func askCard(in window: CGSize) -> some View { - let room = CGSize(width: window.width, height: window.height - geometry.titleBarHeight) - return AskPanelView( - app: app, - width: AskPanelLayout.width(inWindow: room.width), - height: AskPanelLayout.height(inWindow: room.height) - ) - .frame(width: room.width, height: room.height) - .padding(.top, geometry.titleBarHeight) - .transition(.opacity) - } } /// Where the window's own chrome is, measured off the window rather than assumed. @@ -234,16 +210,12 @@ final class SearchPanelOverlayHost: NSHostingView { /// read 72 by 26 at six points of padding and 66 by 20 at three, on every capture, first time. private static let trafficLightPadding: CGFloat = 3 - /// Nothing at all while the panel is closed. - /// - /// This view is installed once and left in the window, because taking it out and putting it - /// back is a chance to land in the wrong place in the subview order. So it has to be honestly - /// invisible to the pointer the rest of the time: without this, a permanently installed sheet - /// of glass over the whole window would eat every click in the app. + /// Nothing at all while the panel is closed, including the interval before its host is removed + /// from the window. `SearchPanelOverlay` installs it above the other views on each opening. /// /// The traffic lights are the second nil, and the head of `SearchPanelWindowOverlay` says why. override func hitTest(_ point: NSPoint) -> NSView? { - guard SearchPanelModel.shared.isOpen || AskPanelModel.shared.isOpen else { return nil } + guard SearchPanelModel.shared.isOpen else { return nil } // `point` arrives in the SUPERVIEW's coordinates and the buttons were measured in the // superview's coordinates, so they are compared there and nothing is converted. Converting // into this view would be the mistake: a hosting view is flipped, so the y would come out diff --git a/Sources/Bloom/Views/Chrome/Settings/AgentSignInSheet.swift b/Sources/Bloom/Views/Chrome/Settings/AgentSignInSheet.swift index cc876b23f..7c88f498a 100644 --- a/Sources/Bloom/Views/Chrome/Settings/AgentSignInSheet.swift +++ b/Sources/Bloom/Views/Chrome/Settings/AgentSignInSheet.swift @@ -10,7 +10,18 @@ struct AgentSignInSheet: View { let kind: AgentKind let executable: String let isSwitchingAccount: Bool + let profile: AgentAccountProfile? + let accountHome: String? let directory = AgentScratchDirectory.current() + + init(kind: AgentKind, executable: String, isSwitchingAccount: Bool, + profile: AgentAccountProfile? = nil, accountHome: String? = nil) { + self.kind = kind + self.executable = executable + self.isSwitchingAccount = isSwitchingAccount + self.profile = profile + self.accountHome = accountHome + } } let request: Request @@ -23,7 +34,7 @@ struct AgentSignInSheet: View { var body: some View { VStack(alignment: .leading, spacing: Metrics.gutter) { - Text("Sign in to \(request.kind.label)") + Text(request.profile.map { "Sign in to \($0.name)" } ?? "Sign in to \(request.kind.label)") .font(Typo.heading) .foregroundStyle(Palette.textPrimary) @@ -32,7 +43,7 @@ struct AgentSignInSheet: View { .foregroundStyle(Palette.textSecondary) .fixedSize(horizontal: false, vertical: true) - if request.isSwitchingAccount { + if request.isSwitchingAccount && request.profile == nil { Text("This changes the account used by \(request.kind.label) on this Mac.") .settingsFootnote() } @@ -68,21 +79,23 @@ struct AgentSignInSheet: View { .foregroundStyle(Palette.warning) .fixedSize(horizontal: false, vertical: true) } else if exit != nil { - Text("Sign-in command finished. Close this window to review your account in Settings.") + Text("Sign-in command finished.") .font(Typo.label) .foregroundStyle(Palette.textSecondary) .fixedSize(horizontal: false, vertical: true) } HStack(spacing: Metrics.gutter) { - Button("Copy sign-in command") { - Clipboard.copy(TerminalLaunchScript.shellCommand( - directory: request.directory, - executable: request.executable, - arguments: request.kind.loginArguments - )) + if request.profile == nil { + Button("Copy sign-in command") { + Clipboard.copy(TerminalLaunchScript.shellCommand( + directory: request.directory, + executable: request.executable, + arguments: request.kind.loginArguments + )) + } + .help("Run this command in your own terminal to sign in.") } - .help("Run this command in your own terminal to sign in.") Spacer() @@ -112,6 +125,9 @@ struct AgentSignInSheet: View { executable: request.executable, arguments: request.kind.loginArguments, directory: request.directory, + environment: AgentAccountEnvironment.applying( + kind: request.kind, home: request.accountHome, to: Shell.environment() + ), onExit: { result in exit = result if result != .exited(0) { diff --git a/Sources/Bloom/Views/Chrome/Settings/AgentsSettingsView.swift b/Sources/Bloom/Views/Chrome/Settings/AgentsSettingsView.swift index 8f9cb81b3..a4a9c7087 100644 --- a/Sources/Bloom/Views/Chrome/Settings/AgentsSettingsView.swift +++ b/Sources/Bloom/Views/Chrome/Settings/AgentsSettingsView.swift @@ -2,6 +2,10 @@ import AppKit import SwiftUI import BloomCore +extension Notification.Name { + static let bloomAgentProfilesChanged = Notification.Name("bloom.agentProfilesChanged") +} + /// Connects Bloom to the agent CLIs installed on the machine. /// /// The screen is deliberately read-mostly. Detection, version reading and account facts all come @@ -19,6 +23,11 @@ struct AgentsSettingsView: View { @State private var isRefreshing = false @State private var saveFailure: String? @State private var loginRequest: AgentSignInSheet.Request? + @State private var accountProfiles: [AgentAccountProfile] = [] + @State private var accountName = "" + @State private var accountError: String? + @State private var accountToRemove: AgentAccountProfile? + @State private var showsRemoveConfirmation = false @State private var pathDraft = "" /// Which agent `pathDraft` belongs to. `selection` has already moved on by the time the /// change handler runs, so committing against it would file one agent's path under another. @@ -65,6 +74,10 @@ struct AgentsSettingsView: View { } else if let status { statusSection(status) + if selection.canRunWorkspaces { + accountsSection(status) + } + capabilitySection if status.connection == .notInstalled { @@ -94,6 +107,15 @@ struct AgentsSettingsView: View { Task { await refresh() } } } + .alert("Remove account?", isPresented: $showsRemoveConfirmation) { + Button("Remove Account", role: .destructive) { + guard let accountToRemove else { return } + Task { await removeAccount(accountToRemove) } + } + Button("Cancel", role: .cancel) { accountToRemove = nil } + } message: { + Text("Bloom will delete the local sign-in files for \(accountToRemove?.name ?? "this account"). This cannot be undone.") + } .onDisappear { commitPathDraft() } .onChange(of: selection) { _, kind in commitPathDraft() @@ -151,6 +173,37 @@ struct AgentsSettingsView: View { } } + private func accountsSection(_ status: AgentStatus) -> some View { + Section("Additional accounts") { + ForEach(accountProfiles.filter { $0.kind == selection }) { profile in + HStack { + Text(profile.name) + Spacer() + Button("Sign in…") { signIn(profile, status: status) } + .disabled(status.executablePath == nil) + Button("Remove…", role: .destructive) { + accountToRemove = profile + showsRemoveConfirmation = true + } + } + } + + HStack { + TextField("Account name", text: $accountName) + .textFieldStyle(.roundedBorder) + Button("Add Account") { Task { await addAccount(status: status) } } + .disabled(accountName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + + if let accountError { + Text(accountError).foregroundStyle(Palette.warning).settingsFootnote() + } + + Text("Each account has its own CLI sign-in. Choose one for a project in Project Settings.") + .settingsFootnote() + } + } + /// A missing CLI is the normal state on a fresh machine, so it is stated in one row rather /// than given the full `ContentUnavailableView` treatment, which centres a large glyph in /// whatever height it is offered and ate most of the window for one sentence. The section @@ -300,6 +353,49 @@ struct AgentsSettingsView: View { ) } + private func signIn(_ profile: AgentAccountProfile, status: AgentStatus) { + guard let executable = status.executablePath, let store = app.store else { return } + loginRequest = AgentSignInSheet.Request( + kind: profile.kind, executable: executable, isSwitchingAccount: false, + profile: profile, accountHome: store.agentAccountHome(profile) + ) + } + + private func addAccount(status: AgentStatus) async { + guard let store = app.store else { return } + do { + let profile = try await store.createAgentAccountProfile(kind: selection, name: accountName) + accountProfiles = try await store.agentAccountProfiles() + accountName = "" + accountError = nil + signIn(profile, status: status) + NotificationCenter.default.post(name: .bloomAgentProfilesChanged, object: nil) + } catch AgentAccountProfileError.duplicateName { + accountError = "An account with that name already exists." + } catch { + accountError = "Could not create the account." + } + } + + private func removeAccount(_ profile: AgentAccountProfile) async { + defer { accountToRemove = nil } + guard let store = app.store else { return } + do { + try await store.removeAgentAccountProfile(id: profile.id) + accountProfiles = try await store.agentAccountProfiles() + accountError = nil + NotificationCenter.default.post(name: .bloomAgentProfilesChanged, object: nil) + } catch AgentAccountProfileError.inUse(let count) { + accountError = "This account is used by \(count) saved conversations. Remove those conversations first." + } catch AgentAccountProfileError.cleanupFailed(let path) { + accountProfiles = (try? await store.agentAccountProfiles()) ?? [] + accountError = "Account removed, but Bloom could not delete its files at \(path)." + NotificationCenter.default.post(name: .bloomAgentProfilesChanged, object: nil) + } catch { + accountError = "Could not remove the account." + } + } + private func chooseExecutable() { Task { await pickExecutable() } } @@ -323,6 +419,7 @@ struct AgentsSettingsView: View { // MARK: - Loading private func bootstrap() async { + accountProfiles = (try? await app.store?.agentAccountProfiles()) ?? [] let loaded = await loadOverrides() overrides = loaded draftKind = selection diff --git a/Sources/Bloom/Views/Chrome/Settings/ApprovalSettingsView.swift b/Sources/Bloom/Views/Chrome/Settings/ApprovalSettingsView.swift index 23550f2a1..fa0530169 100644 --- a/Sources/Bloom/Views/Chrome/Settings/ApprovalSettingsView.swift +++ b/Sources/Bloom/Views/Chrome/Settings/ApprovalSettingsView.swift @@ -30,7 +30,7 @@ struct ApprovalSettingsView: View { } header: { Text("New sessions") } footer: { - Text("Controls what an agent can do without asking. Plan mode in Sessions takes priority. Existing sessions keep their permissions.") + Text("Controls what an agent can do without asking. Plan mode in Sessions takes priority. Existing sessions keep their permissions. New conversations use this default.") .settingsFootnote() } diff --git a/Sources/Bloom/Views/Chrome/Settings/BrowserAccessSettingsView.swift b/Sources/Bloom/Views/Chrome/Settings/BrowserAccessSettingsView.swift new file mode 100644 index 000000000..5efa9bb80 --- /dev/null +++ b/Sources/Bloom/Views/Chrome/Settings/BrowserAccessSettingsView.swift @@ -0,0 +1,121 @@ +import AppKit +import BloomCore +import SwiftUI + +/// Opt-in browser access to the current app instance. The access code lasts until Bloom quits or +/// the switch is turned off, so turning it off also revokes every open browser page. +struct BrowserAccessSettingsView: View { + @Environment(AppModel.self) private var app + + private var isDevelopmentBrowser: Bool { + Bundle.main.bundleIdentifier == Store.devBundleIdentifier + } + + private var enabled: Binding { + Binding(get: { app.localWebEnabled }, set: { app.setLocalWebEnabled($0) }) + } + + var body: some View { + Form { + Section { + HStack(alignment: .center, spacing: 16) { + VStack(alignment: .leading, spacing: 6) { + Text("Bloom in your browser") + .font(.headline) + Text("Browser access is in alpha. It may change or be removed at any time. Some Mac features are still unavailable.") + .settingsFootnote() + Link("Setup instructions", destination: URL(string: "https://runbloom.app/browser-access")!) + .linkButton() + } + Spacer(minLength: 4) + alphaStamp + } + .padding(.vertical, 8) + .listRowBackground(Palette.questionWash) + } + + Section { + Toggle(isOn: enabled) { + Text("Allow browser access") + Text("Browse conversations and send prompts while Bloom is running.") + } + } header: { + Text("This Mac") + } footer: { + Text(isDevelopmentBrowser + ? "Bloom Dev opens at its local address without a code on this Mac." + : "Bloom runs the browser service on this Mac. Open it here without entering an address or code.") + .settingsFootnote() + } + + if app.localWebEnabled { + Section("Connect") { + if let address = app.localWebAddress, let token = app.localWebToken { + Button("Open in Browser") { + app.openLocalWebInBrowser() + } + .buttonStyle(.borderedProminent) + + DisclosureGroup("Manual connection") { + SettingsRow("Address") { + HStack { + Text(address).font(Typo.codeSmall).textSelection(.enabled) + Spacer() + Button("Copy Address") { copy(address) } + } + } + SettingsRow("Access code") { + HStack { + Text(isDevelopmentBrowser + ? "Needed when connecting through another hostname" + : "Required when opening the address manually") + .settingsFootnote() + Spacer() + Button("Copy Code") { copy(token) } + } + } + if let port = URL(string: address)?.port { + SettingsRow("Tailscale") { + Button("Copy Serve Command") { + copy("tailscale serve --bg \(port)") + } + } + } + } + } else if let error = app.localWebError { + Text("Could not start browser access: \(error)") + .foregroundStyle(.red) + } else { + Text("Starting browser access...") + .settingsFootnote() + } + } + } + } + .settingsForm() + } + + private func copy(_ value: String) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(value, forType: .string) + } + + private var alphaStamp: some View { + VStack(spacing: 1) { + Image(systemName: "sparkles") + .font(.system(size: 17, weight: .medium)) + Text("ALPHA") + .font(.system(size: 10, weight: .heavy, design: .rounded)) + .tracking(1.2) + } + .foregroundStyle(Palette.accent) + .frame(width: 66, height: 66) + .background(Palette.surface, in: Circle()) + .overlay { + Circle() + .strokeBorder(Palette.accent.opacity(0.8), style: StrokeStyle(lineWidth: 1.5, dash: [2, 2])) + } + .rotationEffect(.degrees(-9)) + .accessibilityHidden(true) + } +} diff --git a/Sources/Bloom/Views/Chrome/Settings/DirectorySettingsSection.swift b/Sources/Bloom/Views/Chrome/Settings/DirectorySettingsSection.swift index cbb067c20..f95d1e8e7 100644 --- a/Sources/Bloom/Views/Chrome/Settings/DirectorySettingsSection.swift +++ b/Sources/Bloom/Views/Chrome/Settings/DirectorySettingsSection.swift @@ -33,16 +33,6 @@ struct DirectorySettingsSection: View { } Text("Autocomplete also searches beside projects you have already added.") .settingsFootnote() - } - .disabled(!isLoaded) - - Section("Ask Bloom") { - folderRow("Working directory", path: preferences.ask, fallback: "Bloom’s own folder", - message: "Choose the working directory for new Ask Bloom conversations.") { - preferences.ask = $0 - } - Text("New conversations start here. Existing conversations keep their working directory.") - .settingsFootnote() if let error { Text(error).foregroundStyle(Palette.negative) } } .disabled(!isLoaded) diff --git a/Sources/Bloom/Views/Chrome/Settings/SettingsTab.swift b/Sources/Bloom/Views/Chrome/Settings/SettingsTab.swift index 3528dc372..59aae0504 100644 --- a/Sources/Bloom/Views/Chrome/Settings/SettingsTab.swift +++ b/Sources/Bloom/Views/Chrome/Settings/SettingsTab.swift @@ -10,6 +10,7 @@ enum SettingsTab: String, Hashable, CaseIterable { case permissions case prompts case terminal + case browserAccess case commandLine var title: String { @@ -24,6 +25,7 @@ enum SettingsTab: String, Hashable, CaseIterable { case .permissions: "Permissions" case .prompts: "Prompts" case .terminal: "Terminal" + case .browserAccess: "Browser Access" case .commandLine: "Command Line" } } @@ -40,6 +42,7 @@ enum SettingsTab: String, Hashable, CaseIterable { case .permissions: "hand.raised" case .prompts: "text.bubble" case .terminal: "terminal" + case .browserAccess: "network" case .commandLine: "link" } } diff --git a/Sources/Bloom/Views/Chrome/Settings/SettingsView.swift b/Sources/Bloom/Views/Chrome/Settings/SettingsView.swift index c1cba5c08..effb7e786 100644 --- a/Sources/Bloom/Views/Chrome/Settings/SettingsView.swift +++ b/Sources/Bloom/Views/Chrome/Settings/SettingsView.swift @@ -37,7 +37,7 @@ struct SettingsView: View { navigationRows([.agents, .sessions, .presets, .permissions, .prompts]) } Section("Terminal & connections") { - navigationRows([.terminal, .commandLine]) + navigationRows([.terminal, .browserAccess, .commandLine]) } } .listStyle(.sidebar) @@ -126,6 +126,7 @@ struct SettingsView: View { case .permissions: ApprovalSettingsView(defaults: defaultsBinding, isReady: isLoaded) case .prompts: PromptSettingsView() case .terminal: TerminalSettingsView() + case .browserAccess: BrowserAccessSettingsView() case .commandLine: CommandLineSettingsView() } } @@ -136,6 +137,7 @@ struct GeneralSettingsView: View { @AppStorage(SidebarPreferences.showsDiffStatsKey) private var showsSidebarDiffStats = SidebarPreferences.showsDiffStatsByDefault @State private var namesWorkspaces = WorkspaceNamingPreferences().isEnabled + @State private var maximumAgentWorkspaces = WorkspaceStartPreferences().maximumChildren var body: some View { Form { @@ -152,6 +154,14 @@ struct GeneralSettingsView: View { SleepSettingsSection() Section("Workspaces") { + Stepper(value: $maximumAgentWorkspaces, in: WorkspaceStartPreferences.allowedRange) { + Text("Agent workspace limit: \(maximumAgentWorkspaces)") + Text("Maximum workspaces one agent can have running at once.") + } + .onChange(of: maximumAgentWorkspaces) { _, value in + WorkspaceStartPreferences().maximumChildren = value + } + Toggle(isOn: $namesWorkspaces) { Text("Name workspaces automatically") Text("Claude uses your first message to suggest a name, without accessing your code.") diff --git a/Sources/Bloom/Views/Chrome/Window/BloomWindowToolbar.swift b/Sources/Bloom/Views/Chrome/Window/BloomWindowToolbar.swift index 07bd20f79..2a1040f70 100644 --- a/Sources/Bloom/Views/Chrome/Window/BloomWindowToolbar.swift +++ b/Sources/Bloom/Views/Chrome/Window/BloomWindowToolbar.swift @@ -42,6 +42,12 @@ struct BloomWindowToolbar: ToolbarContent { NewTabMenu(model: workspace) } } + if case .conversation(let id) = app.selection, + let state = app.ask.paneState(for: id) { + ToolbarItem(placement: .primaryAction) { + ConversationNewTabMenu(state: state) + } + } if app.isInspectorPresented || app.selectedWorkspace != nil { ToolbarItem(placement: .primaryAction) { diff --git a/Sources/Bloom/Views/Chrome/Window/DetailSplitViewController.swift b/Sources/Bloom/Views/Chrome/Window/DetailSplitViewController.swift index 0d01845b6..81809e98c 100644 --- a/Sources/Bloom/Views/Chrome/Window/DetailSplitViewController.swift +++ b/Sources/Bloom/Views/Chrome/Window/DetailSplitViewController.swift @@ -227,6 +227,10 @@ final class DetailSplitViewController: NSSplitViewController { /// Those frames must not reopen the title-bar band after the app has hidden the pane. private var isInspectorPresented = false private var isUpdating = false + /// AppKit can choose a new divider position when an item is expanded after showing a + /// conversation. Keep the width that was on screen before the collapse. + private var inspectorWidthBeforeCollapse: CGFloat? + private var needsInspectorWidthRestore = false private var collapseObservation: NSKeyValueObservation? var onInspectorPresentationChanged: ((Bool) -> Void)? @@ -370,6 +374,7 @@ final class DetailSplitViewController: NSSplitViewController { /// A drag or a resize. Never a slide: the pane is where it is, and the title bar should follow /// it this frame rather than a quarter of a second from now. @objc private func paneFrameChanged() { + if needsInspectorWidthRestore { restoreInspectorWidth() } // AppKit can shrink the item's container while its hosted view keeps its minimum width. // The header follows the part still visible, including a drag that clips it to zero. let visibleWidth = inspectorHost.view.visibleRect.width @@ -419,12 +424,19 @@ final class DetailSplitViewController: NSSplitViewController { paneFrameChanged() return } + if shouldCollapse { + inspectorWidthBeforeCollapse = inspectorHost.view.frame.width + } if !shouldCollapse { makeRoomForInspector(animated: animated) } // The animated setter runs a layout pass on every frame of the transition, and layout churn // is the condition this window has crashed under, so Reduce Motion turns it off rather than // merely shortening it. guard animated else { inspectorItem.isCollapsed = shouldCollapse + if !shouldCollapse { + needsInspectorWidthRestore = inspectorWidthBeforeCollapse != nil + restoreInspectorWidth() + } publishInspectorWidth(collapsed: shouldCollapse, sliding: false) return } @@ -453,7 +465,28 @@ final class DetailSplitViewController: NSSplitViewController { context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) inspectorItem.animator().isCollapsed = shouldCollapse publishInspectorWidth(collapsed: shouldCollapse, sliding: true) + } completionHandler: { [weak self] in + MainActor.assumeIsolated { + if !shouldCollapse { + self?.needsInspectorWidthRestore = self?.inspectorWidthBeforeCollapse != nil + self?.restoreInspectorWidth() + } + } + } + } + + private func restoreInspectorWidth() { + guard needsInspectorWidthRestore, isInspectorPresented, !inspectorItem.isCollapsed, + let width = inspectorWidthBeforeCollapse else { return } + needsInspectorWidthRestore = false + splitView.layoutSubtreeIfNeeded() + let available = splitView.bounds.width - Self.detailMinimum - splitView.dividerThickness + guard available >= Self.inspectorMinimum else { + needsInspectorWidthRestore = true + return } + let restored = min(max(width, Self.inspectorMinimum), min(available, Self.inspectorMaximum)) + splitView.setPosition(splitView.bounds.width - restored - splitView.dividerThickness, ofDividerAt: 0) } /// Widens the window, if it has to be widened, for a pane that is about to arrive in it. diff --git a/Sources/Bloom/Views/Chrome/Window/TitleBarStrip.swift b/Sources/Bloom/Views/Chrome/Window/TitleBarStrip.swift index 8a6a129eb..cac58595b 100644 --- a/Sources/Bloom/Views/Chrome/Window/TitleBarStrip.swift +++ b/Sources/Bloom/Views/Chrome/Window/TitleBarStrip.swift @@ -116,11 +116,9 @@ final class InspectorGeometry { /// `PullRequestBar` itself is drawn exactly as the inspector drew it, given the width of the pane /// it is now the heading for. /// -/// **What the band cannot say, it says on hover.** 380 points with a Create pull request button -/// in them leaves the branch and the state a couple of dozen characters each, so the band reads -/// `…t-question` over `No pull reque…` on a name nobody would call long. Resting on it opens the -/// pull request card. Nothing new is fetched for it. Everything on it is what -/// `PullRequestBar` is already polling for the band itself. +/// The band has no hover card. It covered the inspector and made a press on its controls feel +/// like a dismissal before the intended action. The branch's context menu still copies its full +/// name, while pull request details remain in the inspector and on GitHub. struct TitleBarStrip: View { let app: AppModel @@ -149,36 +147,6 @@ struct TitleBarStrip: View { .frame(width: inspector.bandWidth, height: height) .background { ground(pullRequest: model.pullRequest, localWork: model.localWork) } .overlay(alignment: .leading) { Hairline(axis: .vertical) } - // The whole band is the target, button included, and that is a decision rather - // than the easy way to write it. The band is one subject: the branch, where it - // is going, and what GitHub says about it, with a button that acts on exactly - // that. A card that opened over the left two thirds and not the right third - // would be an affordance you have to find. What keeps it from firing on a - // pointer crossing the band on its way to that button is the wait, which is - // `Motion.hoverCardDelay`. - // - // The card hangs BELOW, so it never covers the thing it was opened from, and - // it closes when the pointer leaves the band. See `hoverPopover`. - // - // Whether hover works at all in a title bar accessory is the one thing here - // that was reasoned rather than measured, and it is written down as reasoning - // so the next reader knows to check it. The accessory is a real `NSView` in - // the window's title bar container, so SwiftUI installs its tracking area the - // ordinary way, and what AppKit reserves in that band is the window DRAG, - // which is a mouse-down gesture rather than a tracking one. The head of - // `TitleBarStripController` measured the neighbouring half of that: a click on - // a control here reaches the control and a drag on the band's background still - // moves the window. If the card never opens, this is the line to doubt first, - // and an `NSTrackingArea` on the hosting view is the way out. - // A band that leaves under a stationary pointer, when the inspector collapses - // or the selection moves to Home, closes its card: `hoverPopover` does that - // on disappear. - .hoverPopover( - id: model.workspace.id, - arrowEdge: .bottom - ) { - WorkspaceHoverCardView(card: bandCard(for: model), isInPopover: true) - } } } // The band is drawn at the width the PANE settles at, inside an accessory whose own width @@ -216,20 +184,6 @@ struct TitleBarStrip: View { .environment(app) } - /// What the card says, built at the moment it opens rather than on every redraw. - /// - /// **Nothing in here fetches.** The pull request is whatever `PullRequestBar`'s own poll last - /// brought back and the local counts are whatever the inspector last read, which are the two - /// facts the band beside it is already drawn and tinted from. A hover that started a `gh` call - /// would put a subprocess behind moving the pointer across the top of the window. - private func bandCard(for model: WorkspaceModel) -> WorkspaceHoverCard { - WorkspaceHoverCard.pullRequestBand( - workspace: model.workspace, - pullRequest: model.pullRequest, - localWork: model.localWork - ) - } - /// The four points the title bar is taller than the strip. /// /// `PullRequestBar` draws its ground inside a frame of exactly diff --git a/Sources/Bloom/Views/Chrome/Window/WelcomeHostingController.swift b/Sources/Bloom/Views/Chrome/Window/WelcomeHostingController.swift index 52731a11e..7194af894 100644 --- a/Sources/Bloom/Views/Chrome/Window/WelcomeHostingController.swift +++ b/Sources/Bloom/Views/Chrome/Window/WelcomeHostingController.swift @@ -73,3 +73,30 @@ final class WelcomeHostingController: NSHostingController { return CGSize(width: contentWidth, height: ceil(measured.height)) } } + +/// Keep the hosting view inside an ordinary content view. When it is the window's content view, +/// SwiftUI can run `NSHostingView.updateAnimatedWindowSize` during AppKit's layout pass even with +/// `sizingOptions = []`. On macOS 26.0 that path resizes the window, invalidates the hosting view's +/// safe area, and raises an exception for updating constraints during layout. The welcome window +/// is sized by `WelcomeHostingController` on a later turn instead. +@MainActor +final class WelcomeContentController: NSViewController { + private let host: WelcomeHostingController + + init(host: WelcomeHostingController) { + self.host = host + super.init(nibName: nil, bundle: nil) + addChild(host) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("not decoded from a nib") } + + override func loadView() { + let content = NSView() + host.view.frame = content.bounds + host.view.autoresizingMask = [.width, .height] + content.addSubview(host.view) + view = content + } +} diff --git a/Sources/Bloom/Views/Chrome/Window/WelcomeWindow.swift b/Sources/Bloom/Views/Chrome/Window/WelcomeWindow.swift index 0dba5f226..843070b0f 100644 --- a/Sources/Bloom/Views/Chrome/Window/WelcomeWindow.swift +++ b/Sources/Bloom/Views/Chrome/Window/WelcomeWindow.swift @@ -211,7 +211,7 @@ enum WelcomeWindow { window.isMovableByWindowBackground = true window.standardWindowButton(.miniaturizeButton)?.isHidden = true window.standardWindowButton(.zoomButton)?.isHidden = true - window.contentViewController = host + window.contentViewController = WelcomeContentController(host: host) window.center() // Closing this window is how somebody says they have seen it. See // `WelcomeLaunch.recordDismissal`, which is where the reasoning is. diff --git a/Sources/Bloom/Views/Chrome/Window/WindowTitle.swift b/Sources/Bloom/Views/Chrome/Window/WindowTitle.swift index 9615dbac8..3e630605b 100644 --- a/Sources/Bloom/Views/Chrome/Window/WindowTitle.swift +++ b/Sources/Bloom/Views/Chrome/Window/WindowTitle.swift @@ -31,6 +31,10 @@ struct WindowTitle: ViewModifier { private var title: String { if let workspace = app.selectedWorkspace { return workspace.name } + if case .conversation(let id) = app.selection, + let conversation = app.ask.sessions.first(where: { $0.id == id }) { + return app.ask.title(for: conversation) + } return "Bloom" } diff --git a/Sources/Bloom/Views/Code/SourceEditorState.swift b/Sources/Bloom/Views/Code/SourceEditorState.swift index 717da02d7..13b62cf8d 100644 --- a/Sources/Bloom/Views/Code/SourceEditorState.swift +++ b/Sources/Bloom/Views/Code/SourceEditorState.swift @@ -31,7 +31,7 @@ final class SourceEditorState { @ObservationIgnored var navigationTask: Task? var message: String? var prefersEditing = false - /// Whether a Markdown or HTML file shows its rendered document rather than its source. On by + /// Whether a Markdown, HTML or SVG file shows its rendered document rather than its source. On by /// default, since a report or a plan an agent wrote is written to be read. See `FileTabMode`. var prefersPreview = true @ObservationIgnored weak var textView: CodeTextView? diff --git a/Sources/Bloom/Views/CreateWorkspace/CreateWorkspaceView.swift b/Sources/Bloom/Views/CreateWorkspace/CreateWorkspaceView.swift index 8927c573d..7693c4987 100644 --- a/Sources/Bloom/Views/CreateWorkspace/CreateWorkspaceView.swift +++ b/Sources/Bloom/Views/CreateWorkspace/CreateWorkspaceView.swift @@ -26,9 +26,18 @@ struct CreateWorkspaceView: View { var initialRepo: Repo? /// Incremented for every File ▸ New Workspace from Pull Request request while this sheet is up. var pullRequestRevision: UInt64 = 0 + var startsAsConversation = false + + init(initialRepo: Repo?, pullRequestRevision: UInt64 = 0, startsAsConversation: Bool = false) { + self.initialRepo = initialRepo + self.pullRequestRevision = pullRequestRevision + self.startsAsConversation = startsAsConversation + _isConversation = State(initialValue: startsAsConversation) + } @Environment(AppModel.self) private var app @Environment(\.dismiss) private var dismiss + @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var repoID: RepoID? /// The picker's Recent section, newest first. See `RecentProjects`. @@ -87,6 +96,7 @@ struct CreateWorkspaceView: View { @State private var draftID = PromptAttachments.newShortID() @State private var selectedMode: WorkspaceStartMode = .chat + @State private var isConversation = false @State private var usesCLIChat = false @State private var loadedChatPreference = false @@ -155,7 +165,8 @@ struct CreateWorkspaceView: View { } private var mode: WorkspaceStartMode { - selectedMode == .chat + if isConversation { return .chat } + return selectedMode == .chat ? WorkspaceStartMode.chat(usesCLI: usesCLIChat, agent: controls.agentKind) : selectedMode } @@ -180,7 +191,12 @@ struct CreateWorkspaceView: View { /// two ways to finish and only one of them used what had been typed. The mode answers it /// first, so there is nothing left to disagree about. private var canCreate: Bool { - WorkspaceStartPlan.canStart( + if isConversation { + return WorkspaceStartPlan.canStartConversation( + prompt: spokenPrompt, isBusy: app.isCreatingWorkspace || isLoading + ) + } + return WorkspaceStartPlan.canStart( hasProject: repo != nil, prompt: task, hasCheckout: checkout != nil, @@ -198,16 +214,22 @@ struct CreateWorkspaceView: View { /// workspace will be called instead. /// One line saying what pressing Create actually does. /// - /// The sheet asked for a task and a branch and never said what it would make, so somebody - /// meeting Bloom read "New workspace" and had to infer that a workspace is a worktree on disk. - /// It names the two things that happen and nothing else: the checkout, and the setup script - /// where the project has one. No worktree jargon, because a person who knows the word does not - /// need the sentence and a person who does not is not helped by it. + /// The project route says what it creates on disk. The conversation route only needs to say + /// that it has no repository behind it; naming each thing it does not create repeated the + /// choice in the segmented control and made the shorter route wordier than the project one. private var consequence: some View { - Text("Creates a separate copy of the project on this Mac, on its own branch.") - .font(Typo.caption) - .foregroundStyle(Palette.textTertiary) - .fixedSize(horizontal: false, vertical: true) + Group { + if isConversation { + Text("A plain conversation without a repository.") + .transition(.opacity) + } else { + Text("Creates a separate copy of the project on this Mac, on its own branch.") + .transition(.opacity) + } + } + .font(Typo.caption) + .foregroundStyle(Palette.textTertiary) + .fixedSize(horizontal: false, vertical: true) } private var offersName: Bool { checkout == nil } @@ -217,16 +239,17 @@ struct CreateWorkspaceView: View { header Hairline() - if app.repos.isEmpty { + if app.repos.isEmpty && !isConversation { noProjects .padding(Metrics.gutter) } else { composer - if hasSetupScript { + if hasSetupScript && !isConversation { WorkspaceSetupOption(isEnabled: $runSetupScript) .disabled(isLoading) .padding(.horizontal, Metrics.gutter) .padding(.bottom, Metrics.spacingWide) + .transition(.opacity) } } } @@ -236,7 +259,7 @@ struct CreateWorkspaceView: View { // every open (the first pass writes `repoID`, which fired the onChange), and a load left // in flight when the project changed could land another project's branches on this one's // window. `.task(id:)` cancels the stale load; `load` checks before writing. - .task(id: repoID) { await load() } + .task(id: isConversation ? nil : repoID) { await load() } // The empty state's "Choose a folder" adds a project while this window is open, and the // load above resolved the project once, to nothing, before there was one. Picking it here // is what turns the empty state into the composer for the project that was just added @@ -253,6 +276,16 @@ struct CreateWorkspaceView: View { .onChange(of: initialRepo?.id) { _, repoID in guard let repoID else { return } self.repoID = repoID + isConversation = false + } + .onChange(of: startsAsConversation) { _, value in + isConversation = value + } + .onChange(of: isConversation) { _, value in + if value { + selectedMode = .chat + focusTheBox() + } } // A request made while this sheet is already up keeps the sheet and its draft, and moves // the keyboard to the pull-request reference field once for that request. @@ -283,10 +316,14 @@ struct CreateWorkspaceView: View { .font(Typo.heading) .foregroundStyle(Palette.textPrimary) - projectControl + if !isConversation { + projectControl + .transition(.opacity) + } - if repo != nil { + if repo != nil && !isConversation { sourceControl + .transition(.opacity) } Spacer(minLength: 0) @@ -453,26 +490,30 @@ struct CreateWorkspaceView: View { private var composer: some View { VStack(alignment: .leading, spacing: Metrics.gutter) { - modePicker + workspaceKindPicker + if !isConversation { + modePicker + .transition(.opacity.combined(with: .move(edge: .top))) + } - if isEnteringReference { + if isEnteringReference && !isConversation { referenceField } - if let referenceProblem { + if let referenceProblem, !isConversation { Text(referenceProblem) .font(Typo.caption) .foregroundStyle(Palette.negative) } - if let heldProblem { + if let heldProblem, !isConversation { Text(heldProblem) .font(Typo.caption) .foregroundStyle(Palette.negative) .fixedSize(horizontal: false, vertical: true) } - if let carryOn { + if let carryOn, !isConversation { HStack(spacing: Metrics.spacingSmall) { Text(carryOn.sentence) .font(Typo.caption) @@ -487,6 +528,7 @@ struct CreateWorkspaceView: View { Text(heading) .font(Typo.title) .foregroundStyle(Palette.textPrimary) + .transition(.opacity) } switch mode { @@ -498,13 +540,41 @@ struct CreateWorkspaceView: View { } consequence - statusRow + if !isConversation { + statusRow + .transition(.opacity) + } } .padding(Metrics.gutter) } + private var workspaceKindPicker: some View { + Picker("Workspace type", selection: conversationBinding) { + Text("Project").tag(false) + Text("Conversation").tag(true) + } + .pickerStyle(.segmented) + .labelsHidden() + .fixedSize() + .disabled(app.repos.isEmpty) + .accessibilityLabel("Workspace type") + } + + private var conversationBinding: Binding { + Binding( + get: { isConversation }, + set: { value in + guard value != isConversation else { return } + withAnimation(reduceMotion ? nil : .easeInOut(duration: 0.25)) { + if value { selectedMode = .chat } + isConversation = value + } + } + ) + } + private var modePicker: some View { - Picker("Start with", selection: modeBinding) { + Picker("First tab", selection: modeBinding) { Text("Chat").tag(WorkspaceStartMode.chat) Text("Terminal").tag(WorkspaceStartMode.terminal) Text("Browser").tag(WorkspaceStartMode.browser) @@ -513,7 +583,7 @@ struct CreateWorkspaceView: View { .labelsHidden() .fixedSize() .tint(Palette.controlAccent) - .accessibilityLabel("Start with") + .accessibilityLabel("First tab") .help(defaultCLIMode == nil ? "CLI chat supports Claude and Codex. Choose either as your default agent to use it." : "Chat and CLI chat use your default agent configuration") @@ -558,11 +628,15 @@ struct CreateWorkspaceView: View { text: $prompt, caret: $caret, isFocused: $isFocused, - mentionRoot: repo?.path ?? NSHomeDirectory(), + mentionRoot: isConversation ? stagingDirectory : (repo?.path ?? stagingDirectory), attachmentRoot: stagingDirectory, attachmentKey: draftID, - placeholder: "Describe the task, @mention files, run /commands", - accessibilityLabel: "What do you want to work on?", + placeholder: isConversation + ? "What would you like to talk about?" + : "Describe the task, @mention files, run /commands", + accessibilityLabel: isConversation + ? "What would you like to talk about?" + : "What do you want to work on?", editorHeight: editorHeight, onContentHeightChange: { contentHeight = $0 }, onKey: handle(key:), @@ -570,7 +644,10 @@ struct CreateWorkspaceView: View { ) { actions in ComposerFooterView( controls: controls, - onChange: { controls = $0 }, + onChange: { new in + app.rememberModelChoice(from: controls, to: new) + controls = new + }, canSend: canCreate, intent: .create, // This window's width is fixed and was chosen for this row with its words on, so @@ -696,6 +773,7 @@ struct CreateWorkspaceView: View { /// genuinely optional there and the question says so rather than demanding a sentence the /// button no longer requires. private var heading: String? { + if isConversation { return nil } guard mode.runsAnAgent else { // The noun the segmented control was clicked with, in lower case. Stated again here it // would be a second list of the same words, and the heading would go on saying @@ -905,6 +983,11 @@ struct CreateWorkspaceView: View { // MARK: - Actions private func load() async { + if isConversation { + controls = (try? await app.resolvedControls(for: nil)) ?? ComposerControls() + focusTheBox() + return + } if repoID == nil { // Writing `repoID` restarts this task for the resolved project, so the listing below // runs once per project rather than once on open and again on the change. @@ -937,7 +1020,9 @@ struct CreateWorkspaceView: View { let path = repo.path var appDefaults = AppDefaults() if let store = app.store { - appDefaults = await AppDefaults.loadForNewSessions(from: store) + appDefaults = await AppDefaults.loadForNewSessions( + from: store, models: ComposerModelCatalog.shared.models + ) } // The gathering and both branch decisions live in the core, where the suite can reach @@ -961,12 +1046,15 @@ struct CreateWorkspaceView: View { hasSetupScript = !(context.settings.setupScript ?? "") .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty isNamingAvailable = context.isNamingAvailable + let settings = (try? await app.store?.applyingProjectAgentDefaults( + to: context.settings, for: repo.id + )) ?? context.settings controls = ComposerControls( // The Codex list only sharpens the effort here, and the sheet's own footer is what // fetches it: a window opened before that call returns still gets the backend the // Models screen recorded, because that is stored rather than looked up. defaults: ComposerDefaults.resolve( - repo: context.settings, + repo: settings, app: appDefaults, models: ComposerModelCatalog.shared.models ), @@ -1162,9 +1250,11 @@ struct CreateWorkspaceView: View { /// separates them is `opensWith`, which decides whether an opening turn is ever sent and which /// tab the workspace is first shown on. private func create() { - guard let repo, canCreate else { return } + guard canCreate else { return } let chosen = mode + let conversation = isConversation + let selectedRepo = repo // The draft as it was written, files and all, and deliberately not `task`. `task` is // `spokenPrompt` in chat mode, which is the sentence with every attachment taken back out // of it: the right answer for the name, the branch and the Create button, and the wrong @@ -1174,7 +1264,7 @@ struct CreateWorkspaceView: View { let text = WorkspaceStartAttachments.handover( isChatWorkspace: chosen.runsAnAgent, draft: prompt, name: typedName ) - let base = baseBranch.isEmpty ? repo.defaultBranch : baseBranch + let base = baseBranch.isEmpty ? selectedRepo?.defaultBranch : baseBranch let source = checkout let chosenControls = controls let shouldRunSetup = runSetupScript @@ -1197,16 +1287,22 @@ struct CreateWorkspaceView: View { dismiss() Task { - await app.createWorkspace( - in: repo, - prompt: text, - baseBranch: base, - opensWith: chosen, - controls: chosenControls, - staged: staged, - checkout: source, - runSetupScript: shouldRunSetup - ) + if conversation { + await app.ask.startWorkspaceConversation( + prompt: text, controls: chosenControls, staged: staged + ) + } else if let selectedRepo { + await app.createWorkspace( + in: selectedRepo, + prompt: text, + baseBranch: base, + opensWith: chosen, + controls: chosenControls, + staged: staged, + checkout: source, + runSetupScript: shouldRunSetup + ) + } // Whatever survived is in the worktree now, and whatever did not was never going to be. AttachmentStaging.discard(draftID: handedOver) } diff --git a/Sources/Bloom/Views/DetailColumn.swift b/Sources/Bloom/Views/DetailColumn.swift index 3ea5c6545..fbe4287f6 100644 --- a/Sources/Bloom/Views/DetailColumn.swift +++ b/Sources/Bloom/Views/DetailColumn.swift @@ -60,6 +60,10 @@ struct DetailColumn: View { HomeView() case .workspace: EmptyView() + case .conversation(let id): + ConversationColumnView(sessionID: id) + case .archivedConversation(let id): + ArchivedConversationView(sessionID: id) case .crew(let workspaceID, let sessionID): crew(sessionID, in: workspaceID) case .subagent(let workspaceID, let subagentID): diff --git a/Sources/Bloom/Views/Home/HomeConversationRow.swift b/Sources/Bloom/Views/Home/HomeConversationRow.swift new file mode 100644 index 000000000..118e86619 --- /dev/null +++ b/Sources/Bloom/Views/Home/HomeConversationRow.swift @@ -0,0 +1,56 @@ +import SwiftUI +import BloomCore + +/// A project-free conversation among Home's workspace rows. +struct HomeConversationRow: View { + let conversation: Session + let onArchive: () -> Void + let onDelete: () -> Void + + @Environment(AppModel.self) private var app + + var body: some View { + Button { + app.selection = conversation.archivedAt == nil + ? .conversation(conversation.id) + : .archivedConversation(conversation.id) + } label: { + HStack(spacing: Metrics.spacingWide) { + Image(systemName: PaneGlyph.chat) + .foregroundStyle(Palette.textTertiary) + VStack(alignment: .leading, spacing: Metrics.spacingHair) { + Text(app.ask.title(for: conversation)) + .font(Typo.labelEmphasis) + Text(conversation.archivedAt == nil + ? "Conversation · No project" : "Archived conversation") + .font(Typo.caption) + .foregroundStyle(Palette.textTertiary) + } + WorkspaceColourDot( + hex: app.ask.conversationColours[conversation.id], + accessibilityName: nil + ) + Spacer() + if app.ask.pinnedConversations.contains(conversation.id) { + Image(systemName: "pin.fill") + .font(Typo.micro) + .foregroundStyle(Palette.textTertiary) + } + } + .foregroundStyle(Palette.textPrimary) + .frame(minHeight: HomeMetrics.rowHeight) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .contextMenu { + if conversation.archivedAt != nil { + Button("Restore Conversation") { + Task { await app.ask.restore(conversation.id) } + } + Button("Delete…", role: .destructive, action: onDelete) + } else { + Button("Archive Conversation", action: onArchive) + } + } + } +} diff --git a/Sources/Bloom/Views/Home/HomeListRow.swift b/Sources/Bloom/Views/Home/HomeListRow.swift index 1e16c06a5..f546ce5c6 100644 --- a/Sources/Bloom/Views/Home/HomeListRow.swift +++ b/Sources/Bloom/Views/Home/HomeListRow.swift @@ -392,6 +392,50 @@ struct HomeListRow: View { } } +/// The list interaction around a workspace row. Hover is local so scrolling under a stationary +/// pointer updates one row instead of rebuilding Home's entire list at every row boundary. +struct HomeWorkspaceListEntry: View { + let row: HomeRow + let isRunning: Bool + let isAwaitingPermission: Bool + let isStarting: Bool + let now: Date + let isRenaming: Bool + let isSelected: Bool + let isArriving: Bool + let rowInsets: EdgeInsets + let onOpen: () -> Void + let onCommitRename: (String) -> Void + let onCancelRename: () -> Void + let onRename: (WorkspaceID) -> Void + let onDelete: (Workspace) -> Void + + @State private var isHovered = false + + var body: some View { + HomeListRow( + row: row, + isRunning: isRunning, + isAwaitingPermission: isAwaitingPermission, + isStarting: isStarting, + now: now, + isRenaming: isRenaming, + onCommitRename: onCommitRename, + onCancelRename: onCancelRename + ) + .arrivingRow(isArriving) + .simultaneousGesture(TapGesture().onEnded { onOpen() }) + .listRowInsets(rowInsets) + .onHoverChange { isHovered = $0 } + .listRowBackground( + HomeRowBackground(isSelected: isSelected, isHovered: isHovered) + ) + .contextMenu { + HomeRowMenu(row: row, onRename: onRename, onDelete: onDelete) + } + } +} + /// What a Home row is filled with: nothing at rest, the hover tint under the pointer, the quiet /// selection grey when it is the selected row. /// diff --git a/Sources/Bloom/Views/Home/HomeView.swift b/Sources/Bloom/Views/Home/HomeView.swift index 6cd646764..55ca9acdc 100644 --- a/Sources/Bloom/Views/Home/HomeView.swift +++ b/Sources/Bloom/Views/Home/HomeView.swift @@ -1,7 +1,7 @@ import SwiftUI import BloomCore -/// The centre pane when nothing is selected: every workspace on this Mac, newest first. +/// The centre pane when nothing is selected: workspaces and conversations, newest first. /// /// **Why a flat list and not the grid of project blocks this replaced.** Grouping Home by project /// duplicated the sidebar, which is two hundred points to the left, always on screen, and already @@ -35,7 +35,11 @@ struct HomeView: View { /// the sidebar cannot show a worktree that no longer exists. Home is the one screen that has /// to be able to look at them, so it asks `AppModel` for them, once, rather than per row. @State private var archived: [Workspace] = [] + @State private var archivedConversations: [Session] = [] + @State private var deletingConversation: Session? + @State private var archivingConversation: Session? @State private var listing = HomeListing.empty + @State private var mixedGroups: [HomeMixedGroup] = [] /// The list's own selection, not the app's. Writing to `app.selection` would navigate away /// from Home, so arrowing down the list would open every workspace it passed. @State private var selected: WorkspaceID? @@ -59,9 +63,6 @@ struct HomeView: View { /// only when `HomeView` itself is rebuilt, which is when the selection leaves Home and comes /// back. See `HomeListKeyboard`. @State private var hasClaimedKeyboard = false - /// The row under the pointer, held here rather than in each row, so crossing the pane lights - /// one row at a time and a hover invalidates the list rather than nothing at all. - @State private var hovered: WorkspaceID? /// The id of the row being renamed in place, shared across the whole list so only one field /// can ever be open. The same arrangement the sidebar's rows use. @State private var renaming: WorkspaceID? @@ -108,7 +109,7 @@ struct HomeView: View { if hasAnyWorkspace { HomeBar( repos: app.repos, - counts: listing.counts, + counts: displayCounts, isSearching: listing.isSearching, filter: $app.homeFilter ) @@ -146,6 +147,10 @@ struct HomeView: View { archived = await app.archivedWorkspaces() await loadFootprints() } + .task(id: app.ask.archiveRevision) { + archivedConversations = await app.ask.archivedConversations() + } + .task { await app.ask.open() } .task { await keepAgesCurrent() } // The one irreversible act in the app, so it counts what would go rather than asking "are // you sure" over an unnamed quantity. @@ -160,9 +165,47 @@ struct HomeView: View { } onConfirm: { deletion in Task { await delete(deletion) } } + .confirmationDialog( + "Delete this conversation?", + isPresented: Binding( + get: { deletingConversation != nil }, + set: { if !$0 { deletingConversation = nil } } + ), + titleVisibility: .visible + ) { + if let conversation = deletingConversation { + Button("Delete Conversation", role: .destructive) { + Task { await app.ask.deleteArchived(conversation.id) } + deletingConversation = nil + } + } + Button("Cancel", role: .cancel) { deletingConversation = nil } + } message: { + Text("The transcript will be removed. Files in its folder are removed when no other conversation uses them.") + } + .confirmationDialog( + "Archive this conversation?", + isPresented: Binding( + get: { archivingConversation != nil }, + set: { if !$0 { archivingConversation = nil } } + ), + titleVisibility: .visible + ) { + if let conversation = archivingConversation { + Button("Archive Conversation") { + Task { await app.ask.close(conversation.id) } + archivingConversation = nil + } + } + Button("Cancel", role: .cancel) { archivingConversation = nil } + } message: { + Text("The conversation stays available in Archived. A running agent will stop.") + } .onChange(of: app.workspaces, initial: true) { _, _ in rebuild() } .onChange(of: app.repos) { _, _ in rebuild() } .onChange(of: archived) { _, _ in rebuild() } + .onChange(of: archivedConversations) { _, _ in rebuild() } + .onChange(of: app.ask.workspaceConversations) { _, _ in rebuild() } // The two halves of the running state, watched separately because they move separately: // a turn starting and a turn stopping to ask are different moments, and both change what // the "Needs you" and "Running" chips count. @@ -193,7 +236,7 @@ struct HomeView: View { @ViewBuilder private var content: some View { - if let empty = emptyState { + if let empty = emptyState, mixedGroups.isEmpty { empty .frame(maxWidth: .infinity, maxHeight: .infinity) } else { @@ -202,59 +245,29 @@ struct HomeView: View { } /// A real `List`, so the rows come with the things a hand-built stack never gets right: arrow - /// key navigation between rows and row recycling, which is what keeps five hundred workspaces - /// from being five hundred live views. + /// key navigation between rows and row recycling, which keeps hundreds of records from + /// being hundreds of live views. /// /// The date headings are ordinary rows rather than `Section` headers. As headers they pinned /// to the top and the rows slid underneath them, which reads as an index keeping your place in - /// a long document; this is a flat list of workspaces and there is no place to keep. + /// a long document; this is a flat list of work and conversations. /// /// The one thing it is NOT allowed to bring is its own selection fill. Under `.listStyle(.inset)` /// that is a full bleed bar of the system accent, which was a third selection treatment in a /// window that already has one. `HomeRowBackground` is painted over it. private var list: some View { List(selection: $selected) { - ForEach(listing.groups) { group in + ForEach(mixedGroups) { group in Section { HomeGroupHeading(title: group.title) .listRowInsets(Self.rowInsets) .listRowBackground(Color.clear) .selectionDisabled() - ForEach(group.rows) { row in - HomeListRow( - row: row, - isRunning: app.isRunning(row.workspace), - isAwaitingPermission: app.isAwaitingPermission(row.workspace), - isStarting: app.isStarting(row.workspace), - now: now, - isRenaming: renaming == row.id, - onCommitRename: { commitRename(row, to: $0) }, - onCancelRename: { closeField(of: row) } - ) - // Innermost, on the drawing alone. Everything below this line is what the - // list is told about the row, and a row that is fading in is still - // selectable, clickable and right clickable throughout. - .arrivingRow(arrival.isArriving(row.id)) - .tag(row.id) - // Simultaneous rather than `onTapGesture`, so the list still takes the - // click for itself: it is what moves the keyboard into the table, and - // without it the arrow keys have no way of ever reaching these rows. - .simultaneousGesture(TapGesture().onEnded { open(row) }) - .listRowInsets(Self.rowInsets) - // Home's rows answered to the pointer with nothing at all: no tint under - // it, and no menu on a right click, while the identical row in the sidebar - // two hundred points to the left had both. See `HomeRowBackground` for why - // the fill is drawn here rather than left to the list. - .onHoverChange { hovered = $0 ? row.id : (hovered == row.id ? nil : hovered) } - .listRowBackground( - HomeRowBackground( - isSelected: selected == row.id, - isHovered: hovered == row.id - ) - ) - .contextMenu { - HomeRowMenu(row: row, onRename: { renaming = $0 }, onDelete: askToDelete) + ForEach(group.items) { item in + switch item { + case .workspace(let row): workspaceRow(row) + case .conversation(let conversation): conversationRow(conversation) } } } @@ -307,6 +320,37 @@ struct HomeView: View { } } + private func workspaceRow(_ row: HomeRow) -> some View { + HomeWorkspaceListEntry( + row: row, + isRunning: app.isRunning(row.workspace), + isAwaitingPermission: app.isAwaitingPermission(row.workspace), + isStarting: app.isStarting(row.workspace), + now: now, + isRenaming: renaming == row.id, + isSelected: selected == row.id, + isArriving: arrival.isArriving(row.id), + rowInsets: Self.rowInsets, + onOpen: { open(row) }, + onCommitRename: { commitRename(row, to: $0) }, + onCancelRename: { closeField(of: row) }, + onRename: { renaming = $0 }, + onDelete: askToDelete + ) + .tag(row.id) + } + + private func conversationRow(_ conversation: Session) -> some View { + HomeConversationRow( + conversation: conversation, + onArchive: { archivingConversation = conversation }, + onDelete: { deletingConversation = conversation } + ) + .listRowInsets(Self.rowInsets) + .listRowBackground(Color.clear) + .selectionDisabled() + } + /// The second kind of result: workspaces whose transcript matched, with the best few lines /// under each. /// @@ -374,7 +418,8 @@ struct HomeView: View { listing: listing, filter: filter, projects: app.repos.count, - database: databaseSize + database: databaseSize, + conversationCount: homeConversations.count ) } @@ -391,6 +436,33 @@ struct HomeView: View { private var hasAnyWorkspace: Bool { !app.workspaces.isEmpty || !archived.isEmpty + || !app.ask.workspaceConversations.isEmpty || !archivedConversations.isEmpty + } + + private var homeConversations: [Session] { + guard filter.projects.isEmpty, filter.scope != .transcripts else { return [] } + let candidates = app.ask.workspaceConversations + archivedConversations + let query = filter.query.trimmingCharacters(in: .whitespacesAndNewlines) + return candidates.filter { session in + (filter.scope != .archived || session.archivedAt != nil) + && (query.isEmpty || app.ask.title(for: session).localizedCaseInsensitiveContains(query)) + } + } + + private var displayCounts: HomeScopeCounts { + var counts = listing.counts + guard filter.projects.isEmpty else { return counts } + let query = filter.query.trimmingCharacters(in: .whitespacesAndNewlines) + for conversation in app.ask.workspaceConversations + archivedConversations + where query.isEmpty || app.ask.title(for: conversation).localizedCaseInsensitiveContains(query) { + if !query.isEmpty { counts.workspaces += 1 } + if conversation.archivedAt == nil { + counts.live += 1 + } else { + counts.archived += 1 + } + } + return counts } // MARK: - Empty @@ -416,9 +488,9 @@ struct HomeView: View { @ViewBuilder private var emptyState: (some View)? { if let state = HomeEmptyState.resolve( - hasProjects: !app.repos.isEmpty, + hasProjects: !app.repos.isEmpty || hasAnyWorkspace, hasAnyWorkspace: hasAnyWorkspace, - isListEmpty: listing.isEmpty, + isListEmpty: listing.transcripts.isEmpty && mixedGroups.isEmpty, query: filter.query, scope: filter.scope, hasProjectFilter: !filter.projects.isEmpty, @@ -490,6 +562,12 @@ struct HomeView: View { footprints: footprints, now: stamp ) + mixedGroups = HomeMixedList.build( + listing: listing, + conversations: homeConversations, + filter: filter, + now: stamp + ) // Every row in the list, flattened out of its date heading. Which heading a row is filed // under is not identity: a row crosses from Today to Yesterday at midnight and again // whenever `now` is re-stamped, and taking the groups one at a time would read that as a diff --git a/Sources/Bloom/Views/Inspector/BranchActionsPopover.swift b/Sources/Bloom/Views/Inspector/BranchActionsPopover.swift new file mode 100644 index 000000000..a53d4f5f7 --- /dev/null +++ b/Sources/Bloom/Views/Inspector/BranchActionsPopover.swift @@ -0,0 +1,60 @@ +import SwiftUI +import BloomCore + +enum PullRequestCreationRoute: String, CaseIterable { + case agent + case direct + + var label: String { + switch self { + case .agent: "Ask agent to create PR" + case .direct: "Commit and create PR with GitHub CLI" + } + } +} + +/// Chooses who prepares the branch and opens its pull request. +struct BranchActionsPopover: View { + let model: WorkspaceModel + let onCreatePullRequest: (PullRequestCreationRoute) -> Void + + @Environment(\.dismiss) private var dismiss + @State private var route = PullRequestCreationRoute.agent + + var body: some View { + ConfirmationPopover( + title: "Create pull request?", + confirmLabel: "Create PR", + tint: Palette.controlAccent, + canConfirm: route == .agent || (model.gitHubReadiness.isUsable && !model.isRunning), + onConfirm: { + dismiss() + onCreatePullRequest(route) + }, + onCancel: { dismiss() }, + width: 380 + ) { + Picker("Create using", selection: $route) { + ForEach(PullRequestCreationRoute.allCases, id: \.self) { choice in + Text(choice.label).tag(choice) + } + } + .pickerStyle(.radioGroup) + + if route == .direct { + Text("Bloom stages and commits all local changes as “\(model.workspace.name)”, pushes the branch, then opens a pull request with gh.") + .foregroundStyle(.secondary) + if !model.gitHubReadiness.isUsable { + Text("Connect GitHub to use the direct route.") + .foregroundStyle(Palette.negative) + } else if model.isRunning { + Text("Wait for the agent to finish before committing directly.") + .foregroundStyle(Palette.negative) + } + } else { + Text("The agent will prepare the commits, push, and create the pull request using this project's instructions.") + .foregroundStyle(.secondary) + } + } + } +} diff --git a/Sources/Bloom/Views/Inspector/DiffView.swift b/Sources/Bloom/Views/Inspector/DiffView.swift index 7534d218b..52fbd41b3 100644 --- a/Sources/Bloom/Views/Inspector/DiffView.swift +++ b/Sources/Bloom/Views/Inspector/DiffView.swift @@ -416,11 +416,11 @@ struct DiffView: View { } private var markdownPreviewAction: (() -> Void)? { - // A section of the all-files review previews Markdown natively, at a fixed height. An HTML + // A section of the all-files review previews Markdown and SVG at a fixed height. An HTML // report in a box that size is not a way to read one, so it gets its preview in the file tab. let kind = DocumentPreview.kind(path: file.path) guard allowsWorktreeActions, - kind == .markdown || (kind == .html && embeddedWidth == nil), + kind == .markdown || kind == .svg || (kind == .html && embeddedWidth == nil), !file.isBinary, file.change != .deleted else { return nil } @@ -432,7 +432,7 @@ struct DiffView: View { @ViewBuilder private var fileContent: some View { - // The preview renders a Markdown or HTML file by reading it off this Mac, and the links + // The preview renders a Markdown, HTML or SVG file by reading it off this Mac, and the links // inside it open other files the same way, so it belongs to a worktree. Without one // `markdownPreviewAction` is nil as well, so nothing offers a preview that cannot be made. if embeddedWidth == nil { @@ -444,10 +444,18 @@ struct DiffView: View { sourceContent } } else if showsMarkdownPreview { - MarkdownFilePreview(path: fileKey, revision: model.changesGeneration) { - showsMarkdownPreview = false + if DocumentPreview.kind(path: file.path) == .svg { + DocumentPreviewView( + path: fileKey, worktree: model.workspace.path, revision: model.changesGeneration, + openFile: { model.openSource(at: CodeLocation(path: $0)) } + ) + .frame(height: 480) + } else { + MarkdownFilePreview(path: fileKey, revision: model.changesGeneration) { + showsMarkdownPreview = false + } + .frame(height: 480) } - .frame(height: 480) } else { sourceContent } diff --git a/Sources/Bloom/Views/Inspector/FileTreeView.swift b/Sources/Bloom/Views/Inspector/FileTreeView.swift index f42d24eb1..09516f4f9 100644 --- a/Sources/Bloom/Views/Inspector/FileTreeView.swift +++ b/Sources/Bloom/Views/Inspector/FileTreeView.swift @@ -3,9 +3,9 @@ import BloomCore /// The whole worktree, not just what changed. /// -/// `git ls-files` is asked once and turned into a directory index. Rows are then produced only -/// for directories the user actually opened, so a repository with fifty thousand files costs one -/// subprocess and a handful of views rather than a tree of fifty thousand nodes. +/// `git ls-files` is asked on first open and after file system changes, then turned into a +/// directory index. Rows are produced only for directories the user actually opened, so a +/// repository with fifty thousand files draws a handful of views rather than a whole tree. struct FileTreeView: View { let model: WorkspaceModel @@ -15,6 +15,8 @@ struct FileTreeView: View { /// **A live filter never writes to this**, which is what lets a filter be typed and deleted /// without costing somebody the place they had opened their way down to. See `filterOpen`. @State private var expanded: Set = [] + @State private var loadedWorkspace: WorkspaceID? + @State private var loadedPath: String? @State private var selection: String? @State private var revealPath: String? @@ -60,6 +62,7 @@ struct FileTreeView: View { private struct LoadID: Hashable { var workspaceID: WorkspaceID var workspacePath: String? + var revision: Int } var body: some View { @@ -69,18 +72,26 @@ struct FileTreeView: View { /// Reading the listing, and the two watches that come off the workspace itself. private var loading: some View { stack - .task(id: LoadID(workspaceID: model.workspace.id, workspacePath: model.workspace.path)) { - // Nothing is thrown away first. A workspace whose listing has already been read draws - // it on the frame it arrives on, and this returns without a subprocess. - expanded = Set(UserDefaults.standard.stringArray(forKey: expansionKey) ?? []) - selection = nil - revealPath = nil - // The filter goes with the workspace it was typed at. Carrying it across would show - // the next worktree already narrowed by a word nobody typed at it. - query = "" + .task(id: LoadID( + workspaceID: model.workspace.id, + workspacePath: model.workspace.path, + revision: model.fileTreeRevision + )) { + // Keep the previous listing on screen while a changed worktree is read again. + let switching = loadedWorkspace != model.workspace.id || loadedPath != model.workspace.path + if switching { + expanded = Set(UserDefaults.standard.stringArray(forKey: expansionKey) ?? []) + selection = nil + revealPath = nil + // The filter goes with the workspace it was typed at. + query = "" + loadedWorkspace = model.workspace.id + loadedPath = model.workspace.path + } await model.refreshFileTree() guard !Task.isCancelled else { return } - if let path = model.activeReviewPath, let ancestors = FileTreeNode.ancestors(of: path, in: model.fileTree) { + if switching, let path = model.activeReviewPath, + let ancestors = FileTreeNode.ancestors(of: path, in: model.fileTree) { expanded.formUnion(ancestors) rebuildRows() selection = path diff --git a/Sources/Bloom/Views/Inspector/MergeConfirmationPopover.swift b/Sources/Bloom/Views/Inspector/MergeConfirmationPopover.swift index 4cba24f20..8bd35e705 100644 --- a/Sources/Bloom/Views/Inspector/MergeConfirmationPopover.swift +++ b/Sources/Bloom/Views/Inspector/MergeConfirmationPopover.swift @@ -7,6 +7,8 @@ struct MergeConfirmationPopover: View { let baseBranch: String let localWork: LocalWork? let method: GitHub.MergeMethod + let execution: MergeExecution + let onChooseExecution: (MergeExecution) -> Void let deletesBranch: Bool let canMerge: Bool /// The fill of the merge control this is attached to, so the press that asks and the press @@ -18,24 +20,50 @@ struct MergeConfirmationPopover: View { var body: some View { ConfirmationPopover( - title: pullRequest.mergeConfirmationTitle(base: baseBranch), - confirmLabel: method.label, + title: pullRequest.mergeConfirmationTitle(base: pullRequest.baseBranch ?? baseBranch), + confirmLabel: execution == .direct ? method.label : "Ask agent to merge", tint: tint, - canConfirm: canMerge, + canConfirm: canMerge && (execution == .agent || canMergeDirectly), onConfirm: onConfirm, - onCancel: onCancel + onCancel: onCancel, + width: 380 ) { - ForEach(pullRequest.mergeWarnings(base: baseBranch, local: localWork), id: \.self) { warning in + Picker("Merge using", selection: executionBinding) { + ForEach(MergeExecution.allCases, id: \.self) { choice in + Text(choice.label).tag(choice) + } + } + .pickerStyle(.radioGroup) + + ForEach(pullRequest.mergeWarnings(base: pullRequest.baseBranch ?? baseBranch, local: localWork), id: \.self) { warning in Text(warning) .foregroundStyle(Palette.negative) } - Text(pullRequest.mergeConfirmationMessage) - - if let deletion = pullRequest.mergeBranchDeletionMessage(deletesBranch: deletesBranch) { - Text(deletion) + if execution == .direct { + Text("Bloom will run gh pr merge with the selected \(method.label.lowercased()) method. GitHub may queue the merge if this repository requires it.") + Text("Bloom will not delete the branch or change this checkout. GitHub may delete the remote branch if the repository is configured to do so.") .foregroundStyle(.secondary) + if !canMergeDirectly { + Text("Refresh this pull request before merging directly.") + .foregroundStyle(Palette.negative) + } + } else { + Text(pullRequest.mergeConfirmationMessage) + + if let deletion = pullRequest.mergeBranchDeletionMessage(deletesBranch: deletesBranch) { + Text(deletion) + .foregroundStyle(.secondary) + } } } } + + private var canMergeDirectly: Bool { + pullRequest.headRevision != nil && pullRequest.baseBranch != nil + } + + private var executionBinding: Binding { + Binding(get: { execution }, set: { onChooseExecution($0) }) + } } diff --git a/Sources/Bloom/Views/Inspector/MergeSplitButton.swift b/Sources/Bloom/Views/Inspector/MergeSplitButton.swift index 401dcf585..ec519063c 100644 --- a/Sources/Bloom/Views/Inspector/MergeSplitButton.swift +++ b/Sources/Bloom/Views/Inspector/MergeSplitButton.swift @@ -71,8 +71,9 @@ struct MergeSplitButton: View { // captured. The label is re-read on every rebuild. So the button said "Rebase and merge" // over a menu still ticking Squash: two ages of one value, which is the exact fault this // control exists to remove. Giving it the value's identity makes a changed method a new - // control, so there is no older closure left to evaluate. - .id(method) + // control, so there is no older closure left to evaluate. `canMerge` is part of it too: + // the hosted button kept its disabled look after the checks turned green. + .id("\(method.rawValue).\(canMerge)") } private var styled: some View { diff --git a/Sources/Bloom/Views/Inspector/PullRequestBar.swift b/Sources/Bloom/Views/Inspector/PullRequestBar.swift index 128733c87..bd7ef0206 100644 --- a/Sources/Bloom/Views/Inspector/PullRequestBar.swift +++ b/Sources/Bloom/Views/Inspector/PullRequestBar.swift @@ -102,7 +102,9 @@ struct PullRequestBar: View { isWorking: isWorking, branchActions: branchActions, mergeMethod: model.mergeMethod, + mergeExecution: model.mergeExecution, onChooseMergeMethod: chooseMergeMethod, + onChooseMergeExecution: { model.chooseMergeExecution($0) }, onMerge: merge, onMarkReadyForReview: { markReadyForReview(pullRequest) }, onPush: push, @@ -114,6 +116,7 @@ struct PullRequestBar: View { ) } else { PullRequestCreator( + model: model, branch: model.workspace.branch, baseBranch: model.workspace.baseBranch, isWorking: isWorking || model.isLoadingPullRequest, @@ -160,6 +163,7 @@ struct PullRequestBar: View { // project and not to this workspace: picking Rebase in one of a project's worktrees has // to be what the band says in the next one, and switching workspace is what starts this. await model.loadMergeMethod() + await model.loadMergeExecution() var maxAge = PullRequestPolling.arrivalMaxAge while !Task.isCancelled { @@ -169,18 +173,33 @@ struct PullRequestBar: View { } } - /// Creation is the agent's job: it pushes, writes the description and calls `gh` with the - /// project's own conventions in context. Bloom only composes the turn. - private func createPullRequest() { + /// The agent route composes a turn. The direct route commits and publishes in BloomCore. + private func createPullRequest(_ route: PullRequestCreationRoute) { isWorking = true report = nil Task { defer { isWorking = false } - if let refusal = await model.requestPullRequest() { - report = PullRequestNotice( - tone: .info, title: "Nothing was sent", message: refusal - ) + switch route { + case .agent: + if let refusal = await model.requestPullRequest() { + report = PullRequestNotice( + tone: .info, title: "Nothing was sent", message: refusal + ) + } + case .direct: + do { + let created = try await model.createPullRequestDirectly() + report = PullRequestNotice( + tone: .info, title: "Pull request created", + message: "Opened #\(created.number) on GitHub." + ) + } catch { + report = PullRequestNotice( + tone: .failure, title: "Could not create the pull request", + message: String(describing: error) + ) + } } } } @@ -304,28 +323,45 @@ struct PullRequestBar: View { pendingArchive = request } - /// Hands the merge to the agent, the same way Create pull request and Commit and push do. - /// - /// Nothing here runs `gh`, and that is the change this whole file was rewritten for. Bloom used - /// to merge over the network from this function and report the outcome into a notice, which - /// left it with one thing to say about the case that happens most: GitHub refused, and the - /// person needed a conversation rather than a red box. Now the request goes down the composer's - /// own path, the commands run in the transcript under the permission mode the person set, and - /// the strip notices the merge on its next refresh like any other change on GitHub. - /// - /// So there is nothing to report here on success. What used to be a `WorkspaceEvent` row is - /// now the turn itself, a few points to the left, said in the agent's own words. - private func merge(_ method: GitHub.MergeMethod) { + /// The confirmation chose how to execute the merge. The direct path stays in the model, + /// keeping subprocesses out of the view and reporting gh's answer beside the inspector. + private func merge(_ method: GitHub.MergeMethod, execution: MergeExecution) { guard let pullRequest = model.pullRequest else { return } isWorking = true report = nil Task { defer { isWorking = false } - if let refusal = await model.requestMerge(pullRequest, method: method) { - report = PullRequestNotice( - tone: .info, title: "Nothing was sent", message: refusal - ) + switch execution { + case .agent: + if let refusal = await model.requestMerge(pullRequest, method: method) { + report = PullRequestNotice( + tone: .info, title: "Nothing was sent", message: refusal + ) + } + case .direct: + do { + let outcome = try await model.mergePullRequestDirectly(pullRequest, method: method) + let message: String + switch outcome { + case .merged: + message = "#\(pullRequest.number) merged. Bloom did not delete the branch or change this checkout." + case .acceptedButOpen: + message = "#\(pullRequest.number) is still open, possibly waiting in a merge queue." + case .acceptedUnverified: + message = "gh accepted the command, but Bloom could not verify #\(pullRequest.number)'s final status." + } + report = PullRequestNotice( + tone: .info, + title: outcome == .merged ? "Merged on GitHub" : "GitHub accepted the merge", + message: message + ) + } catch { + report = PullRequestNotice( + tone: .failure, title: "Could not merge on GitHub", + message: String(describing: error) + ) + } } } } diff --git a/Sources/Bloom/Views/Inspector/PullRequestCreator.swift b/Sources/Bloom/Views/Inspector/PullRequestCreator.swift index e2ad5e4ca..0e48ff362 100644 --- a/Sources/Bloom/Views/Inspector/PullRequestCreator.swift +++ b/Sources/Bloom/Views/Inspector/PullRequestCreator.swift @@ -1,18 +1,14 @@ import SwiftUI import BloomCore -/// The strip before there is a pull request: which branch you are on, and the button that asks the -/// agent to open one. +/// The strip before there is a pull request: the branch and one button for its next action. /// -/// The button is prominent and it is the only prominent thing in the column, because on a branch -/// with no pull request it is the whole point of the strip. Its counterpart once there is one is -/// Merge, in the same place, at the same weight. +/// The button opens a compact choice beside the strip. Its counterpart once there is a pull +/// request is Merge, in the same place and at the same weight. /// /// On a branch with nothing on it there is no button. See `body`. /// -/// Nothing here is gated on the GitHub CLI. Pressing it composes a turn and sends it to this -/// workspace's own agent, which is already authenticated and already standing in the worktree, so -/// whether Bloom itself can talk to GitHub has no bearing on it. See `WorkspaceModel`. +/// The popover offers an agent turn or a direct commit, push and gh create sequence. /// /// The branch name is the part that gives way. It is given a lower layout priority than the /// button so a long branch truncates from the head, which keeps the readable end of it, rather @@ -26,6 +22,7 @@ import BloomCore /// into a row about an ellipsis. Both strips carry the same detail rung underneath, so the two /// states of the same band still share one rhythm. struct PullRequestCreator: View { + var model: WorkspaceModel var branch: String var baseBranch: String var isWorking: Bool @@ -48,7 +45,9 @@ struct PullRequestCreator: View { /// has just been cut from a landed pull request and an empty branch nobody has done anything /// on, which read identically until `ContinuedBranch` gave the first of them a sentence. var continued: ContinuedBranch? - var action: () -> Void + var action: (PullRequestCreationRoute) -> Void + + @State private var isShowingBranchActions = false var body: some View { HStack(spacing: InspectorLayout.gap) { @@ -59,8 +58,8 @@ struct PullRequestCreator: View { .accessibilityHidden(true) VStack(alignment: .leading, spacing: Metrics.spacingHair) { - // Keep the readable end of a long branch. The band's custom hover card shows - // the full name, so a system tooltip here would duplicate it. + // Keep the readable end of a long branch. The context menu below makes the full + // name copyable without a hover card covering the band. Text(branch) .font(Typo.title) .foregroundStyle(Palette.textPrimary) @@ -124,7 +123,7 @@ struct PullRequestCreator: View { // first candidate, and "Create pull request" therefore kept its title at every // pane width and the icon-only form was unreachable. See // `PullRequestSummary.continueButton`. - createButton.labelStyle(.titleOnly).fixedSize() + branchActionsButton.fixedSize() } } // Reading the name is half of what a truncated branch name is wanted for. The other half @@ -156,26 +155,17 @@ struct PullRequestCreator: View { return "No pull request yet. Target \(baseBranch)." } - /// Explicit so the button reads from the shared semantic token. See - /// `PullRequestSummary.mergeButton` for the one case where a state colour replaces it. - private var createButton: some View { - Button("Create pull request", systemImage: "arrow.triangle.pull", action: action) + /// A single title-bar control. Its popover chooses how to create the PR now. + private var branchActionsButton: some View { + Button("Create PR") { isShowingBranchActions = true } .buttonStyle(.borderedProminent) .buttonBorderShape(.roundedRectangle(radius: Metrics.corner)) .tint(Palette.controlAccent) .controlSize(.regular) .disabled(!branchActions.isAllowed) - .help(helpText) - } - - private var helpText: String { - // The same sentence `PullRequestSummary` shows on the buttons it draws in this band once - // there is a pull request, from the one place it is written: it is a fact about how all - // of these buttons work rather than about this one. - if let reason = branchActions.reason { return reason } - // No path named. There are two, the project's own and Bloom's copy of the default, and - // which one is in play is not something a tooltip should be teaching anybody. - return "Ask this workspace's agent to push the branch and open a pull request against " - + "\(baseBranch), following this project's pull request instructions." + .help("Create a pull request for \(branch)") + .popover(isPresented: $isShowingBranchActions, arrowEdge: .top) { + BranchActionsPopover(model: model, onCreatePullRequest: action) + } } } diff --git a/Sources/Bloom/Views/Inspector/PullRequestSummary.swift b/Sources/Bloom/Views/Inspector/PullRequestSummary.swift index c9382b578..4fed8208a 100644 --- a/Sources/Bloom/Views/Inspector/PullRequestSummary.swift +++ b/Sources/Bloom/Views/Inspector/PullRequestSummary.swift @@ -32,10 +32,12 @@ struct PullRequestSummary: View { /// How this project merges, which the split button promises and its menu ticks. Per project /// and remembered: see `MergeMethodChoice`. var mergeMethod: GitHub.MergeMethod + var mergeExecution: MergeExecution /// Changes the method in force. It never merges, which is the whole difference between this /// and `onMerge`. var onChooseMergeMethod: (GitHub.MergeMethod) -> Void - var onMerge: (GitHub.MergeMethod) -> Void + var onChooseMergeExecution: (MergeExecution) -> Void + var onMerge: (GitHub.MergeMethod, MergeExecution) -> Void var onMarkReadyForReview: () -> Void /// Hands the outstanding work to the workspace's agent to commit and push. var onPush: () -> Void @@ -222,12 +224,14 @@ struct PullRequestSummary: View { baseBranch: baseBranch, localWork: localWork, method: method, + execution: mergeExecution, + onChooseExecution: onChooseMergeExecution, deletesBranch: Self.deletesBranch, canMerge: canConfirmMerge, tint: status.tone.fill, onConfirm: { pendingMerge = nil - onMerge(method) + onMerge(method, mergeExecution) }, onCancel: { pendingMerge = nil } ) @@ -525,8 +529,8 @@ struct PullRequestSummary: View { /// it. When the window loses key, AppKit draws the menu as a neutral capsule that covers that /// fill, so `MergeSplitButton` pins its control to the active state rather than going grey. /// - /// Every path through it opens the confirmation. Nothing here performs a merge, and since - /// merging moved onto the agent nothing anywhere in this target does either. + /// Every path through it opens the confirmation. The owner chooses direct gh or an agent turn + /// there; this view itself performs neither. private var mergeControl: some View { MergeSplitButton( method: mergeMethod, @@ -572,11 +576,9 @@ struct PullRequestSummary: View { // MARK: - Actions - /// The sign in gate stays, even though this app no longer runs `gh` itself. The agent does, - /// with the same credentials on the same machine, so a signed out user gets the same failure - /// one turn later and with a wasted turn in front of it. Catching it here is the cheaper half - /// of the same answer. Opening the page, copying the link and sharing it are ordinary URL - /// handling and work signed out. + /// Both routes use gh with this Mac's credentials, directly or through the agent. Catch a + /// signed out user before showing a confirmation neither route could complete. Opening the + /// page, copying the link and sharing it still work signed out. /// /// What is handed to the gate is "open the confirmation", never "merge". A sign in that /// succeeds re-raises the dialog the user was heading for and they still have to say yes to diff --git a/Sources/Bloom/Views/Markdown/CodeBlockDraftStore.swift b/Sources/Bloom/Views/Markdown/CodeBlockDraftStore.swift new file mode 100644 index 000000000..bce312c0c --- /dev/null +++ b/Sources/Bloom/Views/Markdown/CodeBlockDraftStore.swift @@ -0,0 +1,65 @@ +import SwiftUI +import Observation +import BloomCore + +/// What the reader is writing into a code block of the transcript, kept outside the block. +/// +/// **Not `@State`, because a transcript row is not a place to keep anything.** The table recycles +/// a row once it scrolls out of sight and builds it again on the way back, and a relaunch builds +/// all of them again, so an edit held by the fence was twenty five minutes of work lost to a +/// scroll. Each draft is its own user defaults key, written on every change and removed once it +/// is attached or discarded, so a keystroke writes one string rather than every draft there is. +/// +/// What was last attached is kept beside it, under a key of its own, because the block goes on +/// showing the reader's version once it has been sent until they ask for the original back. +/// +/// One box per block, for `PromptAttachmentStore`'s reason: a dictionary is one observed property, +/// and a keystroke in one fence would rebuild every fence on screen. +@MainActor +final class CodeBlockDraftStore { + static let shared = CodeBlockDraftStore() + + @Observable + final class Draft { + /// The edit in progress, nil when the block is not being edited. + var text: String? + /// The version last attached to a message, nil while the block shows the agent's code. + var attached: String? + } + + private var drafts: [String: Draft] = [:] + + private init() {} + + /// The box a block's draft lives in. Made on the spot, which is safe from a body because the + /// map is not observed: see `PromptAttachmentStore.box`. + func draft(for key: String) -> Draft { + if let held = drafts[key] { return held } + let made = Draft() + made.text = UserDefaults.standard.string(forKey: Self.defaultsKey(key)) + made.attached = UserDefaults.standard.string(forKey: Self.attachedKey(key)) + drafts[key] = made + return made + } + + func set(_ text: String?, for key: String) { + draft(for: key).text = text + Self.write(text, to: Self.defaultsKey(key)) + } + + func setAttached(_ text: String?, for key: String) { + draft(for: key).attached = text + Self.write(text, to: Self.attachedKey(key)) + } + + private static func write(_ text: String?, to defaultsKey: String) { + if let text { + UserDefaults.standard.set(text, forKey: defaultsKey) + } else { + UserDefaults.standard.removeObject(forKey: defaultsKey) + } + } + + private static func defaultsKey(_ key: String) -> String { "transcript.codeBlockDraft.\(key)" } + private static func attachedKey(_ key: String) -> String { "transcript.codeBlockAttached.\(key)" } +} diff --git a/Sources/Bloom/Views/Markdown/CodeBlockView.swift b/Sources/Bloom/Views/Markdown/CodeBlockView.swift index 45f40bb8e..1532d4ddb 100644 --- a/Sources/Bloom/Views/Markdown/CodeBlockView.swift +++ b/Sources/Bloom/Views/Markdown/CodeBlockView.swift @@ -10,21 +10,37 @@ public struct CodeBlockView: View { private let code: String private let language: Language + /// The fence's opening line after the backticks, which is where a file name lives when the + /// agent gave one. + private let info: String @State private var showsAllLines = false + @State private var confirmsDiscard = false + /// Whether an edited fence is showing the agent's code rather than the reader's. A glance, + /// not a mode worth keeping, so `@State` is enough here where the draft itself is not. + @State private var showsOriginal = false + /// An attach in flight. The draft is frozen until it lands, so a second press cannot attach + /// it twice and nothing typed meanwhile is wiped by the clear that follows. + @State private var isAttaching = false @Environment(\.transcriptTextSelection) private var selection + @Environment(\.markdownLinkActions) private var linkActions + @Environment(\.transcriptEntryID) private var entryID /// Whether the answer this fence belongs to is still arriving, which decides which cache the /// preparation goes through. See `CodeBlockPreparationCache`. @Environment(\.markdownIsStreaming) private var isStreaming - public init(code: String, language: Language) { + public init(code: String, language: Language, info: String = "") { self.code = code self.language = language + self.info = info } public var body: some View { + let versions = versions + let showsEditor = versions.showsEditor(original: showsOriginal) + let shown = versions.shown(original: showsOriginal) let prepared = CodeBlockPreparationCache.prepared( - code: code, language: language, isStreaming: isStreaming + code: shown, language: language, isStreaming: isStreaming && shown == code ) let visibleCount = showsAllLines ? prepared.lines.count : min(prepared.lines.count, Self.lineCap) @@ -35,36 +51,41 @@ public struct CodeBlockView: View { Text(Self.displayName(for: language)) .font(Typo.caption) .foregroundStyle(Palette.codeGutter) + if versions.hasEdits { + Picker("Version", selection: $showsOriginal) { + Text("Original").tag(true) + Text("Edited").tag(false) + } + .compactSegmented() + } Spacer(minLength: MarkdownMetrics.blockGap) - CopyButton(text: code, title: "Copy code", size: MarkdownMetrics.iconButton) + editControls(versions) + CopyButton( + text: showsEditor ? versions.draft ?? shown : shown, + title: "Copy code", + size: MarkdownMetrics.iconButton + ) } .padding(.horizontal, MarkdownMetrics.blockGap) .padding(.vertical, Metrics.spacing) Hairline() - ScrollView(.horizontal) { - if selection != nil { - TranscriptTextView( - text: nativeHighlighted(prepared, upTo: visibleCount), - linkColor: Palette.linkNSColor - ) - .fixedSize(horizontal: true, vertical: false) - .padding(MarkdownMetrics.blockGap) - } else { - Text(highlighted(prepared, upTo: visibleCount)) - .font(CodeMetrics.measuredFont) - .lineSpacing(CodeMetrics.rowSpacing) - .foregroundStyle(Palette.codeForeground) - .textSelection(.enabled) - .padding(MarkdownMetrics.blockGap) - } + if showsEditor { + ScriptEditor( + text: Binding { self.versions.draft ?? "" } set: { text in update { $0.draft = text } }, + language: language, + isEditable: !isAttaching + ) + .padding(Metrics.spacing) + } else { + reader(prepared, upTo: visibleCount) } // No `!showsAllLines`: an opened fence keeps the control, now reading the other way. // A fence unfolded once could not be folded again, and two thousand lines is a lot of // pane to have put between the reader and whatever they were scrolling towards. - if prepared.lines.count > Self.lineCap { + if !showsEditor, prepared.lines.count > Self.lineCap { Hairline() Button(TextFold.title(isExpanded: showsAllLines, lines: prepared.lines.count)) { showsAllLines.toggle() @@ -83,6 +104,127 @@ public struct CodeBlockView: View { } } + private func reader(_ prepared: CodeBlockPreparation, upTo visibleCount: Int) -> some View { + ScrollView(.horizontal) { + if selection != nil { + TranscriptTextView( + text: nativeHighlighted(prepared, upTo: visibleCount), + linkColor: Palette.linkNSColor + ) + .fixedSize(horizontal: true, vertical: false) + .padding(MarkdownMetrics.blockGap) + } else { + Text(highlighted(prepared, upTo: visibleCount)) + .font(CodeMetrics.measuredFont) + .lineSpacing(CodeMetrics.rowSpacing) + .foregroundStyle(Palette.codeForeground) + .textSelection(.enabled) + .padding(MarkdownMetrics.blockGap) + } + } + } + + /// A pencil while reading, Cancel and Attach while editing. Not offered on a fence still + /// streaming, whose code is about to change under the edit, nor where nothing can receive it. + @ViewBuilder + private func editControls(_ versions: CodeBlockVersions) -> some View { + if let attach = linkActions.attachEditedCode, !isStreaming { + if let draft = versions.draft { + iconButton("xmark", ink: Palette.textTertiary, title: "Discard edits") { + if versions.hasUnattachedChanges { confirmsDiscard = true } else { update { $0.discard() } } + } + .discardConfirmation( + isPresented: $confirmsDiscard, + title: "Discard your edits?", + message: { + versions.attached == nil + ? "The changes you made to this code block will be lost." + : "The changes since you last attached this code block will be lost." + }, + onConfirm: { update { $0.discard() } } + ) + let canAttach = versions.hasUnattachedChanges && !isAttaching + iconButton( + "checkmark", + ink: canAttach ? Palette.positive : Palette.textTertiary, + title: "Attach the edited code to your next message" + ) { + isAttaching = true + Task { + defer { isAttaching = false } + // Kept until it has arrived: a failed attach leaves the edit where it was. + guard await attach(draft, EditedCodeBlock.filename(info: info, language: language)) + else { return } + update { $0.attach() } + } + } + .disabled(!canAttach) + } else { + iconButton("pencil", ink: Palette.textTertiary, title: "Edit and attach to your next message") { + update { $0.edit() } + } + if versions.attached != nil { + iconButton("arrow.uturn.backward", ink: Palette.textTertiary, title: "Back to the original") { + confirmsDiscard = true + } + .discardConfirmation( + isPresented: $confirmsDiscard, + title: "Back to the original?", + message: { + "Your edited version will no longer be shown here. What you already attached stays in the message." + }, + onConfirm: { update { $0.revert() } } + ) + } + } + } + } + + /// The header's icon buttons, drawn in the square `CopyButton` sits in beside them. + private func iconButton( + _ symbol: String, ink: Color, title: String, action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Image(systemName: symbol) + .font(Typo.caption) + .foregroundStyle(ink) + .frame(width: MarkdownMetrics.iconButton, height: MarkdownMetrics.iconButton) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help(title) + .accessibilityLabel(title) + } + + /// The agent's code with the reader's versions over it. Those are held by + /// `CodeBlockDraftStore`, never by the fence: see there. + private var versions: CodeBlockVersions { + let held = draftKey.map { CodeBlockDraftStore.shared.draft(for: $0) } + return CodeBlockVersions(original: code, draft: held?.text, attached: held?.attached) + } + + private var draftKey: String? { + linkActions.session.map { + EditedCodeBlock.draftKey( + session: $0.rawValue, entry: entryID.map(String.init(describing:)) ?? "-", code: code + ) + } + } + + /// Applies one of `CodeBlockVersions`'s moves and writes back what it changed. Every move + /// brings the reader's own version back in front, so a pencil pressed while glancing at the + /// original opens the editor rather than an edit nobody can see. + private func update(_ change: (inout CodeBlockVersions) -> Void) { + let before = versions + var after = before + change(&after) + showsOriginal = false + guard let draftKey else { return } + let store = CodeBlockDraftStore.shared + if after.draft != before.draft { store.set(after.draft, for: draftKey) } + if after.attached != before.attached { store.setAttached(after.attached, for: draftKey) } + } + /// The visible lines as one `AttributedString`, highlighting and all. /// /// **One `Text` rather than a `Text` per line, so a selection runs across the whole fence.** diff --git a/Sources/Bloom/Views/Markdown/MarkdownView.swift b/Sources/Bloom/Views/Markdown/MarkdownView.swift index ec4615a73..16c2c364b 100644 --- a/Sources/Bloom/Views/Markdown/MarkdownView.swift +++ b/Sources/Bloom/Views/Markdown/MarkdownView.swift @@ -223,8 +223,8 @@ private struct MarkdownBlockView: View { // wall of bold sentences and no structure at all. inlineText(inline, rung: Self.headingFont(level), color: foreground) .padding(.top, isFirst ? 0 : MarkdownMetrics.headingLead) - case let .codeBlock(code, language, _): - CodeBlockView(code: code, language: language) + case let .codeBlock(code, language, info): + CodeBlockView(code: code, language: language, info: info) case let .bulletList(items, tight): list(items: items, start: nil, tight: tight) case let .numberedList(start, items, tight): diff --git a/Sources/Bloom/Views/Preview/DocumentPreviewSchemeHandler.swift b/Sources/Bloom/Views/Preview/DocumentPreviewSchemeHandler.swift index 350762547..b75057450 100644 --- a/Sources/Bloom/Views/Preview/DocumentPreviewSchemeHandler.swift +++ b/Sources/Bloom/Views/Preview/DocumentPreviewSchemeHandler.swift @@ -59,9 +59,12 @@ final class DocumentPreviewSchemeHandler: NSObject, WKURLSchemeHandler { let document = try MarkdownFileDocument.read(path: file.path, draft: draft) let page = MarkdownPreviewPage.html(for: document, title: file.lastPathComponent) return Answer(status: 200, type: type, body: Data(page.utf8)) - case .html where draft != nil: - return Answer(status: 200, type: type, body: Data((draft ?? "").utf8)) - case .html, nil: + case .html, .svg: + if let draft { + return Answer(status: 200, type: type, body: Data(draft.utf8)) + } + return Answer(status: 200, type: type, body: try Data(contentsOf: file, options: .mappedIfSafe)) + case nil: return Answer(status: 200, type: type, body: try Data(contentsOf: file, options: .mappedIfSafe)) } } catch { diff --git a/Sources/Bloom/Views/RepoSettings/RepoSettingsView.swift b/Sources/Bloom/Views/RepoSettings/RepoSettingsView.swift index 06608a7cc..d71a2497d 100644 --- a/Sources/Bloom/Views/RepoSettings/RepoSettingsView.swift +++ b/Sources/Bloom/Views/RepoSettings/RepoSettingsView.swift @@ -13,12 +13,9 @@ import BloomCore /// the two windows read as one app. It was a row of tabs across the top before, then a preference /// toolbar in the title bar, and both of those put the choice somewhere Settings does not put it. /// -/// Two kinds of setting live here and they are stored in two different places, which the screen is -/// explicit about. The name, mark and colour are Bloom's own record of a folder and live in its -/// database. Everything else is stated in the repository's settings files, is shared with whoever -/// else works on it, and is written back to the file it came from. Every field that writes a file -/// names the file underneath it, so the destination is known before Save is pressed. See -/// `SettingsWriter` for why there is no third, invisible copy in the database. +/// The name, mark, colour, default branch and local agent choices live in Bloom's database. +/// Scripts, instructions and workspace setup are stated in the repository's settings files and +/// shared with whoever else works on it. Every field that writes a file names its destination. struct RepoSettingsView: View { let repo: Repo @@ -41,6 +38,10 @@ struct RepoSettingsView: View { /// same place. See `RepoMonogram.initials(for:)` for what counts as one, which is a single /// leading pictograph and nothing else. @State private var name = "" + @State private var defaultBranch = "" + @State private var branchOptions: [String] = [] + @State private var branchError: String? + @State private var isSavingBranch = false /// Which pane the window is showing. What a capture run can ask for, and why, is on /// `RepoSettingsPane.requested`. @State private var pane: RepoSettingsPane = RepoSettingsPane.requested ?? .project @@ -50,6 +51,13 @@ struct RepoSettingsView: View { /// What the last thing the Mark row did came to, when it came to nothing. Cleared as soon as /// something else is pressed, because it is about that press and not about the project. @State private var iconNotice: String? + @State private var accountProfiles: [AgentAccountProfile] = [] + @State private var projectAccounts: [AgentKind: String] = [:] + @State private var usesProjectAgentDefault = false + @State private var agentModel = AppDefaults.fallbackModel + @State private var agentEffort = AppDefaults.fallbackEffort + @State private var agentKind = AppDefaults.fallbackBackend + @State private var agentPreferenceError: String? /// The name is written to the database on commit rather than on every keystroke, because each /// write reloads the whole sidebar and typing a name would do it once a letter. @FocusState private var isEditingName: Bool @@ -108,6 +116,7 @@ struct RepoSettingsView: View { case .workspaces: Form { branchSection + agentSection browserSection RepoFilesToCopySection(model: model) } @@ -145,13 +154,29 @@ struct RepoSettingsView: View { // The stored name verbatim. Nothing is written back on load, so a project whose name // begins with an emoji keeps it whether or not this window is ever opened. name = repo.name + defaultBranch = repo.defaultBranch + branchOptions = [repo.defaultBranch] await model.load() + await loadAgentPreferences() + ComposerModelCatalog.shared.load() + let context = await WorkspaceStartContext.load(repoPath: repo.path) + let available = WorkspaceStartContext.baseBranchOptions( + local: context.branches, + remote: context.remoteBranches, + defaultBranch: repo.defaultBranch + ) + branchOptions = Array(Set(available + [defaultBranch])).sorted { + $0.localizedStandardCompare($1) == .orderedAscending + } } // The usual way a settings file changes while this window is open is a `git pull` in a // terminal beside it, and coming back to the window is when that becomes visible. .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in Task { await model.refresh() } } + .onReceive(NotificationCenter.default.publisher(for: .bloomAgentProfilesChanged)) { _ in + Task { await loadAccountChoices() } + } } /// The list's selection, over a pane that is never nil. @@ -166,6 +191,90 @@ struct RepoSettingsView: View { }) } + private var agentSection: some View { + Section("Agents") { + Toggle("Override default model on this Mac", isOn: $usesProjectAgentDefault) + if usesProjectAgentDefault { + ModelAndEffortPickers( + model: $agentModel, effort: $agentEffort, backend: $agentKind + ) + Text("The model also chooses the agent for new conversations in this project.") + .settingsFootnote() + } + Button("Save Model Choice") { Task { await saveAgentDefault() } } + + ForEach(AgentKind.runnable) { kind in + Picker("\(kind.label) account", selection: accountBinding(for: kind)) { + Text("Default account").tag("") + ForEach(accountProfiles.filter { $0.kind == kind }) { profile in + Text(profile.name).tag(profile.id.rawValue) + } + } + } + Text("These choices are local to this Mac. A conversation keeps the account it first launches with.") + .settingsFootnote() + if let agentPreferenceError { + Text(agentPreferenceError).foregroundStyle(Palette.warning).settingsFootnote() + } + } + } + + private func accountBinding(for kind: AgentKind) -> Binding { + Binding(get: { projectAccounts[kind] ?? "" }, set: { selected in + projectAccounts[kind] = selected + Task { + do { + try await app.store?.setDefaultAccount( + selected.isEmpty ? nil : AgentAccountProfileID(selected), + for: repo.id, kind: kind + ) + agentPreferenceError = nil + } catch { + agentPreferenceError = "Could not save the account choice." + } + } + }) + } + + private func loadAgentPreferences() async { + guard let store = app.store else { return } + await loadAccountChoices() + if let chosen = try? await store.projectAgentDefaults(for: repo.id) { + usesProjectAgentDefault = true + agentModel = chosen.model + agentEffort = chosen.effort + agentKind = chosen.kind + } else { + let defaults = await AppDefaults.loadForNewSessions(from: store) + agentModel = defaults.model + agentEffort = defaults.effort + agentKind = defaults.backend + } + } + + private func loadAccountChoices() async { + guard let store = app.store else { return } + accountProfiles = (try? await store.agentAccountProfiles()) ?? [] + for kind in AgentKind.runnable { + projectAccounts[kind] = (try? await store.defaultAccount(for: repo.id, kind: kind))?.rawValue ?? "" + } + } + + private func saveAgentDefault() async { + guard let store = app.store else { return } + do { + try await store.setProjectAgentDefaults( + for: repo.id, + model: usesProjectAgentDefault ? agentModel : nil, + effort: usesProjectAgentDefault ? agentEffort : nil, + kind: usesProjectAgentDefault ? agentKind : nil + ) + agentPreferenceError = nil + } catch { + agentPreferenceError = "Could not save the default model." + } + } + // MARK: - Project private var projectSection: some View { @@ -461,6 +570,20 @@ struct RepoSettingsView: View { private var branchSection: some View { Section { + SettingsRow("Default branch") { + Picker("Default branch", selection: Binding( + get: { defaultBranch }, + set: { saveDefaultBranch($0) } + )) { + ForEach(branchOptions, id: \.self) { branch in + Text(branch).tag(branch) + } + } + .labelsHidden() + .disabled(isSavingBranch) + .frame(maxWidth: .infinity, alignment: .leading) + } + SettingsRow("Branch prefix") { VStack(alignment: .leading, spacing: Metrics.spacingTight) { TextField("", text: $model.draft.branchPrefix, prompt: Text("None")) @@ -479,6 +602,39 @@ struct RepoSettingsView: View { } } header: { Text("Branches") + } footer: { + if let branchError { + Text(branchError) + .foregroundStyle(Palette.negative) + } else { + Text("New workspaces start from this branch. Saved in Bloom for this project.") + } + } + } + + private func saveDefaultBranch(_ branch: String) { + guard branch != defaultBranch, branchOptions.contains(branch) else { return } + let previous = defaultBranch + defaultBranch = branch + branchError = nil + isSavingBranch = true + Task { + defer { isSavingBranch = false } + guard let store = app.store else { + defaultBranch = previous + branchError = "Project is unavailable." + return + } + do { + guard try await store.update(repoID: repo.id, { $0.defaultBranch = branch }) != nil else { + defaultBranch = previous + branchError = "Project is unavailable." + return + } + } catch { + defaultBranch = previous + branchError = error.readableMessage + } } } diff --git a/Sources/Bloom/Views/RootView.swift b/Sources/Bloom/Views/RootView.swift index b18516ba3..ed32ecc35 100644 --- a/Sources/Bloom/Views/RootView.swift +++ b/Sources/Bloom/Views/RootView.swift @@ -76,7 +76,8 @@ struct RootView: View { .sheet(item: createWorkspaceRequest) { request in CreateWorkspaceView( initialRepo: app.repos.first { $0.id == request.repoID }, - pullRequestRevision: request.pullRequestRevision + pullRequestRevision: request.pullRequestRevision, + startsAsConversation: request.startsAsConversation || app.repos.isEmpty ) } // Send Feedback and Submit a Prompt, raised from the Help menu. Here rather than at the @@ -184,6 +185,19 @@ struct RootView: View { } onConfirm: { request in request.model.runSetupAgain() } + .confirmation($app.pendingScriptFailure) { request in + Confirmation( + title: request.failure.title, + message: request.failure.message, + confirmLabel: request.failure.confirmLabel, + cancelLabel: request.failure.cancelLabel, + alternativeLabel: request.failure.retryLabel + ) + } onConfirm: { request in + Task { await app.archiveAnyway(request) } + } onAlternative: { request in + Task { await app.retryArchive(request) } + } // A single OK that does nothing but dismiss, which `errorAlert` says why it leaves to // the system rather than spelling out. .errorAlert(item: $app.alert) { $0.title } message: { $0.message } @@ -281,18 +295,15 @@ struct RootView: View { } // The post is still the one door every control goes through. Presentation stays here so // every entry point updates the same sheet and repeated asks preserve its draft. - // File, New Ask Bloom Conversation adds a conversation and keeps the existing ones, and - // opens the panel to show it. - .onReceive(NotificationCenter.default.publisher(for: .bloomNewAskConversation)) { _ in - AskPanelModel.shared.open(app: app) - Task { await app.ask.newConversation() } - } .onReceive(NotificationCenter.default.publisher(for: .bloomNewWorkspace)) { note in presentCreateWorkspace( in: note.object as? Repo, asksForPullRequest: note.userInfo?[Notification.bloomPullRequestKey] as? Bool == true ) } + .onReceive(NotificationCenter.default.publisher(for: .bloomNewConversationWorkspace)) { _ in + presentCreateWorkspace(in: nil, asksForPullRequest: false, startsAsConversation: true) + } // Makes the workspace the create sheet's terminal mode makes, for a capture run, which // cannot choose a mode or press a button. It goes through `createWorkspace` exactly as that // window does, sea and all, so what is photographed is the real workspace rather than a @@ -316,10 +327,13 @@ struct RootView: View { /// Presents the sheet on the named project, or on the project most recently started in. /// An ask made while the sheet is already up updates that one request and keeps its draft. - private func presentCreateWorkspace(in repo: Repo?, asksForPullRequest: Bool) { + private func presentCreateWorkspace( + in repo: Repo?, asksForPullRequest: Bool, startsAsConversation: Bool = false + ) { if createWorkspacePresentation.request != nil { createWorkspacePresentation.present( - repoID: repo?.id, asksForPullRequest: asksForPullRequest + repoID: repo?.id, asksForPullRequest: asksForPullRequest, + startsAsConversation: startsAsConversation ) return } @@ -332,6 +346,13 @@ struct RootView: View { return } + if startsAsConversation || app.repos.isEmpty { + createWorkspacePresentation.present( + repoID: nil, startsAsConversation: true + ) + return + } + createWorkspaceResolution = Task { let target = await app.recentProjects().first ?? app.selectedWorkspace.flatMap(app.repo(for:)) @@ -452,10 +473,7 @@ extension Notification.Name { /// has selected, and because the four have always behaved identically by going through one /// door. See `presentCreateWorkspace`. static let bloomNewWorkspace = Notification.Name("bloom.newWorkspace") - /// File, New Ask Bloom Conversation. The same act the toolbar's glyph performs, posted rather - /// than called, because `RootView` owns the flag that raises it and starting fresh archives - /// the conversation it replaces: one writer, whichever control was pressed. - static let bloomNewAskConversation = Notification.Name("bloom.newAskConversation") + static let bloomNewConversationWorkspace = Notification.Name("bloom.newConversationWorkspace") /// Opens the window that starts a project. A notification for the same reason the create /// window is opened by one: the sidebar's `+`, Home's empty state, the toolbar and a /// `Commands` body can none of them reach `openWindow`, and this is where the receiver that diff --git a/Sources/Bloom/Views/Sidebar/ConversationSidebarRow.swift b/Sources/Bloom/Views/Sidebar/ConversationSidebarRow.swift new file mode 100644 index 000000000..4be32985b --- /dev/null +++ b/Sources/Bloom/Views/Sidebar/ConversationSidebarRow.swift @@ -0,0 +1,118 @@ +import SwiftUI +import BloomCore + +/// A project-free conversation, drawn after the project groups. +struct ConversationSidebarRow: View { + let chat: Session + + @Environment(AppModel.self) private var app + @State private var isRenaming = false + @State private var name = "" + @State private var isConfirmingArchive = false + @State private var isHovered = false + @FocusState private var nameFocused: Bool + + var body: some View { + let status = app.ask.status(for: chat.id) + + HStack(spacing: Metrics.spacing) { + WorkspaceStatusGlyph(status: status) + + if isRenaming { + TextField("Conversation name", text: $name) + .textFieldStyle(.plain) + .focused($nameFocused) + .onSubmit(commitRename) + .onExitCommand { isRenaming = false } + .onChange(of: nameFocused) { had, has in + if had, !has, isRenaming { commitRename() } + } + .task { nameFocused = true } + } else { + Text(app.ask.title(for: chat)) + .font(Typo.label) + .lineLimit(1) + WorkspaceColourDot( + hex: app.ask.conversationColours[chat.id], + accessibilityName: app.ask.conversationColours[chat.id] + .flatMap { WorkspaceColour.named($0)?.name } + ) + } + + Spacer(minLength: Metrics.spacingSmall) + if app.ask.pinnedConversations.contains(chat.id) { + Image(systemName: "pin.fill") + .font(Typo.micro) + .foregroundStyle(Palette.textTertiary) + .accessibilityLabel("Pinned") + } + } + .modifier(SidebarRowTrailingControl(isShown: (isHovered || isConfirmingArchive) && !isRenaming) { + archiveButton + }) + .onHover { isHovered = $0 } + .contentShape(Rectangle()) + .contextMenu { + Button("Rename") { + name = app.ask.title(for: chat) + isRenaming = true + } + Button(app.ask.pinnedConversations.contains(chat.id) ? "Unpin" : "Pin to Top") { + Task { await app.ask.togglePin(chat.id) } + } + Menu("Colour") { + Button("None") { Task { await app.ask.setColour(nil, for: chat.id) } } + ForEach(WorkspaceColour.all) { colour in + Button(colour.name) { + Task { await app.ask.setColour(colour.hex, for: chat.id) } + } + } + } + Button("Archive Conversation") { + archive() + } + } + .accessibilityLabel(app.ask.title(for: chat)) + .accessibilityValue(status == .clean ? "Idle" : status.label) + } + + private func commitRename() { + isRenaming = false + Task { await app.ask.rename(chat.id, title: name) } + } + + private var archiveButton: some View { + Button(action: archive) { + Image(systemName: "archivebox") + .font(Typo.caption) + .frame(width: SidebarMetrics.rowButton, height: SidebarMetrics.rowButton) + .contentShape(RoundedRectangle(cornerRadius: Metrics.cornerSmall)) + } + .buttonStyle(.plain) + .foregroundStyle(Palette.textSecondary) + .help("Archive conversation") + .accessibilityLabel("Archive \(app.ask.title(for: chat))") + .popover(isPresented: $isConfirmingArchive, arrowEdge: .leading) { + ConfirmationPopover( + title: app.ask.isRunning(chat.id) + ? "Stop and archive this conversation?" : "Archive this conversation?", + confirmLabel: "Archive conversation", + tint: Palette.controlAccent, + onConfirm: { + isConfirmingArchive = false + Task { await app.ask.close(chat.id) } + }, + onCancel: { isConfirmingArchive = false } + ) { + Text(app.ask.isRunning(chat.id) + ? "The agent will stop. The transcript stays in Archived." + : "The transcript stays in Archived.") + .fixedSize(horizontal: false, vertical: true) + } + } + } + + private func archive() { + isConfirmingArchive = true + } +} diff --git a/Sources/Bloom/Views/Sidebar/NewMenuItems.swift b/Sources/Bloom/Views/Sidebar/NewMenuItems.swift index 1f82c3931..4f3203433 100644 --- a/Sources/Bloom/Views/Sidebar/NewMenuItems.swift +++ b/Sources/Bloom/Views/Sidebar/NewMenuItems.swift @@ -1,11 +1,7 @@ import SwiftUI import BloomCore -/// What the sidebar's two `+` buttons open: the toolbar's and the status bar's. -/// -/// One view, because the two menus were already kept word for word the same by comments pointing -/// at each other, and a section of recent projects added to one and forgotten in the other is the -/// drift those comments existed to prevent. +/// What the sidebar toolbar's `+` button opens. /// /// The generic items stay first, so the position somebody aims at for "New workspace…" did not /// move when the recent projects arrived. A recent project opens the same create window with that @@ -21,6 +17,9 @@ struct NewMenuItems: View { var body: some View { Button("New workspace…", action: onNewWorkspace) Button("New project…", action: onStartProject) + Button("New conversation…") { + NotificationCenter.default.post(name: .bloomNewConversationWorkspace, object: nil) + } if !recent.isEmpty { Divider() diff --git a/Sources/Bloom/Views/Sidebar/RepoHeaderRow.swift b/Sources/Bloom/Views/Sidebar/RepoHeaderRow.swift index 28a368681..9b32fe5ec 100644 --- a/Sources/Bloom/Views/Sidebar/RepoHeaderRow.swift +++ b/Sources/Bloom/Views/Sidebar/RepoHeaderRow.swift @@ -23,6 +23,7 @@ import BloomCore /// already said. struct RepoHeaderRow: View { var repo: Repo + var isFirstProject: Bool /// Whether any of this project's workspaces has a finished turn nobody has read. /// /// Passed in rather than derived from the rows, because the rows are what the filter is @@ -46,8 +47,9 @@ struct RepoHeaderRow: View { /// Lights the `+` only while the pointer is over the button itself. @State private var isCreateHovered = false - /// The list supplies the row's insets. Extra padding around the button makes the - /// project taller than the workspace rows and offsets its content inside the right-click outline. + /// The list supplies the row's insets. Later projects get room above the whole header to + /// separate them from the previous workspaces. The Projects heading already gives the first + /// project enough room. var body: some View { header // A hidden project the owner has asked to see is drawn exactly where it would be, at @@ -124,6 +126,7 @@ struct RepoHeaderRow: View { .onHoverChange { isCreateHovered = $0 } .help("New workspace in \(repo.name)") } + .padding(.top, isFirstProject ? 0 : Metrics.inset) .contentShape(Rectangle()) // Every item and the reasoning for the shape is `ProjectMenuItems`, beside the workspace // row's own menu, so a menu can be photographed rather than only read. diff --git a/Sources/Bloom/Views/Sidebar/SidebarDock.swift b/Sources/Bloom/Views/Sidebar/SidebarDock.swift index af18e7205..e224588b5 100644 --- a/Sources/Bloom/Views/Sidebar/SidebarDock.swift +++ b/Sources/Bloom/Views/Sidebar/SidebarDock.swift @@ -1,97 +1,38 @@ import SwiftUI import BloomCore -/// Home, New and Ask Bloom, at the leading end of the sidebar's status bar. -/// -/// **These were two rows and a plus at the top of the list.** Home carried the pane's one `+`, and -/// Ask Bloom sat under it as a destination of its own. The owner found "Home / Ask Bloom" a waste of -/// the pane's best real estate and asked for Ask behind a button, as Amp does it; the plus came -/// with them, as a third button, second in the row because making something is reached for more -/// often than asking. The rows' sixty points went back to the list. -/// -/// Buttons in a status bar rather than a toolbar, because this strip is already where the pane -/// keeps the controls that are about the whole of it, and it is in both shapes of the pane. +/// The archive shortcut beside filter and help at the trailing end of the sidebar's status bar. +/// The toolbar and section headers already offer creation, so the footer only navigates. struct SidebarDock: View { @Environment(AppModel.self) private var app - var onNewWorkspace: () -> Void - var onStartProject: () -> Void - var onCreateWorkspace: (Repo) -> Void - var recentProjects: [Repo] - - @State private var hovered: Item? - - private enum Item { case home, new, ask } + @State private var isHovered = false var body: some View { - HStack(spacing: Metrics.spacingTight) { - Button { goHome() } label: { - label("Home", systemImage: "house", item: .home, isActive: app.selection == .home) - } - .buttonStyle(.plain) - .help("Home") - - Menu { - NewMenuItems( - recent: recentProjects, - onNewWorkspace: onNewWorkspace, - onStartProject: onStartProject, - onCreateWorkspace: onCreateWorkspace - ) - } label: { - label("New", systemImage: "plus", item: .new, isActive: false) - } - // `.button` with `.plain` rather than `.borderlessButton`, for the reason measured on - // the workspace row's own menu: the borderless style inks its label itself and ignores - // the colour it is handed. - .menuStyle(.button) - .buttonStyle(.plain) - .menuIndicator(.hidden) - .fixedSize() - .help("New workspace or project") - - Button { askBloom() } label: { - label( - AskConversation.title, systemImage: PaneGlyph.chat, item: .ask, - isActive: AskPanelModel.shared.isOpen + Button { showArchive() } label: { + Label("Archive", systemImage: "archivebox") + .labelStyle(.iconOnly) + .font(Typo.label) + .foregroundStyle(isShowingArchive ? Palette.accent : Palette.textSecondary) + .frame(width: Metrics.rowHeight, height: Metrics.rowHeight) + .contentShape(Rectangle()) + .background( + isHovered ? Palette.hover : .clear, + in: RoundedRectangle(cornerRadius: Metrics.cornerSmall) ) - // The mark the Ask Bloom row wore. A question left running behind a closed panel - // is otherwise a turn nothing in the window reports. See `AppModel.askStatus`. - .overlay(alignment: .topTrailing) { - if let status = app.askStatus { - WorkspaceStatusGlyph(status: status) - .scaleEffect(0.7) - .offset(x: Metrics.spacingSmall, y: -Metrics.spacingSmall) - .allowsHitTesting(false) - } - } - } - .buttonStyle(.plain) - .help("Ask Bloom") - .accessibilityValue(app.askStatus?.label ?? "") } + .buttonStyle(.plain) + .onHoverChange { isHovered = $0 } + .help("Archived workspaces and conversations") } - private func goHome() { - AskPanelModel.shared.close() - app.selection = .home - } - - private func askBloom() { - AskPanelModel.shared.toggle(app: app) + private var isShowingArchive: Bool { + app.selection == .home && app.homeFilter.scope == .archived } - private func label(_ title: String, systemImage: String, item: Item, isActive: Bool) -> some View { - Label(title, systemImage: systemImage) - .labelStyle(.iconOnly) - .font(Typo.label) - .foregroundStyle(isActive ? Palette.accent : Palette.textSecondary) - .frame(width: Metrics.rowHeight, height: Metrics.rowHeight) - .contentShape(Rectangle()) - .background( - hovered == item ? Palette.hover : .clear, - in: RoundedRectangle(cornerRadius: Metrics.cornerSmall) - ) - .onHoverChange { hovered = $0 ? item : (hovered == item ? nil : hovered) } + private func showArchive() { + app.homeFilter.scope = .archived + app.homeFilter.projects = [] + app.selection = .home } } diff --git a/Sources/Bloom/Views/Sidebar/SidebarFilterMenuItems.swift b/Sources/Bloom/Views/Sidebar/SidebarFilterMenuItems.swift index 8a58bca34..46dd044fc 100644 --- a/Sources/Bloom/Views/Sidebar/SidebarFilterMenuItems.swift +++ b/Sources/Bloom/Views/Sidebar/SidebarFilterMenuItems.swift @@ -11,17 +11,15 @@ import BloomCore /// ## Two sections, because there are two nouns /// /// The picker narrows WORKSPACES by their state, which is what this control has always done. The -/// toggle decides whether hidden PROJECTS are in the list, which is a different noun, so it goes -/// under a heading of its own rather than as a fourth row of the picker. Conductor puts the same -/// switch at the foot of its project list as a permanent row; the owner asked for it in the filter -/// instead, and a labelled section is what stops it reading as a fourth kind of workspace. +/// toggles decide which PROJECTS are in the list, which is a different noun, so they go under a +/// heading of their own rather than as rows of the workspace picker. /// -/// The toggle is a switch and not another picker row on purpose. The picker is one choice out of -/// three and only one of them can be true; showing hidden projects is orthogonal to all three, -/// and a tick that is not part of the same either/or has to look different from one that is. +/// The toggles are switches and not picker rows on purpose. The picker is one choice out of +/// three and only one of them can be true; showing hidden or empty projects is independent of it. struct SidebarFilterMenuItems: View { @Binding var filter: SidebarFilter @Binding var showsHiddenProjects: Bool + @Binding var showsEmptyProjects: Bool /// How many projects are hidden right now, which the toggle says out loud. Passed in rather /// than read from the model, so the probe can photograph the menu for any number. var hiddenCount: Int @@ -58,10 +56,17 @@ struct SidebarFilterMenuItems: View { // tick in the column an image would go in, so a `systemImage` here is simply not drawn. // The picker rows above keep theirs, because a picker draws both. Section("Projects") { + Button("Show all projects in sidebar") { + filter = .all + grouping = SidebarGrouping.projects.rawValue + showsHiddenProjects = true + showsEmptyProjects = true + } Toggle( ProjectVisibility.toggleTitle(hiddenCount: hiddenCount), isOn: $showsHiddenProjects ) + Toggle("Show projects without workspaces", isOn: $showsEmptyProjects) } } } diff --git a/Sources/Bloom/Views/Sidebar/SidebarKeyboardFocus.swift b/Sources/Bloom/Views/Sidebar/SidebarKeyboardFocus.swift index fbb16b350..364f4a7f3 100644 --- a/Sources/Bloom/Views/Sidebar/SidebarKeyboardFocus.swift +++ b/Sources/Bloom/Views/Sidebar/SidebarKeyboardFocus.swift @@ -1,7 +1,8 @@ import AppKit import SwiftUI -/// Whether the sidebar's table holds the keyboard in the key window, for the edge on its selection. +/// Whether the sidebar's table holds the keyboard in the key window, and a guard against its +/// native accent selection drawing. /// /// **Read from AppKit, because neither SwiftUI signal says it.** `backgroundProminence` inside a row /// under a `listRowBackground` does not follow the table's focus, and `@FocusState` on the list diff --git a/Sources/Bloom/Views/Sidebar/SidebarLegend.swift b/Sources/Bloom/Views/Sidebar/SidebarLegend.swift deleted file mode 100644 index ca8e12257..000000000 --- a/Sources/Bloom/Views/Sidebar/SidebarLegend.swift +++ /dev/null @@ -1,51 +0,0 @@ -import SwiftUI -import BloomCore - -/// What the glyph at the head of each sidebar row means. -/// -/// Every state, not a chosen four. The list is built from `WorkspaceStatus.allCases` and draws the -/// mark with `WorkspaceStatusGlyph`, which is the row's own drawing, so a legend that explains a -/// mark the sidebar no longer draws is not something this file can produce. The hand-kept version -/// named four of the thirteen states and had already drifted on the colour of one of them. -/// -/// Split where the answers come from: the first block is what Bloom can see in the worktree, the -/// second is what GitHub said. That is the split the user is already making when they scan the -/// column, and it is `WorkspaceStatus.describesPullRequest` rather than a second list here. -struct SidebarLegend: View { - /// Wide enough for the longest explanation on one line, narrow enough to read as a legend. - private static let width: CGFloat = 240 - - private static let local = WorkspaceStatus.allCases.filter { !$0.describesPullRequest } - private static let remote = WorkspaceStatus.allCases.filter(\.describesPullRequest) - - var body: some View { - VStack(alignment: .leading, spacing: Metrics.spacingWide) { - section("In the workspace", states: Self.local) - section("On GitHub", states: Self.remote) - } - .padding(Metrics.gutter) - .frame(width: Self.width) - } - - private func section(_ title: String, states: [WorkspaceStatus]) -> some View { - VStack(alignment: .leading, spacing: Metrics.spacingSmall) { - Text(title) - .font(Typo.captionEmphasis) - .foregroundStyle(Palette.textSecondary) - .accessibilityAddTraits(.isHeader) - - ForEach(states, id: \.self) { status in - HStack(spacing: Metrics.spacingWide) { - // The mark is the thing being explained, so the sentence beside it is the whole - // accessible content of the row. - WorkspaceStatusGlyph(status: status) - .accessibilityHidden(true) - Text(status.label) - .font(Typo.caption) - .foregroundStyle(Palette.textPrimary) - Spacer(minLength: 0) - } - } - } - } -} diff --git a/Sources/Bloom/Views/Sidebar/SidebarMetrics.swift b/Sources/Bloom/Views/Sidebar/SidebarMetrics.swift index e3e1e2d8a..6ca452be9 100644 --- a/Sources/Bloom/Views/Sidebar/SidebarMetrics.swift +++ b/Sources/Bloom/Views/Sidebar/SidebarMetrics.swift @@ -13,10 +13,11 @@ import SwiftUI /// `frame(height:)` on the row, `defaultMinListRowHeight` and `controlSize` were each captured /// and each left the pitch at exactly 32 points. The reference draws 28. Reaching it would mean /// leaving `.listStyle(.sidebar)`, and with it AppKit selection, keyboard navigation and the -/// standard insets. Project rows rely on those insets without adding their own vertical padding. +/// standard insets. Workspace rows use those insets; project headers after the first add a little +/// space above their content to separate adjacent groups. /// -/// Project headers are plain rows in the flat list and use the full 32 points. The old `Section` -/// headers had only 19 points for content below 13 points of spacing. +/// Project headers are plain rows in the flat list. The old `Section` headers had only 19 points +/// for content below 13 points of spacing. enum SidebarMetrics { private static let columns = SidebarColumnLayout( disclosureWidth: caretGutter, diff --git a/Sources/Bloom/Views/Sidebar/SidebarProjectsBrowser.swift b/Sources/Bloom/Views/Sidebar/SidebarProjectsBrowser.swift new file mode 100644 index 000000000..e6f6db048 --- /dev/null +++ b/Sources/Bloom/Views/Sidebar/SidebarProjectsBrowser.swift @@ -0,0 +1,79 @@ +import SwiftUI +import BloomCore + +/// A searchable route to every project, including those without workspaces. A cascading native +/// menu made moving from the project list to an item unreliable when the list was this long. +struct SidebarProjectsBrowser: View { + var repos: [Repo] + var onSelect: (Repo) -> Void + var onCreateWorkspace: (Repo) -> Void + + @Environment(AppModel.self) private var app + @Environment(\.openWindow) private var openWindow + @State private var query = "" + @FocusState private var searchFocused: Bool + + private var matches: [Repo] { + guard !query.isEmpty else { return repos } + return repos.filter { $0.name.localizedCaseInsensitiveContains(query) } + } + + var body: some View { + VStack(spacing: Metrics.spacing) { + TextField("Find project", text: $query) + .textFieldStyle(.roundedBorder) + .focused($searchFocused) + + ScrollView { + LazyVStack(spacing: 0) { + ForEach(matches) { repo in + HStack(spacing: Metrics.spacingSmall) { + Button { + onSelect(repo) + } label: { + HStack(spacing: Metrics.spacingSmall) { + RepoIcon(repo: repo) + Text(repo.name) + .lineLimit(1) + if repo.hidden { + Text("Hidden") + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help("Show \(repo.name) in the sidebar") + + Menu { + Button("New workspace") { onCreateWorkspace(repo) } + Button("Project settings…") { + openWindow(id: RepoSettingsWindow.id, value: repo.id) + } + Button(repo.hidden ? "Unhide project" : "Hide project") { + Task { await app.toggleHidden(repo) } + } + } label: { + Image(systemName: "ellipsis") + .frame(width: Metrics.rowHeight, height: Metrics.rowHeight) + } + .menuStyle(.borderlessButton) + .accessibilityLabel("Actions for \(repo.name)") + } + .frame(height: Metrics.rowHeight) + } + } + } + .frame(height: 360) + .overlay { + if matches.isEmpty { + ContentUnavailableView.search(text: query) + } + } + } + .padding(Metrics.spacing) + .frame(width: 330) + .onAppear { searchFocused = true } + } +} diff --git a/Sources/Bloom/Views/Sidebar/SidebarProjectsMenu.swift b/Sources/Bloom/Views/Sidebar/SidebarProjectsMenu.swift deleted file mode 100644 index 0c0af748d..000000000 --- a/Sources/Bloom/Views/Sidebar/SidebarProjectsMenu.swift +++ /dev/null @@ -1,41 +0,0 @@ -import SwiftUI -import BloomCore - -/// Every project, as a submenu of the sidebar's own filter menu: the one route to a project that -/// does not depend on its header being drawn. -/// -/// Two things the pane deliberately no longer does make this necessary. A project with no -/// workspaces is not listed, and in the status view no project headers are listed either, so the -/// header's `+` and context menu have nowhere to live. Their creation and settings actions are -/// both here, per project, with the hidden ones included and marked, which also makes this the one -/// route back to a project somebody hid other than the switch below it. -/// -/// A submenu rather than the popover this replaced. The popover needed a button of its own in the -/// status bar, and the owner's objection to the strip growing controls applies to that button as -/// much as it did to the segmented picker it sat beside. -struct SidebarProjectsMenu: View { - var repos: [Repo] - /// Raised to the sidebar, which posts for the create window, so every entry point behaves - /// identically. See `SidebarView.presentCreate`. - var onCreateWorkspace: (Repo) -> Void - - @Environment(AppModel.self) private var app - @Environment(\.openWindow) private var openWindow - - var body: some View { - Menu("All projects") { - ForEach(repos) { repo in - Menu(repo.hidden ? "\(repo.name) (hidden)" : repo.name) { - Button("New workspace") { onCreateWorkspace(repo) } - Button("Project settings…") { - openWindow(id: RepoSettingsWindow.id, value: repo.id) - } - Button(repo.hidden ? "Unhide project" : "Hide project") { - Task { await app.toggleHidden(repo) } - } - } - } - } - .disabled(repos.isEmpty) - } -} diff --git a/Sources/Bloom/Views/Sidebar/SidebarRepoGroup.swift b/Sources/Bloom/Views/Sidebar/SidebarRepoGroup.swift index e1dd01c0c..bf0f0bbbb 100644 --- a/Sources/Bloom/Views/Sidebar/SidebarRepoGroup.swift +++ b/Sources/Bloom/Views/Sidebar/SidebarRepoGroup.swift @@ -160,6 +160,7 @@ enum SidebarPaneRow: Identifiable { /// minute later, and the workspace they belong to says how many there are either way. static func rows( _ groups: [SidebarRepoGroup], + showingEmptyProjects: Bool = false, crew: (WorkspaceID) -> [CrewRow] = { _ in [] }, subagents: (WorkspaceID) -> [SubagentRow] = { _ in [] }, pending: (RepoID) -> [PendingWorkspace] = { _ in [] }, @@ -168,18 +169,16 @@ enum SidebarPaneRow: Identifiable { var rows: [SidebarPaneRow] = [] for group in groups { let waiting = pending(group.id) - // **A project with nothing in it is not drawn at all.** It used to take two rows, its - // header and a sentence saying there was nothing under it, neither of which is work and - // both of which are in the way of the rows that are. It is still one press away: the - // projects button at the foot of the pane lists every project, empty ones included, - // each with its own `+`. + // Empty projects are optional because a long list of headers with no work can bury + // active work. When shown, they need only a header and its New workspace button. // // A project whose rows the FILTER is hiding keeps its header and says so, which is the // difference `totalWorkspaces` is for: a filter is a question asked for a moment, and a // project disappearing while it is asked would be the pane answering a different one. - guard group.totalWorkspaces > 0 || !waiting.isEmpty else { continue } + guard showingEmptyProjects || group.totalWorkspaces > 0 || !waiting.isEmpty else { continue } rows.append(.project(group)) guard !group.repo.collapsed else { continue } + if group.totalWorkspaces == 0, waiting.isEmpty { continue } // A project whose only row is one being cut is not a project with no workspaces, so // the notice stays away: "No workspaces yet" printed directly above the workspace // being made is the sentence answering itself. diff --git a/Sources/Bloom/Views/Sidebar/SidebarRowLabelStyle.swift b/Sources/Bloom/Views/Sidebar/SidebarRowLabelStyle.swift index f1d076e4a..3f623e0cc 100644 --- a/Sources/Bloom/Views/Sidebar/SidebarRowLabelStyle.swift +++ b/Sources/Bloom/Views/Sidebar/SidebarRowLabelStyle.swift @@ -26,7 +26,7 @@ import SwiftUI /// `Label` raises it for the icon slot and this style raised it as well, on a measurement that was /// read the wrong way round: `arrow.triangle.branch` came out 13.5 points across with it and 10.5 /// without, and the larger was taken for the right one. What it did was push every mark past the -/// 13 point box this column is aligned on, and only in the sidebar, since Home, the legend and the +/// 13 point box this column is aligned on, and only in the sidebar, since Home and the /// title bar hover card draw the same marks with no scale at all. One state, two sizes, depending /// which pane it was read in. The marks carry their own scale now, in `WorkspaceStatusGlyph` and /// `SubagentMarkGlyph`, which is where a decision about how large a mark is belongs. diff --git a/Sources/Bloom/Views/Sidebar/SidebarSectionReorder.swift b/Sources/Bloom/Views/Sidebar/SidebarSectionReorder.swift new file mode 100644 index 000000000..91f98cb30 --- /dev/null +++ b/Sources/Bloom/Views/Sidebar/SidebarSectionReorder.swift @@ -0,0 +1,83 @@ +import SwiftUI +import BloomCore + +/// A List row takes precedence over a SwiftUI drag gesture on its content. An AppKit hit target +/// over just the title receives the mouse drag before the list can turn it into row selection. +private struct SidebarSectionReorder: ViewModifier { + let section: SidebarTopSection + @Binding var order: String + @State private var verticalOffset: CGFloat = 0 + + func body(content: Content) -> some View { + content + .offset(y: verticalOffset) + .overlay { + SectionTitleDragSurface( + order: SidebarSectionOrder(rawValue: order) ?? .fallback, + onDrag: drag, + onEnd: endDrag + ) + } + .accessibilityHint("Drag up or down to move this section") + } + + private func drag(_ translation: CGFloat, from start: SidebarSectionOrder) { + let next = start.dragging(section, translation: Double(translation)) + verticalOffset = next == start ? translation : 0 + if order != next.rawValue { order = next.rawValue } + } + + private func endDrag() { + verticalOffset = 0 + } +} + +extension View { + func reorderableSection(_ section: SidebarTopSection, order: Binding) -> some View { + modifier(SidebarSectionReorder(section: section, order: order)) + } +} + +private struct SectionTitleDragSurface: NSViewRepresentable { + let order: SidebarSectionOrder + let onDrag: (CGFloat, SidebarSectionOrder) -> Void + let onEnd: () -> Void + + func makeNSView(context: Context) -> SectionTitleTrackingView { + let view = SectionTitleTrackingView() + view.setAccessibilityElement(false) + return view + } + + func updateNSView(_ nsView: SectionTitleTrackingView, context: Context) { + nsView.order = order + nsView.onDrag = onDrag + nsView.onEnd = onEnd + } +} + +private final class SectionTitleTrackingView: NSView { + var order = SidebarSectionOrder.fallback + var onDrag: ((CGFloat, SidebarSectionOrder) -> Void)? + var onEnd: (() -> Void)? + private var mouseDownY: CGFloat? + private var startingOrder: SidebarSectionOrder? + + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } + + override func mouseDown(with event: NSEvent) { + mouseDownY = event.locationInWindow.y + startingOrder = order + } + + override func mouseDragged(with event: NSEvent) { + guard let mouseDownY, let startingOrder else { return } + onDrag?(mouseDownY - event.locationInWindow.y, startingOrder) + } + + override func mouseUp(with event: NSEvent) { + mouseDownY = nil + startingOrder = nil + onEnd?() + } +} diff --git a/Sources/Bloom/Views/Sidebar/SidebarSelectionFill.swift b/Sources/Bloom/Views/Sidebar/SidebarSelectionFill.swift index 0f2230f85..88938fffd 100644 --- a/Sources/Bloom/Views/Sidebar/SidebarSelectionFill.swift +++ b/Sources/Bloom/Views/Sidebar/SidebarSelectionFill.swift @@ -19,12 +19,9 @@ import BloomCore /// it was not reliable on its own, so the table's own drawing is switched off as well: see /// `SidebarKeyboardFocus`. /// -/// The keyboard is shown with an edge, not a colour. The fill is identical in both selected -/// states, so nothing about the report can come back, and the edge is what says the arrow keys are -/// pointed at this pane. +/// The fill is identical whether or not the list holds the keyboard. A focus outline around the +/// selected row was too prominent in the sidebar, so focus adds no second mark. struct SidebarSelectionFill: View { - var style: SidebarSelectionStyle - /// Ink at an alpha rather than an opaque step, because the pane is glass over the window's blue /// wash: `Palette.selected` composited to within a few units of what was under it and the /// selected workspace had a fill nobody could see. Measured in `114a389d`, where nine percent @@ -41,14 +38,8 @@ struct SidebarSelectionFill: View { static let inset: CGFloat = 10 var body: some View { - let shape = RoundedRectangle(cornerRadius: Metrics.corner, style: .continuous) - shape + RoundedRectangle(cornerRadius: Metrics.corner, style: .continuous) .fill(Self.fill) - .overlay { - if style.drawsKeyboardEdge { - shape.strokeBorder(Palette.textTertiaryOnGlass, lineWidth: Metrics.hairline) - } - } .padding(.horizontal, Self.inset) } } @@ -66,7 +57,7 @@ extension View { func sidebarSelection(_ style: SidebarSelectionStyle) -> some View { listRowBackground(Group { if style.drawsFill { - SidebarSelectionFill(style: style) + SidebarSelectionFill() } }) .environment(\.backgroundProminence, .standard) diff --git a/Sources/Bloom/Views/Sidebar/SidebarStatusBar.swift b/Sources/Bloom/Views/Sidebar/SidebarStatusBar.swift index cd80fcf60..21c4645ef 100644 --- a/Sources/Bloom/Views/Sidebar/SidebarStatusBar.swift +++ b/Sources/Bloom/Views/Sidebar/SidebarStatusBar.swift @@ -1,9 +1,8 @@ import SwiftUI import BloomCore -/// The strip pinned to the bottom of the sidebar: Home, New and Ask Bloom (`SidebarDock`), the two -/// controls that narrow or explain the list, and a line the pane borrows when it has something to -/// say about itself. +/// The strip pinned to the bottom of the sidebar: a transient note at the leading edge, then +/// Archive (`SidebarDock`) and filter together at the trailing edge. /// /// The filter lives down here rather than in a header, which is where Xcode and Finder put the /// controls that narrow a source list. @@ -11,18 +10,16 @@ struct SidebarStatusBar: View { @Environment(AppModel.self) private var app @Binding var filter: SidebarFilter - /// Raised to the sidebar so the projects submenu's New workspace posts for the create window + /// Raised to the sidebar so the project browser's New workspace posts for the create window /// through the same door every other entry point uses. See `SidebarView.presentCreate`. var onCreateWorkspace: (Repo) -> Void = { _ in } - /// New workspace with no project named, which the create window asks for. - var onNewWorkspace: () -> Void = {} + var onSelectProject: (Repo) -> Void = { _ in } var onStartProject: () -> Void = {} - /// The New menu's recent section. See `NewMenuItems`. - var recentProjects: [Repo] = [] /// Whether the projects the owner has hidden are in the list. A preference rather than this /// window's state, which is why it is `@AppStorage` here and in `SidebarView` rather than a /// second `@State` passed down. See `ProjectVisibility.showsHiddenKey`. @AppStorage(ProjectVisibility.showsHiddenKey) private var showsHiddenProjects = false + @AppStorage(ProjectVisibility.showsEmptyKey) private var showsEmptyProjects = false /// Something the pane has to say about itself, for a moment, in place of the running count. /// The sidebar owns both the sentence and how long it lasts. See `SidebarView.move(from:to:)`. var note: String? @@ -32,36 +29,31 @@ struct SidebarStatusBar: View { /// disagree about it. See `SidebarGrouping`. @AppStorage(SidebarGrouping.storageKey) private var storedGrouping = SidebarGrouping.fallback.rawValue - @State private var isShowingLegend = false @State private var isFilterHovered = false - @State private var isLegendHovered = false + @State private var showingProjectsBrowser = false var body: some View { VStack(spacing: 0) { Hairline() HStack(spacing: Metrics.spacingSmall) { - SidebarDock( - onNewWorkspace: onNewWorkspace, - onStartProject: onStartProject, - onCreateWorkspace: onCreateWorkspace, - recentProjects: recentProjects - ) - noteLabel Spacer(minLength: Metrics.spacingSmall) + SidebarDock() + Menu { SidebarFilterMenuItems( filter: $filter, showsHiddenProjects: $showsHiddenProjects, + showsEmptyProjects: $showsEmptyProjects, hiddenCount: ProjectVisibility.hiddenCount(app.repos), grouping: $storedGrouping ) - SidebarProjectsMenu(repos: app.repos, onCreateWorkspace: onCreateWorkspace) - // The same words the New button's menu uses, so one action is not named two - // ways inside one pane. See `SidebarDock`. + Button("Browse projects…") { showingProjectsBrowser = true } + // The same words the toolbar's New menu uses, so one action is not named two + // ways inside one pane. See `NewMenuItems`. Button("New project…", action: onStartProject) } label: { controlLabel( @@ -83,29 +75,20 @@ struct SidebarStatusBar: View { .onHoverChange { isFilterHovered = $0 } .help("Filter the sidebar") .accessibilityValue(filterValue) - - Button { - isShowingLegend.toggle() - } label: { - controlLabel( - "What the sidebar glyphs mean", - systemImage: "questionmark.circle", - isHovered: isLegendHovered || isShowingLegend + .popover(isPresented: $showingProjectsBrowser, arrowEdge: .trailing) { + SidebarProjectsBrowser( + repos: app.repos, + onSelect: { repo in + showingProjectsBrowser = false + onSelectProject(repo) + }, + onCreateWorkspace: { repo in + showingProjectsBrowser = false + onCreateWorkspace(repo) + } ) - .foregroundStyle(Palette.textSecondary) - } - .buttonStyle(.plain) - .onHoverChange { isLegendHovered = $0 } - .help("What the sidebar glyphs mean") - .popover(isPresented: $isShowingLegend, arrowEdge: .top) { - SidebarLegend() } - // **No Settings cogwheel.** There was one here, and it was the one control in this - // strip that duplicated something every Mac user already knows: Command-comma, and - // the Bloom menu. The other two earn their place because neither is reachable any - // other way, and a third glyph beside them spent the strip's width saying what the - // menu bar says for free. } .padding(.horizontal, Metrics.spacingSmall) .frame(height: Metrics.barHeight) @@ -113,10 +96,8 @@ struct SidebarStatusBar: View { // Let the native sidebar ground continue behind these controls without a second material. } - // The native accessory style did not show a hover fill here. Both labels own the square - // target so the menu and button draw and respond over the same area. The plate is handed its - // flag rather than tracking the pointer, because hover is read off the `Menu` and the legend - // stays lit while its popover is open. + // The native accessory style did not show a hover fill here. The label owns the square + // target, and the plate is handed its flag because hover is read off the `Menu`. private func controlLabel(_ title: String, systemImage: String, isHovered: Bool) -> some View { Label(title, systemImage: systemImage) .labelStyle(.iconOnly) @@ -128,12 +109,16 @@ struct SidebarStatusBar: View { /// Whether the pane is showing what it shows when nothing has been asked of it. private var isDefaultView: Bool { - filter == .all && !showsHiddenProjects + filter == .all && !showsHiddenProjects && !showsEmptyProjects } /// Both halves of the control, in words, since the glyph can only show one of them. private var filterValue: String { - showsHiddenProjects ? "\(filter.rawValue), hidden projects showing" : filter.rawValue + [filter.rawValue, + showsHiddenProjects ? "hidden projects showing" : nil, + showsEmptyProjects ? "empty projects showing" : nil] + .compactMap { $0 } + .joined(separator: ", ") } /// A readout, so it is set as text rather than as the filled `Chip` it used to be. A pill in a diff --git a/Sources/Bloom/Views/Sidebar/SidebarView.swift b/Sources/Bloom/Views/Sidebar/SidebarView.swift index 5fcb417ad..577b13a98 100644 --- a/Sources/Bloom/Views/Sidebar/SidebarView.swift +++ b/Sources/Bloom/Views/Sidebar/SidebarView.swift @@ -43,11 +43,14 @@ struct SidebarView: View { /// hidden project. See `ProjectVisibility.showsHiddenKey`, which the status bar's own menu /// binds to the same key, so the two views cannot disagree. @AppStorage(ProjectVisibility.showsHiddenKey) private var showsHiddenProjects = false + @AppStorage(ProjectVisibility.showsEmptyKey) private var showsEmptyProjects = false /// Which shape the pane is in: projects with their workspaces, or sections by what each /// workspace needs. A preference for the same reason hiding a project is one, and the status /// bar binds the same key so the two cannot disagree. See `SidebarGrouping`. @AppStorage(SidebarGrouping.storageKey) private var storedGrouping = SidebarGrouping.fallback.rawValue + @AppStorage(SidebarSectionOrder.storageKey) + private var storedSectionOrder = SidebarSectionOrder.fallback.rawValue @AppStorage(SidebarPreferences.showsDiffStatsKey) private var showsDiffStats = SidebarPreferences.showsDiffStatsByDefault @@ -63,6 +66,7 @@ struct SidebarView: View { /// What the list itself thinks is selected. See the `onChange` pair below for why this is not /// bound straight to the model. @State private var listSelection: SidebarSelection? + @State private var projectToReveal: RepoID? /// Whether the list's table is the first responder, and whether its window is key. Only the /// edge on the selection reads these; the fill is the same either way. See /// `SidebarKeyboardFocus`. @@ -148,30 +152,16 @@ struct SidebarView: View { /// because the chain as one expression was past what the CI runner's type checker would /// finish in a release build: green on this Mac, red there, three times. private var list: some View { - List(selection: $listSelection) { - // No rows above the projects. Home and Ask Bloom were two here, with the pane's `+` on - // Home, and all three are buttons in the status bar now. See `SidebarDock`. - // - // One native section supplies the Projects heading's font and spacing. The rows - // stay in one ForEach so dragging still uses the same indices in `paneRows`. - // - // The heading is the project view's alone. The status view's headings are its own - // sections, and a "Projects" label over rows that are not grouped by project would be - // naming something that is not there. Starting a project is still one press away, on - // the projects button in the status bar, in both shapes, which is why the heading has - // no button of its own: it had one, and it was the same action twice in one pane. - if grouping == .projects { - Section { - paneRowsList - } header: { - Text("Projects") - .bold() - .accessibilityAddTraits(.isHeader) + ScrollViewReader { proxy in + List(selection: $listSelection) { + ForEach(topSections, id: \.rawValue) { section in + if section == .work { + workSection + } else { + conversationsSection + } } - } else { - Section { paneRowsList } } - } // The native list owns row height, keyboard navigation and which row is selected. How the // selected row is drawn is `SidebarSelectionFill`'s, on each row, for the reason given there. // @@ -182,19 +172,95 @@ struct SidebarView: View { // rows sideways. Reaching 28 means giving up `.listStyle(.sidebar)`, and with it the // selection above, keyboard navigation and the standard insets. Four points is not worth // that. Project rows use the list's own vertical insets too. - .listStyle(.sidebar) - .confirmation($stoppingCrew) { pending in - Confirmation( - title: "Stop \(pending.name)?", - message: Self.crewStopMessage, - confirmLabel: "Stop", - cancelLabel: "Keep Working" - ) - } onConfirm: { pending in - Task { await stop(pending) } + .listStyle(.sidebar) + .animation(reduceMotion ? nil : Motion.pane, value: storedSectionOrder) + .confirmation($stoppingCrew) { pending in + Confirmation( + title: "Stop \(pending.name)?", + message: Self.crewStopMessage, + confirmLabel: "Stop", + cancelLabel: "Keep Working" + ) + } onConfirm: { pending in + Task { await stop(pending) } + } + .onChange(of: projectToReveal) { _, _ in scrollToProject(proxy) } + .onChange(of: paneRows.map(\.id)) { _, _ in scrollToProject(proxy) } + } + } + + @ViewBuilder + private var workSection: some View { + if grouping == .projects, !groups.isEmpty { + Section { + sectionHeader(for: .work, title: "Projects") + .selectionDisabled() + paneRowsList + } + } else if grouping == .status { + Section { + sectionHeader(for: .work, title: "Workspaces") + .selectionDisabled() + paneRowsList + } } } + @ViewBuilder + private var conversationsSection: some View { + if filter == .all { + Section { + HStack { + Text("Conversations") + .font(Typo.label) + .fontWeight(.semibold) + .foregroundStyle(Palette.textSecondary) + .reorderableSection(.conversations, order: $storedSectionOrder) + Spacer() + Button("New conversation", systemImage: "plus") { + NotificationCenter.default.post(name: .bloomNewConversationWorkspace, object: nil) + } + .labelStyle(.iconOnly) + .font(Typo.label) + .frame(width: Metrics.headerButton.width, height: Metrics.headerButton.height) + .buttonStyle(.plain) + } + .accessibilityAddTraits(.isHeader) + .selectionDisabled() + + ForEach(app.ask.workspaceConversations, id: \.id) { chat in + ConversationSidebarRow(chat: chat) + .tag(SidebarSelection.conversation(chat.id)) + .sidebarSelection(selectionStyle(for: .conversation(chat.id))) + } + .onMove { offsets, destination in + Task { await app.ask.reorderConversations(from: offsets, to: destination) } + } + } + } + } + + private func sectionHeader(for section: SidebarTopSection, title: String) -> some View { + HStack { + Text(title) + .font(Typo.label) + .fontWeight(.semibold) + .foregroundStyle(Palette.textSecondary) + .reorderableSection(section, order: $storedSectionOrder) + Spacer() + } + .padding(.trailing, Metrics.inset + Metrics.spacingWide) + .accessibilityAddTraits(.isHeader) + } + + private var sectionOrder: SidebarSectionOrder { + SidebarSectionOrder(rawValue: storedSectionOrder) ?? .fallback + } + + private var topSections: [SidebarTopSection] { + sectionOrder == .conversationsFirst ? [.conversations, .work] : [.work, .conversations] + } + /// The motion keyed to the list's values, one step of `body`. See `list`. private func animating(_ list: some View) -> some View { list @@ -225,6 +291,9 @@ struct SidebarView: View { // pending and stored workspaces share an id, so finishing a create does not reinsert it. .animation(workspaceMotion, value: workspaceIdentities) .animation(workspaceMotion, value: statusArrangement) + // Project-free conversations are drawn outside `paneRows`, so workspace membership never + // changes when one is archived. Key their membership to the same row-removal curve. + .animation(workspaceMotion, value: conversationIdentities) // A subagent's row leaving when its work is done, which is another insertion or removal // and so another reflow, at the one length this pane confirms anything in. // @@ -272,9 +341,8 @@ struct SidebarView: View { SidebarStatusBar( filter: $filter, onCreateWorkspace: { presentCreate(in: $0) }, - onNewWorkspace: { presentCreate(in: nil) }, + onSelectProject: revealProject, onStartProject: startProject, - recentProjects: recentProjects, note: reorderNote?.sentence ) } @@ -294,6 +362,9 @@ struct SidebarView: View { hasSettled = true } .onChange(of: app.repos, initial: true) { _, _ in regroup() } + .task(id: app.isLoaded) { + if app.isLoaded { await app.ask.open() } + } .onChange(of: app.workspaces) { _, _ in regroup() } // The whole run changes shape, and nothing in it counts as having arrived: every row was // already on screen a moment ago, in another order. See the filter's own change below. @@ -350,7 +421,9 @@ struct SidebarView: View { regroup(rescoped: !ProjectVisibilityMotion.filterToggle(reduceMotion: reduceMotion) .fadesArrivals) } + .onChange(of: showsEmptyProjects) { _, _ in reflow() } .onChange(of: listSelection) { _, selected in + app.highlightSidebarSelection(selected) if selected == nil { listSelection = app.selection } } .background { @@ -443,6 +516,7 @@ struct SidebarView: View { case .project(let group): RepoHeaderRow( repo: group.repo, + isFirstProject: groups.first?.id == group.id, hasUnreadWork: group.hasUnreadWork, workspaceCount: group.workspaces.count, onCreateWorkspace: presentCreate @@ -550,6 +624,10 @@ struct SidebarView: View { Set(groups.map(\.id)) } + private var conversationIdentities: Set { + Set(app.ask.workspaceConversations.map(\.id)) + } + /// Hidden projects still on screen, whose headers dim when "Show hidden projects" is on. private var hiddenProjects: [RepoID] { groups.filter(\.repo.hidden).map(\.id) @@ -661,6 +739,7 @@ struct SidebarView: View { case .projects: return SidebarPaneRow.rows( groups, + showingEmptyProjects: showsEmptyProjects, crew: app.crew(of:), subagents: app.subagents(of:), pending: pending(in:), @@ -692,6 +771,24 @@ struct SidebarView: View { } } + private func revealProject(_ repo: Repo) { + filter = .all + storedGrouping = SidebarGrouping.projects.rawValue + if repo.hidden { showsHiddenProjects = true } + if !app.workspaces.contains(where: { $0.repoID == repo.id }) { + showsEmptyProjects = true + } + projectToReveal = repo.id + } + + private func scrollToProject(_ proxy: ScrollViewProxy) { + guard let id = projectToReveal else { return } + let rowID = "project:" + id.rawValue + guard paneRows.contains(where: { $0.id == rowID }) else { return } + proxy.scrollTo(rowID, anchor: .center) + projectToReveal = nil + } + /// What one workspace amounts to, which is the same verdict its row's mark is drawn from. /// /// Asked here rather than inside `SidebarStatusListing` because two of its three inputs are the @@ -732,7 +829,7 @@ struct SidebarView: View { break case .project(let id, let offset): - let visible = groups.map(\.id) + let visible = SidebarReorder.visibleProjects(in: paneRows.map(\.identity)) Task { await app.reorderProjects(id: id, visible: visible, to: offset) } case .workspace(let projectID, let offsets, let offset, let landedOutside): @@ -873,7 +970,7 @@ struct SidebarView: View { // No empty state of its own. With no projects, Home in the middle of the window already says // so and carries the button that starts one, and a second "No projects yet" with a second // Start a project button in the sidebar was the same call to action printed twice on first - // run. The Projects heading's `+` and the status bar's projects button are still here. + // run. The Projects heading's `+` and the status bar's project browser are still here. // MARK: - Actions @@ -1008,7 +1105,7 @@ struct SidebarToolbar: ToolbarContent { Label("New", systemImage: "plus") } .menuIndicator(.hidden) - .help("New workspace or project") + .help("New workspace, project or conversation") SidebarToggleButton() } diff --git a/Sources/Bloom/Views/Sidebar/WorkspaceRow.swift b/Sources/Bloom/Views/Sidebar/WorkspaceRow.swift index 8b261285b..764340d83 100644 --- a/Sources/Bloom/Views/Sidebar/WorkspaceRow.swift +++ b/Sources/Bloom/Views/Sidebar/WorkspaceRow.swift @@ -9,7 +9,7 @@ import BloomCore /// else on the row is secondary and is allowed to truncate. /// /// What the mark distinguishes is decided by `WorkspaceStatus`, not here, and how it is drawn by -/// `WorkspaceStatusGlyph`, which the legend shares so the two cannot describe different marks. The +/// `WorkspaceStatusGlyph`, which conversation rows also use. The /// verdict spans both halves of a workspace's life, the local one (setting up, running, unread, /// changed) and GitHub's (draft, checks, merged), because a column that shows only the local half /// cannot answer the question the user actually has, which is which of these is finished. @@ -445,11 +445,10 @@ struct SidebarRowTrailingControl: ViewModifier { /// The mask that clears the ground under the controls while they are shown. /// - /// A full rectangle at rest, so it changes nothing, with an eraser over the trailing edge - /// that fades in with the controls: opaque behind the buttons themselves, thinning to nothing - /// over `controlsFade` more points. `destinationOut` inside a `compositingGroup` is what - /// punches alpha OUT of a mask that is otherwise solid; the obvious alternative, two mask - /// shapes switched by the hover, cannot animate between its states. + /// A full rectangle at rest, so it changes nothing. On hover the last button-width is clear, + /// with a short fade leading into it. Drawing the mask's alpha directly avoids the + /// `destinationOut` compositing group, which could leave a list row entirely blank until hover + /// caused SwiftUI to redraw it. /// /// A mask on the content rather than a plate behind the controls, because the row does not /// know what it is sitting on: the accent fill when selected with the keyboard, the quiet @@ -457,24 +456,16 @@ struct SidebarRowTrailingControl: ViewModifier { /// the true ground, whichever that is, so the buttons stand on the same fill as the rest of /// the row. private var trailingYield: some View { - Rectangle() - .overlay(alignment: .trailing) { - LinearGradient( - stops: [ - .init(color: .clear, location: 0), - .init( - color: .black, - location: Self.controlsFade / (Self.controlsFade + Self.controlsWidth) - ), - .init(color: .black, location: 1), - ], - startPoint: .leading, - endPoint: .trailing - ) - .frame(width: Self.controlsFade + Self.controlsWidth) - .blendMode(.destinationOut) - .opacity(isShown ? 1 : 0) - } - .compositingGroup() + HStack(spacing: 0) { + Rectangle() + LinearGradient( + colors: [.white, isShown ? .clear : .white], + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: Self.controlsFade) + Color.white.opacity(isShown ? 0 : 1) + .frame(width: Self.controlsWidth) + } } } diff --git a/Sources/Bloom/Views/Sidebar/WorkspaceSourcePicker.swift b/Sources/Bloom/Views/Sidebar/WorkspaceSourcePicker.swift index cff6bc89a..75ffc8983 100644 --- a/Sources/Bloom/Views/Sidebar/WorkspaceSourcePicker.swift +++ b/Sources/Bloom/Views/Sidebar/WorkspaceSourcePicker.swift @@ -116,7 +116,7 @@ struct WorkspaceSourcePicker: View { .buttonStyle(.plain) .fixedSize() .help("Open a pull request or a branch, or cut a new branch") - .accessibilityLabel("Start from") + .accessibilityLabel("Source") .accessibilityValue(label) } @@ -173,7 +173,7 @@ struct WorkspaceSourcePicker: View { /// sized to its two labels and this stack aligns leading, which is what left it short of the /// right edge with a gap after it. private var tabs: some View { - PanelTabs("Start from", tabs: WorkspaceSourceTab.allCases, selection: $tab) { $0.title } + PanelTabs("Source", tabs: WorkspaceSourceTab.allCases, selection: $tab) { $0.title } .padding(.horizontal, Metrics.gutter) .padding(.top, Metrics.spacingWide) .padding(.bottom, Metrics.spacingWide) diff --git a/Sources/Bloom/Views/Sidebar/WorkspaceStatusGlyph.swift b/Sources/Bloom/Views/Sidebar/WorkspaceStatusGlyph.swift index 6b4b55602..3badeadbe 100644 --- a/Sources/Bloom/Views/Sidebar/WorkspaceStatusGlyph.swift +++ b/Sources/Bloom/Views/Sidebar/WorkspaceStatusGlyph.swift @@ -1,10 +1,7 @@ import SwiftUI import BloomCore -/// The mark at the head of a workspace row, and the same mark in the legend that explains it. -/// -/// One type for both, because the two copies had already drifted: the legend drew the branch mark -/// in tertiary where the row draws it in secondary, and it named four of the thirteen states. +/// The mark at the head of workspace and project-free conversation rows. /// /// Every state is a different shape, not only a different colour, so the column can be read at a /// glance and by someone who cannot tell the red one from the green one. That rule is why a draft diff --git a/Sources/Bloom/Views/Terminal/LoginTerminal.swift b/Sources/Bloom/Views/Terminal/LoginTerminal.swift index 15a5e7b2b..434512056 100644 --- a/Sources/Bloom/Views/Terminal/LoginTerminal.swift +++ b/Sources/Bloom/Views/Terminal/LoginTerminal.swift @@ -24,11 +24,12 @@ final class LoginTerminalSession { executable: String, arguments: [String], directory: String, + environment: [String: String]? = nil, onExit: @escaping @MainActor (TerminalExit) -> Void ) { guard let path = Shell.which(executable) else { return nil } - let variables = Shell.terminalEnvironment(inheriting: Shell.environment()) + let variables = Shell.terminalEnvironment(inheriting: environment ?? Shell.environment()) label = ([executable] + arguments).joined(separator: " ") launch = TerminalLaunch( diff --git a/Sources/Bloom/Views/Terminal/TerminalSessionStore.swift b/Sources/Bloom/Views/Terminal/TerminalSessionStore.swift index 6d921040f..84b299f49 100644 --- a/Sources/Bloom/Views/Terminal/TerminalSessionStore.swift +++ b/Sources/Bloom/Views/Terminal/TerminalSessionStore.swift @@ -28,13 +28,14 @@ final class TerminalSessionStore { } func prepareCLICommand( - for session: Session, workspace: Workspace, prompt: String, resuming: String? = nil + for session: Session, workspace: Workspace, prompt: String, resuming: String? = nil, + accountHome: String? = nil ) throws -> String? { let handle = cliBridge(for: session, workspace: workspace) return try session.agentKind.prepareInteractiveCommand( directory: workspace.path, prompt: prompt, sessionID: session.id, model: session.model, effort: session.effort, permissionMode: session.permissionMode, - resuming: resuming, bridge: handle + resuming: resuming, bridge: handle, accountHome: accountHome ) } @@ -178,12 +179,19 @@ final class TerminalSessionStore { /// /// The workspace is passed when the caller knows it, because a tab can be closed from the strip /// without ever having been drawn, and a pane that was never drawn has no owner recorded. - func closePanes(of ownerID: String, workspaceID: WorkspaceID? = nil) { + func closePanes(of ownerID: String, workspaceID: WorkspaceID? = nil, preserveLayout: Bool = false) { for pane in TerminalSplitStore.shared.panes(of: ownerID) { if let workspaceID { paneOwner[pane] = workspaceID } closePane(id: pane) } - TerminalSplitStore.shared.discard(ownerID: ownerID) + if !preserveLayout { TerminalSplitStore.shared.discard(ownerID: ownerID) } + } + + /// An archived conversation may be restored with the same pane ids and fresh shells. + func reopenPanes(of ownerID: String) { + for pane in TerminalSplitStore.shared.panes(of: ownerID) { + closedPanes.remove(pane) + } } /// Whether a shell has been forked for one pane in this run of Bloom. @@ -317,9 +325,7 @@ final class TerminalSessionStore { /// `FolderTerminal.launchDirectory`. func terminal( for tab: TerminalTab, - workspace: Workspace, - repo: Repo?, - port: Int, + owner: TerminalOwner, directory: String = "", output: String? = nil ) -> BloomTerminalView { @@ -339,14 +345,15 @@ final class TerminalSessionStore { if let output { if terminals[tab.id.rawValue] == nil { terminals[tab.id.rawValue] = view - paneOwner[tab.id.rawValue] = workspace.id + paneOwner[tab.id.rawValue] = owner.id } view.showOutput(output) return view } var extra: [String: String] = [:] - if let repo, let store = repoStore { + if case .workspace(let workspace, let repo, let port) = owner, + let repo, let store = repoStore { extra = WorkspaceManager(store: store).environment( for: workspace, repo: repo, port: port ) @@ -354,10 +361,18 @@ final class TerminalSessionStore { // `tab.id.rawValue` is the pane id here: a split hands each pane a `TerminalTab` carrying its own id, // and an unsplit tab is its own single pane. - paneOwner[tab.id.rawValue] = workspace.id - let start = FolderTerminal.launchDirectory(requested: directory, root: workspace.path) - let decision = persistence?.decision(workspaceID: workspace.id, paneID: tab.id.rawValue) - ?? .inProcess + paneOwner[tab.id.rawValue] = owner.id + let start = FolderTerminal.launchDirectory(requested: directory, root: owner.root) + let decision: TerminalStartDecision + switch owner { + case .workspace: + decision = persistence?.decision(workspaceID: owner.id, paneID: tab.id.rawValue) + ?? .inProcess + case .conversation: + // A project-free conversation is absent from the workspace census used by the tmux + // orphan sweep. Its layout persists, but each shell starts fresh on app launch. + decision = .inProcess + } if let command = persistence?.command, let session = decision.session { paneSession[tab.id.rawValue] = session view.start(TerminalLaunch.tmux( @@ -517,11 +532,19 @@ final class TerminalSessionStore { if let workspace = try? await store.workspace(id: workspaceID), CenterTabStore.shared.terminal(for: sessionID, in: workspaceID)?.id == tab.id, !Task.isCancelled { - if let externalSession = externalSession ?? session.agentSessionID, - let resume = try? prepareCLICommand( - for: session, workspace: workspace, prompt: "", resuming: externalSession - ) { - if isPresent { await recall.rememberResume(resume, inPane: tab.id, store: store) } + if let externalSession = externalSession ?? session.agentSessionID { + do { + let accountHome = try await store.accountForLaunch(session: session) + .map(store.agentAccountHome) + if let resume = try prepareCLICommand( + for: session, workspace: workspace, prompt: "", resuming: externalSession, + accountHome: accountHome + ), isPresent { + await recall.rememberResume(resume, inPane: tab.id, store: store) + } + } catch { + // A missing profile must not turn a saved CLI thread into a system login. + } } else { _ = cliBridge(for: session, workspace: workspace) } diff --git a/Sources/Bloom/Views/Terminal/TerminalSplitStore.swift b/Sources/Bloom/Views/Terminal/TerminalSplitStore.swift index 4dd7d7d84..788f48d74 100644 --- a/Sources/Bloom/Views/Terminal/TerminalSplitStore.swift +++ b/Sources/Bloom/Views/Terminal/TerminalSplitStore.swift @@ -99,6 +99,12 @@ final class TerminalSplitStore { apply(layout, to: ownerID, movingFocus: false) } + /// A tab has come to the front. Its selected shell should receive the keyboard even when + /// SwiftUI reused the terminal host and `viewDidMoveToWindow` will not run again. + func requestKeyboard(in ownerID: String) { + focusRequests[ownerID] = focusRequest(for: ownerID) + 1 + } + /// False when there is no pane that way, which lets the same keystroke fall through to the /// app menu, where Cmd+Option+Up and Cmd+Option+Down step through workspaces. func moveFocus(_ direction: SplitDirection, in ownerID: String) -> Bool { diff --git a/Sources/Bloom/Views/Terminal/TerminalSplitView.swift b/Sources/Bloom/Views/Terminal/TerminalSplitView.swift index d715b04e6..689e44ddb 100644 --- a/Sources/Bloom/Views/Terminal/TerminalSplitView.swift +++ b/Sources/Bloom/Views/Terminal/TerminalSplitView.swift @@ -16,9 +16,7 @@ struct TerminalSplitView: View { /// The tab these panes belong to. It is also the id of the first pane, so a tab that has never /// been split keeps the shell it had before splitting existed. var ownerID: String - var workspace: Workspace - var repo: Repo? - var port: Int + var owner: TerminalOwner /// The folder every pane of this tab forks in, empty for the worktree root. See /// `FolderTerminal`. var directory: String = "" @@ -146,10 +144,8 @@ struct TerminalSplitView: View { } TerminalView( - tab: TerminalTab(id: TerminalTabID(id), workspaceID: workspace.id, title: "Terminal"), - workspace: workspace, - repo: repo, - port: port, + tab: TerminalTab(id: TerminalTabID(id), workspaceID: owner.id, title: "Terminal"), + owner: owner, directory: directory, isFocusedPane: isFocused, focusRequest: focusRequest, @@ -158,7 +154,7 @@ struct TerminalSplitView: View { onExit: { finished($0, in: id) }, onContextMenu: { let excerpt = TerminalSessionStore.shared.excerpt( - inPaneID: id, workspaceID: workspace.id, label: terminalLabel + inPaneID: id, workspaceID: owner.id, label: terminalLabel ) let add: (@MainActor () -> Void)? = if let excerpt, let onAddToChat { { onAddToChat(excerpt) } @@ -272,7 +268,7 @@ struct TerminalSplitView: View { // to add to, hands the key back, so the menu bar answers it with a beep rather than nothing. case .addSelectionToChat: guard let onAddToChat, let excerpt = TerminalSessionStore.shared.excerpt( - inPaneID: pane, workspaceID: workspace.id, label: terminalLabel + inPaneID: pane, workspaceID: owner.id, label: terminalLabel ) else { return false } onAddToChat(excerpt) return true diff --git a/Sources/Bloom/Views/Terminal/TerminalView.swift b/Sources/Bloom/Views/Terminal/TerminalView.swift index a617cc487..4c412f9f0 100644 --- a/Sources/Bloom/Views/Terminal/TerminalView.swift +++ b/Sources/Bloom/Views/Terminal/TerminalView.swift @@ -507,9 +507,7 @@ final class TerminalHostView: NSView { /// `TerminalSessionStore`, which is what keeps a shell running across tab and workspace switches. struct TerminalView: NSViewRepresentable { var tab: TerminalTab - var workspace: Workspace - var repo: Repo? - var port: Int + var owner: TerminalOwner /// The folder this pane's shell starts in, empty for the worktree root. Every pane of a tab /// gets the tab's, so splitting a terminal opened on a folder stays in that folder, which is /// what splitting does in every other terminal. @@ -552,7 +550,28 @@ struct TerminalView: NSViewRepresentable { @MainActor private var session: BloomTerminalView { TerminalSessionStore.shared.terminal( - for: tab, workspace: workspace, repo: repo, port: port, directory: directory, output: output + for: tab, owner: owner, directory: directory, output: output ) } } + +/// A terminal's directory and identity without claiming that a project-free conversation is a +/// Git worktree. Conversation shells start in their own directory and have no project environment. +enum TerminalOwner { + case workspace(Workspace, Repo?, port: Int) + case conversation(SessionID, directory: String) + + var id: WorkspaceID { + switch self { + case .workspace(let workspace, _, _): workspace.id + case .conversation(let id, _): WorkspaceID("conversation:" + id.rawValue) + } + } + + var root: String { + switch self { + case .workspace(let workspace, _, _): workspace.path + case .conversation(_, let directory): directory + } + } +} diff --git a/Sources/Bloom/Views/Transcript/PermissionAskRowView.swift b/Sources/Bloom/Views/Transcript/PermissionAskRowView.swift index 2b272a4fa..d421778b6 100644 --- a/Sources/Bloom/Views/Transcript/PermissionAskRowView.swift +++ b/Sources/Bloom/Views/Transcript/PermissionAskRowView.swift @@ -115,27 +115,29 @@ struct PermissionAskRowView: View { // MARK: Parts private var header: some View { - HStack(alignment: .firstTextBaseline, spacing: TranscriptLayout.glyphGap) { - // The same raised hand the sidebar mark and the menu bar strip use. - Image(systemName: "hand.raised.fill") - .font(Typo.caption) - .imageScale(.small) - .foregroundStyle(isOpen ? Palette.warning : Palette.textTertiary) - .accessibilityHidden(true) - - Text(title) - .font(Typo.labelEmphasis) - .foregroundStyle(Palette.textPrimary) + VStack(alignment: .leading, spacing: Metrics.spacingSmall) { + HStack(alignment: .firstTextBaseline, spacing: TranscriptLayout.glyphGap) { + // The same raised hand the sidebar mark and the menu bar strip use. + Image(systemName: "hand.raised.fill") + .font(Typo.caption) + .imageScale(.small) + .foregroundStyle(isOpen ? Palette.warning : Palette.textTertiary) + .accessibilityHidden(true) - Spacer(minLength: TranscriptLayout.glyphGap) + Text(title) + .font(Typo.labelEmphasis) + .foregroundStyle(Palette.textPrimary) + } if !ask.reason.isEmpty { - // The CLI's own sentence, not Bloom's paraphrase of it. + // The CLI's own sentence, not Bloom's paraphrase of it, on a line of its own and + // never cut: a hook or classifier reason is a whole sentence saying why the call + // needs a person, and cut in the middle it says nothing. Text(ask.reason) .font(Typo.caption) .foregroundStyle(Palette.textTertiary) - .lineLimit(1) - .truncationMode(.middle) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) } } } diff --git a/Sources/Bloom/Views/Transcript/ProseRowView.swift b/Sources/Bloom/Views/Transcript/ProseRowView.swift index 64059a69a..5d26709bd 100644 --- a/Sources/Bloom/Views/Transcript/ProseRowView.swift +++ b/Sources/Bloom/Views/Transcript/ProseRowView.swift @@ -6,10 +6,11 @@ struct ProseRowView: View { var isStreaming = false /// Present only on the response that closes a completed turn. var copyText: String? + @State private var isHovered = false var body: some View { HStack(alignment: .top, spacing: TranscriptLayout.block) { - MarkdownView(text, isStreaming: isStreaming) + prose .font(Typo.body) .proseLeading() .textSelection(.enabled) @@ -25,6 +26,7 @@ struct ProseRowView: View { CopyButton( text: copyText, title: "Copy this answer", + isVisible: isHovered, size: 18, imageScale: .small ) @@ -41,5 +43,26 @@ struct ProseRowView: View { // putting a hard line between the work and the response. .padding(.top, TranscriptLayout.block * 2) .padding(.bottom, TranscriptLayout.block) + .onHover { isHovered = $0 } + } + + /// **Plain text while it is still arriving, markdown once it has landed.** + /// + /// Every crash of one shape, `EXC_BAD_ACCESS` and once an impossible allocation inside SwiftUI's + /// stack layout with no frame of ours on the stack, landed while a long answer was streaming, + /// and the layout frames that were there read back to this row's `MarkdownBlocksView`: a stack + /// over a `ForEach` of blocks, several of them `NSTextView`s, whose number and kinds change + /// with every delta. A single `Text` has no stack of blocks to change under itself. + /// + /// It costs the jump `StreamingRowView` describes: a fenced block shows its markers as prose + /// until the answer lands and then becomes a code block. That was the reason streaming moved to + /// `MarkdownView`, and it is a smaller cost than an app that quits while somebody reads it. + @ViewBuilder + private var prose: some View { + if isStreaming { + Text(verbatim: text) + } else { + MarkdownView(text) + } } } diff --git a/Sources/Bloom/Views/Transcript/StreamingRowView.swift b/Sources/Bloom/Views/Transcript/StreamingRowView.swift index de83491ca..36653e755 100644 --- a/Sources/Bloom/Views/Transcript/StreamingRowView.swift +++ b/Sources/Bloom/Views/Transcript/StreamingRowView.swift @@ -15,6 +15,10 @@ import BloomCore /// lined up to avoid. An agent writes fenced code in most answers of any length, so this was most /// answers. /// +/// **And then it went back to plain text, for a reason that outranks the jump.** Every crash of +/// one shape inside SwiftUI's stack layout landed while a long answer was streaming through +/// `MarkdownView`. `ProseRowView.prose` carries the evidence; the jump described above is the price. +/// /// The reason it was plain `Text` was cost, and that was worth measuring rather than assuming. /// A release build parses a finished 2KB answer in 1.1ms and a 10KB one in 2.9ms; the worst single /// delta over a whole 10KB stream measured 2.7ms, well inside a frame, and the parser was written diff --git a/Sources/Bloom/Views/Transcript/TranscriptConversationSelection.swift b/Sources/Bloom/Views/Transcript/TranscriptConversationSelection.swift new file mode 100644 index 000000000..264d9a3ef --- /dev/null +++ b/Sources/Bloom/Views/Transcript/TranscriptConversationSelection.swift @@ -0,0 +1,290 @@ +import AppKit +import BloomCore +import SwiftUI + +extension EnvironmentValues { + @Entry var transcriptConversationSelection: TranscriptConversationSelection? + /// Which table entry a text view is drawn in, so a selection can say which rows it spans. + @Entry var transcriptEntryID: TranscriptEntryID? +} + +extension TranscriptEntryID { + /// Where an entry sits in reading order, for the entries that hold selectable text. The rows + /// are numbered by sequence; the setup log is before all of them and a message on its way is + /// after all of them. + var selectionOrder: Int? { + switch self { + case .row(let seq): seq + case .setup: Int.min + case .sending, .streaming, .pending: Int.max + default: nil + } + } +} + +/// A selection that spans rows: Select All, and a drag that leaves the answer it started in. +/// `TranscriptTextSelection` is an answer's and stops at its edge. +/// +/// **It holds a description, never ranges kept in views.** The table recycles a cell as soon as +/// it scrolls away, and a selection kept in the text views of a recycled row is gone, so a drag +/// longer than one screen would copy a conversation with holes in it. What is kept instead is the +/// two rows the drag started and ended in, the words selected in each, and everything between +/// them. The text is composed from the stored rows (`ConversationText`), the highlight is painted +/// onto whichever views exist and onto any that appear (`adopt`). +/// +/// The table, not a text view, is the first responder while this is active: the view the drag +/// started in is usually recycled before Copy is pressed. +@MainActor +final class TranscriptConversationSelection { + private enum Mode { + case none + case everything + case span(Span) + } + + /// The highlight for a drag across rows, and the words it copies. + private struct Span { + var first: Int + var last: Int + /// Keyed by a block's text: the row's views come and go, their strings do not. + var firstRanges: [String: NSRange] + var lastRanges: [String: NSRange] + var slice: ConversationText.Slice + } + + /// One row's text views as they were when the pointer was over it, in reading order. + private struct RowSnapshot { + struct Block { + let key: String + let text: NSAttributedString + let prefix: String + let separator: String + } + + let seq: Int + let blocks: [Block] + var lengths: [Int] { blocks.map { $0.text.length } } + + func ranges(from start: TranscriptSelection.Position, to end: TranscriptSelection.Position) -> [NSRange] { + TranscriptSelection.ranges(lengths: lengths, anchor: start, end: end) + } + + @MainActor + func text(in ranges: [NSRange]) -> String { + var output = "" + for (block, range) in zip(blocks, ranges) where range.length > 0 { + if !output.isEmpty { output += block.separator } + if range.location == 0 { output += block.prefix } + output += TranscriptLink.selectedText(in: block.text, range: range) + } + return output + } + + func keyed(_ ranges: [NSRange]) -> [String: NSRange] { + Dictionary(zip(blocks.map(\.key), ranges), uniquingKeysWith: { first, _ in first }) + } + } + + private let members = NSHashTable.weakObjects() + private var mode = Mode.none + private weak var anchorView: LinkTextView? + private var anchorRow: RowSnapshot? + private var anchorPosition = TranscriptSelection.Position(block: 0, offset: 0) + /// True while this object is writing ranges, so the text views' own change notifications are + /// not mistaken for the reader starting a selection of their own. + private(set) var isApplying = false + /// The table, which takes the keyboard while a selection spans rows. + weak var responder: NSView? + /// The conversation as text, optionally only a drag's slice of it. Set by the list, which + /// owns the rows. + var text: (ConversationText.Slice?) -> String = { _ in "" } + + var isActive: Bool { + if case .none = mode { false } else { true } + } + + var selectedText: String? { + switch mode { + case .none: nil + case .everything: text(nil) + case .span(let span): text(span.slice) + } + } + + func register(_ view: LinkTextView) { members.add(view) } + func unregister(_ view: LinkTextView) { members.remove(view) } + + /// Whether `responder` is something this selection keeps its highlight for. + func owns(_ candidate: NSResponder?) -> Bool { + guard let candidate else { return false } + return candidate === responder || (candidate as? LinkTextView)?.conversationSelection === self + } + + // MARK: Select All + + func selectAll() { + mode = .everything + takeKeyboard() + paint() + } + + func cancel() { + guard isActive else { return } + mode = .none + paint(clearing: true) + } + + // MARK: Dragging + + /// A press in `view`, which is where a drag across rows would start. + func beginDrag(in view: LinkTextView, offset: Int) { + guard let seq = view.rowSeq else { + anchorView = nil + anchorRow = nil + return + } + let snapshot = Self.snapshot(of: rowViews(seq)) + anchorView = view + anchorRow = snapshot + anchorPosition = .init(block: rowViews(seq).firstIndex(of: view) ?? 0, offset: offset) + } + + /// The pointer moved to `point`. True when that is in a different row from where the press + /// was, and this selection has painted it; false when the answer's own selection should. + func drag(to point: NSPoint) -> Bool { + guard let anchorRow, let target = nearestView(to: point), let targetSeq = target.rowSeq else { + return false + } + guard targetSeq != anchorRow.seq else { + return leaveSpan() + } + let endRow = Self.snapshot(of: rowViews(targetSeq)) + let end = TranscriptSelection.Position( + block: rowViews(targetSeq).firstIndex(of: target) ?? 0, + offset: target.selectionOffset(at: point) + ) + mode = .span(span(from: anchorRow, to: endRow, anchoredAt: anchorPosition, endingAt: end)) + takeKeyboard() + paint() + return true + } + + /// The pointer came back into the row the press was in. Handing control back to the answer + /// is only possible while its view still exists; if it was recycled meanwhile the selection + /// stays as it was rather than being lost. + private func leaveSpan() -> Bool { + guard case .span = mode else { return false } + guard let anchorView, anchorView.window != nil else { return true } + mode = .none + paint(clearing: true) + responder?.window?.makeFirstResponder(anchorView) + return false + } + + private func span( + from anchorRow: RowSnapshot, to endRow: RowSnapshot, + anchoredAt anchor: TranscriptSelection.Position, endingAt end: TranscriptSelection.Position + ) -> Span { + let anchorComesFirst = anchorRow.seq < endRow.seq + let (firstRow, firstFrom) = anchorComesFirst ? (anchorRow, anchor) : (endRow, end) + let (lastRow, lastTo) = anchorComesFirst ? (endRow, end) : (anchorRow, anchor) + let firstRanges = firstRow.ranges( + from: firstFrom, to: .init(block: max(firstRow.blocks.count - 1, 0), offset: .max) + ) + let lastRanges = lastRow.ranges(from: .init(block: 0, offset: 0), to: lastTo) + return Span( + first: firstRow.seq, last: lastRow.seq, + firstRanges: firstRow.keyed(firstRanges), lastRanges: lastRow.keyed(lastRanges), + slice: ConversationText.Slice( + first: firstRow.seq, last: lastRow.seq, + firstText: firstRow.text(in: firstRanges), lastText: lastRow.text(in: lastRanges) + ) + ) + } + + // MARK: Painting + + /// A text view that has just been built, or given new text, while something is selected. + func adopt(_ view: LinkTextView) { + guard isActive, let range = range(for: view) else { return } + isApplying = true + defer { isApplying = false } + view.setSelectedRange(range) + } + + private func paint(clearing: Bool = false) { + isApplying = true + defer { isApplying = false } + for view in members.allObjects { + let wanted = clearing ? NSRange(location: 0, length: 0) : range(for: view) + guard let wanted else { continue } + view.setSelectedRange(wanted) + view.needsDisplay = true + } + } + + private func range(for view: LinkTextView) -> NSRange? { + let nothing = NSRange(location: 0, length: 0) + let everything = NSRange(location: 0, length: view.string.utf16.count) + switch mode { + case .none: + return nil + case .everything: + return everything + case .span(let span): + guard let seq = view.rowSeq else { return nil } + if seq < span.first || seq > span.last { return nothing } + if seq == span.first { return span.firstRanges[view.string] ?? nothing } + if seq == span.last { return span.lastRanges[view.string] ?? nothing } + return everything + } + } + + /// The keyboard goes to the table, so Copy and Select All keep working after the view the + /// selection started in has been recycled. + private func takeKeyboard() { + guard let responder, let window = responder.window, window.firstResponder !== responder else { return } + window.makeFirstResponder(responder) + } + + // MARK: Finding views + + /// The text views of one row, in reading order. + private func rowViews(_ seq: Int) -> [LinkTextView] { + Self.inReadingOrder(members.allObjects.filter { $0.window != nil && $0.rowSeq == seq }) + } + + private static func inReadingOrder(_ views: [LinkTextView]) -> [LinkTextView] { + views.sorted { lhs, rhs in + let left = lhs.convert(lhs.bounds, to: nil) + let right = rhs.convert(rhs.bounds, to: nil) + if abs(left.maxY - right.maxY) > 1 { return left.maxY > right.maxY } + return left.minX < right.minX + } + } + + private static func snapshot(of views: [LinkTextView]) -> RowSnapshot { + RowSnapshot(seq: views.first?.rowSeq ?? 0, blocks: views.map { view in + RowSnapshot.Block( + key: view.string, + text: NSAttributedString(attributedString: view.textStorage ?? NSTextStorage()), + prefix: view.copyPrefix, + separator: view.copySeparatorBefore + ) + }) + } + + /// Among the views that belong to a row, the one whose rectangle is closest to the point. + private func nearestView(to point: NSPoint) -> LinkTextView? { + members.allObjects + .filter { $0.window != nil && $0.rowSeq != nil } + .min { Self.distance(point, from: $0) < Self.distance(point, from: $1) } + } + + private static func distance(_ point: NSPoint, from view: NSView) -> CGFloat { + let rect = view.convert(view.bounds, to: nil) + let dx = max(rect.minX - point.x, 0, point.x - rect.maxX) + let dy = max(rect.minY - point.y, 0, point.y - rect.maxY) + return dx * dx + dy * dy + } +} diff --git a/Sources/Bloom/Views/Transcript/TranscriptGeometry.swift b/Sources/Bloom/Views/Transcript/TranscriptGeometry.swift index 8e1671a1d..bb0ea397d 100644 --- a/Sources/Bloom/Views/Transcript/TranscriptGeometry.swift +++ b/Sources/Bloom/Views/Transcript/TranscriptGeometry.swift @@ -47,10 +47,6 @@ struct TranscriptGeometry: Equatable { /// the pane it is meant to be. static let step: CGFloat = 8 - /// The quantum the reach is rounded to. See `reachToEnd`: two hundred points is about a - /// quarter of a full height pane, and buys under a hundredth of a second of glide. - static let reachStep: Double = 200 - /// The quantum the pane height is rounded to. See `paneHeight`: about two lines of the face /// the setup tail is set in, which is what it takes to change the answer by one line. static let heightStep: CGFloat = 32 @@ -68,14 +64,4 @@ struct TranscriptGeometry: Equatable { guard height > 0 else { return 0 } return max(heightStep, (height / heightStep).rounded(.down) * heightStep) } - - /// How far below the viewport the content still runs, rounded to `reachStep`. - /// - /// Rounded DOWN, so a reader who is a whisker over a step's worth from the end is described as - /// being on the near side of it. Never negative: a bounce past the end, or a top content - /// inset, is nought left to travel rather than a distance behind you. - static func reach(contentHeight: Double, viewportHeight: Double, offset: Double) -> Double { - let raw = max(0, contentHeight - offset - viewportHeight) - return (raw / reachStep).rounded(.down) * reachStep - } } diff --git a/Sources/Bloom/Views/Transcript/TranscriptHeightMemory.swift b/Sources/Bloom/Views/Transcript/TranscriptHeightMemory.swift new file mode 100644 index 000000000..b442684f4 --- /dev/null +++ b/Sources/Bloom/Views/Transcript/TranscriptHeightMemory.swift @@ -0,0 +1,14 @@ +import BloomCore + +/// Row measurements belong to a conversation and pane, not to one table view. Switching to a +/// tool tab destroys the table, and measuring rows again cost 128 to 152 ms on +/// a 2,608 row conversation in `TabProbe`. The core cache already bounds its rows and invalidates +/// measurements when text settings or width change; this box lets a new table keep using it. +@MainActor +final class TranscriptHeightMemory { + var heights = TranscriptRowHeights() + + /// `TranscriptRowHeights.Measure` tracks size and leading, but the selected typeface is a + /// SwiftUI value. A font changed while this chat's table was absent must invalidate it too. + var chatFont: ChatFont? +} diff --git a/Sources/Bloom/Views/Transcript/TranscriptLink.swift b/Sources/Bloom/Views/Transcript/TranscriptLink.swift index fd2fa0826..dacfd70ad 100644 --- a/Sources/Bloom/Views/Transcript/TranscriptLink.swift +++ b/Sources/Bloom/Views/Transcript/TranscriptLink.swift @@ -272,9 +272,11 @@ enum TranscriptLink { /// a right click in a pane does not move the focus to it and splitting the other half of a /// tab would be the one thing the reader did not ask for. @MainActor - static func actions(for model: WorkspaceModel?, pane: String? = nil) -> TranscriptLinkActions { + static func actions( + for model: WorkspaceModel?, pane: String? = nil, session: SessionID? = nil + ) -> TranscriptLinkActions { TranscriptLinkActions( - identity: .workspace(model?.workspace.id, pane: pane), + identity: .workspace(model?.workspace.id, pane: pane, session: session), open: { url, target in if let location = SourceReference.location(url), let model { FileReview.open(location: location, in: model) @@ -304,10 +306,34 @@ enum TranscriptLink { guard let location = SourceReference.location(url), let model else { return nil } let target = FileChipTarget.resolve(location.path, in: model.workspace.path) return PromptAttachment.sent(path: target.path).url(in: target.worktree) + }, + attachEditedCode: model.flatMap { model in + session.map { session in + { @MainActor @Sendable code, filename in + await attachEdited(code, named: filename, to: model, session: session) + } + } } ) } + /// Through `ComposerHandoff`, the door a CI log and a browser screenshot already use, so the + /// edited fence is a chip like any pasted file. Answers whether it arrived, because the fence + /// keeps the reader's draft until it has. + @MainActor + private static func attachEdited( + _ code: String, named filename: String, to model: WorkspaceModel, session: SessionID + ) async -> Bool { + let outcome = await ComposerHandoff.attach( + [.text(code, named: filename)], + to: model, sessionID: session + ) + if let failure = outcome.failure { + model.app.alert = BloomAlert(title: "The edited code was not attached", message: failure) + } + return !outcome.paths.isEmpty + } + // MARK: Opening // // Which addresses may be opened at all is `LinkPolicy.opens`, in the core where the rule is diff --git a/Sources/Bloom/Views/Transcript/TranscriptListView.swift b/Sources/Bloom/Views/Transcript/TranscriptListView.swift index bbf248170..86a22569b 100644 --- a/Sources/Bloom/Views/Transcript/TranscriptListView.swift +++ b/Sources/Bloom/Views/Transcript/TranscriptListView.swift @@ -125,6 +125,8 @@ struct TranscriptListView: View { /// clipped by the pane. Only `TranscriptHoverOverlay` reads it, so a hover never re-runs this /// body. See `TranscriptHoverHost`. @State private var hoverHost = TranscriptHoverHost() + /// Select All across every row, and the copy it answers. See `TranscriptConversationSelection`. + @State private var conversationSelection = TranscriptConversationSelection() @State private var didPosition = false @State private var showsSetup = false /// Whether the window has grown in the last moment, which is the throttle on `growWindow`. @@ -136,6 +138,9 @@ struct TranscriptListView: View { /// owner's 2,981 row session an upward scroll grows about eight times, so that was eight /// rebuilds of up to 2,981 entries each, for nothing. See `GeometryBox`. @State private var isGrowing = GeometryBox(false) + /// Separates an idle preparation pass from a later scroll-triggered growth, which uses the + /// same `isGrowing` flag. An old pass must not resume when that later growth sets it again. + @State private var historyGeneration = GeometryBox(0) /// Whether a reader currently has the transcript's scroll view. Idle history preparation is /// allowed to spend main-thread time only after that gesture has ended. @State private var isLiveScrolling = GeometryBox(false) @@ -151,13 +156,10 @@ struct TranscriptListView: View { /// every frame of every scroll, and as `@State` every one of those frames would re-run this /// body, to store a number the body never reads. @State private var contentOffset = GeometryBox(0.0) - /// How far below the viewport the end of the conversation is, read for one thing only: how - /// long the jump pill's travel back to the live end should run for. - @State private var reachToEnd = GeometryBox(0.0) /// The row at the top of the pane and how far above its top the pane starts, in a box for the - /// reason the two above are: it moves on every frame of a scroll and nothing draws from it. It - /// replaces the set of visible row ids the lazy stack had to keep, because a table can simply - /// be asked which row is at the top. + /// same reason as `contentOffset`: it moves on every frame of a scroll and nothing draws from + /// it. It replaces the set of visible row ids the lazy stack had to keep, because a table can + /// simply be asked which row is at the top. /// /// Both halves, because the row alone is the top of the row: see /// `TranscriptPaneState.anchorDelta`. Nil until the table has a stored row to name, and never @@ -171,10 +173,9 @@ struct TranscriptListView: View { /// Whether the pane is at the live end EXACTLY, which is not `geometry.isNearBottom`. /// /// **Two different questions, and one number was answering both.** `ScrollEnd.threshold` is 96 - /// points and it answers "may an arriving row move the view", which is a question about a - /// reader who has nudged the wheel and is still following along. What is written down here is - /// "where was this person", and somebody ninety points up is somebody who scrolled up ninety - /// points: coming back to the live end instead is the pane deciding it knows better. + /// points and describes a pane near enough to the end for a setup log update. This value + /// records where the reader actually is: somebody ninety points up should return to that + /// position, and a new message should leave them there. /// `TranscriptAnchor.isAtEnd` is the exact test and its doc comment already draws the line. /// /// Exact, and then the two standing claims to be there, because neither of those is a reader @@ -196,9 +197,6 @@ struct TranscriptListView: View { @State private var arrivalSession: SessionID? @State private var controller = TranscriptTableController() - /// The travel the jump pill makes. See `TranscriptLiveEndScroller`, which carries the frame - /// timings that put an AppKit level scroll there in place of a `withAnimation`. - @State private var scroller = TranscriptLiveEndScroller() @State private var liveEndRequest = TranscriptLiveEndRequest() /// What keeps the view with the newest row while a turn runs. See `TranscriptLiveEndFollower`. /// Nothing in this body reads it, on purpose: it writes no SwiftUI state, so following a turn @@ -398,7 +396,9 @@ struct TranscriptListView: View { /// is not a change: see `TranscriptRowEnvironment`, which is what carries it to the rows. private var linkActions: TranscriptLinkActions { TranscriptLink.actions( - for: transcript.workspace.flatMap { app.existingModel(for: $0.id) }, pane: memory?.pane + for: transcript.workspace.flatMap { app.existingModel(for: $0.id) }, + pane: memory?.pane, + session: transcript.session.id ) } @@ -425,6 +425,7 @@ struct TranscriptListView: View { app: app, hoverHost: hoverHost, bubbleWidth: bubbleWidth, + conversation: conversationSelection, linkActions: linkActions, fontScale: fontScale, chatFont: chatFont, @@ -736,7 +737,6 @@ struct TranscriptListView: View { // message. At the live end it keeps only the tight inset, so the // duration sits evenly between its own rule and the composer rule. TurnFooterView( - rows: rows, row: row, worktree: home.worktree, permissionMode: permissionMode, @@ -1051,11 +1051,18 @@ struct TranscriptListView: View { entries: entries, session: transcript.session.id, controller: controller, + heightMemory: memory.map { transcript.heightMemory(for: $0.pane) }, scale: fontScale, + includesLiveEnd: drawnWindow.end >= transcript.rows.count, rowEnvironment: rowEnvironment, onGeometryChange: { measured($0) }, onSettled: { remember() + // A hand reaching the end should finish before old rows are removed from above it. + Task { @MainActor in + await Task.yield() + trimHistoryAtLiveEnd() + } scheduleHistoryPreparation() }, onLiveScrollChange: { hasHold in @@ -1066,13 +1073,13 @@ struct TranscriptListView: View { follower.isPaused = hasHold isLiveScrolling.value = hasHold guard hasHold else { return } + historyGeneration.value += 1 isGrowing.value = false // A card that stayed up while the content moved under it would be pointing at a // chip that is no longer there. hoverHost.request = nil // And a view that goes on dragging somebody somewhere after they have grabbed it // is the worst thing in this file. - scroller.stop() follower.seekLiveEnd(false) }, onContentWillChange: { follower.nudge() }, @@ -1115,8 +1122,18 @@ struct TranscriptListView: View { // settle to fire on. .onDisappear { setColumnActive(false) } .background { WorkspaceColumnActivitySignal(changed: setColumnActive) } + .onChange(of: transcript.session.id, initial: true) { _, _ in + conversationSelection.cancel() + conversationSelection.text = { [model = self.transcript] slice in + ConversationText.text(of: model.rows.lazy.map { + TurnAnswer.Row( + seq: $0.seq, kind: $0.kind, payload: $0.payload, isNested: $0.parentToolUseID != nil + ) + }, in: slice) + } + } .onChange(of: transcript.presentationRevision, initial: true) { _, _ in - updatePinnedQuestion() + updatePinnedQuestion(at: controller.topmostPlace) position() // A row arriving is another chance to notice that the window stops short of it. growWindowDown() @@ -1166,9 +1183,8 @@ struct TranscriptListView: View { .onChange(of: reduceMotion, initial: true) { _, reduced in follower.travels = TranscriptFollow.travels(reduceMotion: reduced) } - // Asked for by the jump pill, and by every button that composes a turn: see - // `TranscriptModel.submit`, which bumps this so that what somebody just asked for is the - // thing they are looking at. + // An explicit request to see the newest row, including the jump pill. Sending a message + // leaves the reader's current position alone. .onChange(of: transcript.liveEndRequests, initial: true) { _, _ in honourLiveEndRequest() } @@ -1178,11 +1194,12 @@ struct TranscriptListView: View { // conversation being left rather than the one being arrived at. remember() // Nothing owed to a conversation the pane has left. - scroller.stop() follower.stop() controller.releaseEnd() didPosition = false isLiveScrolling.value = false + historyGeneration.value += 1 + isGrowing.value = false opening = nil scrollNavigation.pinnedQuestion = nil scrollNavigation.currentTurnSeq = nil @@ -1269,6 +1286,7 @@ struct TranscriptListView: View { guard !Task.isCancelled else { return } adoptScrollView() position() + SwitchTrace.mark("transcript.positioned", workspace: transcript.workspace?.id) // **The pane may be drawn again now.** It has been blank since the session changed: // its rows are in, its window is chosen and `position` has applied the placement, so // what fades in is already where the reader left it rather than at the top on its way @@ -1282,6 +1300,8 @@ struct TranscriptListView: View { await Task.yield() guard !Task.isCancelled else { return } controller.arrived() + SwitchTrace.mark("transcript.revealed", workspace: transcript.workspace?.id) + SwitchTrace.markOnScreen("transcript.revealed", workspace: transcript.workspace?.id) // A pane coming back to a session it has drawn before is already in the window the // reader was reading in and already where they left off, so none of the reveal below @@ -1331,6 +1351,9 @@ struct TranscriptListView: View { SwitchTrace.mark("transcript.history", workspace: transcript.workspace?.id) SwitchTrace.markOnScreen("transcript.history", workspace: transcript.workspace?.id) } + .task(id: transcript.session.id) { + await transcript.watchStoredRows { isVisible.value } + } // Deleting a queued message asks first, in the app's own confirmation. On the list rather // than on the row, so the question survives its row leaving, which is exactly what happens // when the queue moves while it is open. @@ -1352,16 +1375,18 @@ struct TranscriptListView: View { /// A retained column stays in the hierarchy while another workspace is in front. Preserve its /// table, but stop everything whose only owner is a visible reader. private func setColumnActive(_ active: Bool) { + let wasActive = isVisible.value isVisible.value = active controller.setColumnActive(active) follower.isFrontmost = active && activeState != .inactive guard active else { remember() - scroller.stop() follower.stop() + historyGeneration.value += 1 isGrowing.value = false return } + if !wasActive { Task { await transcript.reconcileStoredRows() } } follower.nudge() scheduleHistoryPreparation() } @@ -1390,14 +1415,10 @@ struct TranscriptListView: View { floor: Self.bubbleFloor ) if bubbleWidth.cap != cap { bubbleWidth.cap = cap } - reachToEnd.value = TranscriptGeometry.reach( - contentHeight: table.contentHeight, - viewportHeight: table.viewportHeight, - offset: table.offset - ) contentOffset.value = table.offset - if let place = controller.topmostPlace { topPlace.value = place } - updatePinnedQuestion() + let place = controller.topmostPlace + if let place { topPlace.value = place } + updatePinnedQuestion(at: place) atLiveEnd.value = table.isAtEnd || controller.holdsEnd || follower.isFollowing || follower.isSeekingLiveEnd @@ -1441,6 +1462,8 @@ struct TranscriptListView: View { onScrolledUpChange?(measured.isFarFromEnd) } + trimHistoryAtLiveEnd() + if table.offset < table.viewportHeight { growWindow() } // A scroll that ended against the bottom of a short window is a reader asking for what is // under it, and the geometry may not have CHANGED while they tried. @@ -1449,14 +1472,13 @@ struct TranscriptListView: View { } } - /// Hands the glide and the follower the scroll view the table is in. + /// Hands the follower the scroll view the table is in. /// /// This replaces the zero sized view the lazy stack had to plant inside its scroll content so /// that `enclosingScrollView` had something to walk up from. There is no walking up any more: /// the table owns its `NSScrollView` and simply says which one it is. private func adoptScrollView() { let found = controller.scrollView - if scroller.scrollView !== found { scroller.scrollView = found } if follower.scrollView !== found { follower.scrollView = found } } @@ -1481,10 +1503,8 @@ struct TranscriptListView: View { /// to see. So that one is taken to the row and left there. /// /// `wasAsked` separates the reader's own request from the log moving under them. The request - /// is obeyed wherever they are. A flush is obeyed only while `isNearBottom` still says they - /// are following along, which is the same test, at the same `ScrollEnd.threshold`, that decides - /// whether a running turn is followed. There is one rule about dragging a reader in this - /// window, and this is not a second one. + /// is obeyed wherever they are. A flush is obeyed only while `isNearBottom` says the reader is + /// still close enough to follow the setup log. private func showSetupLogEnd(wasAsked: Bool) { guard transcript.rows.isEmpty else { // The height of a row that has just unfolded 1,381 lines of log is not known until it @@ -1510,73 +1530,22 @@ struct TranscriptListView: View { transcript.liveEndRequests, isReady: arrivalSession == transcript.session.id ) else { return } opening = .liveEnd - if atLiveEnd.value || controller.holdsEnd || follower.isFollowing { - // A send at the bottom extends the existing travel. Jumping to its new destination - // first used to race the follower and expose the whole bubble above the composer. - follower.nudge() - } else { - goToLiveEnd() - } + goToLiveEnd() } - /// Takes the reader back to the newest row, which is what the jump pill asks for, and what - /// every button that composes a turn asks for through `TranscriptModel.submit`. - /// - /// **The travel is a scroll rather than a jump, and the length of it is nearly the same however - /// far it has to go.** See `TranscriptMotion.liveEndMove` for that argument. What belongs here - /// is why it is safe: the table knows where every row is, so a travel to the end costs one - /// arithmetic rather than realising every row between here and there, which is what made the - /// same movement over a `LazyVStack` a thing to be careful with. - /// - /// **And it ends by asking to BE at the end rather than by arriving there**, which is the - /// whole of what was wrong with the first version. The end moves while the travel is in the - /// air: a running turn grows the tail, the rows the travel lands among are drawn and turn out - /// to be taller than they were measured at, and a window that has just been moved to the tail - /// has not been laid out at all. Each of those leaves a scroll that was correct when it was - /// issued a few hundred points short, and short of the end is exactly the state the reader - /// pressed the pill to get out of. `goToEnd` is a standing instruction rather than a movement. + /// Takes the reader straight to the newest row when they press the jump pill. `goToEnd` holds + /// that position while the tail and newly realised rows change height. private func goToLiveEnd() { - scroller.stop() follower.seekLiveEnd(true) - // The live end has to be IN the window before anything can travel to it, and a reader far - // enough from the end to press this is often reading a window that does not hold it. + // The live end has to be in the drawn window before the table can name its position. if drawn.session == transcript.session.id, drawn.window.canGrowDown(rowCount: transcript.rows.count) { drawn.window = TranscriptWindow.liveEnd(rowCount: transcript.rows.count) drawn.rowCount = transcript.rows.count TranscriptDrawn.note(drawn.window.count, session: transcript.session.id) - // **No travel when the window moved.** The rows a glide would pass through are not in - // the table yet: they go in on the next pass over this body, and a travel aimed at the - // end of the content as it stands now is aimed at a row that is about to be somewhere - // else entirely. The standing instruction is what makes the arrival stick once they - // have landed. - controller.goToEnd() - follower.seekLiveEnd(false) - return - } - - switch TranscriptMotion.liveEndMove( - distance: reachToEnd.value, reduceMotion: reduceMotion - ) { - case .jump: - controller.goToEnd() - follower.seekLiveEnd(false) - case .glide(let seconds): - // Let go of the end first, or the instruction and the travel are two things moving the - // same clip view. AppKit rather than `withAnimation`: `TranscriptLiveEndScroller` - // carries the frame timings that settled that. - controller.releaseEnd() - guard scroller.glide( - seconds: seconds, completion: { [controller, follower] in - controller.goToEnd() - follower.seekLiveEnd(false) - } - ) else { - controller.goToEnd() - follower.seekLiveEnd(false) - return - } } + controller.goToEnd() + follower.seekLiveEnd(false) } /// The strip of turns in the margin, clear of the pinned question above and the composer below. @@ -1611,7 +1580,6 @@ struct TranscriptListView: View { ofSeqAtOrAfter: question.seq, in: rows.lazy.map(\.seq) ) else { return } - scroller.stop() follower.stop() controller.releaseEnd() @@ -1644,8 +1612,8 @@ struct TranscriptListView: View { /// Updates the compact header only when the reader crosses a user-turn boundary. The geometry /// callback reaches this every frame, but the binary lookup and one equality check do not make /// scrolling invalidate the transcript. - private func updatePinnedQuestion() { - guard let place = controller.topmostPlace, + private func updatePinnedQuestion(at place: (seq: Int, delta: CGFloat)?) { + guard let place, let question = transcript.pinnedQuestion(atOrBefore: place.seq) else { if scrollNavigation.pinnedQuestion != nil { scrollNavigation.pinnedQuestion = nil } @@ -1834,12 +1802,14 @@ struct TranscriptListView: View { drawn.window.canGrowUp, !isGrowing.value else { return } + historyGeneration.value += 1 + let generation = historyGeneration.value isGrowing.value = true drawn.window = drawn.window.grownUp() TranscriptDrawn.note(drawn.window.count, session: transcript.session.id) Task { @MainActor in try? await Task.sleep(for: .milliseconds(120)) - isGrowing.value = false + if historyGeneration.value == generation { isGrowing.value = false } } } @@ -1861,21 +1831,28 @@ struct TranscriptListView: View { arrivalSession == transcript.session.id, drawn.window.canGrowUp else { return } + historyGeneration.value += 1 + let generation = historyGeneration.value isGrowing.value = true Task { @MainActor in // `onSettled` comes from an NSTableView delegate callback. Leave that callback before // changing the table's rows, or AppKit correctly diagnoses a reentrant delegate pass. await Task.yield() - while isGrowing.value, + while historyGeneration.value == generation, + isGrowing.value, isVisible.value, + !isLiveScrolling.value, arrivalSession == transcript.session.id, drawn.session == transcript.session.id, - let prepared = drawn.window.preparedHistory(afterArrival: true) { + let prepared = drawn.window.preparedHistory( + afterArrival: true, + maximumRows: atLiveEnd.value ? TranscriptWindow.livePreparedLimit : .max + ) { drawn.window = prepared TranscriptDrawn.note(prepared.count, session: transcript.session.id) try? await Task.sleep(for: .milliseconds(180)) } - isGrowing.value = false + if historyGeneration.value == generation { isGrowing.value = false } } } @@ -1888,6 +1865,20 @@ struct TranscriptListView: View { } } + /// The table's height cache survives this trim. Only the row entries are released, so a new + /// arrival pays for the live neighbourhood while an upward scroll can still recover history. + private func trimHistoryAtLiveEnd() { + guard !isLiveScrolling.value, + atLiveEnd.value, + drawn.session == transcript.session.id, + drawn.window.end >= transcript.rows.count, + drawn.window.count > TranscriptWindow.livePreparedLimit + TranscriptWindow.chunk + else { return } + drawn.window = drawn.window.keepingLatest(TranscriptWindow.livePreparedLimit) + drawn.rowCount = transcript.rows.count + TranscriptDrawn.note(drawn.window.count, session: transcript.session.id) + } + // MARK: - Arrivals /// Takes the list in and works out what is new about it, unless the session is still arriving. @@ -1910,9 +1901,8 @@ struct TranscriptListView: View { /// A group opening adds rows and closing removes them. Tell the table this is a reader-driven /// change so it can cross-fade those rows without applying that effect to live arrivals. private func toggleFold(_ firstSeq: Int) { - // A disclosure click is the reader taking the view. Stop any live-end travel first, then - // hold the clicked fold line in place while its children enter or leave below it. - scroller.stop() + // A disclosure click is the reader taking the view. Hold the clicked fold line in place + // while its children enter or leave below it. follower.stop() controller.willChangeFoldRows(.fold(firstSeq)) if unfolded.contains(firstSeq) { @@ -1927,7 +1917,6 @@ struct TranscriptListView: View { /// Open or close every action group in this completed turn, leaving its prose in place. private func toggleTurnFold(_ firstSeq: Int, anchor: TranscriptEntryID) { - scroller.stop() follower.stop() controller.willChangeFoldRows(anchor) if unfoldedTurns.remove(firstSeq) != nil { diff --git a/Sources/Bloom/Views/Transcript/TranscriptLiveEndFollow.swift b/Sources/Bloom/Views/Transcript/TranscriptLiveEndFollow.swift index 1611f01ca..59ec631d4 100644 --- a/Sources/Bloom/Views/Transcript/TranscriptLiveEndFollow.swift +++ b/Sources/Bloom/Views/Transcript/TranscriptLiveEndFollow.swift @@ -11,11 +11,9 @@ import SwiftUI /// /// ## Why this is not a `withAnimation` on the scroll position /// -/// For the reason `TranscriptLiveEndScroller`'s header measures, and a second one on top of it: -/// this runs while a turn streams, so a state write per frame would re-run the list's body and -/// rebuild every realised row with it. Nothing here writes any SwiftUI state. It reads two heights -/// off AppKit and sets an origin, and the transcript's geometry callback picks the result up as if -/// the reader had scrolled there themselves. +/// A state write per frame would rebuild the list's body while a turn streams. Nothing here writes +/// SwiftUI state. It reads two heights off AppKit and sets an origin, and the transcript's geometry +/// callback picks the result up as if the reader had scrolled there themselves. /// /// ## The take-back, and why it is the shape of the thing /// @@ -60,8 +58,8 @@ final class TranscriptLiveEndFollower { /// Whether the reader has hold of the view. Anything that is not an idle scroll phase. var isPaused = false { didSet { refresh() } } - /// An explicit glide owns the clip view until it lands. Streaming nudges must not start a - /// second display link, or reclaim the bottom before that glide has finished. + /// An explicit jump owns the clip view while the table moves its drawn window. Streaming + /// nudges must not reclaim the bottom until that placement has finished. private(set) var isSeekingLiveEnd = false func seekLiveEnd(_ seeking: Bool) { @@ -261,7 +259,7 @@ final class TranscriptLiveEndFollower { guard wants, let scrollView, let document = scrollView.documentView else { return endLink() } // Flipped is what every scroll view in this app is, SwiftUI's included, but it is // asked rather than assumed: unflipped, the end of the document is at the origin and - // everything below would be aimed the wrong way. See `TranscriptLiveEndScroller`. + // everything below would be aimed the wrong way. guard document.isFlipped else { return endLink() } let clip = scrollView.contentView diff --git a/Sources/Bloom/Views/Transcript/TranscriptLiveEndScroll.swift b/Sources/Bloom/Views/Transcript/TranscriptLiveEndScroll.swift deleted file mode 100644 index f4d7d4d4a..000000000 --- a/Sources/Bloom/Views/Transcript/TranscriptLiveEndScroll.swift +++ /dev/null @@ -1,104 +0,0 @@ -import AppKit -import QuartzCore -import SwiftUI - -/// Drives the jump pill's travel back to the live end at AppKit's level rather than SwiftUI's. -/// -/// **This exists because the SwiftUI spelling of it was measured and does not hold.** -/// `withAnimation(.easeOut(duration: 0.26)) { scrollPosition.scrollTo(edge: .bottom) }` is one -/// line and reads better than everything below. On a three thousand row transcript two hundred -/// thousand points long, alternating the two mechanisms press by press inside one process so the -/// machine's load cannot be the difference, it delivers 57 of the 288 frames a 120Hz display -/// wanted across eight presses, a mean of 30ms between frames and a worst gap of 108ms, with the -/// main thread at 74 per cent of a core. Stepping the clip view instead delivers 222 of 288, a -/// mean of 8.6ms, one gap over 33ms in eight presses, at 13 per cent of a core, which is what the -/// same press with no animation on it costs. WindowServer is not involved either way, which is the -/// one thing the numbers rule out. -/// -/// **The travel is not `NSAnimationContext` either**, which was the obvious answer and was tried. -/// `clip.animator().setBoundsOrigin` measures as well as this and cannot be stopped: `animator()` -/// writes the model value straight away, so `bounds.origin` reports the end of the document from -/// the first frame and a hand on the wheel froze the view at the bottom rather than where the -/// travel had got to. That is the teleport this replaced, performed at the moment the reader -/// grabbed the view. Stepping the origin by hand means the current position is simply known. -/// -/// It fails safe: with no scroll view `glide` returns false and the caller jumps instead. -@MainActor -@Observable -final class TranscriptLiveEndScroller { - /// Weak, because the scroll view belongs to the view hierarchy and holding it here would keep - /// a pane's worth of realised rows alive after the pane has gone. - @ObservationIgnored weak var scrollView: NSScrollView? - - @ObservationIgnored private var link: CADisplayLink? - @ObservationIgnored private var start: CFTimeInterval = 0 - @ObservationIgnored private var from: CGFloat = 0 - @ObservationIgnored private var to: CGFloat = 0 - @ObservationIgnored private var duration: Double = 0 - @ObservationIgnored private var arrival: (@MainActor () -> Void)? - - /// Travels to the end of the content over `seconds`, and says whether it could. - /// - /// `completion` runs on arrival and only on arrival. A travel cut short by `stop`, replaced by - /// a second press, or left behind by the pane changing session never calls it, because every - /// one of those is somebody saying they no longer want to be taken there. - @discardableResult - func glide(seconds: Double, completion: @escaping @MainActor () -> Void) -> Bool { - guard let scrollView, let document = scrollView.documentView else { return false } - - let clip = scrollView.contentView - let reach = scrollView.endOffset - guard reach > 0 else { return false } - - // Flipped is what every scroll view in this app is, SwiftUI's included, but it is asked - // rather than assumed: unflipped, the end of the document is at the origin, and travelling - // to `reach` would go the wrong way. - let end = document.isFlipped ? reach : 0 - // Half a point is under a pixel, so there is nothing to travel. Its own number rather than - // `TranscriptFollow.arrived`: the two agree because both are the same claim about a pixel, - // not because one owns the other. - guard abs(clip.bounds.origin.y - end) > 0.5, seconds > 0 else { return false } - - stop() - from = clip.bounds.origin.y - to = end - duration = seconds - start = CACurrentMediaTime() - arrival = completion - - let created = clip.displayLink(target: self, selector: #selector(step)) - created.add(to: .main, forMode: .common) - link = created - return true - } - - /// Ends any travel in flight, leaving the view exactly where it had got to. - /// - /// A hand on the wheel has to win on the frame it arrives, and a view that goes on dragging - /// somebody somewhere after they have taken hold of it is the worst thing this file could do. - /// Nothing is animated to a stop and nothing is settled afterwards. - func stop() { - link?.invalidate() - link = nil - arrival = nil - } - - @objc private func step(_ sender: CADisplayLink) { - MainActor.assumeIsolated { - guard let scrollView else { return stop() } - let clip = scrollView.contentView - let elapsed = CACurrentMediaTime() - start - let ratio = duration > 0 ? min(1, max(0, elapsed / duration)) : 1 - // The same ease out the SwiftUI spelling asked for: fast at the top of the movement, - // settling at the bottom of it, so the travel reads as arriving rather than stopping. - let eased = 1 - pow(1 - ratio, 3) - clip.setBoundsOrigin(NSPoint(x: clip.bounds.origin.x, y: from + (to - from) * eased)) - scrollView.reflectScrolledClipView(clip) - - guard ratio >= 1 else { return } - let landed = arrival - stop() - landed?() - } - } -} diff --git a/Sources/Bloom/Views/Transcript/TranscriptRowEnvironment.swift b/Sources/Bloom/Views/Transcript/TranscriptRowEnvironment.swift index edf128480..72dc296dc 100644 --- a/Sources/Bloom/Views/Transcript/TranscriptRowEnvironment.swift +++ b/Sources/Bloom/Views/Transcript/TranscriptRowEnvironment.swift @@ -23,6 +23,8 @@ import SwiftUI /// /// - `app` for `ToolRowHeader`, `UserTurnRowView`, `ReviewTurnChips` and `WorkspaceEventsView`, /// each of which asks the model something about the workspace the row belongs to. +/// - `conversation`, the one selection every text view in the transcript joins when Select All is +/// pressed. An identity like the two objects below. /// - `hoverHost` and `bubbleWidth`, the two objects above. Identities, never values, so handing /// them down costs a row nothing: see the headers of both. /// - `linkActions` for what a link inside a row's markdown does when it is pressed or chosen from @@ -47,6 +49,7 @@ struct TranscriptRowEnvironment: Equatable { let app: AppModel let hoverHost: TranscriptHoverHost let bubbleWidth: TranscriptBubbleWidth + let conversation: TranscriptConversationSelection let linkActions: TranscriptLinkActions let fontScale: CGFloat let chatFont: ChatFont @@ -63,6 +66,7 @@ struct TranscriptRowEnvironment: Equatable { lhs.app === rhs.app && lhs.hoverHost === rhs.hoverHost && lhs.bubbleWidth === rhs.bubbleWidth + && lhs.conversation === rhs.conversation && lhs.linkActions == rhs.linkActions && lhs.fontScale == rhs.fontScale && lhs.chatFont == rhs.chatFont @@ -104,6 +108,7 @@ extension View { environment(values.app) .environment(\.transcriptHoverHost, values.hoverHost) .environment(\.transcriptBubbleWidth, values.bubbleWidth) + .environment(\.transcriptConversationSelection, values.conversation) .markdownLinkActions(values.linkActions) .environment(\.fontScale, values.fontScale) .environment(\.chatFont, values.chatFont) diff --git a/Sources/Bloom/Views/Transcript/TranscriptTable.swift b/Sources/Bloom/Views/Transcript/TranscriptTable.swift index c0cb6b4cc..ce69150ed 100644 --- a/Sources/Bloom/Views/Transcript/TranscriptTable.swift +++ b/Sources/Bloom/Views/Transcript/TranscriptTable.swift @@ -176,6 +176,14 @@ final class TranscriptTableController { final class TranscriptScrollView: NSScrollView { var willResizeViewport: (() -> Void)? var didResizeViewport: (() -> Void)? + var onUserWheel: (() -> Void)? + + override func scrollWheel(with event: NSEvent) { + // Some mice never send live-scroll notifications. Claim the gesture before AppKit moves + // the clip view, while the old end hold can still be released without a competing snap. + if event.scrollingDeltaY != 0 || event.scrollingDeltaX != 0 { onUserWheel?() } + super.scrollWheel(with: event) + } override func setFrameSize(_ newSize: NSSize) { let changed = newSize != frame.size @@ -196,9 +204,15 @@ struct TranscriptTable: NSViewRepresentable { /// `TranscriptHoldView.hold()`. let session: SessionID let controller: TranscriptTableController + /// Shared with the next table this pane builds for the same conversation. Nil in detached + /// readers and probes, which keep their own cache for the life of that table. + let heightMemory: TranscriptHeightMemory? /// The text scale the rows are drawn at. Part of what the height cache is keyed on, because /// the same row at a different scale is a different height. let scale: CGFloat + /// Whether the rows handed to this table reach the conversation's newest row. A pane opened + /// on older history can be at the end of its drawn window without being at the live end. + var includesLiveEnd = true /// Exactly what a hosted row needs from the environment and nothing else. See /// `TranscriptRowEnvironment`, which carries why this is a named list rather than `\.self`. let rowEnvironment: TranscriptRowEnvironment @@ -209,8 +223,7 @@ struct TranscriptTable: NSViewRepresentable { /// gesture and false when it ends. /// /// Not a debounce over "the clip view moved", which cannot tell a hand from this app's own - /// travel: the jump pill's glide reported itself as a scroll on its first frame and the pane's - /// handler stopped it, leaving the transcript a few points down with the completion never run. + /// placement. Programmatic movement must not release the end hold. /// `NSScrollView` posts `willStartLiveScroll` and `didEndLiveScroll` for user-initiated /// scrolling only, so this app's own movement is silent here. let onLiveScrollChange: @MainActor (Bool) -> Void @@ -219,7 +232,7 @@ struct TranscriptTable: NSViewRepresentable { /// Where a passage selected in these rows is quoted. See `TranscriptTableView.quoteSelection`. var quoteSelection: (@MainActor (String) -> Void)? - func makeCoordinator() -> Coordinator { Coordinator() } + func makeCoordinator() -> Coordinator { Coordinator(heightMemory: heightMemory) } func makeNSView(context: Context) -> TranscriptHoldView { let table = TranscriptTableView() @@ -287,7 +300,9 @@ struct TranscriptTable: NSViewRepresentable { coordinator.onSettled = onSettled coordinator.onLiveScrollChange = onLiveScrollChange coordinator.onContentWillChange = onContentWillChange + coordinator.includesLiveEnd = includesLiveEnd (coordinator.tableView as? TranscriptTableView)?.quoteSelection = quoteSelection + (coordinator.tableView as? TranscriptTableView)?.conversation = rowEnvironment.conversation // Before the entries, so that a pass carrying another conversation's rows is applied to a // pane that has already stopped drawing. coordinator.showing(session: session, in: nsView) @@ -303,7 +318,13 @@ struct TranscriptTable: NSViewRepresentable { @MainActor final class Coordinator: NSObject, NSTableViewDataSource, NSTableViewDelegate, TranscriptHoldDelegate { + init(heightMemory: TranscriptHeightMemory? = nil) { + self.heightMemory = heightMemory ?? TranscriptHeightMemory() + super.init() + } + private(set) var entries: [TranscriptTableEntry] = [] + var includesLiveEnd = true private var ids: [TranscriptEntryID] = [] private var index: [TranscriptEntryID: Int] = [:] private var rowEnvironment: TranscriptRowEnvironment? @@ -321,7 +342,11 @@ struct TranscriptTable: NSViewRepresentable { /// after a row lands as it was before, so nothing under the reader moves, and the number a /// reader's place was written down against still means something. See /// `TranscriptRowHeights`. - private var heights = TranscriptRowHeights() + private let heightMemory: TranscriptHeightMemory + private var heights: TranscriptRowHeights { + _read { yield heightMemory.heights } + _modify { yield &heightMemory.heights } + } private let sentMotion = TranscriptSentMotion() private var settleWork: Task? /// Preparing older rows while nobody is moving. See `warmAhead`. @@ -345,6 +370,10 @@ struct TranscriptTable: NSViewRepresentable { /// table was then told that seventeen hundred estimates had changed, and the rows that had /// actually reported were never told again. See `noted`. private var owedHeights: Set = [] + /// Measurements made by idle warming just as scrolling began. A visible row's own report + /// may drain `owedHeights` during the gesture, so these need a separate queue until the + /// clip view has actually settled. + private var deferredWarmHeights: Set = [] private var owedWork: Task? /// **A standing instruction to be at the end of the conversation.** See @@ -433,6 +462,7 @@ struct TranscriptTable: NSViewRepresentable { if let scroll = scroll as? TranscriptScrollView { scroll.willResizeViewport = { [weak self] in self?.viewportWillResize() } scroll.didResizeViewport = { [weak self] in self?.viewportDidResize() } + scroll.onUserWheel = { [weak self] in self?.wheelScrolled() } } scroll.contentView.postsBoundsChangedNotifications = true scroll.postsFrameChangedNotifications = true @@ -490,11 +520,13 @@ struct TranscriptTable: NSViewRepresentable { let previous = rowEnvironment rowEnvironment = environment let environmentMoved = previous != nil && previous != environment + let fontMoved = heightMemory.chatFont.map { $0 != environment.chatFont } ?? false + heightMemory.chatFont = environment.chatFont // The reload below is what says every cell holds values from the environment it was // built in, and a cell that recycles on its content key alone would take none of it. // See `cellGeneration`. if environmentMoved { cellGeneration += 1 } - let wrapsDifferently = previous?.wraps(differentlyFrom: environment) ?? false + let wrapsDifferently = fontMoved || (previous?.wraps(differentlyFrom: environment) ?? false) if wrapsDifferently { heights.forget() } // The first width the table ever has, and any change of text size. Until a width @@ -551,6 +583,9 @@ struct TranscriptTable: NSViewRepresentable { // Every cell is going anyway. break } + // An in-place edit can change a row the idle pass has already walked past. Its next + // settle starts a fresh pass at the visible row and sees the new content key. + if !changed.isEmpty { warmWork?.cancel() } pendingUnfolds.removeAll() if change == .same, changed.isEmpty, !remeasured, !environmentMoved { @@ -723,7 +758,12 @@ struct TranscriptTable: NSViewRepresentable { // Give that first proposal the real column width instead of a zero-sized frame. cell.setFrameSize(NSSize(width: columnWidth, height: max(Self.hair, height(of: entry)))) cell.onMeasured = { [weak self] id, key, size in - self?.noted(size: size, of: key, for: id) + // Geometry reports arrive during SwiftUI's layout pass. `noted` asks AppKit for + // row rectangles, which can lay out this same table again while SwiftUI still + // holds its stack children. Wait until that pass has returned before asking. + Task { @MainActor [weak self] in + self?.noted(size: size, of: key, for: id) + } } // **Every row in the visible rect gets one of these, and this is where a row's SwiftUI // graph is actually built.** It is outside the pane's own layout pass, so @@ -775,29 +815,15 @@ struct TranscriptTable: NSViewRepresentable { ))) } - /// The one thing a row is hosted in to be measured, kept rather than built per row. See - /// `measure(_:at:)`, which carries why keeping it is safe now and was not before. Lazy, so - /// a coordinator that never measures never builds one. - private lazy var sizer = NSHostingController(rootView: AnyView(EmptyView())) - /// **What this row comes to at this width, asked of a hosting controller.** /// - /// It was a fresh `NSHostingView` per row, with a required width constraint, - /// `layoutSubtreeIfNeeded()` and `fittingSize`. The comment beside it said a reused - /// hosting view "does not reliably forget the row before", and that is a description of - /// `fittingSize` rather than of reuse: `fittingSize` is the Auto Layout engine's answer for - /// the view as it stands, so a reused view hands back whatever the last solve left in it. - /// `sizeThatFits(in:)` takes a PROPOSAL and answers for the content it has been handed, so - /// there is nothing left over to forget and the controller can be kept. It is also the - /// supported width-constrained measurement, where `fittingSize` under a constraint is a - /// solver result that happens to agree. + /// Give each row its own controller. The reused controller replaced its root view for + /// every measurement, including between yields in idle history preparation. Crash reports + /// from macOS 27 show SwiftUI releasing stack layout children during a later graph flush. + /// Keeping a row's graph stable for its whole lifetime removes that source of invalidation. /// - /// Timed on this Mac against the owner's session: 0.270ms a row for the fresh view, 0.103ms - /// for this. Nothing else about the measurement moves. `HostedRow` is untouched, because - /// the measuring copy and the drawing copy passing through identical modifiers is the whole - /// reason that type exists; `.id` is added here, which the drawing copy already had, so the - /// two are now closer rather than further apart, and it is what tells SwiftUI that the next - /// row is a different view rather than this one changing. + /// `sizeThatFits(in:)` still takes a width proposal, and `HostedRow` still gives the + /// measuring and drawing copies the same layout modifiers. /// /// The width is still `columnWidth`, carried in through `TranscriptRowHeights.measure`, /// which is the TABLE's width and not the clip view's. See `columnWidth`, which carries @@ -813,11 +839,13 @@ struct TranscriptTable: NSViewRepresentable { // The one place a row is hosted to measure it, so the one place worth counting. See // `TranscriptHoldCensus`. TranscriptHoldCensus.measured() - sizer.rootView = AnyView( + let sizer = NSHostingController(rootView: AnyView( HostedRow(content: entry.content(), report: { _ in }, fills: false) - .id(entry.id) .transcriptRowEnvironment(rowEnvironment) - ) + // As the drawn row has it, so a code block with an edit open measures as the + // editor it will be drawn as rather than as the code under it. + .environment(\.transcriptEntryID, entry.id) + )) // Unconstrained downwards, which is what `fills: false` is for: the measuring copy // takes its own ideal height rather than filling a row it has not been given. let height = sizer.sizeThatFits( @@ -1159,6 +1187,10 @@ struct TranscriptTable: NSViewRepresentable { private func keepPlace( wasAtEnd: Bool, anchor: (id: TranscriptEntryID, delta: CGFloat)? ) { + // A new row or a late height report can arrive between two wheel events. The + // reader's gesture owns the clip view until it ends, including while AppKit is + // correcting the document height underneath it. + guard !isLiveScrolling else { return } switch TranscriptAnchor.place( holdsEnd: holdsEnd, wasAtEnd: wasAtEnd, @@ -1174,15 +1206,10 @@ struct TranscriptTable: NSViewRepresentable { } } - /// Whether the reader is still following along, which is `ScrollEnd`'s question with a - /// line or two of slack, and not `TranscriptTableGeometry.isAtEnd`'s exact one. + /// Only a reader at the actual end follows new content. Even a small scroll upwards is a + /// reading position to preserve when a message is sent or another row arrives. private var isFollowingAlong: Bool { - let geometry = currentGeometry - return ScrollEnd.isAtEnd( - contentHeight: geometry.contentHeight, - viewportHeight: geometry.viewportHeight, - offset: geometry.offset - ) + includesLiveEnd && currentGeometry.isAtEnd } // MARK: Scrolling @@ -1548,6 +1575,13 @@ struct TranscriptTable: NSViewRepresentable { onLiveScrollChange?(true) } + private func wheelScrolled() { + lastLiveScroll = CACurrentMediaTime() + guard !isLiveScrolling else { return } + liveScrollBegan() + watchForQuiet() + } + /// Belt to the braces above. AppKit posts this for every step of a user-initiated scroll, /// and a gesture that somehow began without a `willStartLiveScroll` still has to take the /// hold down on the frame it reaches here. @@ -1625,6 +1659,12 @@ struct TranscriptTable: NSViewRepresentable { try? await Task.sleep(for: .milliseconds(150)) guard !Task.isCancelled, let self, !isLiveScrolling else { return } settleWork = nil + // A warm pass interrupted by a scroll can leave measured heights that the table + // has not heard yet. Apply those after movement stops, not under the reader's + // hand. Visible rows still report and correct their own heights during a gesture. + owedHeights.formUnion(deferredWarmHeights) + deferredWarmHeights.removeAll() + drainOwedHeights() if isSettlingResizeAtEnd { putEnd() reportGeometry() @@ -1685,15 +1725,17 @@ struct TranscriptTable: NSViewRepresentable { /// under them has already happened while nothing was moving. private func warmTheRowsAbove() async { guard let tableView, let scrollView, heights.isReady, - let sizing = heights.measure else { return } + let sizing = heights.measure, let warmingSession = shownSession else { return } + let visible = scrollView.contentView.documentVisibleRect + guard visible.minY > 1 else { return } + let found = tableView.rows(in: CGRect(x: 0, y: 0, width: 1, height: visible.minY)) + guard found.length > 0 else { return } + let upper = found.location + found.length + let lower = max(found.location, upper - TranscriptWarming.lookbackRows) + var nextUpper = upper while !Task.isCancelled && !isLiveScrolling { - let visible = scrollView.contentView.documentVisibleRect - guard visible.minY > 1 else { return } - let found = tableView.rows(in: CGRect(x: 0, y: 0, width: 1, height: visible.minY)) - guard found.length > 0 else { return } - let upper = found.location + found.length let rows = TranscriptWarming.nextBatch( - in: max(found.location, upper - TranscriptWarming.lookbackRows).. = [] for row in rows { - guard !Task.isCancelled, !isLiveScrolling else { break } + guard !Task.isCancelled, !isLiveScrolling, + shownSession == warmingSession else { break } guard entries.indices.contains(row) else { break } let entry = entries[row] let taken = heights.note( @@ -1721,12 +1768,11 @@ struct TranscriptTable: NSViewRepresentable { } await Task.yield() } - guard !moved.isEmpty else { return } + guard !moved.isEmpty, shownSession == warmingSession else { return } // A gesture can begin after the final yield. Keep what was measured, but defer // the table correction so preparation never moves the document under a hand. if Task.isCancelled || isLiveScrolling { - owedHeights.formUnion(movedIDs) - drainOwedHeights() + deferredWarmHeights.formUnion(movedIDs) return } let wasAtEnd = isFollowingAlong @@ -1875,6 +1921,12 @@ struct TranscriptTable: NSViewRepresentable { holdView = view guard shownSession != session else { return } shownSession = session + // Entry ids repeat across conversations. A correction queued for the one being left + // must not be resolved against the new conversation's rows after the switch. + owedWork?.cancel() + owedWork = nil + owedHeights.removeAll() + deferredWarmHeights.removeAll() viewportWork?.cancel() viewportWork = nil viewportPlace = nil @@ -2290,6 +2342,7 @@ private struct TranscriptCellRoot: View { // `onAppear` fire for the arrival settle. See `HostedRow`. .id(id) .transcriptRowEnvironment(environment) + .environment(\.transcriptEntryID, id) } } } @@ -2299,17 +2352,14 @@ private struct TranscriptCellRoot: View { /// The hosting view is exactly the row, so there is no second opinion about its height. What the /// content wants instead comes back through `HostedRow`, measured by the same layout that drew it. /// -/// **Pinned on all four edges, and it was briefly a frame set in `layout`.** A profile of an upward -/// scroll spent 726 samples of 3,034 inside `-[NSView _layoutSubtreeWithOldSize:]`, which is every -/// live cell sitting in the Auto Layout engine, and a frame is the same geometry with no solver in -/// it. Then a reader resized a pane and got rows drawn over one another, and the constraints came -/// back, because the saving was small and unmeasured on its own while text over text is the app -/// looking broken. -/// -/// It was never proved to be the cause: nothing in the width path changed that night, so a row can -/// be drawn at a height measured for another width either way. It was reverted on the shape of the -/// risk rather than on evidence, and the evidence is a build with this line changed and nothing -/// else. If that build overlaps too, the fault is older than the mask and this can come back. +/// **The host follows the cell with AppKit autoresizing.** Four edge constraints previously sent +/// every live cell through Auto Layout during a scroll. Against the same 2,608 row conversation in +/// a debug probe, replacing those constraints cut first-pass scroll p95 from 32.7ms to 16.7ms and +/// the share of dropped frames from 26.5% to 11.7%. A previous frame-based attempt was reverted +/// after a reader saw rows overlap during resize, but the cause was not isolated. This version +/// passed all 81 `TranscriptLayoutProbe` checks, including narrow and wide pane resizes and the +/// agreement between rendered cells and table row rectangles. Keep that geometry check with any +/// future change to the cell's sizing. final class TranscriptTableCell: NSView { private let host: NSHostingView private var appliedKey: TranscriptContentKey? @@ -2345,14 +2395,9 @@ final class TranscriptTableCell: NSView { // over the content at its own ideal size. `DetailSplitViewController` and `TitleBarStrip` // make the same call for the same reason. host.sizingOptions = [] - host.translatesAutoresizingMaskIntoConstraints = false + host.autoresizingMask = [.width, .height] + host.frame = bounds addSubview(host) - NSLayoutConstraint.activate([ - host.leadingAnchor.constraint(equalTo: leadingAnchor), - host.trailingAnchor.constraint(equalTo: trailingAnchor), - host.topAnchor.constraint(equalTo: topAnchor), - host.bottomAnchor.constraint(equalTo: bottomAnchor), - ]) } @available(*, unavailable) diff --git a/Sources/Bloom/Views/Transcript/TranscriptTableView.swift b/Sources/Bloom/Views/Transcript/TranscriptTableView.swift index f49c623ba..fa9f9469e 100644 --- a/Sources/Bloom/Views/Transcript/TranscriptTableView.swift +++ b/Sources/Bloom/Views/Transcript/TranscriptTableView.swift @@ -11,10 +11,38 @@ final class TranscriptTableView: NSTableView { /// composer to reply in, which is an archived workspace. See `SelectionToChat`. var quoteSelection: (@MainActor (String) -> Void)? + /// What Select All and a drag across rows select. While it holds anything the table has the + /// keyboard, because the text view the selection began in is usually recycled before Copy is + /// pressed. See `TranscriptConversationSelection`. + var conversation: TranscriptConversationSelection? { + didSet { conversation?.responder = self } + } + /// The table has been laid out at a new width, and the rows on screen are owed heights for it. /// See `TranscriptTable.Coordinator.widthChanged`. var didChangeWidth: (@MainActor () -> Void)? + override func selectAll(_ sender: Any?) { + guard let conversation else { super.selectAll(sender); return } + conversation.selectAll() + } + + @objc func copy(_ sender: Any?) { + guard let text = conversation?.selectedText else { return } + TranscriptLink.copy(text) + } + + override func validateUserInterfaceItem(_ item: any NSValidatedUserInterfaceItem) -> Bool { + guard item.action == #selector(copy(_:)) else { return super.validateUserInterfaceItem(item) } + return conversation?.selectedText?.isEmpty == false + } + + override func resignFirstResponder() -> Bool { + let resigned = super.resignFirstResponder() + if resigned { conversation?.cancel() } + return resigned + } + private var isAligningRows = false private var alignmentWork: Task? /// The width `didChangeWidth` was last said at, so a width is said once however many layout diff --git a/Sources/Bloom/Views/Transcript/TranscriptTextSelection.swift b/Sources/Bloom/Views/Transcript/TranscriptTextSelection.swift index 322726d3c..4889426f3 100644 --- a/Sources/Bloom/Views/Transcript/TranscriptTextSelection.swift +++ b/Sources/Bloom/Views/Transcript/TranscriptTextSelection.swift @@ -121,18 +121,30 @@ extension LinkTextView { /// Native text selection tracks inside one text container. Track this answer's containers /// together so a drag can leave a paragraph, pass a code toolbar, and continue into a table. - func trackAnswerSelection(with event: NSEvent, selection: TranscriptTextSelection) -> Bool { + /// A drag that leaves the answer altogether is handed to the conversation's selection, which + /// is also what tracks a bubble, since a bubble belongs to no answer. + func trackAnswerSelection(with event: NSEvent, selection: TranscriptTextSelection?) -> Bool { guard let window else { return false } window.makeFirstResponder(self) let offset = selectionOffset(at: event.locationInWindow) let granularity: NSSelectionGranularity = event.clickCount >= 3 ? .selectByParagraph : event.clickCount == 2 ? .selectByWord : .selectByCharacter let initial = selectionRange(forProposedRange: NSRange(location: offset, length: 0), granularity: granularity) - selection.begin(in: self, offset: initial.location, extending: event.modifierFlags.contains(.shift)) - selection.extend(to: self, offset: NSMaxRange(initial)) + if let selection { + selection.begin(in: self, offset: initial.location, extending: event.modifierFlags.contains(.shift)) + selection.extend(to: self, offset: NSMaxRange(initial)) + } else { + setSelectedRange(initial) + } + conversationSelection?.beginDrag(in: self, offset: initial.location) var latest = event var dragged = false - while self.window === window { + // Found once, before the drag moves anything. The view the press landed in is recycled by + // the table as soon as it scrolls away, and a loop that asked this view for its window or + // its scroll view on every pass ended the drag the moment that happened: the selection + // scrolled for a screen and then stopped, with the button still held. + let scrollView = enclosingVerticalScrollView + while window.isVisible && window.isKeyWindow { let next = window.nextEvent( matching: [.leftMouseDragged, .leftMouseUp], until: Date(timeIntervalSinceNow: 0.05), inMode: .eventTracking, dequeue: true @@ -140,21 +152,41 @@ extension LinkTextView { if let next { if next.type == .leftMouseUp { break } latest = next + } else if NSEvent.pressedMouseButtons & 1 == 0 { + // A mouse-up can be lost when the gesture is interrupted. Without this escape, + // the tracking loop keeps requesting only drag and mouse-up events, leaving every + // later click in the window unhandled until Bloom quits. + break } guard latest.type == .leftMouseDragged else { continue } dragged = true - // Use the transcript's vertical scroll view, including when this text is inside a + // The transcript's vertical scroll view, including when this text is inside a // horizontally scrolling code fence. Repeating at the edge also scrolls a held drag. - var ancestor: NSView? = self - while let current = ancestor { - if let scroll = current as? NSScrollView, scroll.hasVerticalScroller { - _ = scroll.contentView.autoscroll(with: latest) - break - } - ancestor = current.superview + _ = scrollView?.contentView.autoscroll(with: latest) + let point = latest.locationInWindow + if conversationSelection?.drag(to: point) == true { continue } + if let selection { + selection.extend(at: point) + } else { + extendSelection(from: initial, to: point) } - selection.extend(at: latest.locationInWindow) } return dragged } + + private var enclosingVerticalScrollView: NSScrollView? { + var ancestor: NSView? = self + while let current = ancestor { + if let scroll = current as? NSScrollView, scroll.hasVerticalScroller { return scroll } + ancestor = current.superview + } + return nil + } + + /// A bubble is one text view, so dragging inside it only has to join the press to the pointer. + private func extendSelection(from initial: NSRange, to windowPoint: NSPoint) { + let offset = selectionOffset(at: windowPoint) + let start = min(initial.location, offset) + setSelectedRange(NSRange(location: start, length: max(NSMaxRange(initial), offset) - start)) + } } diff --git a/Sources/Bloom/Views/Transcript/TranscriptTextView.swift b/Sources/Bloom/Views/Transcript/TranscriptTextView.swift index 46ff2ee1a..986339dac 100644 --- a/Sources/Bloom/Views/Transcript/TranscriptTextView.swift +++ b/Sources/Bloom/Views/Transcript/TranscriptTextView.swift @@ -33,10 +33,12 @@ struct TranscriptLinkActions: Sendable, Equatable { enum Identity: Hashable, Sendable { /// The default value, which does nothing at all. case inert - case workspace(WorkspaceID?, pane: String?) + /// The session is part of it because an edited code block is attached to the conversation + /// it was edited in, and a pane switching conversations keeps its name. + case workspace(WorkspaceID?, pane: String?, session: SessionID?) /// The same, with a file chip's door added. Its own case because a value that can open a /// file must never compare equal to one that cannot. - case workspaceOpeningFiles(WorkspaceID?, pane: String?) + case workspaceOpeningFiles(WorkspaceID?, pane: String?, session: SessionID?) } var identity: Identity = .inert @@ -65,6 +67,18 @@ struct TranscriptLinkActions: Sendable, Equatable { var previewFile: @MainActor @Sendable (String) -> URL? = { _ in nil } var previewSource: @MainActor @Sendable (URL) -> URL? = { _ in nil } + /// Puts a code block the reader edited into its own conversation's composer as an attachment, + /// under the name given, answering whether it arrived. Nil where there is no conversation to + /// attach to, and the fence then offers no edit button at all. + var attachEditedCode: (@MainActor @Sendable (_ code: String, _ filename: String) async -> Bool)? + + /// The conversation these actions belong to, which keys a code block's draft. + var session: SessionID? { + switch identity { + case .inert: nil + case .workspace(_, _, let session), .workspaceOpeningFiles(_, _, let session): session + } + } static func == (lhs: Self, rhs: Self) -> Bool { lhs.identity == rhs.identity } } @@ -113,6 +127,8 @@ struct FileChipHover: Equatable, Sendable { struct TranscriptTextView: NSViewRepresentable { var text: NSAttributedString @Environment(\.transcriptTextSelection) private var selection + @Environment(\.transcriptConversationSelection) private var conversation + @Environment(\.transcriptEntryID) private var entryID /// The ink a link is drawn in when the pointer is elsewhere. The underline is not part of it: /// see `LinkTextView.hovered`. var linkColor: NSColor @@ -194,6 +210,7 @@ struct TranscriptTextView: NSViewRepresentable { static func dismantleNSView(_ view: LinkTextView, coordinator: Coordinator) { view.answerSelection?.unregister(view) + view.conversationSelection?.unregister(view) } private func apply(to view: LinkTextView) { @@ -204,11 +221,19 @@ struct TranscriptTextView: NSViewRepresentable { view.answerSelection = selection selection?.register(view) } + view.entryID = entryID + if view.conversationSelection !== conversation { + view.conversationSelection?.unregister(view) + view.conversationSelection = conversation + conversation?.register(view) + } if view.textStorage?.isEqual(to: text) != true { view.textStorage?.setAttributedString(text) view.bubbleAlignmentWidth = nil - view.lastMeasurement = nil + view.measurements.removeAll() + view.lastReportedMeasurement = nil } + conversation?.adopt(view) view.linkColor = linkColor // No underline at rest. The pointing hand is asked for here and set for real in // `LinkTextView.pointer`: this dictionary only reaches the screen through the cursor @@ -249,7 +274,7 @@ struct TranscriptTextView: NSViewRepresentable { // out again at 80 and wrapped: two lines of ink in a bubble one line tall, the second // drawn over the caption under it, and the bubble narrower than its own words. A width a // run has reported must be a width it fits in when it is handed it back, which only the - // exact proposal promises. Repeated proposals are still answered from `lastMeasurement`, + // exact proposal promises. Repeated proposals are still answered from `measurements`, // and the pane itself reflows in steps during a drag, which is where the saving is. let width = TranscriptTextMeasure.layoutWidth(proposed: proposed) // Only when it has actually moved. Whether TextKit throws its layout away on being handed @@ -258,12 +283,22 @@ struct TranscriptTextView: NSViewRepresentable { // proposals a `.textSelection(.enabled)` block generates repeat the same widths. Not // depending on the answer costs one comparison. let wanted = CGSize(width: width, height: CGFloat.greatestFiniteMagnitude) - if let last = nsView.lastMeasurement, - last.proposedWidth == proposed, - last.layoutWidth == width, - last.alignsBubbleInk == alignsBubbleInk, - container.containerSize == wanted { - return last.size + let key = TranscriptTextMeasureCache.Key( + proposed: proposed, alignsBubbleInk: alignsBubbleInk + ) + // **An answer already given is given again without typesetting**, whichever question came + // in between. See `TranscriptTextMeasureCache` for the multi-second switches this was. + // + // The container still follows the question, exactly as it did when every question was + // typeset, because the view draws at whatever width it was last asked about. Setting a + // container's size only marks the layout stale, so TextKit lays the run out when it is next + // drawn or hit tested, at the width the pass left it at, instead of once per question. + if let known = nsView.measurements.value(for: key) { + if container.containerSize != wanted { container.containerSize = wanted } + nsView.bubbleInkOffset = known.bubbleInkOffset + nsView.bubbleAlignmentWidth = alignsBubbleInk ? width : nil + nsView.lastReportedMeasurement = (known.size, alignsBubbleInk) + return known.size } if container.containerSize != wanted { container.containerSize = wanted } layout.ensureLayout(for: container) @@ -285,10 +320,10 @@ struct TranscriptTextView: NSViewRepresentable { hasGlyphs: (nsView.textStorage?.length ?? 0) > 0 ) let result = CGSize(width: size.width, height: size.height) - nsView.lastMeasurement = LinkTextView.Measurement( - proposedWidth: proposed, layoutWidth: width, - alignsBubbleInk: alignsBubbleInk, size: result + nsView.measurements.store( + LinkTextView.Measurement(size: result, bubbleInkOffset: nsView.bubbleInkOffset), for: key ) + nsView.lastReportedMeasurement = (result, alignsBubbleInk) return result } @@ -346,7 +381,8 @@ struct TranscriptTextView: NSViewRepresentable { init(actions: TranscriptLinkActions) { self.actions = actions } func textViewDidChangeSelection(_ notification: Notification) { - guard let view = notification.object as? LinkTextView else { return } + guard let view = notification.object as? LinkTextView, + view.conversationSelection?.isApplying != true else { return } view.answerSelection?.nativeSelectionChanged(in: view) } @@ -412,23 +448,57 @@ private final class TranscriptLayoutManager: NSLayoutManager { /// The text view itself: hover, and the menu over a link. final class LinkTextView: NSTextView, HoverQuickLookSource { weak var answerSelection: TranscriptTextSelection? + weak var conversationSelection: TranscriptConversationSelection? + /// The table entry this text is drawn in, which orders it among the rows of the conversation. + var entryID: TranscriptEntryID? + var rowSeq: Int? { entryID?.selectionOrder } var copyPrefix = "" var copySeparatorBefore = "\n\n" + /// `sizeThatFits` can leave the container wider than the placed frame, which drew a long + /// pasted turn past its bubble. Only an overflowing run is laid out again. + override func viewWillDraw() { + if let container = textContainer, let measured = lastReportedMeasurement, + measured.size.width > bounds.width + Self.overflowTolerance { + container.containerSize = CGSize(width: bounds.width, height: CGFloat.greatestFiniteMagnitude) + lastReportedMeasurement = nil + if measured.alignsBubbleInk, let layout = layoutManager { + layout.ensureLayout(for: container) + bubbleInkOffset = BubbleTextAlignment.offset(layout: layout, container: container) + bubbleAlignmentWidth = bounds.width + } + } + super.viewWillDraw() + } + + /// Frames land on half points on a Retina display, and a reported width rounded up can sit + /// that far over one without a glyph crossing the edge. + private static let overflowTolerance: CGFloat = 0.5 + + /// Whether whoever has the keyboard is selecting this view's text along with its own. + private var isSelectedWithTheKeyboardOwner: Bool { + guard let window, window.isKeyWindow else { return false } + let owner = window.firstResponder + if let conversationSelection, conversationSelection.isActive, conversationSelection.owns(owner) { + return owner !== self + } + guard let owner = owner as? LinkTextView, owner !== self, let answerSelection else { return false } + return owner.answerSelection === answerSelection + } + override func draw(_ dirtyRect: NSRect) { super.draw(dirtyRect) // AppKit highlights only the first responder in the active colour. The other paragraphs // in one answer looked deselected even though Copy included them. Draw their selection - // with the same ink while the answer owns the keyboard. - guard let answerSelection, let window, window.isKeyWindow, - let owner = window.firstResponder as? LinkTextView, - owner !== self, owner.answerSelection === answerSelection, + // with the same ink while the answer, or the whole conversation, owns the keyboard. + guard isSelectedWithTheKeyboardOwner, let layout = layoutManager, let container = textContainer else { return } let range = selectedRange() guard range.length > 0 else { return } let glyphs = layout.glyphRange(forCharacterRange: range, actualCharacterRange: nil) let origin = textContainerOrigin - NSColor.selectedTextBackgroundColor.setFill() + let fill = selectedTextAttributes[.backgroundColor] as? NSColor ?? .selectedTextBackgroundColor + fill.setFill() layout.enumerateEnclosingRects( forGlyphRange: glyphs, withinSelectedGlyphRange: glyphs, in: container ) { rect, _ in @@ -439,33 +509,48 @@ final class LinkTextView: NSTextView, HoverQuickLookSource { override func resignFirstResponder() -> Bool { let resigned = super.resignFirstResponder() - if resigned, let selection = answerSelection { - // Resignation happens before AppKit installs the next responder. A click in another - // block of this answer keeps the selection; leaving the answer clears every block. - Task { @MainActor [weak self, weak selection] in - guard let self, let selection, - (window?.firstResponder as? LinkTextView)?.answerSelection !== selection else { return } - selection.clear() + guard resigned, answerSelection != nil || conversationSelection != nil else { return resigned } + // Resignation happens before AppKit installs the next responder. A click in another + // block of this answer keeps the selection; leaving the answer clears every block. While + // the conversation holds a selection the table has taken the keyboard, which is not + // leaving it. + Task { @MainActor [weak self, weak answerSelection, weak conversationSelection] in + guard let self else { return } + if let conversationSelection, conversationSelection.isActive { + if !conversationSelection.owns(window?.firstResponder) { conversationSelection.cancel() } + return } + let next = window?.firstResponder as? LinkTextView + if let answerSelection, next?.answerSelection !== answerSelection { answerSelection.clear() } } return resigned } override func selectAll(_ sender: Any?) { - guard let answerSelection else { super.selectAll(sender); return } - answerSelection.selectAll() + if let conversationSelection { + conversationSelection.selectAll() + } else if let answerSelection { + answerSelection.selectAll() + } else { + super.selectAll(sender) + } } override func validateUserInterfaceItem(_ item: any NSValidatedUserInterfaceItem) -> Bool { - if item.action == #selector(copy(_:)), let answerSelection { - return !answerSelection.selectedText.isEmpty - } + guard item.action == #selector(copy(_:)) else { return super.validateUserInterfaceItem(item) } + if let everything = conversationSelection?.selectedText { return !everything.isEmpty } + if let answerSelection { return !answerSelection.selectedText.isEmpty } return super.validateUserInterfaceItem(item) } override func copy(_ sender: Any?) { - guard let answerSelection else { super.copy(sender); return } - TranscriptLink.copy(answerSelection.selectedText) + if let everything = conversationSelection?.selectedText { + TranscriptLink.copy(everything) + } else if let answerSelection { + TranscriptLink.copy(answerSelection.selectedText) + } else { + super.copy(sender) + } } override func viewDidMoveToWindow() { @@ -479,16 +564,20 @@ final class LinkTextView: NSTextView, HoverQuickLookSource { return actions.previewSource(url) } + /// One answer this run has given: its size, and where its ink sat in a bubble at that width. struct Measurement { - var proposedWidth: Double? - var layoutWidth: CGFloat - var alignsBubbleInk: Bool var size: CGSize + var bubbleInkOffset: CGFloat } - /// SwiftUI asks a representable for the same proposal several times in one layout pass. - /// TextKit's layout and widest-line scan only need to run again when its text or width moves. - var lastMeasurement: Measurement? + /// SwiftUI asks a representable the same questions several times in one layout pass, at + /// several widths. TextKit's layout and widest-line scan only need to run again when the text + /// changes or a width is asked that has not been asked before. `apply` is the only place the + /// text storage is written, and it clears these; anything that ever edits the storage in place + /// has to clear them too. + var measurements = TranscriptTextMeasureCache() + /// The last answer handed to SwiftUI, for correcting a wider layout before drawing a bubble. + var lastReportedMeasurement: (size: CGSize, alignsBubbleInk: Bool)? var bubbleAlignmentWidth: CGFloat? var bubbleInkOffset: CGFloat = 0 { didSet { @@ -595,10 +684,11 @@ final class LinkTextView: NSTextView, HoverQuickLookSource { /// short line reports the last character on it, so without the bounds test a click in the /// white space beside a one-line turn would open its file. override func mouseDown(with event: NSEvent) { + conversationSelection?.cancel() guard let chip = fileChip(at: convert(event.locationInWindow, from: nil)), let path = chip.subject.path else { - if let answerSelection { + if answerSelection != nil || conversationSelection != nil { let point = convert(event.locationInWindow, from: nil) let url = link(at: point) let dragged = trackAnswerSelection(with: event, selection: answerSelection) @@ -716,6 +806,10 @@ final class LinkTextView: NSTextView, HoverQuickLookSource { guard type == .string, let storage = textStorage else { return super.writeSelection(to: pasteboard, type: type) } + if let everything = conversationSelection?.selectedText { + pasteboard.setString(everything, forType: .string) + return true + } if let answerSelection { pasteboard.setString(answerSelection.selectedText, forType: .string) return true @@ -757,11 +851,17 @@ final class LinkTextView: NSTextView, HoverQuickLookSource { for offered in actions.items(url) { menu.addItem(item(offered.title, url: url, target: offered.target)) } - if let file = actions.previewSource(url), QuickLookTarget.url(for: file.path) != nil { - let preview = NSMenuItem(title: "Quick Look", action: #selector(previewSource(_:)), keyEquivalent: "") - preview.target = self - preview.represent(file) - menu.addItem(preview) + if let file = actions.previewSource(url) { + if QuickLookTarget.url(for: file.path) != nil { + let preview = NSMenuItem(title: "Quick Look", action: #selector(previewSource(_:)), keyEquivalent: "") + preview.target = self + preview.represent(file) + menu.addItem(preview) + } + let reveal = NSMenuItem(title: "Show in Finder", action: #selector(showSourceInFinder(_:)), keyEquivalent: "") + reveal.target = self + reveal.represent(file) + menu.addItem(reveal) } menu.addItem(.separator()) let copy = NSMenuItem(title: "Copy Link", action: #selector(copyLink(_:)), keyEquivalent: "") @@ -771,6 +871,16 @@ final class LinkTextView: NSTextView, HoverQuickLookSource { return menu } + override func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { + // NSTextView disables actions it does not recognise, even when a custom menu item names + // this view as its target. The link is attached to the item when the menu is built. + if menuItem.action == #selector(copyLink(_:)) + || menuItem.action == #selector(showSourceInFinder(_:)) { + return menuItem.represented(URL.self) != nil + } + return super.validateMenuItem(menuItem) + } + private func item(_ title: String, url: URL, target: TranscriptLinkTarget) -> NSMenuItem { let item = NSMenuItem(title: title, action: #selector(openLink(_:)), keyEquivalent: "") item.target = self @@ -797,6 +907,11 @@ final class LinkTextView: NSTextView, HoverQuickLookSource { HoverQuickLookController.shared.show(url, in: window) } + @objc private func showSourceInFinder(_ sender: NSMenuItem) { + guard let url = sender.represented(URL.self) else { return } + Reveal.inFinder(url.path) + } + @objc private func copyLink(_ sender: NSMenuItem) { guard let url = sender.represented(URL.self) else { return } TranscriptLink.copy(url.absoluteString) diff --git a/Sources/Bloom/Views/Transcript/TurnFooterView.swift b/Sources/Bloom/Views/Transcript/TurnFooterView.swift index c5411dd93..3f31f2d55 100644 --- a/Sources/Bloom/Views/Transcript/TurnFooterView.swift +++ b/Sources/Bloom/Views/Transcript/TurnFooterView.swift @@ -5,7 +5,6 @@ import BloomCore /// The line that closes a turn: how long it took, what it changed, and the handles to do something /// with it. struct TurnFooterView: View { - var rows: [TranscriptRow] var row: TranscriptRow /// What the file chips show their paths relative to. See `TurnFile.display(in:)`. var worktree: String @@ -308,6 +307,7 @@ struct TurnFooterView: View { files = known return } + guard let rows = transcript?.rows else { return } let scanned = await Task.detached(priority: .utility) { [rows, seq = row.seq] in TurnScan.files(rows: rows, endingAt: seq) }.value diff --git a/Sources/Bloom/Views/Transcript/UserTurnRowView.swift b/Sources/Bloom/Views/Transcript/UserTurnRowView.swift index d7f1c9afe..e31e8a38a 100644 --- a/Sources/Bloom/Views/Transcript/UserTurnRowView.swift +++ b/Sources/Bloom/Views/Transcript/UserTurnRowView.swift @@ -74,10 +74,13 @@ struct UserTurnRowView: View { /// moment it arrives. Nil for every bubble nobody is pointing at, which is what keeps the /// probe below and the timer beside it off every other row in the transcript. @State private var hovered: FileChipHover? + @State private var isBubbleHovered = false /// Where the sentence is in the window, read only while a pill in it is under the pointer. See /// `chipProbe`. @State private var textFrame: CGRect = .zero @State private var hoverTask: Task? + /// Whether the pointer is anywhere on this row, which is when the copy control shows itself. + @State private var isPointerOnRow = false /// The card this bubble put up, if it is still up. Recorded rather than recomputed, exactly as /// `ToolRowHeader` records its own: what has to be taken down is what was PUT up, and the /// pointer crossing from one chip to the next raises the second before the first is told it @@ -111,47 +114,85 @@ struct UserTurnRowView: View { private var side: HorizontalEdge { .trailing } + /// The square the copy control is drawn and hit in, the same one the agent's answers use. + private static let copyControlSize: CGFloat = 18 + + /// Drawn in the empty margin beside the bubble as an overlay, which takes no width: the + /// bubble's cap is measured by the list, and a control that shared the row's width would wrap + /// the text differently from the height the table was told. + @ViewBuilder + private var copyControl: some View { + if !text.isEmpty { + CopyButton( + text: text, + title: "Copy this message", + isVisible: isPointerOnRow, + size: Self.copyControlSize, + imageScale: .small + ) + .offset(x: -(Self.copyControlSize + Metrics.spacingSmall)) + } + } + var body: some View { - HStack(spacing: 0) { + HStack(alignment: .top, spacing: 0) { if side == .trailing { Spacer(minLength: Self.inset) } - VStack(alignment: side == .leading ? .leading : .trailing, spacing: TranscriptLayout.tight) { - CappedWidth(width: maxWidth) { - bubble.padding(Self.padding) + HStack(alignment: .top, spacing: 0) { + if !copyText.isEmpty { + CopyButton( + text: copyText, + title: "Copy this prompt", + isVisible: isBubbleHovered, + size: 18, + imageScale: .small + ) + .padding(.trailing, TranscriptLayout.tight) + .padding(.top, Self.padding.top) + } + + VStack(alignment: side == .leading ? .leading : .trailing, spacing: TranscriptLayout.tight) { + CappedWidth(width: maxWidth) { + bubble.padding(Self.padding) + } + .padding(.bottom, OutgoingBubbleShape.tailDrop) + .background( + background, + in: OutgoingBubbleShape(cornerRadius: Self.corner, tail: side) + ) + // No stroke around the fill. A border on a filled shape is a control's outline, + // and the fill already separates the turn from the ground in both appearances. + // + // Everything inside is told it is sitting on the accent fill, which is the same + // signal a selected sidebar row sends. `Chip`, `DiffStatLabel`, `RepoIcon` and + // `AttachmentChip` all read it and swap to the variant that survives the inversion, + // so a chip inside a user turn needs no knowledge of this view. + .environment(\.isOnEmphasizedSelection, true) + // And that the ground under them is dark, which on a light page it now is. + // + // This is not a stylistic flourish, it is what makes the text selectable in any + // useful sense. Selecting text paints `selectedTextBackgroundColor` BEHIND the glyphs + // and leaves the foreground exactly as it was: on the light ramp that colour is a pale + // blue, so dragging over a white sentence on this fill wrote it in white on near white + // and the selection was unreadable while it was being made. Measured off a probe of + // this exact bubble: the highlight comes out #BAD6FB and white on it is 1.5 to 1. + // + // Naming the scheme resolves every appearance-dependent colour inside the bubble on + // the dark ramp. The claim is honest rather than a trick: this bubble IS a dark + // surface whatever the page around it is doing. AppKit cannot read it, which is why + // the text view below is handed `Palette.bubbleTextSelection` as well. + .environment(\.colorScheme, .dark) + .overlay(alignment: .bottomLeading) { copyControl } } - .padding(.bottom, OutgoingBubbleShape.tailDrop) - .background( - background, - in: OutgoingBubbleShape(cornerRadius: Self.corner, tail: side) - ) - // No stroke around the fill. A border on a filled shape is a control's outline, - // and the fill already separates the turn from the ground in both appearances. - // - // Everything inside is told it is sitting on the accent fill, which is the same - // signal a selected sidebar row sends. `Chip`, `DiffStatLabel`, `RepoIcon` and - // `AttachmentChip` all read it and swap to the variant that survives the inversion, - // so a chip inside a user turn needs no knowledge of this view. - .environment(\.isOnEmphasizedSelection, true) - // And that the ground under them is dark, which on a light page it now is. - // - // This is not a stylistic flourish, it is what makes the text selectable in any - // useful sense. Selecting text paints `selectedTextBackgroundColor` BEHIND the glyphs - // and leaves the foreground exactly as it was: on the light ramp that colour is a pale - // blue, so dragging over a white sentence on this fill wrote it in white on near white - // and the selection was unreadable while it was being made. Measured off a probe of - // this exact bubble: the highlight comes out #BAD6FB and white on it is 1.5 to 1. - // - // Naming the scheme resolves every appearance-dependent colour inside the bubble on - // the dark ramp. The claim is honest rather than a trick: this bubble IS a dark - // surface whatever the page around it is doing. AppKit cannot read it, which is why - // the text view below is handed `Palette.bubbleTextSelection` as well. - .environment(\.colorScheme, .dark) } + .contentShape(Rectangle()) + .onHover { isBubbleHovered = $0 } if side == .leading { Spacer(minLength: Self.inset) } } .padding(.horizontal, TranscriptLayout.inset) .padding(.vertical, TranscriptLayout.inset) + .onHover { isPointerOnRow = $0 } .onChange(of: hovered) { _, chip in hoverTask?.cancel() guard let chip else { @@ -173,6 +214,8 @@ struct UserTurnRowView: View { } } + private var copyText: String { SentTurn.withoutInstructions(text) } + /// The words and then the files, which is the order they were written in and the order the /// composer showed them in, except that up there the chips sat above the text because that is /// where the row of them lives. Here the sentence comes first: it is what the turn is about. @@ -363,8 +406,8 @@ extension TranscriptLinkActions { copy.previewFile = preview // The identity moves with the closure, or a bubble that can open a file would compare // equal to the list's value that cannot, and the environment would never see the change. - if case let .workspace(id, pane) = identity { - copy.identity = .workspaceOpeningFiles(id, pane: pane) + if case let .workspace(id, pane, session) = identity { + copy.identity = .workspaceOpeningFiles(id, pane: pane, session: session) } return copy } diff --git a/Sources/BloomCore/Agent/AgentAccountProfile.swift b/Sources/BloomCore/Agent/AgentAccountProfile.swift new file mode 100644 index 000000000..5b4fa86e1 --- /dev/null +++ b/Sources/BloomCore/Agent/AgentAccountProfile.swift @@ -0,0 +1,228 @@ +import Foundation + +/// A separate CLI configuration home. The CLI owns the credentials inside it. +public struct AgentAccountProfile: Codable, Hashable, Identifiable, Sendable { + public let id: AgentAccountProfileID + public let kind: AgentKind + public var name: String + + public init(id: AgentAccountProfileID = .new(), kind: AgentKind, name: String) { + self.id = id + self.kind = kind + self.name = name + } +} + +public enum AgentAccountEnvironment { + public static func shellArguments(kind: AgentKind, home: String?) -> [String] { + guard let home else { return [] } + switch kind { + case .claudeCode: + return [ + "-u", "ANTHROPIC_API_KEY", "-u", "CLAUDE_CODE_OAUTH_TOKEN", + "CLAUDE_CONFIG_DIR=\(home)" + ] + case .codex: return ["-u", "OPENAI_API_KEY", "-u", "CODEX_API_KEY", "CODEX_HOME=\(home)"] + case .grok: return ["-u", "XAI_API_KEY", "GROK_HOME=\(home)"] + case .cursor, .openCode: return [] + } + } + + public static func applying( + kind: AgentKind, home: String?, to environment: [String: String] + ) -> [String: String] { + guard let home else { return environment } + var result = environment + switch kind { + case .claudeCode: + result.removeValue(forKey: "ANTHROPIC_API_KEY") + result.removeValue(forKey: "CLAUDE_CODE_OAUTH_TOKEN") + result["CLAUDE_CONFIG_DIR"] = home + case .codex: + result.removeValue(forKey: "OPENAI_API_KEY") + result.removeValue(forKey: "CODEX_API_KEY") + result["CODEX_HOME"] = home + case .grok: + result.removeValue(forKey: "XAI_API_KEY") + result["GROK_HOME"] = home + case .cursor, .openCode: + break + } + return result + } +} + +extension Store { + private static var agentProfilesKey: String { "agent.accounts.profiles" } + + public func agentAccountProfiles() throws -> [AgentAccountProfile] { + guard let raw = try setting(Self.agentProfilesKey), let data = raw.data(using: .utf8) else { return [] } + return try JSONDecoder().decode([AgentAccountProfile].self, from: data) + } + + public func createAgentAccountProfile(kind: AgentKind, name: String) throws -> AgentAccountProfile { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, kind.canRunWorkspaces else { + throw AgentAccountProfileError.invalidNameOrProvider + } + var profiles = try agentAccountProfiles() + guard !profiles.contains(where: { + $0.kind == kind && $0.name.localizedCaseInsensitiveCompare(trimmed) == .orderedSame + }) else { throw AgentAccountProfileError.duplicateName } + let profile = AgentAccountProfile(kind: kind, name: trimmed) + let home = agentAccountHome(profile) + try FileManager.default.createDirectory( + atPath: home, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + profiles.append(profile) + let data = try JSONEncoder().encode(profiles) + try setSetting(Self.agentProfilesKey, String(decoding: data, as: UTF8.self)) + return profile + } + + /// A profile with saved conversations still owns their CLI thread. Refuse removal rather than + /// making the next turn silently switch accounts or lose its server-side context. + public func removeAgentAccountProfile(id: AgentAccountProfileID) throws { + guard let uuid = UUID(uuidString: id.rawValue), + uuid.uuidString.lowercased() == id.rawValue else { + throw AgentAccountProfileError.invalidIdentifier + } + let profiles = try agentAccountProfiles() + guard let profile = profiles.first(where: { $0.id == id }) else { + throw AgentAccountProfileError.unknownProfile + } + let bound = try db.query( + """ + SELECT COUNT(*) AS n FROM settings preference + JOIN sessions chat ON preference.key = 'agent.session.' || chat.id || '.account' + WHERE preference.value = ? + """, [.text(id.rawValue)] + ).first?.int("n") ?? 0 + guard bound == 0 else { throw AgentAccountProfileError.inUse(Int(bound)) } + + let home = URL(fileURLWithPath: agentAccountHome(profile)) + let staged = home.deletingLastPathComponent() + .appendingPathComponent("\(id.rawValue).removing-\(UUID().uuidString.lowercased())") + let hasHome = FileManager.default.fileExists(atPath: home.path) + if hasHome { try FileManager.default.moveItem(at: home, to: staged) } + + do { + let remaining = profiles.filter { $0.id != id } + let encoded = try JSONEncoder().encode(remaining) + try db.transaction { + try setSetting( + Self.agentProfilesKey, + remaining.isEmpty ? nil : String(decoding: encoded, as: UTF8.self) + ) + try db.run( + "DELETE FROM settings WHERE key LIKE 'agent.project.%' AND value = ?", + [.text(id.rawValue)] + ) + try db.run( + "DELETE FROM settings WHERE key LIKE 'agent.session.%.account' AND value = ?", + [.text(id.rawValue)] + ) + } + } catch { + if hasHome { try? FileManager.default.moveItem(at: staged, to: home) } + throw error + } + + if hasHome { + do { + try FileManager.default.removeItem(at: staged) + } catch { + throw AgentAccountProfileError.cleanupFailed(staged.path) + } + } + } + + public nonisolated func agentAccountHome(_ profile: AgentAccountProfile) -> String { + URL(fileURLWithPath: path).deletingLastPathComponent() + .appendingPathComponent("Agent Accounts", isDirectory: true) + .appendingPathComponent(profile.kind.rawValue, isDirectory: true) + .appendingPathComponent(profile.id.rawValue, isDirectory: true).path + } + + public func defaultAgent(for repoID: RepoID) throws -> AgentKind? { + try setting("agent.project.\(repoID.rawValue).default").flatMap(AgentKind.init(rawValue:)) + } + + public func projectAgentDefaults(for repoID: RepoID) throws -> (model: String, effort: String, kind: AgentKind)? { + guard let kind = try defaultAgent(for: repoID), + let model = try setting("agent.project.\(repoID.rawValue).model") else { return nil } + return (model, try setting("agent.project.\(repoID.rawValue).effort") ?? "high", kind) + } + + public func setProjectAgentDefaults( + for repoID: RepoID, model: String?, effort: String?, kind: AgentKind? + ) throws { + guard model == nil || (kind?.canRunWorkspaces == true && !(model ?? "").isEmpty) else { + throw AgentAccountProfileError.invalidNameOrProvider + } + try db.transaction { + let prefix = "agent.project.\(repoID.rawValue)" + try setSetting("\(prefix).default", kind?.rawValue) + try setSetting("\(prefix).model", model) + try setSetting("\(prefix).effort", effort) + } + } + + /// Local project choices outrank a model stated in a shared repository file. + public func applyingProjectAgentDefaults( + to settings: RepoSettings, for repoID: RepoID + ) throws -> RepoSettings { + guard let chosen = try projectAgentDefaults(for: repoID) else { return settings } + var result = settings + result.defaultModel = "\(chosen.kind.executableName):\(chosen.model)" + result.defaultEffort = chosen.effort + return result + } + + public func defaultAccount(for repoID: RepoID, kind: AgentKind) throws -> AgentAccountProfileID? { + try setting("agent.project.\(repoID.rawValue).\(kind.rawValue).account") + .map(AgentAccountProfileID.init) + } + + public func setDefaultAccount( + _ profileID: AgentAccountProfileID?, for repoID: RepoID, kind: AgentKind + ) throws { + if let profileID { + guard try agentAccountProfiles().contains(where: { $0.id == profileID && $0.kind == kind }) else { + throw AgentAccountProfileError.unknownProfile + } + } + try setSetting("agent.project.\(repoID.rawValue).\(kind.rawValue).account", profileID?.rawValue) + } + + /// Bind a conversation on first launch, so changing the project default cannot redirect its + /// existing CLI thread to another account on a later turn. + public func accountForLaunch(session: Session) throws -> AgentAccountProfile? { + guard let workspaceID = session.workspaceID, + let workspace = try workspace(id: workspaceID) else { return nil } + let key = "agent.session.\(session.id.rawValue).account" + let selectedID: AgentAccountProfileID? + if let bound = try setting(key) { + selectedID = bound == "system" ? nil : AgentAccountProfileID(bound) + } else { + selectedID = session.agentSessionID == nil + ? try defaultAccount(for: workspace.repoID, kind: session.agentKind) : nil + try setSetting(key, selectedID?.rawValue ?? "system") + } + guard let selectedID else { return nil } + guard let profile = try agentAccountProfiles().first(where: { + $0.id == selectedID && $0.kind == session.agentKind + }) else { throw AgentAccountProfileError.unknownProfile } + return profile + } +} + +public enum AgentAccountProfileError: Error { + case invalidNameOrProvider + case duplicateName + case invalidIdentifier + case unknownProfile + case inUse(Int) + case cleanupFailed(String) +} diff --git a/Sources/BloomCore/Agent/AgentKind+Interactive.swift b/Sources/BloomCore/Agent/AgentKind+Interactive.swift index 0529ea70b..166c56a75 100644 --- a/Sources/BloomCore/Agent/AgentKind+Interactive.swift +++ b/Sources/BloomCore/Agent/AgentKind+Interactive.swift @@ -9,7 +9,8 @@ public extension AgentKind { effort: String, permissionMode: PermissionMode? = nil, resuming: String? = nil, - bridge: BridgeHandle? = nil + bridge: BridgeHandle? = nil, + accountHome: String? = nil ) -> String? { guard let arguments = interactiveArguments( prompt: prompt, sessionID: sessionID, model: model, @@ -19,7 +20,9 @@ public extension AgentKind { directory: directory, executable: "/usr/bin/env", arguments: [ - "-u", "NO_COLOR", "TERM=xterm-256color", "COLORTERM=truecolor", + "-u", "NO_COLOR" + ] + AgentAccountEnvironment.shellArguments(kind: self, home: accountHome) + [ + "TERM=xterm-256color", "COLORTERM=truecolor", "BLOOM_CLI_STATUS_FILE=\(Self.interactiveStatusURL(sessionID: sessionID).path)", executableName ] + arguments diff --git a/Sources/BloomCore/Agent/AgentKind+InteractiveLaunch.swift b/Sources/BloomCore/Agent/AgentKind+InteractiveLaunch.swift index 32a34dd29..b44862426 100644 --- a/Sources/BloomCore/Agent/AgentKind+InteractiveLaunch.swift +++ b/Sources/BloomCore/Agent/AgentKind+InteractiveLaunch.swift @@ -10,12 +10,13 @@ public extension AgentKind { permissionMode: PermissionMode? = nil, resuming: String? = nil, base: URL? = nil, - bridge: BridgeHandle? = nil + bridge: BridgeHandle? = nil, + accountHome: String? = nil ) throws -> String? { guard let script = interactiveCommand( directory: directory, prompt: prompt, sessionID: sessionID, model: model, effort: effort, permissionMode: permissionMode, - resuming: resuming, bridge: bridge + resuming: resuming, bridge: bridge, accountHome: accountHome ) else { return nil } let folder = try Self.interactiveLaunchDirectory(sessionID: sessionID, base: base) let file = folder.appendingPathComponent(resuming == nil ? "start.sh" : "resume.sh") diff --git a/Sources/BloomCore/Agent/AgentRunner.swift b/Sources/BloomCore/Agent/AgentRunner.swift index b34df21ee..23342e60a 100644 --- a/Sources/BloomCore/Agent/AgentRunner.swift +++ b/Sources/BloomCore/Agent/AgentRunner.swift @@ -147,6 +147,7 @@ public actor AgentRunner { /// stderr only ever surfaces when the process dies without a `result`, so a short tail is all /// that is worth holding. private static let stderrTailLimit = 40 + private let accountHome: String? /// - Parameter shutdownBudget: how long a turn waits for a cancelled process to die before it /// refuses. Five seconds covers SIGTERM, the three second grace period and the SIGKILL @@ -160,6 +161,7 @@ public actor AgentRunner { session: Session, store: Store, mcpConfigPath: String? = nil, + accountHome: String? = nil, shutdownBudget: Duration = .seconds(5), makeProcess: @escaping @Sendable (AgentLaunch) -> any AgentProcessing = AgentRunner.spawn ) { @@ -168,6 +170,7 @@ public actor AgentRunner { self.session = session self.store = store self.mcpConfigPath = mcpConfigPath + self.accountHome = accountHome self.shutdownBudget = shutdownBudget self.makeProcess = makeProcess self.grants = SessionGrants(store: store, workspaceID: session.workspaceID) @@ -294,9 +297,6 @@ public actor AgentRunner { if let resume, !resume.isEmpty { arguments += ["--resume", resume] } - if session.workspaceID == nil { - arguments += ["--append-system-prompt", AskConversation.instructions] - } return arguments } @@ -326,7 +326,9 @@ public actor AgentRunner { mcpConfigPath: mcpConfigPath ), cwd: workspacePath, - environment: Shell.environment() + environment: AgentAccountEnvironment.applying( + kind: .claudeCode, home: accountHome, to: Shell.environment() + ) ) } diff --git a/Sources/BloomCore/Agent/AskConversation.swift b/Sources/BloomCore/Agent/AskConversation.swift index dfa6dfc36..d0364b7e4 100644 --- a/Sources/BloomCore/Agent/AskConversation.swift +++ b/Sources/BloomCore/Agent/AskConversation.swift @@ -1,68 +1,51 @@ import Foundation -/// The chat that belongs to Bloom rather than to a workspace: what it is called, where it runs, -/// and what it is allowed to assume. +/// Shared rules for a conversation that has no project or Git worktree. /// -/// Everything the conversation itself needs already exists. `BridgeIdentity.owner` is a caller in -/// no workspace, `BridgeOwnerToken` is the credential that outlives a relaunch, and eleven owner -/// tools already list projects, list workspaces with their checks, register a repository and cut a -/// worktree. This type is the four decisions that were left: the directory, the mode, the name and -/// the words. +/// The former Ask Bloom panel used the same persisted sessions. Its directory and title remain +/// readable so existing conversations appear in the new sidebar section with their history. public enum AskConversation { - /// What the chat is called, in the sidebar and on its row. - public static let title = "Ask Bloom" + /// The placeholder title for a conversation that has not been used yet. + public static let title = "New conversation" + public static let legacyTitle = "Ask Bloom" - /// Without host context, Ask Bloom picked an installed Conductor skill to create workspaces. - /// Both backends receive these instructions when starting or resuming an Ask conversation. - public static let instructions = """ - You are Ask Bloom, running inside the Bloom macOS app. Bloom manages the user's projects, \ - workspaces and agent conversations. This conversation belongs to Bloom and has no workspace \ - of its own. + public static let placeholder = "Ask anything…" - When the user asks to create, open, inspect or manage projects or workspaces, use Bloom by \ - default unless they explicitly name another app. Use the connected Bloom MCP tools. Discover \ - them with tool search if needed: project_list and workspace_list find existing work, \ - project_add registers a repository, and workspace_start creates a workspace and starts its \ - agent with the supplied prompt. Include any requested exploration or implementation in that \ - opening prompt, and let Bloom choose its configured workspace directory. - - Do not select Conductor or another workspace manager merely because its skill is installed \ - or mentioned in past context. If Bloom's tools are unavailable or fail, report the problem \ - instead of silently creating workspaces elsewhere. Follow the current permission mode and \ - any approval requests from the tools. - """ - - /// The placeholder in the composer. It says what this chat is for, because a chat with no - /// worktree looks exactly like a chat with one until you ask it something it cannot do. - public static let placeholder = "Ask about your projects and workspaces, or ask for one" + /// The directory for new project-free conversations. Older sessions retain their saved + /// directory, which may be the former Ask directory or one the owner chose in Settings. + public static func workspaceDirectory( + besideDatabaseAt databasePath: String, id: SessionID + ) -> String { + let container = (databasePath as NSString).deletingLastPathComponent + let root = (container as NSString).appendingPathComponent("Conversations") + return (root as NSString).appendingPathComponent(id.rawValue) + } - /// The mode it opens on, and it is deliberately not the app's default. - /// - /// **The default is Full access.** `AppDefaults.fallbackPermissionMode` is - /// `bypassPermissions`, and the Models tab can pin anything over it, so this chat inheriting - /// the owner's default would have meant an agent above every project running whatever it liked - /// without asking. That default is a reasonable one for a worktree, which is a copy: the - /// argument for it is that a session which stops before its first command is a session somebody - /// has to sit and watch. Neither half of that argument holds here, because there is no worktree - /// and because the owner is by definition sitting in front of this conversation. - /// - /// `acceptEdits` would be no better and is not the middle ground it sounds like: there is no - /// worktree for an accepted edit to be in, so what it accepts is edits anywhere the agent can - /// reach. So the mode is the narrowest one there is, and the composer still offers the others - /// for the day somebody wants one. `ComposerDefaults.resolve` is what stops the owner's - /// default landing on this chat the first time it is opened. - public static let permissionMode = PermissionMode.auto + public static func prepareWorkspaceDirectory( + besideDatabaseAt databasePath: String, id: SessionID + ) -> String? { + let path = workspaceDirectory(besideDatabaseAt: databasePath, id: id) + do { + try FileManager.default.createDirectory( + atPath: path, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + return path + } catch { + return nil + } + } - /// The fallback working directory, used until the owner chooses a folder in Settings. - /// Each conversation keeps the directory it started in. See `AskTabs`. + /// The legacy working directory for conversations made before the sidebar flow. + /// Each existing session keeps the directory stored under its id. See `AskTabs`. /// - /// **This is a permission decision rather than a tidiness one**, and it is the second lock - /// rather than the first. `permissionMode` above is what stops this chat opening on the mode - /// the owner's other chats open on; this is what makes the mode it does open on mean something. + /// **This is a permission decision rather than a tidiness one.** These chats follow the owner's + /// default permission mode, so its working directory still needs a deliberate boundary. /// A chat started in the owner's home directory would treat every file in it as inside the - /// working directory, which is the one place a mode stops asking about. An empty directory of - /// its own makes every filesystem reach a reach *outside* it, which is a reach that asks, and - /// it pushes the agent towards the bridge tools instead, which are the audited surface. + /// working directory. With a mode that asks before leaving that directory, an empty one keeps + /// those reaches visible and pushes the agent towards the audited bridge tools. Full access + /// still means full access, as the permission picker explains. /// /// Beside the database rather than at a fixed path, for the reason `BridgeOwnerToken.beside` /// gives: `Store.databaseDirectoryName` is the one rule that decides which copy of Bloom a @@ -95,42 +78,13 @@ public enum AskConversation { } } - /// What this conversation opens on, given the mode its row was last left holding. - /// - /// Nil means leave the row alone. - /// - /// **The lock has to be reapplied, or it holds exactly once.** `ComposerDefaults.resolve` - /// settles a chat's mode the first time it is opened and never again, and the composer's - /// picker writes the owner's choice onto the row. So a Full access chosen once to get - /// something done would have been what this conversation opened on for ever after, which is - /// the state the mode exists to prevent, arrived at quietly. - /// - /// The answer is not to take the picker away. A mode chosen mid-conversation was chosen by - /// somebody sitting in front of the conversation with the facts on the screen, and overruling - /// that while they watch would be a control that does not work. What expires is not the choice - /// but the presence behind it: a launch later, the agent that choice was made for is gone and - /// so is the memory of making it, and the mode would go on governing everything asked - /// tomorrow. This is the same reasoning `Store.resetRunningSessions` and - /// `abandonPendingPermissionAsks` are built on, which is that a launch boundary ends what a - /// person's presence justified. - /// - /// So: honoured for the rest of the launch, back to the narrowest mode on the next one, and - /// the permission menu's own footnote says so before the choice is made. See - /// `ComposerControls.permissionModeNote`. - public static func modeOnOpening( - stored: PermissionMode, - isFirstOpenSinceLaunch: Bool - ) -> PermissionMode? { - guard isFirstOpenSinceLaunch, stored != permissionMode else { return nil } - return permissionMode - } - - /// A brand new Ask chat, with no workspace behind it. - public static func newSession(sortOrder: Int = 0) -> Session { + /// A brand new chat, with no project worktree behind it. + public static func newSession(id: SessionID = .new(), sortOrder: Int = 0) -> Session { Session( + id: id, workspaceID: nil, title: title, - permissionMode: permissionMode, + permissionMode: AppDefaults.fallbackPermissionMode, sortOrder: sortOrder ) } @@ -143,21 +97,17 @@ public enum AskConversation { /// opens by listing its own limits reads as an apology for existing. The limits are real and /// they are said where they change a decision, which is the permission menu's footnote. /// - /// So this says what to ask it for, in the order somebody would want it: start something, find - /// out where everything stands, then go to the thing you found. - public static let emptyHeading = "Ask Bloom anything about your work" + /// It starts with an open invitation, since the conversation can be about any topic. + public static let emptyHeading = "Start a conversation" public static let emptyDetail = - "Start a new project or a workspace, ask what is running and what needs you, " - + "find the workspace with the failing checks, and open it." + "Ask a question, explore an idea, or keep a discussion going without a project." /// The title a conversation takes from its first message, or nil when it already has one. /// - /// Every conversation is made as "Ask Bloom", so a rail of them read "Ask Bloom" down the whole - /// column. The first line of the first thing asked is what a person would call it, and it is - /// there the moment the message is sent, with no model to wait on. A title somebody typed is - /// never overwritten, because only the default is replaced. + /// Both the current placeholder and the old Ask Bloom title take their name from the first + /// message. A title somebody typed is never overwritten. public static func automaticTitle(current: String, prompt: String) -> String? { - guard current == title else { return nil } + guard current == title || current == legacyTitle else { return nil } let trimmed = prompt.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } return Git.title(from: trimmed, maxLength: 40) @@ -165,24 +115,19 @@ public enum AskConversation { /// Whether a conversation has never been used: never named, never started. /// - /// The rail leaves these out. Closing the last conversation leaves a blank one behind so the - /// panel keeps a composer to type into, and a rail with one "Ask Bloom" row in it after you - /// closed everything looked like the close had not worked. + /// The sidebar leaves these out. Closing the last conversation can leave a blank session + /// behind, and a row for it would make the archive look as if it had failed. public static func isBlank(_ session: Session) -> Bool { - session.title == title && session.agentSessionID == nil + (session.title == title || session.title == legacyTitle) && session.agentSessionID == nil } /// What the empty pane offers to send in one click, under `suggestionsHeading`. /// - /// Each is a whole question, answered with nothing else typed: a chip that needs finishing is - /// a chip that makes somebody click, then type, then send, which is the clicking around the - /// owner asked this pane to stop. Every one is something the bridge's tools can actually do. - public static let suggestionsHeading = "Try asking Bloom:" + /// Each suggestion is a complete prompt, ready to send. + public static let suggestionsHeading = "Try asking:" public static let suggestions = [ - "What needs my attention right now?", - "Which workspaces have failing checks?", - "What is running at the moment?", - "Which workspaces did I touch yesterday?", - "Archive the workspaces whose pull requests are merged", + "Help me think through an idea", + "Summarise this document", + "Draft an outline for a post", ] } diff --git a/Sources/BloomCore/Agent/ClaudeModel+AgentModel.swift b/Sources/BloomCore/Agent/ClaudeModel+AgentModel.swift index 71b9d1a84..8f7c0d69d 100644 --- a/Sources/BloomCore/Agent/ClaudeModel+AgentModel.swift +++ b/Sources/BloomCore/Agent/ClaudeModel+AgentModel.swift @@ -19,10 +19,29 @@ extension ClaudeModel { } } +extension Array where Element == ClaudeModel { + /// Claude Code can lag a newly released model even while its full id works with `--model`. + /// Keep the explicit Sonnet 5.5 choice until the CLI lists it or resolves `sonnet` to it. + public var agentModels: [AgentModel] { + guard !isEmpty else { return [] } + var mapped = map(\.agentModel) + if !contains(where: { $0.id == "claude-sonnet-5-5" || $0.resolvedModel == "claude-sonnet-5-5" }) { + mapped.append(AgentModel( + id: "claude-sonnet-5-5", displayName: "Sonnet 5.5", + supportedEfforts: ["low", "medium", "high", "xhigh", "max"].map { + AgentModelEffort(id: $0, label: CodexReasoningEffort(id: $0).label) + }, + defaultEffort: "medium" + )) + } + return mapped + } +} + extension AgentModelSource { public static func claude(_ catalog: ClaudeModelCatalog = .shared) -> AgentModelSource { AgentModelSource( - models: { try await catalog.models().map(\.agentModel) }, + models: { try await catalog.models().agentModels }, invalidate: {} ) } diff --git a/Sources/BloomCore/Agent/ClaudeModelCatalog.swift b/Sources/BloomCore/Agent/ClaudeModelCatalog.swift index 4cab6ba36..d815f907a 100644 --- a/Sources/BloomCore/Agent/ClaudeModelCatalog.swift +++ b/Sources/BloomCore/Agent/ClaudeModelCatalog.swift @@ -4,6 +4,8 @@ import Foundation public struct ClaudeModel: Sendable, Hashable { /// What `--model` takes: `opus[1m]`, `sonnet`, `claude-fable-5-1[1m]`. public var id: String + /// The full model id behind an alias such as `sonnet`. + public var resolvedModel: String? /// "Opus 5 with 1M context", read off the CLI's own description, which names the version where /// its display name ("Opus (1M context)") does not. public var label: String @@ -12,10 +14,14 @@ public struct ClaudeModel: Sendable, Hashable { /// The one the CLI recommends: whichever entry resolves to what its `default` entry does. public var isDefault: Bool - public init(id: String, label: String, efforts: [String], supportsFastMode: Bool = false, isDefault: Bool = false) { + public init( + id: String, label: String, efforts: [String], resolvedModel: String? = nil, + supportsFastMode: Bool = false, isDefault: Bool = false + ) { self.id = id self.label = label self.efforts = efforts + self.resolvedModel = resolvedModel self.supportsFastMode = supportsFastMode self.isDefault = isDefault } @@ -50,6 +56,7 @@ public enum ClaudeModelList { models.append(ClaudeModel( id: id, label: label(entry, id: id), efforts: (entry["supportedEffortLevels"]?.arrayValue ?? []).compactMap(\.stringValue), + resolvedModel: entry["resolvedModel"]?.stringValue, supportsFastMode: entry["supportsFastMode"]?.boolValue ?? false, isDefault: isDefault )) diff --git a/Sources/BloomCore/Agent/Codex/CodexClient.swift b/Sources/BloomCore/Agent/Codex/CodexClient.swift index 6357219cb..922bc0f69 100644 --- a/Sources/BloomCore/Agent/Codex/CodexClient.swift +++ b/Sources/BloomCore/Agent/Codex/CodexClient.swift @@ -79,7 +79,9 @@ public actor CodexClient { public static func launch(_ configuration: Configuration) -> AgentLaunch { var environment = configuration.environment if let home = configuration.codexHome, !home.isEmpty { - environment["CODEX_HOME"] = home + environment = AgentAccountEnvironment.applying( + kind: .codex, home: home, to: environment + ) } // `-c` overrides belong to the `app-server` subcommand, so they follow it. Never // `--strict-config` beside them: it is not Claude Code's `--strict-mcp-config` but it is diff --git a/Sources/Bloom/Views/Transcript/CodexItemPresenter.swift b/Sources/BloomCore/Agent/Codex/CodexItemPresenter.swift similarity index 89% rename from Sources/Bloom/Views/Transcript/CodexItemPresenter.swift rename to Sources/BloomCore/Agent/Codex/CodexItemPresenter.swift index 52f04f3d7..8b1aa789a 100644 --- a/Sources/Bloom/Views/Transcript/CodexItemPresenter.swift +++ b/Sources/BloomCore/Agent/Codex/CodexItemPresenter.swift @@ -1,5 +1,4 @@ -import SwiftUI -import BloomCore +import Foundation /// Turns one Codex thread item into the single line a collapsed transcript row shows. /// @@ -238,27 +237,3 @@ enum CodexItemPresenter { text.components(separatedBy: .newlines).first { !$0.trimmingCharacters(in: .whitespaces).isEmpty } ?? "" } } - -// MARK: - Routing - -/// Which presenter draws a call. -/// -/// One call site rather than a decision spread through the transcript views, and the switch is on -/// the row's own payload rather than on the session, so a transcript loaded from the database -/// draws correctly before anything has looked up which backend the chat is on. -/// -/// The rows themselves are shared and need no change: `ToolRowView`, `ExpandableRow`, -/// `DetailCodeBlock` and `ToolResultView` all draw a `ToolPresentation` and do not care where it -/// came from. -enum TranscriptPresenter { - static func present(_ use: AgentToolUse, worktree: String = "") -> ToolPresentation { - CodexItemPresenter.present(use, worktree: worktree) ?? ToolPresenter.present(use, worktree: worktree) - } - - static func present(name: String, input: JSONValue, worktree: String = "") -> ToolPresentation { - if let item = CodexTranslation.item(in: input) { - return CodexItemPresenter.present(item, worktree: worktree) - } - return ToolPresenter.present(name: name, input: input, worktree: worktree) - } -} diff --git a/Sources/BloomCore/Agent/Codex/CodexModelCatalog.swift b/Sources/BloomCore/Agent/Codex/CodexModelCatalog.swift index b8e5854e7..a404c9471 100644 --- a/Sources/BloomCore/Agent/Codex/CodexModelCatalog.swift +++ b/Sources/BloomCore/Agent/Codex/CodexModelCatalog.swift @@ -110,6 +110,25 @@ public struct CodexModel: Sendable, Hashable, Identifiable { static func decodeList(_ json: JSONValue) -> [CodexModel] { (json["data"]?.arrayValue ?? []).compactMap(CodexModel.decode) } + + /// Codex can accept a newly released model before its `model/list` advertises it. Keep the + /// published Sol choice available during that gap, then use the CLI's own metadata once it + /// arrives. The documented Codex effort range includes Ultra; the API's range ends at Max. + static func includingReleasedSol(_ models: [CodexModel]) -> [CodexModel] { + guard !models.isEmpty, !models.contains(where: { $0.id == "gpt-6.1-sol" }) else { return models } + let efforts = ["low", "medium", "high", "xhigh", "max", "ultra"].map { + CodexReasoningEffort(id: $0) + } + let sol = CodexModel( + id: "gpt-6.1-sol", + displayName: "GPT-6.1 Sol", + supportedEfforts: efforts, + defaultEffort: "medium", + inputModalities: ["text", "image"], + fastServiceTier: "priority" + ) + return models + [sol] + } } /// One reasoning level, with the sentence the server wrote for it. The description is worth @@ -189,7 +208,7 @@ public actor CodexModelCatalog { )) defer { Task { await client.stop() } } try await client.start() - return try await client.listModels() + return CodexModel.includingReleasedSol(try await client.listModels()) }) } diff --git a/Sources/BloomCore/Agent/Codex/CodexModelRank.swift b/Sources/BloomCore/Agent/Codex/CodexModelRank.swift index 192bd5846..875f953a1 100644 --- a/Sources/BloomCore/Agent/Codex/CodexModelRank.swift +++ b/Sources/BloomCore/Agent/Codex/CodexModelRank.swift @@ -11,7 +11,9 @@ import Foundation /// So the order is derived from the names, which is the only thing here that says anything about /// capability. Three rules, in this order: /// -/// 1. **Version, descending.** `gpt-5.6` above `gpt-5.5` above `gpt-5.4`. Parsed as numbers rather +/// 1. **GPT-6 family, then version.** Astra remains above Sol even though `gpt-6.1-sol` has a +/// newer minor number; OpenAI calls Astra the most capable model and Sol the lower-cost option. +/// Within a family, `gpt-5.6` above `gpt-5.5` above `gpt-5.4`. Parsed as numbers rather /// than compared as text, because `gpt-5.10` sorts below `gpt-5.9` as a string and above it as /// a version, and the string answer is wrong. A major with no minor after it is that major and /// zero, which is how `gpt-6-astra` came to lead the list the day it appeared without anything @@ -37,6 +39,9 @@ public enum CodexModelRank { .sorted { left, right in let a = key(left.element) let b = key(right.element) + if a.version.major == 6, b.version.major == 6, a.family != b.family { + return a.family < b.family + } if a.version != b.version { return a.version.isAbove(b.version) } if a.isReduced != b.isReduced { return !a.isReduced } // The arrival index, which is what makes this stable rather than merely sorted. @@ -45,8 +50,19 @@ public enum CodexModelRank { .map(\.element) } - static func key(_ model: CodexModel) -> (version: Version, isReduced: Bool) { - (version(of: model.id), isReduced(model.id)) + static func key(_ model: CodexModel) -> (version: Version, family: Int, isReduced: Bool) { + let words = model.id.lowercased().split { !$0.isLetter && !$0.isNumber } + let family: Int + if words.contains("astra") { + family = 0 + } else if words.contains("sol") { + family = 1 + } else if words.contains("luna") { + family = 2 + } else { + family = 3 + } + return (version(of: model.id), family, isReduced(model.id)) } /// A version as its parts rather than as one number, which is the whole point. diff --git a/Sources/BloomCore/Agent/Codex/CodexRunner.swift b/Sources/BloomCore/Agent/Codex/CodexRunner.swift index ae889a92d..67dcf4d69 100644 --- a/Sources/BloomCore/Agent/Codex/CodexRunner.swift +++ b/Sources/BloomCore/Agent/Codex/CodexRunner.swift @@ -19,6 +19,7 @@ public actor CodexRunner: SessionRunner { private let store: Store private let makeClient: @Sendable (CodexClient.Configuration) -> CodexClient + private let accountHome: String? private var session: Session private var client: CodexClient? @@ -81,6 +82,7 @@ public actor CodexRunner: SessionRunner { session: Session, store: Store, bridge: BridgeAttachment? = nil, + accountHome: String? = nil, makeClient: @escaping @Sendable (CodexClient.Configuration) -> CodexClient = CodexRunner.spawn ) { self.workspacePath = workspacePath @@ -88,6 +90,7 @@ public actor CodexRunner: SessionRunner { self.session = session self.store = store self.bridge = bridge + self.accountHome = accountHome self.makeClient = makeClient self.grants = SessionGrants(store: store, workspaceID: session.workspaceID) self.translation = CodexTranslation(context: CodexTranslation.Context( @@ -447,6 +450,7 @@ public actor CodexRunner: SessionRunner { let client = makeClient(CodexClient.Configuration( executable: AgentCatalog.executable(for: .codex, override: stored), cwd: workspacePath, + codexHome: accountHome, clientName: "Bloom", clientVersion: Self.clientVersion, bridge: bridge, @@ -475,11 +479,10 @@ public actor CodexRunner: SessionRunner { if let threadID { return threadID } let sandbox = Self.sandboxMode(for: session.permissionMode) - let instructions = session.workspaceID == nil ? AskConversation.instructions : nil let handle: CodexThreadHandle if let stored = session.agentSessionID, !stored.isEmpty { handle = try await client.resumeThread( - stored, cwd: workspacePath, sandbox: sandbox, developerInstructions: instructions + stored, cwd: workspacePath, sandbox: sandbox, developerInstructions: nil ) } else { handle = try await client.startThread( @@ -488,7 +491,7 @@ public actor CodexRunner: SessionRunner { approvalPolicy: Self.approvalPolicy(for: session.permissionMode), sandbox: sandbox, approvalsReviewer: Self.approvalsReviewer(for: session.permissionMode), - developerInstructions: instructions + developerInstructions: nil ) } diff --git a/Sources/BloomCore/Agent/Codex/CodexSpeed.swift b/Sources/BloomCore/Agent/Codex/CodexSpeed.swift index 6f1e7e3dd..ebec5a2f9 100644 --- a/Sources/BloomCore/Agent/Codex/CodexSpeed.swift +++ b/Sources/BloomCore/Agent/Codex/CodexSpeed.swift @@ -37,7 +37,7 @@ public struct CodexSpeed: Equatable, Sendable { do { try await client.start() let config = try await client.readConfiguration(cwd: cwd) - let models = try await client.listModels() + let models = CodexModel.includingReleasedSol(try await client.listModels()) let resolvedModel = ModelIdentifier.resolve(modelID).model guard let model = models.first(where: { $0.id == resolvedModel }) else { throw CodexClientError.unexpectedResult(method: "model/list") diff --git a/Sources/BloomCore/Agent/ComposerControls.swift b/Sources/BloomCore/Agent/ComposerControls.swift index 3a8302ee7..e3f5f551a 100644 --- a/Sources/BloomCore/Agent/ComposerControls.swift +++ b/Sources/BloomCore/Agent/ComposerControls.swift @@ -56,7 +56,7 @@ public struct ComposerControls: Equatable, Sendable { /// `CodexContextWindow.modelDefault` for whatever Codex's own catalogue says. Ignored on /// every other backend, which is why the picker is not drawn there. See `CodexContextWindow`. public var codexContextWindow: Int - /// Whether this chat has a worktree behind it. False is Ask Bloom, and it is here rather than + /// Whether this chat has a worktree behind it. False is a project-free conversation, and it is here rather than /// left to the view because it changes what the permission menu has to say. See /// `permissionModeNote`. public var hasWorktree: Bool @@ -149,10 +149,9 @@ public struct ComposerControls: Equatable, Sendable { /// the same words would mean the whole machine. public var permissionModeNote: String? { guard !hasWorktree else { return nil } - return "This conversation has no worktree, so anything wider than " - + "\(PermissionMode.auto.label(on: agentKind)) reaches the whole machine " - + "rather than a copy of a project. Whatever you choose lasts until Bloom " - + "next starts, and then it is back to that." + return "This conversation has no worktree, so permissions beyond " + + "\(PermissionMode.auto.label(on: agentKind)) can reach the whole machine " + + "rather than a copy of a project. Your choice is saved for this conversation." } /// Whether this backend has output styles at all. diff --git a/Sources/BloomCore/Agent/ComposerDefaults.swift b/Sources/BloomCore/Agent/ComposerDefaults.swift index 57dcfb260..4c7253452 100644 --- a/Sources/BloomCore/Agent/ComposerDefaults.swift +++ b/Sources/BloomCore/Agent/ComposerDefaults.swift @@ -31,20 +31,8 @@ public struct ComposerDefaults: Equatable { /// Pure, so the rules above can be checked without a store, a repository or a view. /// - /// - Parameter hasWorktree: false for a conversation that is in no workspace, which is Ask - /// Bloom and nothing else today. It changes exactly one answer, and it is the one that - /// matters: the permission mode becomes `AskConversation.permissionMode` rather than the - /// owner's default. - /// - /// **The default that overrules is not `acceptEdits`.** - /// `AppDefaults.fallbackPermissionMode` is `bypassPermissions`, and the Models tab can pin - /// anything over it, so a chat that inherited it would have opened on Full access: an agent - /// sitting above every project, running whatever it liked without asking. Neither of the two - /// modes that grant anything means what it usually means here anyway, because there is no - /// worktree for an accepted edit to be in. - /// - /// The model and the effort are still the owner's, because those are about what a - /// conversation costs and that is the same question here as anywhere. + /// - Parameter hasWorktree: false for Ask Bloom. Its permission mode follows the same app + /// preference; the flag still keeps Codex Plan's work mode out of a chat with no workspace. /// - Parameter running: the backend the chat is already on, which is what an id nothing /// recognises stays on. It is no longer what the answer runs on: the model decides that, and /// the mode follows it. "Start in plan mode" is an app-wide switch, chosen before any model @@ -86,6 +74,21 @@ public struct ComposerDefaults: Equatable { running: running, models: models ) + // A new chat must not start on a Codex model absent from the account's list. A Codex chat + // already running on an unknown id keeps its backend: discovery cannot move that chat to + // Claude Code just because the list changed after it opened. + let keepsRunningCodex = running == .codex && resolved.kind == .codex + guard keepsRunningCodex || ModelAvailability.isUsable( + model: resolved.model, on: resolved.kind, models: models + ) else { + return resolve( + repo: repo.withoutModelPins(), + app: app.withBuiltInModel(), + hasWorktree: hasWorktree, + running: running, + models: models + ) + } return ComposerDefaults( // `resolved.model` rather than the string the file held. A settings file has to name a // model for either CLI in one key, so it can write the backend in front of it, and @@ -98,9 +101,8 @@ public struct ComposerDefaults: Equatable { // Through the backend the model just landed on rather than through the caller's, which // is the whole point of settling the two together: Plan plus a Codex default used to // depend on the caller passing the right backend in, and the create window passed none. - permissionMode: (hasWorktree - ? (app.planMode && resolved.kind != .codex ? .plan : app.permissionMode) - : AskConversation.permissionMode).nearest(on: resolved.kind), + permissionMode: (app.planMode && resolved.kind != .codex + ? .plan : app.permissionMode).nearest(on: resolved.kind), backend: resolved.kind, interactionMode: hasWorktree && app.planMode && resolved.kind == .codex ? .plan : .build ) @@ -118,3 +120,26 @@ public struct ComposerDefaults: Equatable { return fallback } } + +private extension RepoSettings { + func withoutModelPins() -> RepoSettings { + var next = self + next.defaultModel = nil + next.defaultEffort = nil + next.homeDefaultModel = nil + next.homeDefaultEffort = nil + return next + } +} + +private extension AppDefaults { + func withBuiltInModel() -> AppDefaults { + var next = self + next.model = AppDefaults.fallbackModel + next.storedModel = nil + next.effort = AppDefaults.fallbackEffort + next.storedEffort = nil + next.backend = AppDefaults.fallbackBackend + return next + } +} diff --git a/Sources/BloomCore/Agent/Crew.swift b/Sources/BloomCore/Agent/Crew.swift index 9287197d4..9b4b32c5c 100644 --- a/Sources/BloomCore/Agent/Crew.swift +++ b/Sources/BloomCore/Agent/Crew.swift @@ -27,7 +27,7 @@ import Foundation public enum Crew { /// How many subagents may be running in one workspace at once. /// - /// Ten, on the same reasoning as the eight running children a parent may have and the six + /// Ten, on the same reasoning as the running child limit a parent has and the six /// starts in fifteen minutes the owner's own client gets: not a safety limit, a number that /// makes somebody notice at ten rather than at thirty. It was three, which is what a person /// splitting work by hand reaches for, and it turned out to be the wrong shape for the thing diff --git a/Sources/BloomCore/Agent/Grok/GrokClient.swift b/Sources/BloomCore/Agent/Grok/GrokClient.swift index b39e6666d..41ffad7ff 100644 --- a/Sources/BloomCore/Agent/Grok/GrokClient.swift +++ b/Sources/BloomCore/Agent/Grok/GrokClient.swift @@ -58,7 +58,9 @@ public actor GrokClient { var environment = configuration.environment environment["GROK_DISABLE_AUTOUPDATER"] = "1" if let home = configuration.grokHome, !home.isEmpty { - environment["GROK_HOME"] = home + environment = AgentAccountEnvironment.applying( + kind: .grok, home: home, to: environment + ) } var arguments: [String] = ["agent"] if configuration.alwaysApprove { arguments.append("--always-approve") } diff --git a/Sources/BloomCore/Agent/Grok/GrokRunner.swift b/Sources/BloomCore/Agent/Grok/GrokRunner.swift index c052d7de9..fe70649d9 100644 --- a/Sources/BloomCore/Agent/Grok/GrokRunner.swift +++ b/Sources/BloomCore/Agent/Grok/GrokRunner.swift @@ -14,6 +14,7 @@ public actor GrokRunner: SessionRunner { private let store: Store private let makeClient: @Sendable (GrokClient.Configuration) -> GrokClient + private let accountHome: String? private var session: Session private var client: GrokClient? @@ -44,6 +45,7 @@ public actor GrokRunner: SessionRunner { session: Session, store: Store, bridge: BridgeAttachment? = nil, + accountHome: String? = nil, makeClient: @escaping @Sendable (GrokClient.Configuration) -> GrokClient = GrokRunner.spawn ) { self.workspacePath = workspacePath @@ -51,6 +53,7 @@ public actor GrokRunner: SessionRunner { self.session = session self.store = store self.bridge = bridge + self.accountHome = accountHome self.makeClient = makeClient self.grants = SessionGrants(store: store, workspaceID: session.workspaceID) self.translation = GrokTranslation(context: GrokTranslation.Context( @@ -210,6 +213,7 @@ public actor GrokRunner: SessionRunner { let client = makeClient(GrokClient.Configuration( executable: AgentCatalog.executable(for: .grok, override: stored), cwd: workspacePath, + grokHome: accountHome, clientName: "Bloom", clientVersion: Self.clientVersion, model: wireModel, diff --git a/Sources/BloomCore/Agent/LastModelChoice.swift b/Sources/BloomCore/Agent/LastModelChoice.swift new file mode 100644 index 000000000..e490031fe --- /dev/null +++ b/Sources/BloomCore/Agent/LastModelChoice.swift @@ -0,0 +1,80 @@ +import Foundation + +/// The model, effort and backend somebody last picked in a composer footer, kept so the next +/// workspace opens on it rather than on whatever Settings says. +/// +/// **It sits between the two things that already exist.** A default preset is a statement that +/// every new chat should start there, so it still wins. Settings, Models is the standing default +/// and this overrides it, because a choice made a minute ago is better evidence of what somebody +/// wants than a choice made at onboarding. A repository's own `models.default` still outranks +/// both, which `ComposerDefaults.resolve` already guarantees by reading the repository first. +/// +/// Only the three fields that name a model are kept. The permission mode and the output style are +/// about how a chat behaves rather than what runs it, and carrying them from one workspace to the +/// next would quietly widen what an agent is allowed to do. +public struct LastModelChoice: Codable, Equatable, Sendable { + public static let key = "composer.lastModelChoice" + + public var model: String + public var effort: String + /// Stored beside the model for the reason `AppDefaults.backend` is: Codex's list is fetched, + /// and a new workspace has to open on the right CLI before, or without, that fetch. + public var backend: AgentKind + + public init(model: String, effort: String, backend: AgentKind) { + self.model = model + self.effort = effort + self.backend = backend + } + + public init(_ controls: ComposerControls) { + self.init(model: controls.model, effort: controls.effort, backend: controls.agentKind) + } + + public static func load(from store: Store) async -> LastModelChoice? { + guard let raw = try? await store.setting(key), let data = raw.data(using: .utf8) else { + return nil + } + return try? JSONDecoder().decode(LastModelChoice.self, from: data) + } + + public func save(to store: Store) async throws { + let data = try JSONEncoder().encode(self) + try await store.setSetting(Self.key, String(decoding: data, as: UTF8.self)) + } +} + +public extension AppDefaults { + /// These defaults with the last footer choice written over the model, the effort and the + /// backend. The stored fields move too, for the reason `applying(_ preset:)` gives: they are + /// what `ComposerDefaults.resolve` reads to decide whether anything was chosen. + func applying(_ choice: LastModelChoice) -> AppDefaults { + var next = self + next.model = choice.model + next.storedModel = choice.model + next.effort = choice.effort + next.storedEffort = choice.effort + next.backend = choice.backend + return next + } +} + +/// Whether a model can be offered to somebody who may not have it. +/// +/// **Only Codex can be asked, and only once its list has arrived.** Codex's `model/list` is +/// authoritative for its own ids, and a model that is not on the list the signed in account +/// fetched is one the account cannot run. Claude Code's ids cannot be checked the same way: +/// `opus-5-1m` is a real id no list holds. And an empty list means the fetch has not answered, not +/// that nothing is allowed, so it never disqualifies anything: a default that changed while a list +/// loads would be worse than one that was occasionally wrong. +public enum ModelAvailability { + public static func isUsable( + model: String, + on kind: AgentKind, + models: [AgentKind: [AgentModel]] + ) -> Bool { + guard kind == .codex, let list = models[kind], !list.isEmpty else { return true } + let id = ModelIdentifier.resolve(model, models: models).model + return list.contains { $0.id == id } + } +} diff --git a/Sources/BloomCore/Agent/ModelPreset.swift b/Sources/BloomCore/Agent/ModelPreset.swift index 1bf03c1f4..37d564271 100644 --- a/Sources/BloomCore/Agent/ModelPreset.swift +++ b/Sources/BloomCore/Agent/ModelPreset.swift @@ -312,11 +312,22 @@ public extension AppDefaults { } /// What a brand new session inherits: the Settings defaults, with the default preset over - /// them when there is one. Settings itself reads `load`, so the screen keeps showing and - /// saving its own values rather than the preset's. - static func loadForNewSessions(from store: Store) async -> AppDefaults { + /// them when there is one, and otherwise the last model chosen in a footer. Settings itself + /// reads `load`, so the screen keeps showing and saving its own values rather than these. + /// + /// A last choice the account cannot run is skipped rather than applied, so the next layer down + /// answers instead of a chat opening on a model that refuses it. See `ModelAvailability`. + static func loadForNewSessions( + from store: Store, + models: [AgentKind: [AgentModel]] = [:] + ) async -> AppDefaults { let defaults = await load(from: store) - guard let preset = await ModelPresetList.load(from: store).defaultPreset else { return defaults } - return defaults.applying(preset) + if let preset = await ModelPresetList.load(from: store).defaultPreset { + return defaults.applying(preset) + } + guard let last = await LastModelChoice.load(from: store), + ModelAvailability.isUsable(model: last.model, on: last.backend, models: models) + else { return defaults } + return defaults.applying(last) } } diff --git a/Sources/BloomCore/Agent/TranscriptPresenter.swift b/Sources/BloomCore/Agent/TranscriptPresenter.swift new file mode 100644 index 000000000..33a7c1d64 --- /dev/null +++ b/Sources/BloomCore/Agent/TranscriptPresenter.swift @@ -0,0 +1,15 @@ +import Foundation + +/// Which presenter draws a call. Mac, web and future clients get the same readable row. +public enum TranscriptPresenter { + public static func present(_ use: AgentToolUse, worktree: String = "") -> ToolPresentation { + CodexItemPresenter.present(use, worktree: worktree) ?? ToolPresenter.present(use, worktree: worktree) + } + + public static func present(name: String, input: JSONValue, worktree: String = "") -> ToolPresentation { + if let item = CodexTranslation.item(in: input) { + return CodexItemPresenter.present(item, worktree: worktree) + } + return ToolPresenter.present(name: name, input: input, worktree: worktree) + } +} diff --git a/Sources/BloomCore/Bridge/BridgeToolApproval.swift b/Sources/BloomCore/Bridge/BridgeToolApproval.swift index 78d0a5504..edd11f288 100644 --- a/Sources/BloomCore/Bridge/BridgeToolApproval.swift +++ b/Sources/BloomCore/Bridge/BridgeToolApproval.swift @@ -14,7 +14,7 @@ import Foundation /// /// Bloom is on both ends of this question. It wrote the tool, it minted the token the caller is /// using, it knows which workspace is asking, and it enforces every limit itself: the handler refuses -/// a caller whose workspace was itself agent-started, and eight running children is the ceiling. There is nothing for a person to +/// a caller whose workspace was itself agent-started, and running children have a configurable ceiling. There is nothing for a person to /// weigh that Bloom has not already decided, and the ask carries no information a person could /// act on beyond "an agent would like to use Bloom". /// @@ -104,6 +104,10 @@ public enum BridgeToolApproval { public static let selfApproved: Set = [ "whoami", "workspace_start", + // A restore rebuilds an archived worktree from its surviving branch. It never writes + // over an occupied path, and the archived record remains if rebuilding fails. An owner + // who asked for it should not have to answer a second question for Bloom's own tool. + "workspace_restore", // Both add something the reader can see and close, in the workspace whose agent is // asking and nowhere else. Nothing is destroyed and nothing is hidden, which is a very // different weight from `workspace_merge`, which is deliberately not on this list. @@ -219,8 +223,9 @@ public enum BridgeToolApproval { // The projects Bloom has, read from its own database. It changes nothing, and it is the // call a workspace agent makes before naming another project to `workspace_start`, so an // ask here would hang the turn that is about to hand work on, with nothing for a person - // to weigh: the names are the owner's own sidebar. `project_add` and `project_hide` stay - // off the list and off the workspace role, because they change that sidebar. + // to weigh: the names are the owner's own sidebar. `project_add` is offered to a + // workspace agent but stays off this list because it changes that sidebar. + // `project_hide` and `project_unhide` also stay off the workspace role. "project_list", "workspace_list", ] diff --git a/Sources/BloomCore/Bridge/ProjectAddTool.swift b/Sources/BloomCore/Bridge/ProjectAddTool.swift index f8c9830ed..38197f426 100644 --- a/Sources/BloomCore/Bridge/ProjectAddTool.swift +++ b/Sources/BloomCore/Bridge/ProjectAddTool.swift @@ -18,12 +18,12 @@ import Foundation /// talking to a client, which is `agentSentence` instead of `sentence` and a JSON answer instead /// of a window. /// -/// Owner only. A workspace agent works in the project it was started in, and a tool that let it -/// register another one would let it widen its own reach without anybody deciding to. +/// A workspace agent may register an existing repository when the owner asks it to set up work +/// there. This changes Bloom's sidebar, so Bloom does not self-approve its permission question. public struct ProjectAddTool: BridgeToolHandling { public init() {} - public let roles: Set = [.owner] + public let roles: Set = [.workspace, .owner] public let tool = BridgeTool( name: "project_add", diff --git a/Sources/BloomCore/Bridge/ProjectListTool.swift b/Sources/BloomCore/Bridge/ProjectListTool.swift index b82733d20..3ade4b3a8 100644 --- a/Sources/BloomCore/Bridge/ProjectListTool.swift +++ b/Sources/BloomCore/Bridge/ProjectListTool.swift @@ -9,8 +9,9 @@ import Foundation /// could act in no other, so the names and paths of every other repository were information it had /// no use for. That stopped being true when `workspace_start` let a workspace agent name another /// project to hand work to, and a tool that takes a project name from a caller who cannot find out -/// the names is a tool that gets guessed at. The rest of the project tools stay with the owner: -/// adding a repository and hiding one are the owner's decisions about the sidebar, not an agent's. +/// the names is a tool that gets guessed at. A workspace agent may also register an existing +/// repository with `project_add` when the owner asks it to work there. The hiding tools remain +/// owner only. /// /// Read only, and it says so in the description because that is what the model weighs before /// deciding whether the call is worth making. diff --git a/Sources/BloomCore/Bridge/WorkspaceMergeTool.swift b/Sources/BloomCore/Bridge/WorkspaceMergeTool.swift index 0d16b7196..e6ca8fe27 100644 --- a/Sources/BloomCore/Bridge/WorkspaceMergeTool.swift +++ b/Sources/BloomCore/Bridge/WorkspaceMergeTool.swift @@ -35,7 +35,7 @@ public enum WorkspaceMergeHandoff: Sendable, Equatable { case refused(String) } -/// Asking a workspace's agent to merge, as the strip's Merge button does it. +/// Asking a workspace's agent to merge, as the strip's agent-guided choice does it. /// /// Injected for the same reason `WorkspaceStarting` is: everything that turns a request into a /// turn lives in the main-actor UI graph, and a bridge handler runs off it on a background task @@ -58,9 +58,8 @@ public typealias WorkspaceMergeRequesting = /// is merged", "Bloom cannot undo this". A tool reading `can_merge: true` skipped the only /// protective part of the whole arrangement. /// -/// Bloom does not merge any more. `GitHub.merge` and `deleteRemoteBranch` are gone. Merging is a -/// prompt composed from `PromptRegistry.mergePullRequest`, sent into the workspace's chat, and run -/// by the agent under the owner's eyes. That is what makes a tool possible. +/// This tool only composes the agent route through `PromptRegistry.mergePullRequest`. The window +/// also offers a direct gh route behind its own confirmation, which an MCP caller cannot press. /// /// ## The old objection did not evaporate, it moved, and here is where /// @@ -117,8 +116,8 @@ public struct WorkspaceMergeTool: BridgeToolHandling { description: """ Ask a workspace's own agent to merge its pull request. - This does not merge anything. It composes the request Bloom's own Merge button \ - composes, with the project's merge instructions attached, and sends it into that \ + This does not merge anything. It composes the request Bloom's agent-guided merge \ + choice composes, with the project's merge instructions attached, and sends it into that \ workspace's chat as an ordinary message. The agent runs `gh pr merge` there, in front \ of the owner, under whatever permission mode they set, and can say what GitHub \ answered if it refuses. @@ -131,7 +130,7 @@ public struct WorkspaceMergeTool: BridgeToolHandling { It refuses a pull request GitHub will not take, a worktree holding work GitHub has not \ got, and a workspace whose agent is busy. When it refuses, do not run `gh pr merge` \ yourself and do not ask another agent to. Merging publishes to a server other people \ - share, and Bloom only does it where the owner can watch it happen. + share, and this tool only asks the workspace's own agent to do it. Name the workspace by the id workspace_list reports, not by its name. It squash merges \ unless you say otherwise. diff --git a/Sources/BloomCore/Bridge/WorkspaceRestoreTool.swift b/Sources/BloomCore/Bridge/WorkspaceRestoreTool.swift new file mode 100644 index 000000000..e2e7b1cf1 --- /dev/null +++ b/Sources/BloomCore/Bridge/WorkspaceRestoreTool.swift @@ -0,0 +1,81 @@ +import Foundation + +/// The app-side result of rebuilding an archived workspace's worktree. +public enum WorkspaceRestoreToolOutcome: Sendable, Equatable { + case restored(path: String, relocatedFrom: String?) + case refused(String) +} + +public typealias WorkspaceRestoring = @Sendable (Workspace) async -> WorkspaceRestoreToolOutcome + +/// Restores an archived workspace through the same lifecycle as Bloom's Restore button. +public struct WorkspaceRestoreTool: BridgeToolHandling { + private let restore: WorkspaceRestoring + + public init(_ restore: @escaping WorkspaceRestoring) { + self.restore = restore + } + + public let roles: Set = [.owner] + + public let tool = BridgeTool( + name: "workspace_restore", + description: """ + Restore an archived workspace by rebuilding its worktree from its surviving local or \ + remote branch. Its chats and notes stay with it, and it returns to the active list. \ + Pass the exact archived workspace id from workspace_list with include_archived: true. \ + Only call when the owner has asked to restore that workspace. If its old path is \ + occupied, Bloom uses a free path and reports both paths. If the branch is gone, the \ + call is refused and the archived record remains readable. Restoring does not run the \ + project's setup script or recreate uncommitted files that were lost at archive time. + """, + inputSchema: .object([ + "type": .string("object"), + "properties": .object([ + "id": .object([ + "type": .string("string"), + "description": .string("The exact id of an archived workspace from workspace_list."), + ]), + ]), + "required": .array([.string("id")]), + "additionalProperties": .bool(false), + ]) + ) + + public func call( + _ request: MCPRequest, as identity: BridgeIdentity, store: Store + ) async -> BridgeToolResult { + guard identity.role == .owner else { + return .failure("Only the owner's own client can restore an archived workspace.") + } + guard case .object(let arguments)? = request.params, + Set(arguments.keys) == ["id"], + let rawID = request.stringParam("id")?.trimmingCharacters(in: .whitespacesAndNewlines), + !rawID.isEmpty else { + return .failure("Pass only 'id', using an archived workspace's exact id from workspace_list.") + } + + let workspace: Workspace + do { + guard let found = try await store.workspace(id: WorkspaceID(rawID)) else { + return .failure("No workspace has that id. Call workspace_list with include_archived: true.") + } + workspace = found + } catch { + return .failure("Bloom could not read that workspace. Nothing was restored; try again shortly.") + } + guard workspace.state == .archived else { + return .failure("'\(workspace.name)' is active already. Nothing was restored.") + } + + switch await restore(workspace) { + case let .restored(path, relocatedFrom): + if let relocatedFrom { + return BridgeToolResult(text: "Restored '\(workspace.name)' at '\(path)'. Its old path '\(relocatedFrom)' was occupied.") + } + return BridgeToolResult(text: "Restored '\(workspace.name)' at '\(path)'.") + case .refused(let reason): + return .failure("Could not restore '\(workspace.name)'. \(reason)") + } + } +} diff --git a/Sources/BloomCore/Bridge/WorkspaceStartTool.swift b/Sources/BloomCore/Bridge/WorkspaceStartTool.swift index 41f8628a6..6c96c2f5c 100644 --- a/Sources/BloomCore/Bridge/WorkspaceStartTool.swift +++ b/Sources/BloomCore/Bridge/WorkspaceStartTool.swift @@ -211,15 +211,18 @@ public typealias PullRequestCheckoutResolving = /// The argument that two identical asks a minute apart from a person are two asks is an argument /// about the Create sheet, where each of them is a press; here neither of them is. public struct WorkspaceStartTool: BridgeToolHandling { + private let preferences: WorkspaceStartPreferences private let start: WorkspaceStarting private let resolvePullRequest: PullRequestCheckoutResolving public init( + preferences: WorkspaceStartPreferences = .init(), start: @escaping WorkspaceStarting, resolvePullRequest: @escaping PullRequestCheckoutResolving = { await WorkspaceCheckoutResolver.resolve($0, repoPath: $1) } ) { + self.preferences = preferences self.start = start self.resolvePullRequest = resolvePullRequest } @@ -234,10 +237,9 @@ public struct WorkspaceStartTool: BridgeToolHandling { Name the project to start it in with 'project', giving the name or the path that \ project_list reports. Bloom only starts workspaces in repositories it already has, so \ - a project that is not on that list has to be registered with project_add first, which \ - only the owner can do. If you are \ - yourself running inside a Bloom workspace, leave 'project' out to start it in the \ - project you are already in, or name another project to hand the work to that \ + a project that is not on that list has to be registered with project_add first. \ + If you are yourself running inside a Bloom workspace, leave 'project' out to start \ + it in the project you are already in, or name another project to hand the work to that \ repository instead. The new workspace is still counted as one you started. Use it when a task splits into parts that do not need to see each other's edits, and \ @@ -666,7 +668,9 @@ public struct WorkspaceStartTool: BridgeToolHandling { func overAllowance( _ origin: WorkspaceOrigin, store: Store, now: Date = Date() ) async throws -> String? { - let allowance = WorkspaceStartAllowance.of(origin) + let allowance = WorkspaceStartAllowance.of( + origin, maximumChildren: preferences.maximumChildren + ) switch allowance { case .unlimited: diff --git a/Sources/BloomCore/Git/FileTreeDirectoryListing.swift b/Sources/BloomCore/Git/FileTreeDirectoryListing.swift new file mode 100644 index 000000000..1ebb79b0f --- /dev/null +++ b/Sources/BloomCore/Git/FileTreeDirectoryListing.swift @@ -0,0 +1,30 @@ +import Foundation + +/// Directory entries Git cannot name, including empty and ignored folders. +/// +/// The All files tree still asks Git for its files. This walk adds folders with no files beneath +/// them, while leaving Git's internal directories out of a listing of worktree content. +public enum FileTreeDirectoryListing { + public static func paths(in worktree: String) -> [String] { + let root = URL(fileURLWithPath: worktree, isDirectory: true).standardizedFileURL + let prefix = root.path.hasSuffix("/") ? root.path : root.path + "/" + let keys: Set = [.isDirectoryKey, .isSymbolicLinkKey] + guard let enumerator = FileManager.default.enumerator( + at: root, includingPropertiesForKeys: Array(keys) + ) else { return [] } + + var paths: [String] = [] + for case let url as URL in enumerator { + if Task.isCancelled { break } + guard url.path.hasPrefix(prefix) else { continue } + if url.lastPathComponent == ".git" { + enumerator.skipDescendants() + continue + } + guard let values = try? url.resourceValues(forKeys: keys), + values.isDirectory == true, values.isSymbolicLink != true else { continue } + paths.append(String(url.path.dropFirst(prefix.count)) + "/") + } + return paths + } +} diff --git a/Sources/BloomCore/Git/FileTreeNode.swift b/Sources/BloomCore/Git/FileTreeNode.swift index 85f12977e..07d502d2f 100644 --- a/Sources/BloomCore/Git/FileTreeNode.swift +++ b/Sources/BloomCore/Git/FileTreeNode.swift @@ -34,13 +34,18 @@ public struct FileTreeNode: Identifiable, Hashable, Sendable { var children: [String: Set] = [:] for path in paths where !path.isEmpty { - let components = path.components(separatedBy: "/") + // A trailing slash is an explicit directory, including one with no files below it. + // Git names files; the worktree directory walk adds these entries. + let explicitDirectory = path.hasSuffix("/") + var components = path.components(separatedBy: "/") + if explicitDirectory { components.removeLast() } + guard !components.isEmpty, components.allSatisfy({ !$0.isEmpty }) else { continue } var parent = "" for (offset, component) in components.enumerated() { let isLast = offset == components.count - 1 let full = parent.isEmpty ? component : parent + "/" + component children[parent, default: []].insert( - FileTreeNode(name: component, path: full, isDirectory: !isLast) + FileTreeNode(name: component, path: full, isDirectory: !isLast || explicitDirectory) ) if isLast { break } parent = full diff --git a/Sources/BloomCore/GitHub/DirectPullRequestCreation.swift b/Sources/BloomCore/GitHub/DirectPullRequestCreation.swift new file mode 100644 index 000000000..a5355e4dd --- /dev/null +++ b/Sources/BloomCore/GitHub/DirectPullRequestCreation.swift @@ -0,0 +1,58 @@ +import Foundation + +/// Commits and publishes a workspace branch before asking GitHub to open its pull request. +/// Each step is explicit so a failure leaves the completed git work in place and reports it. +public enum DirectPullRequestCreation { + public static func create(for workspace: Workspace) async throws -> PullRequest { + let worktree = workspace.path + guard try await Git.currentBranch(of: worktree) == workspace.branch else { + throw GitHubError("This worktree is no longer on \(workspace.branch).") + } + guard Git.isValidBranchName(workspace.baseBranch) else { + throw GitHubError("The target branch is not a valid Git branch name.") + } + + switch await GitHub.readPullRequest(for: workspace, maxAge: .zero) { + case .current(.some(let existing)): + throw GitHubError("Pull request #\(existing.number) already exists for this branch.") + case .current(nil): + break + case .unavailable(let failure): + throw GitHubError(failure.message) + } + + let title = Git.title(from: workspace.name, maxLength: 72) + let local = try await Git.localWork(worktree: worktree) + if local.hasUncommitted { + try await Git.stageAll(in: worktree) + guard !((try await Git.stagedPaths(in: worktree)).isEmpty) else { + throw GitHubError("There are no changes to commit after applying this repository's ignore rules.") + } + // Respect the repository's signing configuration. A failed commit leaves the index + // staged so the owner can resolve it without losing the selected files. + do { + try await Git.check(["commit", "--message", title], in: worktree) + } catch { + throw GitHubError("Changes were staged, but the commit failed: \(error)") + } + } + + do { + try await GitHub.push( + worktree: worktree, branch: workspace.branch, setUpstream: !local.hasUpstream + ) + } catch { + let prefix = local.hasUncommitted + ? "Changes were committed locally, but the push failed: " + : "The push failed: " + throw GitHubError(prefix + String(describing: error)) + } + do { + return try await GitHub.createPullRequestFromCommits( + worktree: worktree, base: workspace.baseBranch, title: title + ) + } catch { + throw GitHubError("The branch was pushed, but GitHub did not create the pull request: \(error)") + } + } +} diff --git a/Sources/BloomCore/GitHub/GitHub.swift b/Sources/BloomCore/GitHub/GitHub.swift index 0d0472dbd..040b6adf3 100644 --- a/Sources/BloomCore/GitHub/GitHub.swift +++ b/Sources/BloomCore/GitHub/GitHub.swift @@ -61,6 +61,14 @@ public struct GitHubError: Error, Sendable, CustomStringConvertible { public var description: String { message } } +public enum DirectMergeOutcome: Sendable, Equatable { + case merged + /// gh accepted the command, but a fresh read still reports the pull request open. + case acceptedButOpen + /// gh accepted the command, but the follow-up status read failed. + case acceptedUnverified +} + private struct PullRequestPayload: Decodable { let number: Int? let title: String? @@ -71,10 +79,18 @@ private struct PullRequestPayload: Decodable { let mergeStateStatus: String? let reviewDecision: String? let headRefName: String? + let baseRefName: String? + let headRevision: String? let statusCheckRollup: [CheckPayload]? /// When the pull request stopped being open. GitHub sets it for merged as well as closed, and /// leaves it null while the pull request is open, so one field answers both. let closedAt: String? + + enum CodingKeys: String, CodingKey { + case number, title, url, state, isDraft, mergeable, mergeStateStatus, reviewDecision + case headRefName, baseRefName, statusCheckRollup, closedAt + case headRevision = "headRefOid" + } } /// The two fields choosing between several pull requests on one head name needs. @@ -188,6 +204,7 @@ public enum GitHub { private static let unreadableChecks = UnreadableChecks() private static let fieldList = [ "number", "title", "url", "state", "isDraft", "mergeable", + "baseRefName", "headRefOid", "mergeStateStatus", "reviewDecision", "headRefName", "statusCheckRollup", // Read for one reason: telling a pull request that ended before this workspace was // started apart from this workspace's own. See `PullRequestOwnership`. @@ -301,6 +318,23 @@ public enum GitHub { // read as one. var arguments = ["pr", "create", "--base", base, "--title", title, "--body", body] if draft { arguments.append("--draft") } + return try await createPullRequest(arguments: arguments, worktree: worktree) + } + + /// Uses the pushed commits for the body while keeping the workspace's chosen title. + public static func createPullRequestFromCommits( + worktree: String, base: String, title: String + ) async throws -> PullRequest { + guard Git.isValidBranchName(base) else { + throw GitHubError("refusing to open a pull request against '\(base)': not a valid branch name") + } + let arguments = ["pr", "create", "--base", base, "--title", title, "--fill"] + return try await createPullRequest(arguments: arguments, worktree: worktree) + } + + private static func createPullRequest( + arguments: [String], worktree: String + ) async throws -> PullRequest { try await checkGH(arguments, worktree: worktree) // Through the same retry as every other read, or a token that cannot see check runs opens @@ -354,14 +388,70 @@ public enum GitHub { return whole.stdout } - // Merging is not here, and its absence is the point. - // - // `gh pr merge` used to run from this file, followed by an explicit - // `git push --delete -- origin refs/heads/`, because `--delete-branch` makes gh check - // out the base branch and a worktree cannot check out the branch the main copy is standing on. - // Both commands now live in `MergeInstructions`, in the words the turn hands the workspace's - // agent, and everything that was known about them here is written down there. There is one - // way to merge a pull request in this app and it goes through the transcript. + /// Merge exactly the pull request and head commit the owner confirmed, with no override or + /// automatic merge flag. GitHub may put a repository with a merge queue into that queue. + /// The result says whether it has actually landed, rather than calling a queued request done. + public static func mergePullRequest( + _ expected: PullRequest, into base: String, method: MergeMethod, worktree: String + ) async throws -> DirectMergeOutcome { + guard expected.number > 0, !expected.url.isEmpty, + let expectedHead = expected.headRevision, Self.isCommitID(expectedHead), + let expectedBase = expected.baseBranch, !expectedBase.isEmpty else { + throw GitHubError("Refresh the pull request before merging directly.") + } + if !expected.branch.isEmpty, + try await Git.currentBranch(of: worktree) != expected.branch { + throw GitHubError("This worktree is no longer on the pull request's branch.") + } + + let local = try await Git.localWork(worktree: worktree) + guard !local.isAhead else { + throw GitHubError("This worktree has changes or commits GitHub has not got. Commit and push them, or choose the agent route after reviewing the warning.") + } + guard try await Git.headSHA(of: worktree) == expectedHead else { + throw GitHubError("This checkout is not at the pull request's head commit. Refresh or update it before merging directly.") + } + + let context = try await Git.repositoryContext(in: worktree, baseBranch: base) + guard expectedBase == context.baseBranch else { + throw GitHubError("The pull request targets \(expectedBase), not \(context.baseBranch). Refresh it before merging.") + } + guard let current = try await snapshot( + forNumber: expected.number, worktree: worktree, maxAge: .zero + )?.pullRequest else { + throw GitHubError("GitHub no longer has pull request #\(expected.number).") + } + guard current.url == expected.url, current.branch == expected.branch, + current.baseBranch == expectedBase, current.headRevision == expectedHead else { + throw GitHubError("The pull request changed since it was shown. Refresh it before merging.") + } + guard current.isOpen, current.status.canMerge else { + throw GitHubError(current.status.blockedReason ?? "This pull request can no longer be merged.") + } + + let arguments = [ + "pr", "merge", String(expected.number), method.flag, + "--match-head-commit", expectedHead, + ] + let result = try await run("gh", arguments, cwd: worktree, timeout: .seconds(60)) + if !result.ok { + if let after = try? await snapshot( + forNumber: expected.number, worktree: worktree, maxAge: .zero + )?.pullRequest, after.isMerged { return .merged } + throw shellError(arguments: arguments, result: result) + } + + guard let after = try? await snapshot( + forNumber: expected.number, worktree: worktree, maxAge: .zero + )?.pullRequest else { return .acceptedUnverified } + return after.isMerged ? .merged : .acceptedButOpen + } + + private static func isCommitID(_ value: String) -> Bool { + (value.count == 40 || value.count == 64) && value.utf8.allSatisfy { + (48...57).contains($0) || (65...70).contains($0) || (97...102).contains($0) + } + } /// Pushes the current HEAD to `branch` on its configured publication remote. /// @@ -668,6 +758,8 @@ public enum GitHub { checksSummary: summary, reviewDecision: payload.reviewDecision, branch: payload.headRefName ?? "", + baseBranch: payload.baseRefName, + headRevision: payload.headRevision, closedAt: parseDate(payload.closedAt) ), runs: runs diff --git a/Sources/BloomCore/GitHub/MergeExecutionChoice.swift b/Sources/BloomCore/GitHub/MergeExecutionChoice.swift new file mode 100644 index 000000000..e0a0989b0 --- /dev/null +++ b/Sources/BloomCore/GitHub/MergeExecutionChoice.swift @@ -0,0 +1,35 @@ +import Foundation + +/// Whether Bloom sends a merge turn or executes the chosen gh command itself. +public enum MergeExecution: String, CaseIterable, Sendable { + case direct + case agent + + public var label: String { + switch self { + case .direct: "Merge with GitHub CLI" + case .agent: "Ask agent to merge" + } + } +} + +/// A per-project UI preference, kept out of the repository's tracked settings file. +public enum MergeExecutionChoice { + public static let fallback = MergeExecution.direct + + public static func key(repoID: RepoID) -> String { + "repo.\(repoID).mergeExecution" + } + + public static func resolve(_ stored: String?) -> MergeExecution { + stored.flatMap(MergeExecution.init(rawValue:)) ?? fallback + } + + public static func load(repoID: RepoID, from store: Store) async -> MergeExecution { + resolve(try? await store.setting(key(repoID: repoID))) + } + + public static func save(_ choice: MergeExecution, repoID: RepoID, to store: Store) async { + try? await store.setSetting(key(repoID: repoID), choice.rawValue) + } +} diff --git a/Sources/BloomCore/GitHub/MergeInstructions.swift b/Sources/BloomCore/GitHub/MergeInstructions.swift index 39711db11..51f551474 100644 --- a/Sources/BloomCore/GitHub/MergeInstructions.swift +++ b/Sources/BloomCore/GitHub/MergeInstructions.swift @@ -2,8 +2,9 @@ import Foundation /// How Bloom lands a pull request, in the words the agent reads. /// -/// Pressing Merge does not call `gh`. It composes a turn and sends it, exactly as if the user had -/// typed it. The same arrangement `PullRequestInstructions` already made for opening one, and for +/// The agent-guided choice composes a turn and sends it, exactly as if the user had typed it. +/// The direct choice runs a guarded gh command instead. The same arrangement +/// `PullRequestInstructions` already made for opening one, and for /// the same reasons plus one that only applies here: merging is the one destructive, off-machine /// thing this app offers, and an agent doing it in the transcript is an agent whose command the /// reader can see, whose permission prompt the reader answers, and whose refusal the reader is diff --git a/Sources/BloomCore/Model/Identifier.swift b/Sources/BloomCore/Model/Identifier.swift index 0d7770718..7dd5a7d52 100644 --- a/Sources/BloomCore/Model/Identifier.swift +++ b/Sources/BloomCore/Model/Identifier.swift @@ -58,6 +58,12 @@ public struct RepoID: Identifier { public init(_ rawValue: String) { self.rawValue = rawValue } } +/// A named, isolated CLI sign-in managed by Bloom. +public struct AgentAccountProfileID: Identifier { + public let rawValue: String + public init(_ rawValue: String) { self.rawValue = rawValue } +} + /// A worktree on disk. The id outlives the worktree: an archived workspace keeps its row, and the /// row is what a restore reads. public struct WorkspaceID: Identifier { @@ -65,6 +71,12 @@ public struct WorkspaceID: Identifier { public init(_ rawValue: String) { self.rawValue = rawValue } } +/// One dated or ordered group in Home's mixed workspace and conversation list. +public struct HomeMixedGroupID: Identifier { + public let rawValue: String + public init(_ rawValue: String) { self.rawValue = rawValue } +} + /// One chat inside a workspace. Not the agent's own session id, which is a different value with a /// different lifetime: see `Session.agentSessionID`. public struct SessionID: Identifier { @@ -79,6 +91,12 @@ public struct TerminalTabID: Identifier { public init(_ rawValue: String) { self.rawValue = rawValue } } +/// A pane in a project-free conversation. Its first pane also names the tab containing it. +public struct ConversationPaneID: Identifier { + public let rawValue: String + public init(_ rawValue: String) { self.rawValue = rawValue } +} + /// A review note left on a line of a diff. public struct ReviewCommentID: Identifier { public let rawValue: String diff --git a/Sources/BloomCore/Model/Models.swift b/Sources/BloomCore/Model/Models.swift index dff80116b..bce6939d8 100644 --- a/Sources/BloomCore/Model/Models.swift +++ b/Sources/BloomCore/Model/Models.swift @@ -658,9 +658,12 @@ public struct PullRequest: Sendable, Hashable, Codable { public var checksSummary: String public var reviewDecision: String? /// The head branch as GitHub knows it. Empty when the gh version in use did not report it. - /// This is the branch merging deletes, so the confirmation names this one rather than the - /// local checkout's idea of it. + /// The agent route may delete this branch after merging, so its confirmation names the + /// server's branch rather than the local checkout's idea of it. public var branch: String + /// GitHub's target and head commit when last read. Direct merges require both to still match. + public var baseBranch: String? + public var headRevision: String? /// When it stopped being open, whether by merging or by being closed. Nil while it is open, /// and nil from a gh old enough not to report it. /// @@ -680,6 +683,8 @@ public struct PullRequest: Sendable, Hashable, Codable { checksSummary: String = "", reviewDecision: String? = nil, branch: String = "", + baseBranch: String? = nil, + headRevision: String? = nil, closedAt: Date? = nil ) { self.number = number @@ -692,6 +697,8 @@ public struct PullRequest: Sendable, Hashable, Codable { self.checksSummary = checksSummary self.reviewDecision = reviewDecision self.branch = branch + self.baseBranch = baseBranch + self.headRevision = headRevision self.closedAt = closedAt } } diff --git a/Sources/BloomCore/Persistence/Store+Messages.swift b/Sources/BloomCore/Persistence/Store+Messages.swift index db457f77d..0a4373604 100644 --- a/Sources/BloomCore/Persistence/Store+Messages.swift +++ b/Sources/BloomCore/Persistence/Store+Messages.swift @@ -13,6 +13,22 @@ extension Store { ).map(Self.message(from:)) } + /// The browser client opens at the live end, without reading a whole long transcript first. + public func recentMessages(sessionID: SessionID, limit: Int = 200) throws -> [Message] { + try db.query( + "SELECT * FROM messages WHERE session_id = ? ORDER BY seq DESC LIMIT ?", + [.text(sessionID), .int(Int64(limit))] + ).map(Self.message(from:)).reversed() + } + + /// A page before the one a browser or phone client is reading, still returned oldest first. + public func messages(sessionID: SessionID, beforeSeq: Int, limit: Int) throws -> [Message] { + try db.query( + "SELECT * FROM messages WHERE session_id = ? AND seq < ? ORDER BY seq DESC LIMIT ?", + [.text(sessionID), .int(Int64(beforeSeq)), .int(Int64(limit))] + ).map(Self.message(from:)).reversed() + } + /// How many rows a chat has. public func messageCount(sessionID: SessionID) throws -> Int { Int(try db.query("SELECT COUNT(*) AS c FROM messages WHERE session_id = ?", diff --git a/Sources/BloomCore/Persistence/Store+Sessions.swift b/Sources/BloomCore/Persistence/Store+Sessions.swift index fa5d2ac8c..cfc2e3826 100644 --- a/Sources/BloomCore/Persistence/Store+Sessions.swift +++ b/Sources/BloomCore/Persistence/Store+Sessions.swift @@ -233,17 +233,80 @@ extension Store { /// The chats that belong to no worktree, oldest first. /// - /// Ask Bloom's, and nothing else today. It is a separate method rather than a nil argument to + /// Project-free conversations. It is a separate method rather than a nil argument to /// `sessions(workspaceID:)` because `= NULL` is never true in SQL and a caller that passed nil /// there would get an empty list and no error, which is the quietest way to be wrong. - public func sessionsWithoutWorkspace() throws -> [Session] { - try db.query( + public func sessionsWithoutWorkspace(includeArchived: Bool = false) throws -> [Session] { + let archivedClause = includeArchived ? "archived_at IS NOT NULL" : "archived_at IS NULL" + return try db.query( + "SELECT * FROM sessions WHERE workspace_id IS NULL AND \(archivedClause) " + + "ORDER BY sort_order, created_at" + ).map(Self.session(from:)) + } + + /// Finished turns in active project-free conversations that have not been read yet. + /// A user message alone is not news, and the indexed result lookup answers the whole list in + /// one store read rather than one actor hop per conversation at launch. + public func unreadProjectFreeConversationIDs() throws -> Set { + let rows = try db.query( """ - SELECT * FROM sessions - WHERE workspace_id IS NULL AND archived_at IS NULL - ORDER BY sort_order, created_at + SELECT s.id FROM sessions s + WHERE s.workspace_id IS NULL AND s.archived_at IS NULL + AND EXISTS ( + SELECT 1 FROM messages m + WHERE m.session_id = s.id AND m.seq > s.last_read_seq AND m.kind = 'result' + ) """ - ).map(Self.session(from:)) + ) + return Set(rows.compactMap { $0.string("id").map(SessionID.init) }) + } + + /// Legacy conversations may still have the old placeholder title and no agent thread id. + /// Their transcript or draft, rather than the title, decides whether they belong in a list. + public func projectFreeSessionsWithHistory(includeArchived: Bool = false) throws -> Set { + let archivedClause = includeArchived ? "s.archived_at IS NOT NULL" : "s.archived_at IS NULL" + let rows = try db.query( + """ + SELECT s.id FROM sessions s + WHERE s.workspace_id IS NULL AND \(archivedClause) + AND ( + EXISTS (SELECT 1 FROM messages m WHERE m.session_id = s.id) + OR EXISTS (SELECT 1 FROM deliveries d WHERE d.target_session_id = s.id) + OR EXISTS (SELECT 1 FROM drafts f WHERE f.session_id = s.id AND length(trim(f.body)) > 0) + ) + """ + ) + return Set(rows.compactMap { $0.string("id").map(SessionID.init) }) + } + + /// Brings a project-free conversation back to the live list. + @discardableResult + public func restoreConversation(id: SessionID) throws -> Session? { + try db.transaction { + guard let session = try session(id: id), session.workspaceID == nil, + session.archivedAt != nil else { return nil } + let restored = try update(sessionID: id) { $0.archivedAt = nil } + try setSetting(AskTabs.selectionKey, id.rawValue) + return restored + } + } + + /// Removes only an archived project-free conversation and its dependent records. + public func deleteArchivedConversation(id: SessionID) throws -> Bool { + try db.transaction { + let eligible = try db.query( + "SELECT id FROM sessions WHERE id = ? AND workspace_id IS NULL AND archived_at IS NOT NULL", + [.text(id)] + ).first != nil + guard eligible else { return false } + try db.run("DELETE FROM drafts WHERE session_id = ?", [.text(id)]) + try db.run("DELETE FROM deliveries WHERE target_session_id = ?", [.text(id)]) + try setSetting(AskTabs.directoryKey(id), nil) + try setSetting(ConversationListing.pinnedKey(id), nil) + try setSetting(ConversationListing.colourKey(id), nil) + try db.run("DELETE FROM sessions WHERE id = ?", [.text(id)]) + return true + } } public func session(id: SessionID) throws -> Session? { diff --git a/Sources/BloomCore/Persistence/Store+Settings.swift b/Sources/BloomCore/Persistence/Store+Settings.swift index 68c872f3b..dbaffa839 100644 --- a/Sources/BloomCore/Persistence/Store+Settings.swift +++ b/Sources/BloomCore/Persistence/Store+Settings.swift @@ -18,7 +18,7 @@ extension Store { if remembered != mode.rawValue { try setSetting(key, mode.rawValue) } } - public func planImplementationMode(sessionID: SessionID, hasWorktree: Bool) throws -> PermissionMode { + public func planImplementationMode(sessionID: SessionID, hasWorktree _: Bool) throws -> PermissionMode { if let session = try session(id: sessionID), session.permissionMode != .plan { return PlanApproval.implementationMode(session.permissionMode) } @@ -26,9 +26,9 @@ extension Store { let mode = PermissionMode(rawValue: raw) { return PlanApproval.implementationMode(mode) } - guard hasWorktree else { return AskConversation.permissionMode } let configured = try setting(AppDefaults.Key.permissionMode).flatMap(PermissionMode.init(rawValue:)) - return PlanApproval.implementationMode(configured ?? AppDefaults.fallbackPermissionMode) + let preset = ModelPresetList.decode(try setting(ModelPresetList.key)).defaultPreset + return PlanApproval.implementationMode(preset?.permissionMode ?? configured ?? AppDefaults.fallbackPermissionMode) } public func setting(_ key: String) throws -> String? { @@ -68,14 +68,15 @@ extension Store { /// Ask tabs also carry their directory and persisted selection into the replacement. public func replaceAskConversation( - id: SessionID, controls: ComposerControls, draft: String = "" + id: SessionID, controls: ComposerControls, draft: String = "", + nextID: SessionID = .new(), replacementDirectory: String? = nil ) throws -> Session { try db.transaction { guard let current = try session(id: id), current.workspaceID == nil, current.archivedAt == nil else { throw SQLiteError(message: "This conversation is no longer current.", sql: nil) } - var next = AskConversation.newSession(sortOrder: current.sortOrder) + var next = AskConversation.newSession(id: nextID, sortOrder: current.sortOrder) next.model = controls.model next.effort = controls.effort next.agentKind = controls.agentKind @@ -86,7 +87,8 @@ extension Store { try setSetting(key, value) } try saveDraft(sessionID: next.id, body: draft) - let directory = try setting(AskTabs.directoryKey(id)) + let savedDirectory = try setting(AskTabs.directoryKey(id)) + let directory = replacementDirectory ?? savedDirectory ?? AskConversation.directory(besideDatabaseAt: path) try setSetting(AskTabs.directoryKey(next.id), directory) try setSetting(AskTabs.selectionKey, next.id.rawValue) @@ -97,17 +99,21 @@ extension Store { /// Inserting the chat and its controls, draft, directory and selection is one transaction. public func createAskConversation( - directory: String, controls: ComposerControls? = nil, draft: String = "" + directory: String, controls: ComposerControls? = nil, draft: String = "", + title: String? = nil, id: SessionID = .new() ) throws -> Session { try db.transaction { let existing = try sessionsWithoutWorkspace() - var next = AskConversation.newSession(sortOrder: (existing.map(\.sortOrder).max() ?? -1) + 1) + var next = AskConversation.newSession( + id: id, sortOrder: (existing.map(\.sortOrder).max() ?? -1) + 1 + ) + if let title { next.title = title } if let controls { next.model = controls.model next.effort = controls.effort next.agentKind = controls.agentKind next.permissionMode = controls.permissionMode - next.interactionMode = controls.interactionMode + next.interactionMode = controls.interactionMode } try upsert(next) if let controls { diff --git a/Sources/BloomCore/Presentation/AskPanelLayout.swift b/Sources/BloomCore/Presentation/AskPanelLayout.swift deleted file mode 100644 index 28fdc72b0..000000000 --- a/Sources/BloomCore/Presentation/AskPanelLayout.swift +++ /dev/null @@ -1,81 +0,0 @@ -import Foundation -#if canImport(CoreGraphics) -import CoreGraphics -#endif - -/// How big the Ask Bloom panel is, the order its conversations are listed in, and the mark each -/// one wears. -/// -/// # Why Ask Bloom is a panel -/// -/// It was a destination: a second row under Home at the top of the sidebar, selecting it swapped -/// the centre column for the conversation. The owner's words were that "Home / Ask Bloom" spent -/// the pane's best real estate on two rows, and that Ask belongs behind a button, as an overlay, -/// the way Amp opens its own. So the two rows went, Home and Ask became buttons in the sidebar's -/// status bar beside New, and Ask opens over the window in the same ground the search panel -/// already uses. `SearchPanelWindowOverlay` is why that ground and not a SwiftUI overlay on the -/// split view: the overlay dimmed the centre column and left the inspector lit. -/// -/// There is no full pane any more. It was one button away in the panel's header, and the owner -/// found the panel enough on its own, so the button, the pane and its tab strip all went. -/// -/// # Why the size is here -/// -/// A panel holding a transcript and a composer is a different card from the search panel's list, -/// and it needs room for a rail of conversations beside the conversation. A proportion of the -/// window clamped at both ends, for the reason `SearchPanelLayout` gives for its own width: a flat -/// number is a small card adrift in a wide window, and a bare proportion is a transcript line a -/// thousand points long on an ultrawide. -public enum AskPanelLayout { - /// The rail of conversations down the card's leading edge. A title of about thirty characters, - /// which is what an Ask conversation's automatic title comes out at. - public static let railWidth: CGFloat = 220 - - /// Narrow enough to sit inside the narrowest window with room either side, wide enough for the - /// rail and a composer that does not wrap its footer. - public static let minimumWidth: CGFloat = 640 - /// A transcript wider than this is lines somebody has to move their eyes along. - public static let maximumWidth: CGFloat = 980 - public static let widthProportion: CGFloat = 0.62 - - public static let minimumHeight: CGFloat = 420 - public static let maximumHeight: CGFloat = 820 - public static let heightProportion: CGFloat = 0.8 - - /// What is always left clear around the card, so it reads as in front of the window rather - /// than as the window. - public static let margin: CGFloat = 32 - - public static func width(inWindow window: CGFloat) -> CGFloat { - clamp(window * widthProportion, minimum: minimumWidth, maximum: maximumWidth, room: window) - } - - public static func height(inWindow window: CGFloat) -> CGFloat { - clamp(window * heightProportion, minimum: minimumHeight, maximum: maximumHeight, room: window) - } - - /// The room wins over the floor. A window shorter than the minimum plus its margins gets a card - /// that fits, because a card cut off at the bottom has lost its composer. - private static func clamp(_ value: CGFloat, minimum: CGFloat, maximum: CGFloat, room: CGFloat) -> CGFloat { - let fits = max(room - margin * 2, 0) - return min(min(max(value, minimum), maximum), fits) - } - - /// Newest first, as Mail and Messages list a conversation. - /// - /// The store hands conversations back in the order they were made. A list read top down wants - /// the other way round: the conversation just started is the one being looked for. - public static func railOrder(_ ids: [ID]) -> [ID] { - ids.reversed() - } - - /// The mark a conversation's row wears, in the shape the sidebar's workspace rows use. - /// - /// Waiting on a person outranks running, because a turn that has stopped to ask is the one - /// that goes nowhere until somebody looks. - public static func status(isRunning: Bool, isAwaitingPermission: Bool) -> WorkspaceStatus? { - if isAwaitingPermission { return .awaitingPermission } - if isRunning { return .running } - return nil - } -} diff --git a/Sources/BloomCore/Presentation/ComposerTypeAhead.swift b/Sources/BloomCore/Presentation/ComposerTypeAhead.swift new file mode 100644 index 000000000..bbe38e349 --- /dev/null +++ b/Sources/BloomCore/Presentation/ComposerTypeAhead.swift @@ -0,0 +1,23 @@ +import Foundation + +/// Which presses, made while nothing takes typing, move the keyboard to the composer: printable +/// characters and Command+V. Everything else keeps its own meaning. +public enum ComposerTypeAhead { + /// - Parameter characters: the characters ignoring modifiers for a dead key, so the accent + /// starts in the composer. + public static func belongsToComposer(characters: String?, modifiers: MenuShortcut.Modifiers) -> Bool { + guard let characters, let first = characters.unicodeScalars.first else { return false } + if modifiers == .command { return characters.lowercased() == "v" } + guard modifiers.isDisjoint(with: [.command, .control]) else { return false } + // Not Space: over a file it opens the preview. + return !CharacterSet.whitespacesAndNewlines.contains(first) + && characters.unicodeScalars.allSatisfy(isTyped) + } + + /// Arrows and function keys are private use characters in AppKit. + private static func isTyped(_ scalar: Unicode.Scalar) -> Bool { + !CharacterSet.controlCharacters.contains(scalar) && !functionKeys.contains(scalar.value) + } + + private static let functionKeys: ClosedRange = 0xF700...0xF8FF +} diff --git a/Sources/BloomCore/Presentation/ConversationLayout.swift b/Sources/BloomCore/Presentation/ConversationLayout.swift new file mode 100644 index 000000000..5360b23d7 --- /dev/null +++ b/Sources/BloomCore/Presentation/ConversationLayout.swift @@ -0,0 +1,151 @@ +import Foundation + +/// Tabs and split panes around one project-free conversation. The chat stays reachable while +/// terminals and browsers come and go, and no pane needs a Git workspace to exist. +public struct ConversationLayout: Codable, Sendable, Equatable { + public enum Kind: String, Codable, Sendable { + case chat + case terminal + case browser + } + + public struct Pane: Codable, Sendable, Equatable, Identifiable { + public var id: ConversationPaneID + public var kind: Kind + public var title: String + public var url: String + + public init(id: ConversationPaneID, kind: Kind, title: String, url: String = "") { + self.id = id + self.kind = kind + self.title = title + self.url = url + } + } + + public struct Tab: Codable, Sendable, Equatable, Identifiable { + public var id: ConversationPaneID + public var title: String + public var layout: SplitLayout + public var panes: [ConversationPaneID: Pane] + + public func pane(_ id: ConversationPaneID) -> Pane? { panes[id] } + } + + public static let chatID = ConversationPaneID("chat") + public private(set) var tabs: [Tab] + public private(set) var selectedTabID: ConversationPaneID + + public init() { + let chat = Pane(id: Self.chatID, kind: .chat, title: "Conversation") + tabs = [Tab( + id: Self.chatID, title: chat.title, + layout: SplitLayout(pane: chat.id.rawValue), panes: [chat.id: chat] + )] + selectedTabID = Self.chatID + } + + public var selectedTab: Tab? { tabs.first { $0.id == selectedTabID } } + public func tab(_ id: ConversationPaneID) -> Tab? { tabs.first { $0.id == id } } + + /// A stored layout is used only when its chat and every live pane can still be named. + public var isValid: Bool { + guard tabs.first?.id == Self.chatID, + tabs[0].panes[Self.chatID]?.kind == .chat, + Set(tabs.map(\.id)).count == tabs.count, + tabs.contains(where: { $0.id == selectedTabID }) else { return false } + return tabs.allSatisfy { tab in + Set(tab.layout.panes.map(ConversationPaneID.init)) == Set(tab.panes.keys) + && tab.panes.allSatisfy { $0.key == $0.value.id } + } + } + + @discardableResult + public mutating func open(_ kind: Kind, title: String? = nil, url: String = "") -> Pane { + let pane = Self.makePane(kind, title: title, url: url, among: tabs) + tabs.append(Tab( + id: pane.id, title: pane.title, + layout: SplitLayout(pane: pane.id.rawValue), panes: [pane.id: pane] + )) + selectedTabID = pane.id + return pane + } + + @discardableResult + public mutating func split( + tabID: ConversationPaneID, paneID: ConversationPaneID, axis: SplitAxis, opening kind: Kind + ) -> Pane? { + guard let index = tabs.firstIndex(where: { $0.id == tabID }) else { return nil } + let pane = Self.makePane(kind, title: nil, url: "", among: tabs) + guard tabs[index].layout.split(paneID.rawValue, axis: axis, into: pane.id.rawValue) else { return nil } + tabs[index].panes[pane.id] = pane + return pane + } + + /// Closing a pane never removes the original conversation. A tool tab's last pane closes its + /// tab, while a split tab keeps the panes that remain. + @discardableResult + public mutating func closePane(tabID: ConversationPaneID, paneID: ConversationPaneID) -> Pane? { + guard let index = tabs.firstIndex(where: { $0.id == tabID }), + let pane = tabs[index].panes[paneID] else { return nil } + if tabID == Self.chatID && paneID == Self.chatID { return nil } + if tabs[index].layout.paneCount == 1 { + guard tabID != Self.chatID else { return nil } + tabs.remove(at: index) + if selectedTabID == tabID { selectedTabID = tabs[max(0, index - 1)].id } + return pane + } + guard tabs[index].layout.close(paneID.rawValue) else { return nil } + tabs[index].panes[paneID] = nil + return pane + } + + /// Returns every pane whose shell or browser must be closed with a tool tab. + @discardableResult + public mutating func closeTab(_ id: ConversationPaneID) -> [Pane] { + guard id != Self.chatID, let index = tabs.firstIndex(where: { $0.id == id }) else { return [] } + let removed = Array(tabs.remove(at: index).panes.values) + if selectedTabID == id { selectedTabID = tabs[max(0, index - 1)].id } + return removed + } + + public mutating func select(_ id: ConversationPaneID) { + guard tabs.contains(where: { $0.id == id }) else { return } + selectedTabID = id + } + + public mutating func focus(_ paneID: ConversationPaneID, in tabID: ConversationPaneID) { + guard let index = tabs.firstIndex(where: { $0.id == tabID }) else { return } + tabs[index].layout.setFocus(paneID.rawValue) + } + + public mutating func setRatio(_ ratio: Double, at path: [Int], in tabID: ConversationPaneID) { + guard let index = tabs.firstIndex(where: { $0.id == tabID }) else { return } + tabs[index].layout.setRatio(ratio, at: path) + } + + public mutating func rename(_ title: String, tabID: ConversationPaneID) { + let name = title.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty, let index = tabs.firstIndex(where: { $0.id == tabID }) else { return } + tabs[index].title = name + } + + public mutating func setURL(_ url: String, paneID: ConversationPaneID, in tabID: ConversationPaneID) { + guard let index = tabs.firstIndex(where: { $0.id == tabID }), + var pane = tabs[index].panes[paneID], pane.kind == .browser else { return } + pane.url = url + tabs[index].panes[paneID] = pane + } + + private static func makePane(_ kind: Kind, title: String?, url: String, among tabs: [Tab]) -> Pane { + let count = tabs.flatMap { $0.panes.values }.filter { $0.kind == kind }.count + let base: String + switch kind { + case .chat: base = "Conversation" + case .terminal: base = "Terminal" + case .browser: base = "Browser" + } + let name = title ?? (count == 0 ? base : "\(base) \(count + 1)") + return Pane(id: .new(), kind: kind, title: name, url: url) + } +} diff --git a/Sources/BloomCore/Presentation/ConversationListing.swift b/Sources/BloomCore/Presentation/ConversationListing.swift new file mode 100644 index 000000000..cd4227fc9 --- /dev/null +++ b/Sources/BloomCore/Presentation/ConversationListing.swift @@ -0,0 +1,49 @@ +import Foundation + +/// Saved presentation choices for a conversation with no project. +public enum ConversationListing { + public static func pinnedKey(_ id: SessionID) -> String { + "conversation.pinned.\(id.rawValue)" + } + + public static func colourKey(_ id: SessionID) -> String { + "conversation.colour.\(id.rawValue)" + } + + /// A pin changes the group. Within each group, creation order stays stable while a turn runs. + public static func ordered(_ sessions: [Session], pinned: Set) -> [Session] { + sessions.sorted { left, right in + let leftPinned = pinned.contains(left.id) + let rightPinned = pinned.contains(right.id) + if leftPinned != rightPinned { return leftPinned } + if left.sortOrder != right.sortOrder { return left.sortOrder > right.sortOrder } + return left.id.rawValue < right.id.rawValue + } + } + + /// Applies a sidebar drag while keeping pinned and unpinned rows in their own runs. + public static func reordered( + _ sessions: [Session], pinned: Set, from offsets: IndexSet, to destination: Int + ) -> [Session]? { + guard !offsets.isEmpty, offsets.allSatisfy(sessions.indices.contains), + (0...sessions.count).contains(destination) else { return nil } + let moving = offsets.map { sessions[$0] } + var remaining = sessions.enumerated().compactMap { offset, session in + offsets.contains(offset) ? nil : session + } + let adjusted = destination - offsets.filter { $0 < destination }.count + remaining.insert(contentsOf: moving, at: adjusted) + let oldPins = sessions.map { pinned.contains($0.id) } + let newPins = remaining.map { pinned.contains($0.id) } + return oldPins == newPins ? remaining : nil + } + + public static func status( + isRunning: Bool, isAwaitingPermission: Bool, isUnread: Bool + ) -> WorkspaceStatus { + if isAwaitingPermission { return .awaitingPermission } + if isRunning { return .running } + if isUnread { return .unread } + return .clean + } +} diff --git a/Sources/BloomCore/Presentation/CreateWorkspacePresentation.swift b/Sources/BloomCore/Presentation/CreateWorkspacePresentation.swift index bd4a91b4c..493906b09 100644 --- a/Sources/BloomCore/Presentation/CreateWorkspacePresentation.swift +++ b/Sources/BloomCore/Presentation/CreateWorkspacePresentation.swift @@ -10,6 +10,7 @@ public struct CreateWorkspacePresentation: Sendable { public let id: UInt64 public fileprivate(set) var repoID: RepoID? public fileprivate(set) var pullRequestRevision: UInt64 + public fileprivate(set) var startsAsConversation: Bool } public private(set) var request: Request? @@ -17,10 +18,15 @@ public struct CreateWorkspacePresentation: Sendable { public init() {} - public mutating func present(repoID: RepoID?, asksForPullRequest: Bool = false) { + public mutating func present( + repoID: RepoID?, asksForPullRequest: Bool = false, startsAsConversation: Bool = false + ) { if var request { - if let repoID { request.repoID = repoID } + if let repoID { + request.repoID = repoID + } if asksForPullRequest { request.pullRequestRevision &+= 1 } + request.startsAsConversation = startsAsConversation self.request = request return } @@ -29,7 +35,8 @@ public struct CreateWorkspacePresentation: Sendable { request = Request( id: nextID, repoID: repoID, - pullRequestRevision: asksForPullRequest ? 1 : 0 + pullRequestRevision: asksForPullRequest ? 1 : 0, + startsAsConversation: startsAsConversation ) } diff --git a/Sources/BloomCore/Presentation/DocumentPreview.swift b/Sources/BloomCore/Presentation/DocumentPreview.swift index 508b4b68c..26e2797cb 100644 --- a/Sources/BloomCore/Presentation/DocumentPreview.swift +++ b/Sources/BloomCore/Presentation/DocumentPreview.swift @@ -3,8 +3,8 @@ import Foundation import UniformTypeIdentifiers #endif -/// A file drawn as the document it describes rather than as its source: Markdown rendered, and an -/// HTML report run with its styles, its SVG and its scripts. +/// A file drawn as the document it describes rather than as its source: Markdown rendered, an +/// HTML report run with its styles and scripts, or an SVG drawn at its native resolution. /// /// # Why a scheme of its own, and not `file://` /// @@ -24,6 +24,7 @@ public enum DocumentPreview { public enum Kind: Sendable, Equatable { case markdown case html + case svg } public static let scheme = "bloom-preview" @@ -34,6 +35,7 @@ public enum DocumentPreview { switch (path as NSString).pathExtension.lowercased() { case "md", "markdown", "mdown", "mkd": .markdown case "html", "htm", "xhtml": .html + case "svg": .svg default: nil } } @@ -82,12 +84,15 @@ public enum DocumentPreview { /// no `` in it and every accented letter comes out as two. public static func contentType(forFile path: String) -> String { let fileExtension = (path as NSString).pathExtension.lowercased() - if kind(path: path) != nil { return "text/html; charset=utf-8" } + switch kind(path: path) { + case .markdown, .html: return "text/html; charset=utf-8" + case .svg: return "image/svg+xml; charset=utf-8" + case nil: break + } switch fileExtension { case "js", "mjs", "cjs": return "text/javascript; charset=utf-8" case "json", "map": return "application/json; charset=utf-8" case "css": return "text/css; charset=utf-8" - case "svg": return "image/svg+xml; charset=utf-8" case "wasm": return "application/wasm" default: break } diff --git a/Sources/BloomCore/Presentation/HomeList.swift b/Sources/BloomCore/Presentation/HomeList.swift index 9cd2d4019..7cdfe82a5 100644 --- a/Sources/BloomCore/Presentation/HomeList.swift +++ b/Sources/BloomCore/Presentation/HomeList.swift @@ -530,7 +530,30 @@ public enum HomeList { listing: HomeListing, filter: HomeFilter, projects: Int, - database: DatabaseSize? = nil + database: DatabaseSize? = nil, + conversationCount: Int = 0 + ) -> String { + let projectSummary = projectSummary( + listing: listing, filter: filter, projects: projects, database: database + ) + guard conversationCount > 0 else { return projectSummary } + if listing.isSearching { + let found = listing.counts.count(of: filter.scope, searching: true) + conversationCount + let query = filter.query.trimmingCharacters(in: .whitespaces) + return "\(ArchiveDeletion.count(found, "result")) for \u{201C}\(query)\u{201D}" + } + let phrase = ArchiveDeletion.count(conversationCount, "conversation") + if filter.scope == .archived, listing.counts.archived == 0 { + return "\(conversationCount) archived \(conversationCount == 1 ? "conversation" : "conversations")" + } + return projectSummary.isEmpty ? phrase : projectSummary + " \u{00B7} " + phrase + } + + private static func projectSummary( + listing: HomeListing, + filter: HomeFilter, + projects: Int, + database: DatabaseSize? ) -> String { guard listing.considered > 0 || listing.archived > 0 else { return "" } diff --git a/Sources/BloomCore/Presentation/HomeMixedList.swift b/Sources/BloomCore/Presentation/HomeMixedList.swift new file mode 100644 index 000000000..f7616efe8 --- /dev/null +++ b/Sources/BloomCore/Presentation/HomeMixedList.swift @@ -0,0 +1,104 @@ +import Foundation + +/// A workspace or project-free conversation in the same Home list. +public enum HomeMixedItem: Identifiable, Sendable { + case workspace(HomeRow) + case conversation(Session) + + public var id: HomeMixedItemID { + switch self { + case .workspace(let row): .workspace(row.id) + case .conversation(let session): .conversation(session.id) + } + } + + var date: Date { + switch self { + case .workspace(let row): row.workspace.lastActivityAt + // Archiving does not make an old conversation new work in the dated list. + case .conversation(let session): session.updatedAt + } + } + + var bytes: Int { + switch self { + case .workspace(let row): row.bytes ?? 0 + case .conversation: 0 + } + } + + var sortKey: String { + switch self { + case .workspace(let row): "workspace:\(row.id.rawValue)" + case .conversation(let session): "conversation:\(session.id.rawValue)" + } + } +} + +public enum HomeMixedItemID: Hashable, Sendable { + case workspace(WorkspaceID) + case conversation(SessionID) +} + +public struct HomeMixedGroup: Identifiable, Sendable { + public var id: HomeMixedGroupID + public var title: String + public var items: [HomeMixedItem] + + public init(id: HomeMixedGroupID, title: String, items: [HomeMixedItem]) { + self.id = id + self.title = title + self.items = items + } +} + +/// Interleaves conversations with HomeList's already filtered workspace rows. +public enum HomeMixedList { + public static func build( + listing: HomeListing, + conversations: [Session], + filter: HomeFilter, + now: Date = Date(), + calendar: Calendar = .current + ) -> [HomeMixedGroup] { + var items = listing.groups.flatMap(\.rows).map(HomeMixedItem.workspace) + items.append(contentsOf: conversations.map(HomeMixedItem.conversation)) + guard !items.isEmpty else { return [] } + + let order = HomeOrder.applies(scope: filter.scope, searching: listing.isSearching) + ? filter.order : .recent + switch order { + case .recent: + items.sort { first, second in + if first.date != second.date { return first.date > second.date } + return first.sortKey < second.sortKey + } + case .largest: + items.sort { first, second in + if first.bytes != second.bytes { return first.bytes > second.bytes } + return first.sortKey < second.sortKey + } + } + + if listing.isSearching { + let title = conversations.isEmpty ? "Workspaces" : "Results" + return [HomeMixedGroup(id: HomeMixedGroupID("results"), title: title, items: items)] + } + if let heading = order.heading { + return [HomeMixedGroup(id: HomeMixedGroupID("order-\(order.rawValue)"), title: heading, items: items)] + } + + var groups: [HomeMixedGroup] = [] + for item in items { + let bucket = HomeList.bucket(for: item.date, now: now, calendar: calendar) + if groups.last?.id == HomeMixedGroupID(bucket.id) { + groups[groups.count - 1].items.append(item) + } else { + groups.append(HomeMixedGroup( + id: HomeMixedGroupID(bucket.id), title: bucket.title, items: [item] + )) + } + } + return groups + } +} diff --git a/Sources/BloomCore/Presentation/HomeScope.swift b/Sources/BloomCore/Presentation/HomeScope.swift index 25b24a535..9feb71372 100644 --- a/Sources/BloomCore/Presentation/HomeScope.swift +++ b/Sources/BloomCore/Presentation/HomeScope.swift @@ -22,8 +22,8 @@ public enum HomeScope: String, Hashable, Sendable, CaseIterable, Codable { case needsYou /// An agent is mid turn right now. case running - /// Still has a worktree on disk. This is what the old "Hide archived" switch was for, kept as - /// a scope rather than as a switch of its own. + /// Still has a worktree on disk, which the strip calls "Active". This is what the old "Hide + /// archived" switch was for, kept as a scope rather than as a switch of its own. case live /// Archived: readable, restorable, with nothing left on disk. case archived @@ -34,24 +34,21 @@ public enum HomeScope: String, Hashable, Sendable, CaseIterable, Codable { /// The chips on offer, in the order they are drawn. /// - /// **Browsing, there are two: `all` and `archived`.** There were five, and the three that went - /// (`live`, `needsYou`, `running`) were each a true fact about the list drawn as a control. - /// The owner asked for them to go, and the reason is visible in the strip they made: five - /// chips across the top of the first screen he sees, of which he pressed one. A filter nobody - /// reaches for is a row of words to read past. + /// **Browsing, there are three: `all`, `live` and `archived`.** There were five, and `needsYou` + /// and `running` went because each was a true fact about the list drawn as a control nobody + /// pressed. `live` came back, as "Active", because with the archive growing past the live work + /// the only way to see what can still be acted on was to scroll past it. /// - /// What is lost is real and worth naming rather than pretending away: **there is now no way to - /// ask "which of these is waiting on me" from this strip.** The list still answers it, by the - /// glyph on each row and by the order, and the sidebar answers it too. If that turns out not - /// to be enough, `needsYou` is the one to bring back, because it is the only one of the three - /// that asks something the list does not already show at a glance. + /// What is still lost is real: **there is no way to ask "which of these is waiting on me" from + /// this strip.** The list answers it by the glyph on each row and by the order, and the sidebar + /// answers it too. If that is not enough, `needsYou` is the one to bring back. /// /// Searching, `all` still leads and the two kind chips stay, because there the chips split an /// answer rather than narrowing a list nobody asked to narrow. public static func offered(searching: Bool) -> [HomeScope] { searching ? [.all, .workspaces, .transcripts, .archived] - : [.all, .archived] + : [.all, .live, .archived] } /// What the strip is set to when nothing has been asked of it. @@ -79,7 +76,7 @@ public enum HomeScope: String, Hashable, Sendable, CaseIterable, Codable { case .all: searching ? "Everything" : "All" case .needsYou: "Needs you" case .running: "Running" - case .live: "Live" + case .live: "Active" case .archived: "Archived" case .workspaces: "Workspaces" case .transcripts: "Transcripts" @@ -217,14 +214,13 @@ public struct HomeScopeCounts: Sendable, Equatable { /// /// The chip stays on the strip either way. Dropping it would reflow the row every time an /// agent started or stopped, which is movement under the pointer for no gain. - /// **Only the two chips whose number is a size, and not the three whose number is a state.** - /// Live, Needs you and Running each carried one too, and five numbers across one strip is a row - /// of figures to read rather than a set of filters to press. The two that keep theirs are the - /// ones where the number is the point: All says how much there is, and Archived says how much - /// of it is behind you. Live is answered by the list underneath it, and the whole argument - /// below about a nought being worth nothing applies twice over to a three. + /// + /// **Only the chips whose number is a size, and not the two whose number is a state.** Needs + /// you and Running each carried one too, and five numbers across one strip is a row of figures + /// to read rather than a set of filters to press. All, Active and Archived keep theirs because + /// together they say how much there is and how it splits. public func badge(of scope: HomeScope, searching: Bool) -> Int? { - guard scope == .all || scope == .archived else { return nil } + guard scope == .all || scope == .live || scope == .archived else { return nil } let value = count(of: scope, searching: searching) return value == 0 ? nil : value } diff --git a/Sources/BloomCore/Presentation/MarkdownHTML.swift b/Sources/BloomCore/Presentation/MarkdownHTML.swift index 9d42fd46f..394cc7ddf 100644 --- a/Sources/BloomCore/Presentation/MarkdownHTML.swift +++ b/Sources/BloomCore/Presentation/MarkdownHTML.swift @@ -16,8 +16,8 @@ public enum MarkdownHTML { public var hasMermaid: Bool } - public static func render(_ text: String) -> Rendered { - var writer = Writer() + public static func render(_ text: String, imagesAsAltText: Bool = false) -> Rendered { + var writer = Writer(imagesAsAltText: imagesAsAltText) writer.blocks(MarkdownParser.parse(text)) return Rendered(html: writer.output, hasMermaid: writer.hasMermaid) } @@ -77,6 +77,7 @@ public enum MarkdownHTML { } private struct Writer { + var imagesAsAltText: Bool var output = "" var hasMermaid = false var slugs: [String: Int] = [:] @@ -89,13 +90,13 @@ public enum MarkdownHTML { switch block { case let .paragraph(inline): if tight { - output += MarkdownHTML.inline(inline) + output += MarkdownHTML.inline(inline, imagesAsAltText: imagesAsAltText) } else { - output += "

\(MarkdownHTML.inline(inline))

\n" + output += "

\(MarkdownHTML.inline(inline, imagesAsAltText: imagesAsAltText))

\n" } case let .heading(level, inline): let level = min(max(level, 1), 6) - output += "\(MarkdownHTML.inline(inline))\n" + output += "\(MarkdownHTML.inline(inline, imagesAsAltText: imagesAsAltText))\n" case let .codeBlock(code, language, info): codeBlock(code, language: language, info: info) case let .bulletList(items, isTight): @@ -111,7 +112,7 @@ public enum MarkdownHTML { for item in items { let checked = item.checked ? " checked" : "" output += "
  • " - output += MarkdownHTML.inline(item.inline) + output += MarkdownHTML.inline(item.inline, imagesAsAltText: imagesAsAltText) output += "
  • \n" } output += "\n" @@ -161,14 +162,14 @@ public enum MarkdownHTML { } output += "\n" for (column, header) in headers.enumerated() { - output += "\(MarkdownHTML.inline(header))" + output += "\(MarkdownHTML.inline(header, imagesAsAltText: imagesAsAltText))" } output += "\n\n" for row in rows { output += "" for column in headers.indices { let cell = row.indices.contains(column) ? row[column] : [] - output += "\(MarkdownHTML.inline(cell))" + output += "\(MarkdownHTML.inline(cell, imagesAsAltText: imagesAsAltText))" } output += "\n" } @@ -184,28 +185,30 @@ public enum MarkdownHTML { } } - static func inline(_ values: [MarkdownInline]) -> String { + static func inline(_ values: [MarkdownInline], imagesAsAltText: Bool = false) -> String { var output = "" for value in values { switch value { case let .text(text): output += escape(text) case let .emphasis(children): - output += "\(inline(children))" + output += "\(inline(children, imagesAsAltText: imagesAsAltText))" case let .strong(children): - output += "\(inline(children))" + output += "\(inline(children, imagesAsAltText: imagesAsAltText))" case let .strikethrough(children): - output += "\(inline(children))" + output += "\(inline(children, imagesAsAltText: imagesAsAltText))" case let .code(text): output += "\(escape(text))" case let .link(text, url): if let address = safeAddress(url) { - output += "\(inline(text))" + output += "\(inline(text, imagesAsAltText: imagesAsAltText))" } else { - output += inline(text) + output += inline(text, imagesAsAltText: imagesAsAltText) } case let .image(alt, url): - if let address = safeAddress(url, forImage: true) { + if imagesAsAltText { + output += escape(alt) + } else if let address = safeAddress(url, forImage: true) { output += "\"\(escape(alt))\"" } else { output += escape(alt) diff --git a/Sources/BloomCore/Presentation/MenuBarCatalogue.swift b/Sources/BloomCore/Presentation/MenuBarCatalogue.swift index f826da78e..59b1ae8c3 100644 --- a/Sources/BloomCore/Presentation/MenuBarCatalogue.swift +++ b/Sources/BloomCore/Presentation/MenuBarCatalogue.swift @@ -32,9 +32,9 @@ public enum MenuBarCatalogue { // MARK: File - MenuBarItem(.newWorkspace, in: .file, "New Workspace…", key: .command("n"), availability: .needsProject), + MenuBarItem(.newWorkspace, in: .file, "New Workspace…", key: .command("n"), availability: .always), MenuBarItem(.newWorkspaceFromPullRequest, in: .file, "New Workspace from Pull Request…", availability: .needsProject), - MenuBarItem(.newAskConversation, in: .file, "New Ask Bloom Conversation", availability: .always), + MenuBarItem(.newConversation, in: .file, "New Conversation…", key: .command("j"), availability: .always), MenuBarItem(.projectSettings, in: .file, "Project Settings…", key: .init("comma", .command, .shift), availability: .needsProject), MenuBarItem(.searchFiles, in: .file, "Search Files…", key: .command("p"), availability: .needsWorkspace), MenuBarItem(.newSession, in: .file, "New Session", key: .command("t"), availability: .needsConversationArea), @@ -54,6 +54,7 @@ public enum MenuBarCatalogue { // you had said anything. Which verb a target needs is worked out from the target now. See // `ProjectTargetVerdict`, and `StartProjectView` for the window it draws. MenuBarItem(.startProject, in: .file, "Start a Project…", key: .init("n", .command, .option)), + MenuBarItem(.openWebClient, in: .file, "Open Bloom in Browser", availability: .sometimes), MenuBarItem(.save, in: .file, "Save", key: .command("s"), availability: .sometimes), // MARK: Edit @@ -107,10 +108,6 @@ public enum MenuBarCatalogue { MenuBarItem(.previousWorkspace, in: .view, "Previous Workspace", key: .init(.upArrow, .command, .option), availability: .needsAnyWorkspace), MenuBarItem(.nextUnread, in: .view, "Next Unread", key: .init("u", .command, .shift), availability: .sometimes), MenuBarItem(.goToHome, in: .view, "Go to Home", key: .init("h", .command, .shift), availability: .sometimes), - // Command-J opens and closes the Ask Bloom panel. It had no key while Ask was a place in the - // sidebar, because a destination is a click away; a panel in front of the window is the - // kind of thing a key is for, and J was free. See `AskPanelModel`. - MenuBarItem(.goToAsk, in: .view, "Ask Bloom", key: .command("j"), availability: .sometimes), MenuBarItem(.zoomIn, in: .view, "Zoom In", key: .command("+"), availability: .sometimes), MenuBarItem(.zoomOut, in: .view, "Zoom Out", key: .command("-"), availability: .sometimes), MenuBarItem(.actualSize, in: .view, "Actual Size", key: .command("0"), availability: .sometimes), @@ -180,7 +177,7 @@ public enum MenuBarAction: String, CaseIterable, Sendable { case newWorkspace case newWorkspaceFromPullRequest - case newAskConversation + case newConversation case projectSettings case newSession case newTerminalTab @@ -191,6 +188,7 @@ public enum MenuBarAction: String, CaseIterable, Sendable { case renameTab case closeTab case startProject + case openWebClient case save case addSelectionToChat @@ -220,7 +218,6 @@ public enum MenuBarAction: String, CaseIterable, Sendable { case previousWorkspace case nextUnread case goToHome - case goToAsk case zoomIn case zoomOut case actualSize @@ -303,7 +300,7 @@ public enum MenuBarAvailability: String, Equatable, Sendable { case needsProject /// A workspace is selected in the sidebar. case needsWorkspace - /// A workspace or Ask Bloom is selected. + /// A workspace or project-free conversation is selected. case needsConversationArea /// Any workspace exists at all, selected or not. case needsAnyWorkspace diff --git a/Sources/BloomCore/Presentation/SidebarReorder.swift b/Sources/BloomCore/Presentation/SidebarReorder.swift index 0214a3050..f9836b926 100644 --- a/Sources/BloomCore/Presentation/SidebarReorder.swift +++ b/Sources/BloomCore/Presentation/SidebarReorder.swift @@ -292,6 +292,15 @@ extension SidebarReorder { } } + /// The project headers in the same flat run `destination` reads. Empty projects omitted by + /// the pane are absent here too, so a destination offset cannot count a row that was not drawn. + public static func visibleProjects(in rows: [Row]) -> [RepoID] { + rows.compactMap { row in + guard case .project(let id) = row else { return nil } + return id + } + } + /// The smallest set of writes that puts the projects in the order a header drag asked for. /// /// Ordered by `sort_order` and read back by the same, so a project whose number is already diff --git a/Sources/BloomCore/Presentation/SidebarSectionOrder.swift b/Sources/BloomCore/Presentation/SidebarSectionOrder.swift new file mode 100644 index 000000000..fbca0faae --- /dev/null +++ b/Sources/BloomCore/Presentation/SidebarSectionOrder.swift @@ -0,0 +1,25 @@ +import Foundation + +/// The two movable groups in Bloom's sidebar. Project rows keep their own order within Work. +public enum SidebarTopSection: String, Sendable { + case work + case conversations +} + +/// A preference, rather than a per-window choice, so a section stays where it was dropped. +public enum SidebarSectionOrder: String, Sendable { + case workFirst + case conversationsFirst + + public static let fallback = Self.workFirst + public static let storageKey = "sidebar.sectionOrder" + + /// A heading moving one row towards the other section swaps the two groups. The order at + /// drag start stays the reference, so pulling back reverses the swap before release. + public func dragging(_ section: SidebarTopSection, translation: Double) -> Self { + let first: SidebarTopSection = self == .workFirst ? .work : .conversations + let reachesOther = section == first ? translation > 32 : translation < -32 + guard reachesOther else { return self } + return self == .workFirst ? .conversationsFirst : .workFirst + } +} diff --git a/Sources/BloomCore/Presentation/SidebarSelection.swift b/Sources/BloomCore/Presentation/SidebarSelection.swift index 64c56f3b8..18d4d9a71 100644 --- a/Sources/BloomCore/Presentation/SidebarSelection.swift +++ b/Sources/BloomCore/Presentation/SidebarSelection.swift @@ -14,6 +14,10 @@ import Foundation public enum SidebarSelection: Hashable, Sendable { case home case workspace(WorkspaceID) + /// A saved conversation whose session has no project or worktree. + case conversation(SessionID) + /// The saved transcript of a conversation removed from the live sidebar. + case archivedConversation(SessionID) /// An archived workspace, open for reading. /// /// Its own case rather than a flag on `workspace`, because an archived workspace is not a @@ -84,7 +88,7 @@ public enum SidebarSelection: Hashable, Sendable { public var workspaceID: WorkspaceID? { switch self { case .workspace(let id), .subagent(let id, _), .subagentCall(let id, _), .crew(let id, _): id - case .home, .archived: nil + case .home, .archived, .conversation, .archivedConversation: nil } } diff --git a/Sources/BloomCore/Presentation/SidebarSelectionStyle.swift b/Sources/BloomCore/Presentation/SidebarSelectionStyle.swift index 35bc463d7..9be13686a 100644 --- a/Sources/BloomCore/Presentation/SidebarSelectionStyle.swift +++ b/Sources/BloomCore/Presentation/SidebarSelectionStyle.swift @@ -13,10 +13,8 @@ import Foundation /// could read on `#197593`. /// /// The owner had already chosen the grey, comparing the pane with Finder's sidebar, where the -/// selected item keeps its ordinary ink on a quiet fill whatever has the keyboard. So the fill is -/// the same in every state, and the one thing that changes is an edge, drawn only while the arrow -/// keys really do move this list. The edge is what a keyboard user reads to know where the next -/// arrow lands, and it carries no accent, so it cannot reproduce the report. +/// selected item keeps its ordinary ink on a quiet fill whatever has the keyboard. The fill is +/// the same in every state, including while the sidebar has keyboard focus. public enum SidebarSelectionStyle: Sendable, Equatable { /// Not the selected row. Nothing is drawn, so the list's own hover wash still shows. case unselected @@ -41,6 +39,4 @@ public enum SidebarSelectionStyle: Sendable, Equatable { /// Whether the quiet fill is drawn. The same fill for both selected styles, by design. public var drawsFill: Bool { self != .unselected } - /// Whether the keyboard edge is drawn over the fill. - public var drawsKeyboardEdge: Bool { self == .keyboard } } diff --git a/Sources/BloomCore/Presentation/TranscriptMotion.swift b/Sources/BloomCore/Presentation/TranscriptMotion.swift index 2a493c71d..b97790784 100644 --- a/Sources/BloomCore/Presentation/TranscriptMotion.swift +++ b/Sources/BloomCore/Presentation/TranscriptMotion.swift @@ -121,75 +121,4 @@ public enum TranscriptMotion { public static func disclosure(reduceMotion: Bool) -> Double? { arrival(reduceMotion: reduceMotion)?.seconds } - - // MARK: - Going back to the live end - - /// How the transcript travels when the reader asks to be taken back to the newest row. - public enum LiveEndMove: Equatable, Sendable { - /// There instantly, with no travel to watch. What Reduce Motion gets, and what a reader - /// who is already at the end gets, since there is nowhere to go. - case jump - /// A short scroll, so the transcript arrives rather than teleports. - case glide(seconds: Double) - } - - /// The shortest a glide can be, which is what a small hop gets. - /// - /// The same fifth of a second the rest of the window answers a press in. See `Motion.pane`. - public static let glideFloor: Double = 0.16 - - /// The longest a glide can be, whatever the distance. - /// - /// **Nearly constant, and deliberately not proportional to the distance.** A reader who is - /// five thousand points up is not asking for a tour of the conversation they scrolled past, - /// they are asking to be at the end of it, and a scroll that took a second and a half to cover - /// that would be an effect rather than a movement. What the travel is for is the sense of - /// which way you went, and a quarter of a second of it carries that whether the distance was - /// two hundred points or twenty thousand. - public static let glideCeiling: Double = 0.26 - - /// The distance at which a glide is already as long as it will ever be. - /// - /// Roughly two panes of a full height window. Past this the answer stops moving, which is the - /// whole of the paragraph above. - public static let glideRamp: Double = 1400 - - /// Below this there is nothing worth animating: the reader is at the end already, or close - /// enough to it that a curve would be a flicker rather than a movement. The same threshold - /// `ScrollEnd` uses to decide whether the reader is following along, and for the same reason: - /// there is one rule about how near the end counts as being at it, and this is not a second. - public static let glideFloorDistance: Double = ScrollEnd.threshold - - /// How to travel to the live end. - /// - /// `distance` is how far below the viewport the end of the content is, and it is allowed to be - /// a coarse number: the answer moves by a tenth of a second across the whole of its useful - /// range, so the caller can quantise it hard enough that reporting it costs a scroll nothing. - /// See `TranscriptGeometry`. - /// - /// Reduce Motion drops the movement rather than slowing it, which is what every other call - /// site in this app does: the setting is about movement, and there is no slower version of - /// this worth having. - public static func liveEndMove(distance: Double, reduceMotion: Bool) -> LiveEndMove { - guard !reduceMotion else { return .jump } - guard distance >= glideFloorDistance else { return .jump } - let ramp = min(1, max(0, distance) / glideRamp) - return .glide(seconds: glideFloor + (glideCeiling - glideFloor) * ramp) - } - - // MARK: - What used to be here - // - // **`reassertsLiveEnd(after:isStreaming:)` was removed, and the reason it existed is worth - // keeping.** It answered whether a glide to the live end had to say the end a second time - // when it landed, and the answer was yes while a turn was streaming: the end of the content - // moves down while the travel is in the air, so the glide lands a little short of the end it - // was aimed at, and short of the end was exactly the state in which the lazy stack dropped - // its size-change anchor and stopped following the tail. The caller kept a task to say it - // again a beat later. - // - // What made it obsolete is that arriving at the end is no longer a movement. The transcript - // asks to BE at the end, the table holds that instruction and re-asserts it every time - // anything changes where the end is, and a turn growing the content while a travel is in - // flight is one of the things it re-asserts for. A rule about saying something twice is not - // needed by a thing that never stopped saying it. See `TranscriptTableController.goToEnd`. } diff --git a/Sources/BloomCore/System/LocalPage.swift b/Sources/BloomCore/System/LocalPage.swift index 3cd135d24..1fa82d96a 100644 --- a/Sources/BloomCore/System/LocalPage.swift +++ b/Sources/BloomCore/System/LocalPage.swift @@ -14,10 +14,8 @@ import Foundation public enum LocalPage { /// The extensions a browser opening is offered for. /// - /// `html` and `htm` are the request. `svg` is in because the review pane is the reason it - /// would be wanted: `FileMediaView.isMedia` refuses an SVG on purpose, since it conforms to - /// `.sourceCode` as well as to `.image`, so the pane draws its XML and a browser is the only - /// place in this window that draws the picture. + /// `html` and `htm` are the request. `svg` also has a browser opening, for inspecting the + /// image as a standalone page beyond the review pane's own preview. /// /// Not `md`, and not `markdown`: Bloom has a Markdown viewer of its own, and WebKit handed a /// `.md` file shows the asterisks. Not `pdf`, and none of the images or the video: the review diff --git a/Sources/BloomCore/Transcript/CodeBlockVersions.swift b/Sources/BloomCore/Transcript/CodeBlockVersions.swift new file mode 100644 index 000000000..29c11063a --- /dev/null +++ b/Sources/BloomCore/Transcript/CodeBlockVersions.swift @@ -0,0 +1,60 @@ +import Foundation + +/// The three versions of a code block the reader can edit, and every rule about which is in front. +/// +/// The agent's code never changes. Over it sit the edit in progress and the version last attached +/// to a message, which the block goes on showing once it is sent until the reader asks for the +/// original back. Here rather than in `CodeBlockView` because which one is shown, what counts as +/// unchanged and what may be attached are decisions, and a decision inside a view is one nothing +/// can test. +public struct CodeBlockVersions: Equatable, Sendable { + /// The agent's code, as it is in the transcript. + public let original: String + /// The edit in progress, nil when the block is not being edited. + public var draft: String? + /// The version last attached to a message, nil while the block shows the agent's code. + public var attached: String? + + public init(original: String, draft: String? = nil, attached: String? = nil) { + self.original = original + self.draft = draft + self.attached = attached + } + + /// What an edit starts from, and what discarding it goes back to. + public var base: String { attached ?? original } + + public var isEditing: Bool { draft != nil } + + /// Whether there is a version of the reader's to set beside the original. + public var hasEdits: Bool { (draft ?? attached).map { $0 != original } ?? false } + + /// Whether the edit changed anything since the last attach. It is what makes attaching worth + /// doing, so the same file is not attached twice, and what makes discarding worth asking + /// about, because it is the typing that would be lost. + public var hasUnattachedChanges: Bool { draft.map { $0 != base } ?? false } + + /// The editor, unless the reader is glancing at the original. + public func showsEditor(original showsOriginal: Bool) -> Bool { + isEditing && !(showsOriginal && hasEdits) + } + + /// What the read-only surface shows: the agent's code, or the attached version over it. + public func shown(original showsOriginal: Bool) -> String { + showsOriginal ? original : base + } + + public mutating func edit() { draft = base } + + public mutating func discard() { draft = nil } + + /// The edit, once it has arrived in a message. Attaching the agent's own code back leaves + /// nothing of the reader's to keep showing. + public mutating func attach() { + guard let draft else { return } + attached = draft == original ? nil : draft + self.draft = nil + } + + public mutating func revert() { attached = nil } +} diff --git a/Sources/BloomCore/Transcript/ConversationText.swift b/Sources/BloomCore/Transcript/ConversationText.swift new file mode 100644 index 000000000..18946e816 --- /dev/null +++ b/Sources/BloomCore/Transcript/ConversationText.swift @@ -0,0 +1,76 @@ +import Foundation + +/// What Select All, or a drag across several rows, puts on the pasteboard for a conversation. +/// +/// The table only builds the rows near the reader, so the text cannot be read back out of the +/// views: it is composed from the stored rows, which are all there. Tool calls, thinking and +/// footers are left out because the transcript folds them away by default and nobody pastes a +/// session to read forty file reads. +public enum ConversationText { + static let userLabel = "You" + static let agentLabel = "Agent" + + /// A drag across rows: every row from `first` to `last` in full, except the two it started + /// and ended in, where only the words under the drag are taken. Those two are given as text + /// because they were read off screen while their rows were on it. + public struct Slice: Equatable, Sendable { + public var first: Int + public var last: Int + public var firstText: String + public var lastText: String + + public init(first: Int, last: Int, firstText: String, lastText: String) { + self.first = first + self.last = last + self.firstText = firstText + self.lastText = lastText + } + } + + public static func text( + of rows: Rows, in slice: Slice? = nil + ) -> String where Rows.Element == TurnAnswer.Row { + rows.compactMap { turn(of: $0, in: slice) }.joined(separator: "\n\n") + } + + private static func turn(of row: TurnAnswer.Row, in slice: Slice?) -> String? { + guard !row.isNested, let body = body(of: row) else { return nil } + let wanted = selected(body, of: row.seq, in: slice) + let trimmed = wanted.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + return "\(label(of: row.kind)):\n\(trimmed)" + } + + private static func selected(_ body: String, of seq: Int, in slice: Slice?) -> String { + guard let slice else { return body } + if seq < slice.first || seq > slice.last { return "" } + if seq == slice.first { return slice.firstText } + if seq == slice.last { return slice.lastText } + return body + } + + private static func label(of kind: MessageKind) -> String { + kind == .user ? userLabel : agentLabel + } + + private static func body(of row: TurnAnswer.Row) -> String? { + switch row.kind { + case .user: + return userBody(row.payload) + case .assistantText: + guard case .assistantText(let block)? = AgentEvent.decode( + line: String(decoding: row.payload, as: UTF8.self) + ) else { return nil } + return block.text + default: + return nil + } + } + + /// The words the bubble shows: a review turn's typed message, or the sentence without the + /// attachment paths Bloom appended for the agent. + private static func userBody(_ payload: Data) -> String { + let typed = UserTurnPrompt.text(in: payload) + return ReviewTurn.split(typed)?.message ?? AttachmentTrailer.split(typed).body + } +} diff --git a/Sources/BloomCore/Transcript/EditedCodeBlock.swift b/Sources/BloomCore/Transcript/EditedCodeBlock.swift new file mode 100644 index 000000000..722f0aa76 --- /dev/null +++ b/Sources/BloomCore/Transcript/EditedCodeBlock.swift @@ -0,0 +1,95 @@ +import Foundation +import CryptoKit + +/// A fence from an agent's answer, edited in the transcript and handed back as an attachment. +/// +/// A file rather than the code pasted into the draft, for the reason a CI log is one: a fence worth +/// editing is usually long, and a draft holding forty lines of it is a draft nobody can write a +/// sentence around. +public enum EditedCodeBlock { + /// The file the fence says it is, when it says: ```` ```swift title="Store.swift" ```` or + /// ```` ```swift Sources/Store.swift ````, attached as `Store.swift` so the agent recognises + /// its own file. Only the last path component, because the attachment folder is flat and a + /// slash would quietly make another one. Otherwise the dated name below. + public static func filename( + info: String, + language: Language, + at date: Date = .now, + timeZone: TimeZone = .current + ) -> String { + named(in: info) ?? filename(language: language, at: date, timeZone: timeZone) + } + + /// Named like a pasted picture, `Edited 2026-10-09 at 07.59.04.swift`, so a folder of them sorts + /// by when they were made and the chip says what the file is. The extension comes from the + /// fence's language, so the agent and anything that opens the file read it as that language. + public static func filename( + language: Language, + at date: Date = .now, + timeZone: TimeZone = .current + ) -> String { + "Edited \(PastedAttachment.timestamp(date, in: timeZone)).\(fileExtension(for: language))" + } + + static func named(in info: String) -> String? { + let keyed = /(?:^|\s)(?:title|file|filename|path|name)=(?:"([^"]*)"|'([^']*)'|(\S+))/ + let candidate: Substring? = if let match = info.firstMatch(of: keyed) { + match.1 ?? match.2 ?? match.3 + } else { + // A bare word after the language is a name only when it looks like one, so + // ```` ```js {1,3} ```` is not attached as `{1,3}`, nor an attribute nobody named as + // the file as `highlight_name=x.sh`. + info.split(whereSeparator: \.isWhitespace).dropFirst().first { + $0.contains(".") && !$0.contains("=") + } + } + guard let candidate else { return nil } + + // A colon is rewritten by the Finder and a backtick would close the code span the path + // is written in, as `CheckFailureHandoff.logFilename` found first. + var safe = (String(candidate) as NSString).lastPathComponent + for bad in [":", "`"] { safe = safe.replacingOccurrences(of: bad, with: "-") } + safe = safe.trimmingCharacters(in: .whitespaces) + while safe.hasPrefix(".") { safe.removeFirst() } + return safe.isEmpty ? nil : safe + } + + /// Which block a draft belongs to: the conversation, the row, and the code itself. The row + /// because an agent repeats a command or a snippet from one answer to the next, and an edit of + /// one of them is not an edit of all of them; the code because a row can hold several fences + /// and nothing else tells them apart. Hashed so the key stays short whatever the fence holds, + /// and SHA-256 rather than `hashValue`, which changes on every launch and would orphan every + /// draft a relaunch was meant to keep. + public static func draftKey(session: String, entry: String, code: String) -> String { + let digest = SHA256.hash(data: Data(code.utf8)).prefix(12).map { String(format: "%02x", $0) }.joined() + return "\(session)/\(entry)/\(digest)" + } + + /// The inverse of `Language.detect(path:)`, which is what the test holds it to. + static func fileExtension(for language: Language) -> String { + switch language { + case .php: "php" + case .swift: "swift" + case .javascript: "js" + case .typescript: "ts" + case .python: "py" + case .ruby: "rb" + case .go: "go" + case .rust: "rs" + case .java: "java" + case .kotlin: "kt" + case .css: "css" + case .html: "html" + case .json: "json" + case .yaml: "yaml" + case .toml: "toml" + case .markdown: "md" + case .shell: "sh" + case .sql: "sql" + case .blade: "blade.php" + case .vue: "vue" + case .xml: "xml" + case .plainText: "txt" + } + } +} diff --git a/Sources/BloomCore/Transcript/TranscriptClientRow.swift b/Sources/BloomCore/Transcript/TranscriptClientRow.swift new file mode 100644 index 000000000..7b0283d89 --- /dev/null +++ b/Sources/BloomCore/Transcript/TranscriptClientRow.swift @@ -0,0 +1,264 @@ +import Foundation + +/// A platform-neutral reading of one stored event. The host owns provider parsing so a browser +/// and a future phone client do not each learn Claude Code, Codex and Grok wire formats. +/// +/// Rows are projected only for the requested page. A long transcript remains in the store until +/// a client asks for its rows, just as the Mac table decodes only what is on screen. +public struct TranscriptClientRow: Encodable, Sendable { + public struct Question: Encodable, Sendable { + public struct Option: Encodable, Sendable { + public let label: String + public let detail: String + public let preview: String? + } + + public let answerKey: String + public let text: String + public let header: String + public let multiSelect: Bool + public let allowsOther: Bool + public let isSecret: Bool + public let options: [Option] + + init(_ question: AgentQuestion) { + answerKey = question.id + text = question.question + header = question.header + multiSelect = question.multiSelect + allowsOther = question.allowsOther + isSecret = question.isSecret + options = question.options.map { Option(label: $0.label, detail: $0.description, preview: $0.preview) } + } + } + + public struct FileReference: Encodable, Sendable { + public let label: String + public let path: String + public let line: Int + + public init(label: String, path: String, line: Int = 1) { + self.label = label + self.path = path + self.line = line + } + } + public enum Kind: String, Encodable, Sendable { + case user + case assistant + case thinking + case tool + case toolResult + case permission + case error + case notice + case session + case plan + case crew + case turnEnd + } + + public let seq: Int + public let kind: Kind + public let title: String + public let text: String + /// HTML from `MarkdownHTML`, which escapes raw markup and filters unsafe addresses. + public let html: String? + public let detail: String + public let chips: [String] + public let files: [FileReference] + public let tint: String + public let refID: String? + public let parentToolUseID: String? + public let requestID: String? + public let isError: Bool + public let canAnswer: Bool + public let questions: [Question] + public let planMode: String? + + private init( + seq: Int, kind: Kind, title: String = "", text: String = "", html: String? = nil, + detail: String = "", chips: [String] = [], files: [FileReference] = [], tint: String = "neutral", + refID: String? = nil, parentToolUseID: String? = nil, requestID: String? = nil, + isError: Bool = false, canAnswer: Bool = false, questions: [Question] = [], planMode: String? = nil + ) { + self.seq = seq + self.kind = kind + self.title = title + self.text = text + self.html = html + self.detail = detail + self.chips = chips + self.files = files + self.tint = tint + self.refID = refID + self.parentToolUseID = parentToolUseID + self.requestID = requestID + self.isError = isError + self.canAnswer = canAnswer + self.questions = questions + self.planMode = planMode + } + + public static func present(_ message: Message, worktree: String = "") -> Self? { + let raw = String(decoding: message.payload, as: UTF8.self) + let event = AgentEvent.decode(line: raw) + let seq = message.seq + switch message.kind { + case .user: + let typed = UserTurnPrompt.text(in: message.payload) + if let review = ReviewTurn.split(typed) { + return Self( + seq: seq, kind: .user, text: review.message, + chips: review.chips.map(\.label), + files: review.chips.compactMap { chip in + guard let path = FileChipTarget.resolve(chip.filePath, in: worktree).opens else { return nil } + return FileReference(label: chip.label, path: path, line: chip.line) + } + ) + } + let turn = AttachmentTrailer.split(typed) + let segments = SentTurn.segments(in: turn.body) + let instructions = segments.compactMap { segment -> String? in + if case .instructions(let block) = segment { return block.title } + return nil + } + let inlineFiles = segments.compactMap { segment -> FileReference? in + guard case .file(let path) = segment, + let relative = FileChipTarget.resolve(path, in: worktree).opens else { return nil } + return FileReference(label: AttachmentDraft.token(for: path), path: relative) + } + return Self( + seq: seq, kind: .user, text: SentTurn.withoutInstructions(turn.body), + chips: turn.paths.map { ($0 as NSString).lastPathComponent } + instructions, + files: turn.paths.compactMap { path in + guard let relative = FileChipTarget.resolve(path, in: worktree).opens else { return nil } + return FileReference(label: (path as NSString).lastPathComponent, path: relative) + } + inlineFiles + ) + case .assistantText: + guard case .assistantText(let block)? = event, !block.text.isEmpty else { return nil } + return Self( + seq: seq, kind: .assistant, text: block.text, + html: MarkdownHTML.render(block.text, imagesAsAltText: true).html, + files: references(in: block.text, worktree: worktree), + parentToolUseID: block.parentToolUseID + ) + case .thinking: + guard case .thinking(let block)? = event, !block.text.isEmpty else { return nil } + return Self(seq: seq, kind: .thinking, title: "Thinking", text: block.text, + parentToolUseID: block.parentToolUseID) + case .toolUse: + guard case .toolUse(let use)? = event else { return nil } + if let plan = PlanRow.markdown(of: use, resultText: nil) { + return Self(seq: seq, kind: .plan, title: "Plan", text: plan, + html: MarkdownHTML.render(plan, imagesAsAltText: true).html, refID: use.id, + parentToolUseID: use.parentToolUseID) + } + let presentation = TranscriptPresenter.present(use, worktree: worktree) + return Self( + seq: seq, kind: .tool, title: presentation.label, + detail: presentation.detailLine, + chips: presentation.chips.map(\.text), + files: presentation.chips.compactMap { chip in + guard case .file(let path) = chip, + let relative = FileChipTarget.resolve(path, in: worktree).opens else { return nil } + return FileReference(label: chip.text, path: relative) + }, tint: presentation.tint.rawValue, + refID: use.id, parentToolUseID: use.parentToolUseID + ) + case .toolResult: + guard case .toolResult(let result)? = event else { return nil } + return Self( + seq: seq, kind: .toolResult, title: result.isError ? "Failed" : "Result", + text: result.text, refID: result.toolUseID, + parentToolUseID: result.parentToolUseID, isError: result.isError + ) + case .permissionAsk: + guard case .permissionAsk(let ask)? = event else { return nil } + let questions = ask.isQuestion ? AgentQuestionnaire.questions(in: ask.input).map(Question.init) : [] + let planMode = ask.isPlanApproval + ? PlanApproval.implementationMode(ask.implementationMode ?? .acceptEdits).rawValue : nil + return Self( + seq: seq, kind: .permission, title: ask.label, + text: ask.summary.isEmpty ? ask.reason : ask.summary, + detail: ask.reason, tint: "warning", refID: ask.toolUseID, + requestID: ask.requestID, canAnswer: !ask.isQuestion || !questions.isEmpty, + questions: questions, planMode: planMode + ) + case .error: + let exit = AgentExit.read(JSONValue.parse(message.payload)) + return Self(seq: seq, kind: .error, title: exit.title, + text: exit.summary, detail: exit.advice, tint: "negative", isError: true) + case .notice: + guard let sentence = ConversationNotResumed.sentence(fromNote: message.payload) + ?? RateLimitNotice.sentence(forRateLimitEvent: message.payload) else { return nil } + return Self(seq: seq, kind: .notice, title: "Notice", text: sentence) + case .crew: + guard let crew = CrewMessage.decode(message.payload), !crew.text.isEmpty else { return nil } + return Self(seq: seq, kind: .crew, title: "Workspace message", text: crew.text, + html: MarkdownHTML.render(crew.text, imagesAsAltText: true).html) + case .result: + return Self(seq: seq, kind: .turnEnd, title: "Turn finished") + case .system: + guard !TranscriptRowInk.drawsNothing(kind: message.kind, payload: message.payload) else { return nil } + switch event { + case .initialized(let info): + return Self(seq: seq, kind: .session, title: "Started with", + detail: [info.agentKind.rawValue, info.model, info.permissionMode] + .filter { !$0.isEmpty }.joined(separator: " · ")) + case .contextCompacted: + return Self(seq: seq, kind: .notice, title: "Conversation compacted") + default: + return nil + } + } + } + + private static func references(in text: String, worktree: String) -> [FileReference] { + func reference(_ url: URL?, label: String) -> FileReference? { + guard let url, let location = SourceReference.location(url), + let path = FileChipTarget.resolve(location.path, in: worktree).opens else { return nil } + return FileReference(label: label, path: path, line: location.line) + } + func inline(_ values: [MarkdownInline]) -> [FileReference] { + values.flatMap { value -> [FileReference] in + switch value { + case .text(let value): + return SourceReference.links(in: value).compactMap { range, url in + reference(url, label: (value as NSString).substring(with: range)) + } + case .code(let value): + return reference(SourceReference.url(value), label: value).map { [$0] } ?? [] + case .link(let children, let address): + let label = children.map { child -> String in + switch child { + case .text(let text), .code(let text): text + default: "" + } + }.joined() + return reference(SourceReference.url(address), label: label).map { [$0] } ?? inline(children) + case .image(let alt, let address): + return reference(SourceReference.url(address), label: alt).map { [$0] } ?? [] + case .emphasis(let children), .strong(let children), .strikethrough(let children): + return inline(children) + case .lineBreak: return [] + } + } + } + func blocks(_ values: [MarkdownBlock]) -> [FileReference] { + values.flatMap { block -> [FileReference] in + switch block { + case .paragraph(let values), .heading(_, let values): return inline(values) + case .bulletList(let items, _), .numberedList(_, let items, _): return items.flatMap(blocks) + case .taskList(let items): return items.flatMap { inline($0.inline) } + case .blockQuote(let values): return blocks(values) + case .table(let headers, let rows, _): + return headers.flatMap(inline) + rows.flatMap { $0.flatMap(inline) } + case .codeBlock, .thematicBreak: return [] + } + } + } + return blocks(MarkdownParser.parse(text)) + } +} diff --git a/Sources/BloomCore/Transcript/TranscriptFollow.swift b/Sources/BloomCore/Transcript/TranscriptFollow.swift index 505396ff9..27c168a56 100644 --- a/Sources/BloomCore/Transcript/TranscriptFollow.swift +++ b/Sources/BloomCore/Transcript/TranscriptFollow.swift @@ -3,7 +3,7 @@ import Foundation /// How the transcript keeps up with content arriving at the live end. /// /// **This is about the reader who is already at the end.** Somebody who has scrolled up to read -/// something is nobody's business but their own: `ScrollEnd` says who is at the end, the jump pill +/// something is nobody's business but their own: the table checks the exact end, the jump pill /// says there is more below, and nothing here ever moves a view that somebody else has taken. That /// one rule is what makes the rest of this safe. /// @@ -42,7 +42,7 @@ import Foundation /// its own. Content growing steadily at `r` points a second leaves the view `r * timeConstant` /// points behind, so at a fifth of a second per line of prose the view trails by a couple of /// points, and it would take growth of well over a thousand points a second to trail by the -/// `ScrollEnd.threshold` that would put the jump pill up. Nothing an agent prints does that. +/// `ScrollEnd.threshold` used for near-end behavior. Nothing an agent prints does that. public enum TranscriptFollow { /// How far back the view is put when content lands under a reader who is at the end, in /// points, and therefore how much travel there is to watch. @@ -71,8 +71,8 @@ public enum TranscriptFollow { /// animation". /// /// Nearly a tenth of a second instead, which puts an ordinary row's travel at about a quarter - /// of a second and a full take-back at about the same as the jump pill's glide. Longer than - /// this and the view is visibly behind the words, which is the failure in the other direction. + /// of a second, including for a full take-back. Longer than this and the view is visibly + /// behind the words, which is the failure in the other direction. public static let timeConstant: Double = 0.09 /// Within this many points the travel is over. Half a point is under a pixel on every display diff --git a/Sources/BloomCore/Transcript/TranscriptTextMeasureCache.swift b/Sources/BloomCore/Transcript/TranscriptTextMeasureCache.swift new file mode 100644 index 000000000..7256be758 --- /dev/null +++ b/Sources/BloomCore/Transcript/TranscriptTextMeasureCache.swift @@ -0,0 +1,65 @@ +import Foundation + +/// The answers one run of transcript prose has already given, one per question. +/// +/// **One remembered answer was not enough, and the reason is how SwiftUI asks.** A run inside an +/// `HStack`, which is every item of a markdown list with its marker beside it, is asked the same +/// question at several widths in one layout pass: zero, infinity, a hair, and then the width it is +/// placed at. The view used to keep only the last answer, so each of those questions threw the +/// previous one away and TextKit typeset the whole run again. A nested list is another stack at +/// every level, asking its children the same questions. +/// +/// Measured with temporary counters over a contributor's conversations, during twelve workspace +/// visits and some scrolling: about 320,000 size questions, 262,000 of them answered by typesetting +/// again. The misses moved between 0, infinity, 1 and the pane's width, over and over, and the +/// heaviest content was nested lists. +/// +/// So every question keeps its own answer until the text changes. The number kept is bounded, +/// because a divider drag asks a new width on every frame and the old ones are never asked again. +/// The answer asked for least recently is the one dropped, so the questions every pass repeats +/// stay and the widths a drag has moved past go. +/// +/// Keyed on the proposal exactly as SwiftUI made it, not on the width it is laid out at, because +/// `TranscriptTextMeasure.size` reads the proposal as well as the layout, and a cache that is +/// keyed more loosely than the function it stands in for can answer a question it was never asked. +public struct TranscriptTextMeasureCache { + public struct Key: Hashable, Sendable { + public var proposed: Double? + public var alignsBubbleInk: Bool + + public init(proposed: Double?, alignsBubbleInk: Bool) { + self.proposed = proposed + self.alignsBubbleInk = alignsBubbleInk + } + } + + /// Enough for every question one pass asks (zero, a hair, infinity, and nothing when a caller + /// proposes none), the width the run is drawn at, the width the row measurer asks, and a little + /// of a drag. + public static var capacity: Int { 8 } + + private var entries: [(key: Key, value: Value)] = [] + + public init() {} + + public var count: Int { entries.count } + + /// The answer to this question, if it has been given, which then counts as the most recent. + public mutating func value(for key: Key) -> Value? { + guard let index = entries.firstIndex(where: { $0.key == key }) else { return nil } + let entry = entries.remove(at: index) + entries.append(entry) + return entry.value + } + + public mutating func store(_ value: Value, for key: Key) { + entries.removeAll { $0.key == key } + if entries.count >= Self.capacity { entries.removeFirst(entries.count - Self.capacity + 1) } + entries.append((key, value)) + } + + /// The text changed, so every answer is about text that is no longer there. + public mutating func removeAll() { + entries.removeAll() + } +} diff --git a/Sources/BloomCore/Transcript/TranscriptWindow.swift b/Sources/BloomCore/Transcript/TranscriptWindow.swift index 9edca4b2c..56482cb29 100644 --- a/Sources/BloomCore/Transcript/TranscriptWindow.swift +++ b/Sources/BloomCore/Transcript/TranscriptWindow.swift @@ -59,6 +59,10 @@ public struct TranscriptWindow: Equatable, Sendable { /// flick, and small enough that adding it is a layout of a few milliseconds. public static let chunk = 400 + /// Keep a few screens of history ready at the live end. Preparing the entire session made + /// every new row rebuild and compare thousands of entries, even while nobody scrolled. + public static let livePreparedLimit = 1_200 + /// How many rows above a row that has to be reachable the window starts. /// /// A search result opens centred in the pane, so the rows above it have to exist or there is @@ -104,9 +108,17 @@ public struct TranscriptWindow: Equatable, Sendable { /// first frame. Once that arrival is complete, however, an `NSTableView` is cheaper when its /// lightweight row entries arrive before the reader asks for them. Returning nil makes the /// caller's settle chain stop naturally when there is no history left. - public func preparedHistory(afterArrival arrived: Bool, by chunk: Int = chunk) -> Self? { - guard arrived, canGrowUp else { return nil } - return grownUp(by: chunk) + public func preparedHistory( + afterArrival arrived: Bool, by chunk: Int = chunk, maximumRows: Int = .max + ) -> Self? { + guard arrived, canGrowUp, count < maximumRows else { return nil } + return grownUp(by: min(chunk, maximumRows - count)) + } + + /// Drop old table entries after a reader returns to the live end. The model still owns every + /// row, and the table can grow upward again when the reader asks for that history. + public func keepingLatest(_ rows: Int) -> Self { + Self(start: max(start, end - max(0, rows)), end: end) } /// The window grown downward, which is what the reader approaching the bottom of an opened-on- diff --git a/Sources/BloomCore/Workspace/ArchiveScriptFailure.swift b/Sources/BloomCore/Workspace/ArchiveScriptFailure.swift new file mode 100644 index 000000000..8fe03e71a --- /dev/null +++ b/Sources/BloomCore/Workspace/ArchiveScriptFailure.swift @@ -0,0 +1,39 @@ +import Foundation + +/// What the owner is told when a project's archive script fails, and what pressing "Archive +/// Anyway" costs them. +/// +/// A failing script stops the archive on purpose: containers still running or a database still +/// there are mess with nothing left to clean it up from once the worktree is gone. But the script +/// failing is not always fixable right away (a Docker daemon that is stuck, a resource that is gone +/// for good), and with no way past it the only exit was deleting the worktree by hand outside +/// Bloom. So the refusal carries the way out, and the words say plainly what it leaves behind. +public struct ArchiveScriptFailure: Equatable, Sendable { + public let title = "The archive script failed" + public let confirmLabel = "Archive Anyway" + public let retryLabel = "Retry" + public let cancelLabel = "Keep Workspace" + public let message: String + + /// Nil for any error that is not the archive script failing, so the caller has one place to + /// ask rather than a `switch` of its own. + public init?(_ error: WorkspaceError, workspaceName: String) { + let reason: String + switch error { + case .archiveScriptFailed(let status, let output): + // The tail is where a script says why it gave up. + let tail = output.trimmingCharacters(in: .whitespacesAndNewlines).suffix(Self.outputLimit) + reason = "The script exited with status \(status).\n\n\(tail)" + case .archiveScriptIncomplete(let failure): + reason = "The script did not finish: \(failure.description)" + default: + return nil + } + message = "\u{201C}\(workspaceName)\u{201D} is still here: its worktree and its branch are " + + "untouched. Any agent it was running has been stopped.\n\n\(reason)\n\n" + + "Archiving anyway skips the script. Whatever it was meant to remove (databases, " + + "containers and so on) will be left behind." + } + + private static let outputLimit = 1_000 +} diff --git a/Sources/BloomCore/Workspace/ProjectVisibility.swift b/Sources/BloomCore/Workspace/ProjectVisibility.swift index 2386daca2..e30a579c9 100644 --- a/Sources/BloomCore/Workspace/ProjectVisibility.swift +++ b/Sources/BloomCore/Workspace/ProjectVisibility.swift @@ -48,6 +48,9 @@ public enum ProjectVisibility { /// same reason `DiffLayoutSetting` exists in the app target. public static let showsHiddenKey = "sidebar.showsHiddenProjects" + /// Whether project headers with no workspaces are kept in the sidebar's project grouping. + public static let showsEmptyKey = "sidebar.showsEmptyProjects" + /// The projects to draw, in the order they were handed over. public static func listed(_ repos: [Repo], showingHidden: Bool) -> [Repo] { showingHidden ? repos : repos.filter { !$0.hidden } diff --git a/Sources/BloomCore/Workspace/WorkspaceManager.swift b/Sources/BloomCore/Workspace/WorkspaceManager.swift index b99eae2c4..90466e9d6 100644 --- a/Sources/BloomCore/Workspace/WorkspaceManager.swift +++ b/Sources/BloomCore/Workspace/WorkspaceManager.swift @@ -736,6 +736,8 @@ public struct WorkspaceManager: Sendable { /// once the branch is gone commits nothing else points at are unreachable. So unless the /// caller passes `force`, this refuses up front and throws a report of what is at stake, /// before it has touched anything. + /// - Parameter skipArchiveScript: Only for an owner who has been shown the script fail and + /// chose to archive anyway. `force` does not imply it, see the ordering tests. /// - Parameter isPullRequestMerged: GitHub's own answer for this branch, when the caller has /// one. Nothing here can ask: `gh` lives above this layer and a report that shelled out to /// the network would make every archive wait on it. Passing it in is what stops a squash @@ -746,6 +748,7 @@ public struct WorkspaceManager: Sendable { deleteBranch: Bool? = nil, force: Bool = false, isPullRequestMerged: Bool = false, + skipArchiveScript: Bool = false, archiveScriptTimeout: Duration = WorkspaceManager.archiveScriptTimeout ) async throws { // Already archived, so there is nothing here to wind down. Everything below this line acts @@ -796,7 +799,7 @@ public struct WorkspaceManager: Sendable { // reason a missing setup script does not stop a workspace being created. let archiveRuns: Bool switch archiveLaunch { - case .executable, .source: archiveRuns = true + case .executable, .source: archiveRuns = !skipArchiveScript case .missing, nil: archiveRuns = false } if let archiveLaunch, archiveRuns, diff --git a/Sources/BloomCore/Workspace/WorkspaceStartAllowance.swift b/Sources/BloomCore/Workspace/WorkspaceStartAllowance.swift index 3f3a5dd1b..84c6bf43b 100644 --- a/Sources/BloomCore/Workspace/WorkspaceStartAllowance.swift +++ b/Sources/BloomCore/Workspace/WorkspaceStartAllowance.swift @@ -3,8 +3,8 @@ import Foundation /// How many workspaces a caller may start, and how fast. /// /// Three answers to one question, in one place, because they were in three and nothing said they -/// were related. The Create sheet is uncapped, a parent agent may have eight children running, and -/// the owner's own client may start six in a quarter of an hour. Read apart, those look like an +/// were related. The Create sheet is uncapped, a parent agent has a configurable running child +/// limit, and the owner's own client may start six in a quarter of an hour. Read apart, those look like an /// omission, a safety limit and a rate limit. Read together they are one rule with one variable in /// it, which is **how much a workspace costs the caller to ask for**. /// @@ -20,8 +20,8 @@ import Foundation /// so a ceiling would refuse the eleventh workspace of a busy fortnight, which is ordinary use /// rather than a runaway. What is not ordinary is the rate, so the brake is on how fast. /// -/// Neither number is a safety limit. Six worktrees and six agents is already real money, and the -/// point of both is that somebody notices at six or at eight instead of at forty. +/// Neither number is a safety limit. Every worktree and agent costs real money, and the point of +/// both is that somebody notices before an accidental loop grows without bound. public enum WorkspaceStartAllowance: Sendable, Equatable { /// No brake, because the asking is already the brake. The Create sheet, a `bloom://` link, the /// Services menu and a Shortcut: each of them is one deliberate gesture per workspace. @@ -43,9 +43,8 @@ public enum WorkspaceStartAllowance: Sendable, Equatable { /// How many a single agent may have running at once. /// /// Not a safety limit, a sanity one: an agent that misreads its own instructions can ask for - /// workspaces in a loop. Eight is more than anyone has wanted and small enough that a runaway - /// is noticed rather than discovered on the next bill. - public static let maximumChildren = 8 + /// workspaces in a loop. The owner can change this in General settings. + public static let maximumChildren = 20 /// How many the owner's own client may start inside `ownerWindow`. /// @@ -63,10 +62,12 @@ public enum WorkspaceStartAllowance: Sendable, Equatable { /// The one switch, and the reason `WorkspaceOrigin` is the seam rather than a parallel notion /// of who is calling. Adding a fourth kind of caller makes this stop compiling, which is the /// point: a new door with no answer to "how many" is the gap `workspace_start` shipped with. - public static func of(_ origin: WorkspaceOrigin) -> WorkspaceStartAllowance { + public static func of( + _ origin: WorkspaceOrigin, maximumChildren: Int = Self.maximumChildren + ) -> WorkspaceStartAllowance { switch origin { case .user: .unlimited - case .agent: .running(limit: maximumChildren) + case .agent: .running(limit: max(1, maximumChildren)) case .ownerClient: .rate(limit: maximumOwnerStarts, window: ownerWindow) } } diff --git a/Sources/BloomCore/Workspace/WorkspaceStartPlan.swift b/Sources/BloomCore/Workspace/WorkspaceStartPlan.swift index 327078d5d..7e76b4a4f 100644 --- a/Sources/BloomCore/Workspace/WorkspaceStartPlan.swift +++ b/Sources/BloomCore/Workspace/WorkspaceStartPlan.swift @@ -7,6 +7,10 @@ import Foundation /// empty box for every route at once. Words are the right requirement for a chat and the wrong one /// for a shell, and one condition covering both is how the wrong one gets applied. public enum WorkspaceStartPlan { + public static func canStartConversation(prompt: String, isBusy: Bool) -> Bool { + !isBusy && !prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + /// Whether Create may be pressed. /// /// Words are required for a chat, and for nothing else. diff --git a/Sources/BloomCore/Workspace/WorkspaceStartPreferences.swift b/Sources/BloomCore/Workspace/WorkspaceStartPreferences.swift new file mode 100644 index 000000000..ea17280bd --- /dev/null +++ b/Sources/BloomCore/Workspace/WorkspaceStartPreferences.swift @@ -0,0 +1,31 @@ +import Foundation + +/// The per-installation ceiling for workspaces started by one agent. +/// +/// A preference belongs to this app installation, not to a repository's shared settings file. +/// UserDefaults is thread safe, though its Swift declaration is not Sendable. +public struct WorkspaceStartPreferences: @unchecked Sendable { + public static let maximumChildrenKey = "workspaceStart.maximumChildren" + public static let allowedRange = 1...100 + + private let defaults: UserDefaults + + public init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + public var maximumChildren: Int { + get { + guard let stored = defaults.object(forKey: Self.maximumChildrenKey) as? Int else { + return WorkspaceStartAllowance.maximumChildren + } + return min(max(Self.allowedRange.lowerBound, stored), Self.allowedRange.upperBound) + } + nonmutating set { + defaults.set( + min(max(Self.allowedRange.lowerBound, newValue), Self.allowedRange.upperBound), + forKey: Self.maximumChildrenKey + ) + } + } +} diff --git a/Tests/BloomCoreTests/AgentAccountProfileTests.swift b/Tests/BloomCoreTests/AgentAccountProfileTests.swift new file mode 100644 index 000000000..b6a309baa --- /dev/null +++ b/Tests/BloomCoreTests/AgentAccountProfileTests.swift @@ -0,0 +1,101 @@ +import Foundation +import Testing +@testable import BloomCore + +@Suite("Agent account profiles", .tags(.persistence), .scratchDirectory) +struct AgentAccountProfileTests { + @Test("A session keeps its first account when its project default changes") + func sessionBinding() async throws { + let store = try makeTestStore("agent-accounts") + let repo = try await store.upsert(Repo(name: "client", path: TestScratch.unique("repo"))) + let workspace = try await store.upsert(Workspace( + repoID: repo.id, name: "work", branch: "work", path: TestScratch.unique("work"), + baseBranch: "main" + )) + var session = Session(workspaceID: workspace.id, title: "Chat") + session.agentKind = .codex + session = try await store.upsert(session) + + let first = try await store.createAgentAccountProfile(kind: .codex, name: "Client A") + let second = try await store.createAgentAccountProfile(kind: .codex, name: "Client B") + try await store.setDefaultAccount(first.id, for: repo.id, kind: .codex) + let launched = try await store.accountForLaunch(session: session) + try await store.setDefaultAccount(second.id, for: repo.id, kind: .codex) + let resumed = try await store.accountForLaunch(session: session) + + #expect(launched?.id == first.id) + #expect(resumed?.id == first.id) + #expect(try await store.agentAccountProfiles().count == 2) + } + + @Test("Profiles isolate their home and inherited API key") + func environment() { + let environment = AgentAccountEnvironment.applying( + kind: .codex, home: "/tmp/client", to: ["OPENAI_API_KEY": "old", "PATH": "/bin"] + ) + #expect(environment["CODEX_HOME"] == "/tmp/client") + #expect(environment["OPENAI_API_KEY"] == nil) + #expect(environment["PATH"] == "/bin") + #expect(AgentAccountEnvironment.shellArguments(kind: .codex, home: "/tmp/client") + .contains("CODEX_HOME=/tmp/client")) + #expect(AgentAccountEnvironment.applying( + kind: .claudeCode, home: "/tmp/claude", to: ["CLAUDE_CODE_OAUTH_TOKEN": "old"] + )["CLAUDE_CODE_OAUTH_TOKEN"] == nil) + #expect(AgentAccountEnvironment.shellArguments(kind: .grok, home: "/tmp/grok") + .contains("GROK_HOME=/tmp/grok")) + } + + @Test("A local project default selects its provider and model") + func projectModel() async throws { + let store = try makeTestStore("agent-project-default") + let repoID = RepoID.new() + try await store.setProjectAgentDefaults( + for: repoID, model: "gpt-6.1-sol", effort: "medium", kind: .codex + ) + var shared = RepoSettings() + shared.defaultModel = "opus" + let project = try await store.applyingProjectAgentDefaults(to: shared, for: repoID) + let resolved = ComposerDefaults.resolve(repo: project, app: AppDefaults()) + + #expect(resolved.backend == .codex) + #expect(resolved.model == "gpt-6.1-sol") + #expect(resolved.effort == "medium") + } + + @Test("Removing an unused profile clears project choices and local files") + func removesUnusedProfile() async throws { + let store = try makeTestStore("remove-agent-account") + let profile = try await store.createAgentAccountProfile(kind: .claudeCode, name: "Old client") + let home = store.agentAccountHome(profile) + let repoID = RepoID.new() + try await store.setDefaultAccount(profile.id, for: repoID, kind: .claudeCode) + + try await store.removeAgentAccountProfile(id: profile.id) + + #expect(try await store.agentAccountProfiles().isEmpty) + #expect(try await store.defaultAccount(for: repoID, kind: .claudeCode) == nil) + #expect(!FileManager.default.fileExists(atPath: home)) + } + + @Test("A profile used by a saved conversation cannot be removed") + func refusesBoundProfile() async throws { + let store = try makeTestStore("bound-agent-account") + let repo = try await store.upsert(Repo(name: "client", path: TestScratch.unique("repo"))) + let workspace = try await store.upsert(Workspace( + repoID: repo.id, name: "work", branch: "work", path: TestScratch.unique("work"), + baseBranch: "main" + )) + let session = try await store.upsert(Session(workspaceID: workspace.id, title: "Chat")) + let profile = try await store.createAgentAccountProfile(kind: .claudeCode, name: "Client") + try await store.setDefaultAccount(profile.id, for: repo.id, kind: .claudeCode) + _ = try await store.accountForLaunch(session: session) + + do { + try await store.removeAgentAccountProfile(id: profile.id) + Issue.record("A bound profile was removed") + } catch AgentAccountProfileError.inUse(let count) { + #expect(count == 1) + } + #expect(try await store.agentAccountProfiles().contains(profile)) + } +} diff --git a/Tests/BloomCoreTests/AgentRunnerTests.swift b/Tests/BloomCoreTests/AgentRunnerTests.swift index 908b10b85..e6a2ae4c7 100644 --- a/Tests/BloomCoreTests/AgentRunnerTests.swift +++ b/Tests/BloomCoreTests/AgentRunnerTests.swift @@ -157,13 +157,13 @@ private final class ProcessRecorder: @unchecked Sendable { /// travels inside `--settings`, which is documented and takes a JSON string as well as a path. @Suite("AgentRunner argv", .tags(.agentProtocol), .scratchDirectory) struct AgentRunnerArgvTests { - @Test("Ask Bloom receives host instructions on new and resumed conversations", + @Test("project-free conversations use no forced host prompt", arguments: [false, true], [false, true]) func askBloomInstructions(hasWorkspace: Bool, resumed: Bool) { let session = Session(workspaceID: hasWorkspace ? WorkspaceID("w") : nil) let argv = AgentRunner.argv(session: session, resume: resumed ? "existing-chat" : nil) - #expect(value(of: "--append-system-prompt", in: argv) == (hasWorkspace ? nil : AskConversation.instructions)) + #expect(value(of: "--append-system-prompt", in: argv) == nil) #expect(!argv.contains("--system-prompt")) #expect(value(of: "--resume", in: argv) == (resumed ? "existing-chat" : nil)) } diff --git a/Tests/BloomCoreTests/AskConversationTests.swift b/Tests/BloomCoreTests/AskConversationTests.swift index 031477fdc..75c956d52 100644 --- a/Tests/BloomCoreTests/AskConversationTests.swift +++ b/Tests/BloomCoreTests/AskConversationTests.swift @@ -45,80 +45,59 @@ struct AskConversationTests { #expect(AskConversation.prepareDirectory(besideDatabaseAt: database) == path) } - /// It opens on Ask, and the thing it is not opening on is Full access. - @Test("a new chat has no workspace and opens on Ask") - func newSessionOpensOnAsk() { + @Test("a new chat has no workspace and starts with the built-in permission fallback") + func newSessionStartsWithFallback() { let session = AskConversation.newSession() #expect(session.workspaceID == nil) - #expect(session.permissionMode == .auto) - #expect(session.permissionMode != AppDefaults.fallbackPermissionMode) + #expect(session.permissionMode == AppDefaults.fallbackPermissionMode) #expect(session.title == AskConversation.title) #expect(session.state == .idle) } - /// The row was created on `.auto` and the composer settles a new chat's defaults the first - /// time it is opened, so without this the mode would have been overwritten before the first - /// turn with whatever the Models tab holds. The default is `bypassPermissions`, so the chat - /// that sits above every project would have opened on Full access. - @Test("the owner's default permission mode does not land on a chat with no worktree") - func defaultsDoNotOverruleTheMode() { + @Test("new conversations use an isolated directory and keep legacy titles readable") + func workspaceDirectoryAndLegacyTitle() throws { + let root = TestScratch.unique("conversation-directory") + let database = root + "/bloom.sqlite" + let first = SessionID("first") + let path = try #require(AskConversation.prepareWorkspaceDirectory( + besideDatabaseAt: database, id: first + )) + #expect(path == root + "/Conversations/first") + #expect(AskConversation.workspaceDirectory( + besideDatabaseAt: database, id: SessionID("second") + ) != path) + #expect(AskConversation.isBlank(Session(workspaceID: nil, title: "Ask Bloom"))) + #expect(AskConversation.automaticTitle(current: "Ask Bloom", prompt: "Plan a trip") == "Plan a trip") + } + + @Test("Ask Bloom inherits the permission preference with its model and effort") + func defaultsReachAskBloom() { var defaults = AppDefaults() - defaults.permissionMode = .bypassPermissions + defaults.permissionMode = .acceptEdits defaults.planMode = false let inWorktree = ComposerDefaults.resolve(repo: RepoSettings(), app: defaults) - #expect(inWorktree.permissionMode == .bypassPermissions) + #expect(inWorktree.permissionMode == .acceptEdits) let ask = ComposerDefaults.resolve(repo: RepoSettings(), app: defaults, hasWorktree: false) - #expect(ask.permissionMode == AskConversation.permissionMode) - // And only the mode. What a conversation costs is the same question here as anywhere. + #expect(ask.permissionMode == inWorktree.permissionMode) #expect(ask.model == inWorktree.model) #expect(ask.effort == inWorktree.effort) } - /// Plan mode is the more specific instruction and beats the picker in a worktree. It does not - /// beat this, because this is not a preference being overruled: it is the one mode that is - /// true of a conversation with nothing to edit. - @Test("start in plan mode does not reach it either") - func planModeDoesNotOverruleTheMode() { + @Test("Claude Code's plan preference also reaches Ask Bloom") + func planModeReachesAskBloom() { var defaults = AppDefaults() defaults.planMode = true #expect(ComposerDefaults.resolve(repo: RepoSettings(), app: defaults).permissionMode == .plan) #expect( ComposerDefaults.resolve(repo: RepoSettings(), app: defaults, hasWorktree: false) - .permissionMode == AskConversation.permissionMode - ) - } - - /// Without this the lock holds exactly once. `ComposerDefaults.resolve` settles a chat's mode - /// the first time it is opened and never again, and the picker writes the owner's choice onto - /// the row, so a Full access chosen once to get something done would have been what this - /// conversation opened on for ever after. - @Test("the mode goes back to Ask on the next launch, and not before") - func theModeIsReappliedEachLaunch() { - // What the owner left it on, a launch later. - #expect( - AskConversation.modeOnOpening(stored: .bypassPermissions, isFirstOpenSinceLaunch: true) - == AskConversation.permissionMode - ) - - // The same choice, still in the launch it was made in. Overruling it while somebody is - // watching would be a picker that does not work. - #expect( - AskConversation.modeOnOpening(stored: .bypassPermissions, isFirstOpenSinceLaunch: false) - == nil - ) - - // Nothing to do, so nothing is written: this is a column with two writers. - #expect( - AskConversation.modeOnOpening( - stored: AskConversation.permissionMode, isFirstOpenSinceLaunch: true - ) == nil + .permissionMode == .plan ) } - /// The choice expires, so the menu has to say so before it is made rather than after. + /// The menu must explain the wider scope and when the preference returns. @Test("the permission menu says what a mode means with no worktree") func theMenuSaysWhatAModeMeansHere() throws { let inWorktree = ComposerControls( @@ -135,7 +114,7 @@ struct AskConversationTests { #expect(!ask.hasWorktree) let note = try #require(ask.permissionModeNote) #expect(note.contains("whole machine")) - #expect(note.contains("Bloom next starts")) + #expect(note.contains("saved for this conversation")) } /// The empty state is the first thing the owner sees, and it used to open by listing what this diff --git a/Tests/BloomCoreTests/AskPanelLayoutTests.swift b/Tests/BloomCoreTests/AskPanelLayoutTests.swift deleted file mode 100644 index 84c99b244..000000000 --- a/Tests/BloomCoreTests/AskPanelLayoutTests.swift +++ /dev/null @@ -1,33 +0,0 @@ -import CoreGraphics -import Testing -@testable import BloomCore - -@Suite("Ask Bloom panel") -struct AskPanelLayoutTests { - @Test func widthIsAProportionClampedAtBothEnds() { - #expect(AskPanelLayout.width(inWindow: 900) == AskPanelLayout.minimumWidth) - #expect(AskPanelLayout.width(inWindow: 1_440) == 1_440 * AskPanelLayout.widthProportion) - #expect(AskPanelLayout.width(inWindow: 3_440) == AskPanelLayout.maximumWidth) - } - - @Test func aWindowTooSmallForTheFloorStillGetsACardThatFits() { - #expect(AskPanelLayout.width(inWindow: 600) == 600 - AskPanelLayout.margin * 2) - #expect(AskPanelLayout.height(inWindow: 400) == 400 - AskPanelLayout.margin * 2) - } - - @Test func heightIsAProportionClampedAtBothEnds() { - #expect(AskPanelLayout.height(inWindow: 900) == 900 * AskPanelLayout.heightProportion) - #expect(AskPanelLayout.height(inWindow: 2_000) == AskPanelLayout.maximumHeight) - } - - @Test func theRailListsTheNewestConversationFirst() { - #expect(AskPanelLayout.railOrder([1, 2, 3]) == [3, 2, 1]) - #expect(AskPanelLayout.railOrder([Int]()).isEmpty) - } - - @Test func waitingOnAPersonOutranksRunning() { - #expect(AskPanelLayout.status(isRunning: true, isAwaitingPermission: true) == .awaitingPermission) - #expect(AskPanelLayout.status(isRunning: true, isAwaitingPermission: false) == .running) - #expect(AskPanelLayout.status(isRunning: false, isAwaitingPermission: false) == nil) - } -} diff --git a/Tests/BloomCoreTests/AskTabsTests.swift b/Tests/BloomCoreTests/AskTabsTests.swift index f345958d1..05f7ec5e7 100644 --- a/Tests/BloomCoreTests/AskTabsTests.swift +++ b/Tests/BloomCoreTests/AskTabsTests.swift @@ -28,6 +28,25 @@ struct AskTabsTests { #expect(AskTabs.selectionAfterClosing(first.id, selected: first.id, sessions: [first]) == nil) } + @Test func unreadConversationsRequireAnUnseenFinishedTurn() async throws { + let store = try makeTestStore("ask-unread-conversations") + let chat = try await store.createAskConversation(directory: "/first") + _ = try await store.createAskConversation(directory: "/second") + + try await store.appendNext(sessionID: chat.id, kind: .user, payload: Data("hello".utf8)) + #expect(try await store.unreadProjectFreeConversationIDs().isEmpty) + + try await store.appendNext(sessionID: chat.id, kind: .result, payload: Data("{}".utf8)) + #expect(try await store.unreadProjectFreeConversationIDs() == Set([chat.id])) + + try await store.updateLastReadSeq(sessionID: chat.id, seq: 1) + #expect(try await store.unreadProjectFreeConversationIDs().isEmpty) + + try await store.appendNext(sessionID: chat.id, kind: .result, payload: Data("{}".utf8)) + _ = try await store.closeAskConversation(id: chat.id, selected: chat.id) + #expect(try await store.unreadProjectFreeConversationIDs().isEmpty) + } + @Test func replacingOneTabPreservesItsLocationAndNeighbours() async throws { let store = try makeTestStore("ask-replace-tab") let first = try await store.createAskConversation(directory: "/first") @@ -39,6 +58,25 @@ struct AskTabsTests { #expect(try await store.session(id: first.id)?.archivedAt != nil) } + @Test func replacementCanUseItsOwnDirectory() async throws { + let store = try makeTestStore("conversation-replacement-directory") + let first = try await store.createAskConversation(directory: "/first") + let nextID = SessionID.new() + let directory = AskConversation.workspaceDirectory( + besideDatabaseAt: store.path, id: nextID + ) + let replacement = try await store.replaceAskConversation( + id: first.id, controls: ComposerControls( + session: first, isFastMode: false, outputStyle: OutputStyle.defaultName + ), + nextID: nextID, replacementDirectory: directory + ) + + #expect(replacement.id == nextID) + #expect(try await store.setting(AskTabs.directoryKey(first.id)) == "/first") + #expect(try await store.setting(AskTabs.directoryKey(nextID)) == directory) + } + @Test func missingCustomDirectoryDoesNotFallBack() throws { let root = TestScratch.unique("ask-custom-directory") #expect(AskTabs.prepareDirectory(root + "/missing", databasePath: root + "/db") == nil) @@ -71,6 +109,51 @@ struct AskTabsTests { #expect(try await store.setting(ComposerControls.outputStyleKey(sessionID: chat.id)) == "Concise") } + @Test func projectFreeConversationCanBeArchivedRestoredAndDeleted() async throws { + let store = try makeTestStore("conversation-workspace-lifecycle") + let conversation = try await store.createAskConversation( + directory: "/conversations", draft: "keep this", title: "A loose conversation" + ) + try await store.append(Message( + sessionID: conversation.id, seq: 0, kind: .user, payload: Data("Hello".utf8) + )) + let neighbour = try await store.createAskConversation(directory: "/another") + + #expect(conversation.workspaceID == nil) + #expect(conversation.title == "A loose conversation") + #expect(try await store.projectFreeSessionsWithHistory().contains(conversation.id)) + #expect(try await store.deleteArchivedConversation(id: conversation.id) == false) + + _ = try await store.closeAskConversation(id: conversation.id, selected: conversation.id) + #expect(try await store.sessionsWithoutWorkspace().map(\.id) == [neighbour.id]) + #expect(try await store.sessionsWithoutWorkspace(includeArchived: true).map(\.id) == [conversation.id]) + + let restored = try #require(await store.restoreConversation(id: conversation.id)) + #expect(restored.archivedAt == nil) + #expect(try await store.setting(AskTabs.selectionKey) == conversation.id.rawValue) + #expect(try await store.draft(sessionID: conversation.id) == "keep this") + #expect(try await store.messageCount(sessionID: conversation.id) == 1) + #expect(try await store.restoreConversation(id: conversation.id) == nil) + + _ = try await store.closeAskConversation(id: conversation.id, selected: conversation.id) + #expect(try await store.deleteArchivedConversation(id: conversation.id)) + #expect(try await store.session(id: conversation.id) == nil) + #expect(try await store.messageCount(sessionID: conversation.id) == 0) + #expect(try await store.session(id: neighbour.id) != nil) + } + + @Test func legacyTitleDoesNotHideAnArchivedDraft() async throws { + let store = try makeTestStore("legacy-conversation-draft") + let old = try await store.createAskConversation( + directory: "/old", draft: "saved words", title: "Ask Bloom" + ) + _ = try await store.createAskConversation(directory: "/next") + _ = try await store.closeAskConversation(id: old.id, selected: old.id) + + #expect(AskConversation.isBlank(old)) + #expect(try await store.projectFreeSessionsWithHistory(includeArchived: true).contains(old.id)) + } + @Test func legacyConversationKeepsItsOriginalDirectoryWhenReplaced() async throws { let store = try makeTestStore("ask-tab-legacy") let original = try await store.upsert(AskConversation.newSession()) diff --git a/Tests/BloomCoreTests/BridgeServerTests.swift b/Tests/BloomCoreTests/BridgeServerTests.swift index 68c076d4a..1c74de984 100644 --- a/Tests/BloomCoreTests/BridgeServerTests.swift +++ b/Tests/BloomCoreTests/BridgeServerTests.swift @@ -130,10 +130,11 @@ struct BridgeServerTests { // reaches nothing but the store, while starting, saying and stopping all need the window. // `workspace_diff` reads a worktree through git, which needs no window either. // `project_list` is here because a workspace agent may name another project to - // `workspace_start`, and this is how it learns the names; `workspace_list` for the same - // reason one level down. + // `workspace_start`, and this is how it learns the names; `project_add` registers an + // existing repository before starting work there. `workspace_list` does the same one + // level down. #expect(names == [ - "agent_list", "chat_list", "chat_read", "project_list", "quick_prompt_create", + "agent_list", "chat_list", "chat_read", "project_add", "project_list", "quick_prompt_create", "quick_prompt_list", "whoami", "workspace_diff", "workspace_list", "workspace_rename", ]) diff --git a/Tests/BloomCoreTests/Characterisation.swift b/Tests/BloomCoreTests/Characterisation.swift new file mode 100644 index 000000000..bd966e3cf --- /dev/null +++ b/Tests/BloomCoreTests/Characterisation.swift @@ -0,0 +1,22 @@ +import Foundation +import Testing + +/// Holds `actual` against `Tests/fixtures/characterisation/`, byte for byte. The suite never +/// writes a fixture: a mismatch prints the text JSON encoded on a `BLOOM-FIXTURE` line instead, +/// so whitespace survives a CI log. +func expectCharacterised( + _ actual: String, _ name: String, sourceLocation: SourceLocation = #_sourceLocation +) { + let path = "characterisation/\(name)" + let expected = bloomFixtureURL(path).flatMap { try? String(contentsOf: $0, encoding: .utf8) } + guard actual != expected else { return } + + let encoder = JSONEncoder() + encoder.outputFormatting = .withoutEscapingSlashes + let encoded = (try? encoder.encode(actual)).map { String(decoding: $0, as: UTF8.self) } ?? "?" + let problem = expected == nil ? "is missing" : "differs" + Issue.record( + Comment(rawValue: "Fixture \(path) \(problem).\nBLOOM-FIXTURE \(path) \(encoded)"), + sourceLocation: sourceLocation + ) +} diff --git a/Tests/BloomCoreTests/ClaudeModelListTests.swift b/Tests/BloomCoreTests/ClaudeModelListTests.swift index a8cb3f6cb..1a9a36d73 100644 --- a/Tests/BloomCoreTests/ClaudeModelListTests.swift +++ b/Tests/BloomCoreTests/ClaudeModelListTests.swift @@ -20,6 +20,7 @@ struct ClaudeModelListTests { #expect(models.first?.efforts == ["low", "medium", "high", "xhigh", "max"]) #expect(models.last?.efforts == [], "a model with no effort levels offers none") #expect(models.map(\.isDefault) == [true, false, false, false], "`default` marks the model it resolves to") + #expect(models[2].resolvedModel == "claude-sonnet-5") #expect(models.first?.supportsFastMode == true) } diff --git a/Tests/BloomCoreTests/ClaudeModelSourceTests.swift b/Tests/BloomCoreTests/ClaudeModelSourceTests.swift index d0d4454d3..ce8413d4d 100644 --- a/Tests/BloomCoreTests/ClaudeModelSourceTests.swift +++ b/Tests/BloomCoreTests/ClaudeModelSourceTests.swift @@ -27,4 +27,30 @@ struct ClaudeModelSourceTests { func registered() { #expect(Set(AgentModelSource.live().keys) == [.claudeCode, .codex, .grok]) } + + @Test("a newly released Sonnet remains selectable while Claude Code lists the previous one") + func supplementsOlderCLI() { + let models = ClaudeModelList.parse(ClaudeModelListTests.output).agentModels + let sonnet = models.first { $0.id == "claude-sonnet-5-5" } + + #expect(sonnet?.displayName == "Sonnet 5.5") + #expect(sonnet?.supportedEfforts.map(\.id) == ["low", "medium", "high", "xhigh", "max"]) + #expect(sonnet?.defaultEffort == "medium") + #expect(models.contains { $0.id == "sonnet" && $0.displayName == "Sonnet 5" }) + } + + @Test("the CLI's Sonnet 5.5 alias replaces the temporary explicit choice") + func currentCLI() { + let listed = [ClaudeModel( + id: "sonnet", label: "Sonnet 5.5", efforts: ["low", "medium", "high", "xhigh", "max"], + resolvedModel: "claude-sonnet-5-5" + )] + + #expect(listed.agentModels.map(\.id) == ["sonnet"]) + } + + @Test("an empty CLI answer leaves the fallback menu in use") + func emptyCLI() { + #expect([ClaudeModel]().agentModels.isEmpty) + } } diff --git a/Tests/BloomCoreTests/CodeBlockVersionsTests.swift b/Tests/BloomCoreTests/CodeBlockVersionsTests.swift new file mode 100644 index 000000000..5c85d8d9c --- /dev/null +++ b/Tests/BloomCoreTests/CodeBlockVersionsTests.swift @@ -0,0 +1,52 @@ +import Testing +@testable import BloomCore + +@Suite("Code block versions") +struct CodeBlockVersionsTests { + @Test("An untouched edit can be discarded freely and not attached") + func untouched() { + var versions = CodeBlockVersions(original: "a") + versions.edit() + #expect(versions.draft == "a") + #expect(!versions.hasUnattachedChanges) + #expect(!versions.hasEdits) + #expect(versions.showsEditor(original: true)) + } + + @Test("An attached edit stays in front, and editing again starts from it") + func attachThenEditAgain() { + var versions = CodeBlockVersions(original: "a") + versions.edit() + versions.draft = "b" + #expect(versions.hasUnattachedChanges) + #expect(!versions.showsEditor(original: true)) + + versions.attach() + #expect(versions == CodeBlockVersions(original: "a", attached: "b")) + #expect(versions.shown(original: false) == "b") + #expect(versions.shown(original: true) == "a") + #expect(versions.hasEdits) + + versions.edit() + #expect(versions.draft == "b") + #expect(!versions.hasUnattachedChanges) + + versions.draft = "c" + versions.discard() + #expect(versions.shown(original: false) == "b") + } + + @Test("Reverting shows the agent's code again, and attaching it back keeps nothing") + func revertAndAttachOriginal() { + var versions = CodeBlockVersions(original: "a", attached: "b") + versions.revert() + #expect(versions == CodeBlockVersions(original: "a")) + + versions = CodeBlockVersions(original: "a", attached: "b") + versions.edit() + versions.draft = "a" + #expect(versions.hasUnattachedChanges) + versions.attach() + #expect(versions == CodeBlockVersions(original: "a")) + } +} diff --git a/Tests/BloomCoreTests/CodexModelRankTests.swift b/Tests/BloomCoreTests/CodexModelRankTests.swift index b2b89f547..c853f96c3 100644 --- a/Tests/BloomCoreTests/CodexModelRankTests.swift +++ b/Tests/BloomCoreTests/CodexModelRankTests.swift @@ -78,6 +78,14 @@ struct CodexModelRankTests { #expect(ordered.map(\.id) == ["gpt-6-astra", "gpt-5.6-sol", "gpt-5.6-terra"]) } + @Test("Astra stays above the newer Sol release") + func astraAboveSol() { + let ordered = CodexModelRank.ordered([ + model("gpt-6-sol"), model("gpt-6.1-sol"), model("gpt-6-astra"), model("gpt-6-luna"), + ]) + #expect(ordered.map(\.id) == ["gpt-6-astra", "gpt-6.1-sol", "gpt-6-sol", "gpt-6-luna"]) + } + /// `isReduced` matches whole words, so a name that merely contains the letters is not demoted. @Test("a word inside another word is not a size") func wordBoundary() { diff --git a/Tests/BloomCoreTests/CodexProtocolTests.swift b/Tests/BloomCoreTests/CodexProtocolTests.swift index 6159e09f2..e55c14af3 100644 --- a/Tests/BloomCoreTests/CodexProtocolTests.swift +++ b/Tests/BloomCoreTests/CodexProtocolTests.swift @@ -411,6 +411,29 @@ private func events(_ name: String) throws -> [CodexEvent] { #expect(models.filter(\.acceptsImages).count == models.count) } + @Test func offersReleasedSolUntilCodexListsIt() throws { + let listed = CodexModel.decodeList(try codexFixtureJSON("codex-model-list-astra.json")) + let models = CodexModel.includingReleasedSol(listed) + let sol = try #require(models.first { $0.id == "gpt-6.1-sol" }) + + #expect(sol.displayName == "GPT-6.1 Sol") + #expect(sol.effortIDs == ["low", "medium", "high", "xhigh", "max", "ultra"]) + #expect(sol.defaultEffort == "medium") + #expect(sol.acceptsImages) + #expect(sol.fastServiceTier == "priority") + #expect(CodexModelRank.ordered(models).prefix(2).map(\.id) == ["gpt-6-astra", "gpt-6.1-sol"]) + } + + @Test func usesCodexMetadataWhenItListsReleasedSol() { + let listed = [CodexModel( + id: "gpt-6.1-sol", displayName: "Sol from Codex", isDefault: true, + supportedEfforts: [CodexReasoningEffort(id: "high")], defaultEffort: "high" + )] + + #expect(CodexModel.includingReleasedSol(listed) == listed) + #expect(CodexModel.includingReleasedSol([]).isEmpty) + } + // MARK: - A generation that arrived after this code was written /// `codex-model-list-astra.json` is the same call answered by codex-cli 0.153.0 on this diff --git a/Tests/BloomCoreTests/CodexRunnerTests.swift b/Tests/BloomCoreTests/CodexRunnerTests.swift index cf24eaf29..991e36319 100644 --- a/Tests/BloomCoreTests/CodexRunnerTests.swift +++ b/Tests/BloomCoreTests/CodexRunnerTests.swift @@ -195,7 +195,7 @@ private func eventually( #expect(stored?.state == .running) } - @Test("Ask Bloom sends host instructions when starting and resuming Codex", + @Test("project-free conversations send no forced host prompt", arguments: [false, true], [false, true]) func askBloomInstructions(hasWorkspace: Bool, resumed: Bool) async throws { let store = try makeTestStore("codex-ask-instructions") @@ -214,8 +214,7 @@ private func eventually( let method = resumed ? "thread/resume" : "thread/start" let frame = try #require(box.process.sentFrame { $0["method"]?.stringValue == method }) - #expect(frame["params"]?["developerInstructions"]?.stringValue == - (hasWorkspace ? nil : AskConversation.instructions)) + #expect(frame["params"]?["developerInstructions"]?.stringValue == nil) #expect(frame["params"]?["baseInstructions"] == nil) await runner.shutdown() } diff --git a/Tests/BloomCoreTests/ComposerTypeAheadTests.swift b/Tests/BloomCoreTests/ComposerTypeAheadTests.swift new file mode 100644 index 000000000..0f606b113 --- /dev/null +++ b/Tests/BloomCoreTests/ComposerTypeAheadTests.swift @@ -0,0 +1,47 @@ +import Testing +@testable import BloomCore + +@Suite("Composer type-ahead") +struct ComposerTypeAheadTests { + @Test("printable characters go to the composer", arguments: ["a", "A", "é", "^", "@", "1", "😀"]) + func typed(characters: String) { + #expect(ComposerTypeAhead.belongsToComposer(characters: characters, modifiers: [])) + } + + @Test("option and shift still type", arguments: [ + MenuShortcut.Modifiers.option, .shift, [MenuShortcut.Modifiers.option, .shift], + ]) + func optionAndShift(modifiers: MenuShortcut.Modifiers) { + #expect(ComposerTypeAhead.belongsToComposer(characters: "|", modifiers: modifiers)) + } + + @Test("command V pastes", arguments: ["v", "V"]) + func paste(characters: String) { + #expect(ComposerTypeAhead.belongsToComposer(characters: characters, modifiers: .command)) + } + + @Test("other shortcuts keep their meaning", arguments: [ + MenuShortcut.Modifiers.control, [MenuShortcut.Modifiers.command, .shift], + [MenuShortcut.Modifiers.command, .option], + ]) + func shortcuts(modifiers: MenuShortcut.Modifiers) { + #expect(!ComposerTypeAhead.belongsToComposer(characters: "v", modifiers: modifiers)) + } + + @Test("command with any other letter is a shortcut") + func commandLetter() { + #expect(!ComposerTypeAhead.belongsToComposer(characters: "a", modifiers: .command)) + } + + @Test("keys that type nothing stay where they are", arguments: [ + " ", "\r", "\t", "\u{1B}", "\u{7F}", "\u{F700}", "\u{F704}", "", + ]) + func untyped(characters: String) { + #expect(!ComposerTypeAhead.belongsToComposer(characters: characters, modifiers: [])) + } + + @Test("no characters at all") + func missing() { + #expect(!ComposerTypeAhead.belongsToComposer(characters: nil, modifiers: [])) + } +} diff --git a/Tests/BloomCoreTests/ConversationLayoutTests.swift b/Tests/BloomCoreTests/ConversationLayoutTests.swift new file mode 100644 index 000000000..f40615a91 --- /dev/null +++ b/Tests/BloomCoreTests/ConversationLayoutTests.swift @@ -0,0 +1,39 @@ +import Foundation +import Testing +@testable import BloomCore + +@Suite("Project-free conversation panes") +struct ConversationLayoutTests { + @Test func chatRemainsReachableWhileToolsOpenAndClose() { + var layout = ConversationLayout() + let terminal = layout.open(.terminal) + let browser = layout.open(.browser) + + #expect(layout.tabs.map(\.id) == [ConversationLayout.chatID, terminal.id, browser.id]) + #expect(layout.selectedTabID == browser.id) + #expect(layout.closeTab(browser.id).map(\.id) == [browser.id]) + #expect(layout.selectedTabID == terminal.id) + #expect(layout.closeTab(ConversationLayout.chatID).isEmpty) + #expect(layout.tabs.first?.id == ConversationLayout.chatID) + } + + @Test func splitAndCloseKeepTheConversationAndPersistTheShape() throws { + var layout = ConversationLayout() + let created = layout.split( + tabID: ConversationLayout.chatID, paneID: ConversationLayout.chatID, + axis: .horizontal, opening: .browser + ) + let browser = try #require(created) + layout.setURL("https://example.com", paneID: browser.id, in: ConversationLayout.chatID) + layout.setRatio(0.35, at: [], in: ConversationLayout.chatID) + + let restored = try JSONDecoder().decode(ConversationLayout.self, from: JSONEncoder().encode(layout)) + #expect(restored.isValid) + #expect(restored.tab(ConversationLayout.chatID)?.layout.paneCount == 2) + #expect(restored.tab(ConversationLayout.chatID)?.pane(browser.id)?.url == "https://example.com") + + #expect(layout.closePane(tabID: ConversationLayout.chatID, paneID: ConversationLayout.chatID) == nil) + #expect(layout.closePane(tabID: ConversationLayout.chatID, paneID: browser.id)?.id == browser.id) + #expect(layout.tab(ConversationLayout.chatID)?.layout.paneCount == 1) + } +} diff --git a/Tests/BloomCoreTests/ConversationListingTests.swift b/Tests/BloomCoreTests/ConversationListingTests.swift new file mode 100644 index 000000000..b16878612 --- /dev/null +++ b/Tests/BloomCoreTests/ConversationListingTests.swift @@ -0,0 +1,48 @@ +import Foundation +import Testing +@testable import BloomCore + +@Suite("Project-free conversation listing") +struct ConversationListingTests { + @Test func pinnedConversationsLeadWithoutMovingTheirPeers() { + let first = Session(id: SessionID("first"), workspaceID: nil, sortOrder: 0) + let second = Session(id: SessionID("second"), workspaceID: nil, sortOrder: 1) + let third = Session(id: SessionID("third"), workspaceID: nil, sortOrder: 2) + + let ordered = ConversationListing.ordered( + [first, second, third], pinned: [first.id, second.id] + ) + #expect(ordered.map(\.id) == [second.id, first.id, third.id]) + } + + @Test func keysAreScopedToOneConversation() { + let id = SessionID("chat") + #expect(ConversationListing.pinnedKey(id) != ConversationListing.colourKey(id)) + #expect(ConversationListing.pinnedKey(id) != ConversationListing.pinnedKey(SessionID("other"))) + } + + @Test func dragStaysWithinPinnedAndUnpinnedRuns() throws { + let pinned = Session(id: SessionID("pinned"), workspaceID: nil, sortOrder: 2) + let first = Session(id: SessionID("first"), workspaceID: nil, sortOrder: 1) + let second = Session(id: SessionID("second"), workspaceID: nil, sortOrder: 0) + let rows = [pinned, first, second] + let moved = try #require(ConversationListing.reordered( + rows, pinned: [pinned.id], from: IndexSet(integer: 2), to: 1 + )) + #expect(moved.map(\.id) == [pinned.id, second.id, first.id]) + #expect(ConversationListing.reordered( + rows, pinned: [pinned.id], from: IndexSet(integer: 2), to: 0 + ) == nil) + } + + @Test func waitingOutranksRunning() { + #expect(ConversationListing.status(isRunning: true, isAwaitingPermission: true, isUnread: true) + == .awaitingPermission) + #expect(ConversationListing.status(isRunning: true, isAwaitingPermission: false, isUnread: true) + == .running) + #expect(ConversationListing.status(isRunning: false, isAwaitingPermission: false, isUnread: true) + == .unread) + #expect(ConversationListing.status(isRunning: false, isAwaitingPermission: false, isUnread: false) + == .clean) + } +} diff --git a/Tests/BloomCoreTests/ConversationTextTests.swift b/Tests/BloomCoreTests/ConversationTextTests.swift new file mode 100644 index 000000000..bbcb50a75 --- /dev/null +++ b/Tests/BloomCoreTests/ConversationTextTests.swift @@ -0,0 +1,74 @@ +import Foundation +import Testing +@testable import BloomCore + +@Suite("The text Select All copies for a whole conversation") +struct ConversationTextTests { + @Test func labelsBothSidesInOrderAndSkipsEverythingElse() { + let rows = [ + user(1, "Fix the bug"), prose(2, "Looking."), row(3, .toolUse), + prose(4, "Fixed."), row(5, .result), + ] + #expect(ConversationText.text(of: rows) == "You:\nFix the bug\n\nAgent:\nLooking.\n\nAgent:\nFixed.") + } + + @Test func leavesOutSubagentConversations() { + let rows = [user(1, "Go"), prose(2, "A subagent", isNested: true), prose(3, "Done")] + #expect(ConversationText.text(of: rows) == "You:\nGo\n\nAgent:\nDone") + } + + @Test func dropsTheAttachmentPathsTheBubbleHides() { + let sent = AttachmentTrailer.compose(text: "Look at this", paths: [".bloom/attachments/A1/shot.png"]) + let rows = [user(1, sent)] + #expect(ConversationText.text(of: rows) == "You:\nLook at this") + } + + @Test func skipsEmptyTurnsAndAnEmptyConversation() { + #expect(ConversationText.text(of: [user(1, " \n"), prose(2, ""), row(3, .assistantText)]).isEmpty) + #expect(ConversationText.text(of: [TurnAnswer.Row]()).isEmpty) + } + + @Test func aDragTakesWholeRowsBetweenAndOnlyTheSelectedWordsAtBothEnds() { + let rows = [ + user(1, "Before"), prose(2, "An answer that was only partly dragged over"), + user(3, "Whole"), prose(4, "Whole too"), user(5, "A question cut short"), prose(6, "After"), + ] + let slice = ConversationText.Slice( + first: 2, last: 5, firstText: "dragged over", lastText: "A question" + ) + #expect( + ConversationText.text(of: rows, in: slice) + == "Agent:\ndragged over\n\nYou:\nWhole\n\nAgent:\nWhole too\n\nYou:\nA question" + ) + } + + @Test func aDragThroughNothingThatIsTextCopiesNothing() { + let rows = [row(1, .toolUse), row(2, .toolUse)] + let slice = ConversationText.Slice(first: 1, last: 2, firstText: "x", lastText: "y") + #expect(ConversationText.text(of: rows, in: slice).isEmpty) + } + + private func row(_ seq: Int, _ kind: MessageKind) -> TurnAnswer.Row { + TurnAnswer.Row(seq: seq, kind: kind, payload: Data()) + } + + private func user(_ seq: Int, _ text: String) -> TurnAnswer.Row { + let payload = JSONValue.object([ + "type": .string("user"), + "message": .object(["role": .string("user"), "content": .string(text)]), + ]) + return TurnAnswer.Row(seq: seq, kind: .user, payload: Data(payload.compactJSON.utf8)) + } + + private func prose(_ seq: Int, _ text: String, isNested: Bool = false) -> TurnAnswer.Row { + let payload = JSONValue.object([ + "type": .string("assistant"), + "message": .object([ + "content": .array([.object(["type": .string("text"), "text": .string(text)])]), + ]), + ]) + return TurnAnswer.Row( + seq: seq, kind: .assistantText, payload: Data(payload.compactJSON.utf8), isNested: isNested + ) + } +} diff --git a/Tests/BloomCoreTests/CreateWorkspacePresentationTests.swift b/Tests/BloomCoreTests/CreateWorkspacePresentationTests.swift index 91df6dfdf..7b2b55ffc 100644 --- a/Tests/BloomCoreTests/CreateWorkspacePresentationTests.swift +++ b/Tests/BloomCoreTests/CreateWorkspacePresentationTests.swift @@ -50,4 +50,24 @@ import Testing #expect(reopened.id != first.id) #expect(reopened.pullRequestRevision == 0) } + + @Test func projectAskTakesAnOpenConversationSheetBackToProject() throws { + var presentation = CreateWorkspacePresentation() + presentation.present(repoID: nil, startsAsConversation: true) + #expect(try #require(presentation.request).startsAsConversation) + + presentation.present(repoID: RepoID("project")) + let moved = try #require(presentation.request) + #expect(moved.repoID == RepoID("project")) + #expect(!moved.startsAsConversation) + } + + @Test func genericWorkspaceAskRestoresProjectDefaultInOpenSheet() throws { + var presentation = CreateWorkspacePresentation() + presentation.present(repoID: RepoID("project"), startsAsConversation: true) + + presentation.present(repoID: nil) + let request = try #require(presentation.request) + #expect(!request.startsAsConversation) + } } diff --git a/Tests/BloomCoreTests/DocumentPreviewTests.swift b/Tests/BloomCoreTests/DocumentPreviewTests.swift index 2bee65c7d..adf5fbe49 100644 --- a/Tests/BloomCoreTests/DocumentPreviewTests.swift +++ b/Tests/BloomCoreTests/DocumentPreviewTests.swift @@ -13,13 +13,14 @@ struct DocumentPreviewTests { return URL(filePath: root).resolvingSymlinksInPath().path } - @Test("Markdown and HTML have a preview, other files do not") + @Test("Markdown, HTML and SVG have a preview, other files do not") func kinds() { #expect(DocumentPreview.kind(path: "README.md") == .markdown) #expect(DocumentPreview.kind(path: "docs/Architecture.HTML") == .html) #expect(DocumentPreview.kind(path: "index.htm") == .html) #expect(DocumentPreview.kind(path: "main.swift") == nil) - #expect(DocumentPreview.kind(path: "logo.svg") == nil) + #expect(DocumentPreview.kind(path: "logo.svg") == .svg) + #expect(DocumentPreview.kind(path: "public/LOGO.SVG") == .svg) } @Test("a file's address resolves back to the same file, spaces and hashes included") @@ -81,6 +82,7 @@ struct DocumentPreviewTests { #expect(DocumentPreview.contentType(forFile: "plan.md") == "text/html; charset=utf-8") #expect(DocumentPreview.contentType(forFile: "app.css") == "text/css; charset=utf-8") #expect(DocumentPreview.contentType(forFile: "app.mjs") == "text/javascript; charset=utf-8") + #expect(DocumentPreview.contentType(forFile: "logo.svg") == "image/svg+xml; charset=utf-8") #expect(DocumentPreview.contentType(forFile: "flow.png") == "image/png") #expect(DocumentPreview.contentType(forFile: "blob.unknownextension") == "application/octet-stream") } diff --git a/Tests/BloomCoreTests/EditedCodeBlockTests.swift b/Tests/BloomCoreTests/EditedCodeBlockTests.swift new file mode 100644 index 000000000..9d38f8175 --- /dev/null +++ b/Tests/BloomCoreTests/EditedCodeBlockTests.swift @@ -0,0 +1,51 @@ +import Foundation +import Testing +@testable import BloomCore + +@Suite("Edited code blocks") +struct EditedCodeBlockTests { + @Test("An edited fence is named like a paste and dated to the second") + func name() throws { + let utc = try #require(TimeZone(identifier: "UTC")) + let date = Date(timeIntervalSince1970: 1_791_532_744) + #expect(EditedCodeBlock.filename(language: .swift, at: date, timeZone: utc) + == "Edited 2026-10-09 at 07.59.04.swift") + } + + @Test("Every language is written under an extension that reads back as that language") + func roundTrip() { + for language in Language.allCases { + let name = EditedCodeBlock.filename(language: language) + #expect(Language.detect(path: name) == language, "\(name)") + } + } + + @Test("A draft's key is the same on every launch, and tells fences apart by row and by code") + func draftKey() { + let key = EditedCodeBlock.draftKey(session: "s1", entry: "row(4)", code: "let a = 1") + #expect(key == "s1/row(4)/2c07d877ba5b1548307d0bbc") + #expect(EditedCodeBlock.draftKey(session: "s1", entry: "row(4)", code: "let b = 2") != key) + #expect(EditedCodeBlock.draftKey(session: "s1", entry: "row(9)", code: "let a = 1") != key) + } + + @Test("A fence that names its file is attached under that name", arguments: [ + ("swift title=\"Store.swift\"", "Store.swift"), + ("swift title='My Store.swift'", "My Store.swift"), + ("php file=app/Models/User.php", "User.php"), + ("ts src/index.ts", "index.ts"), + ("swift filename=\"a:b`c.swift\"", "a-b-c.swift"), + ]) + func named(info: String, expected: String) { + #expect(EditedCodeBlock.filename(info: info, language: .plainText) == expected) + } + + @Test("A fence with no name falls back to the dated one", arguments: [ + "", "swift", "js {1,3}", "sh title=\"..\"", "sh highlight_name=x.sh", "sh foo/..", + ]) + func unnamed(info: String) throws { + let utc = try #require(TimeZone(identifier: "UTC")) + let date = Date(timeIntervalSince1970: 1_791_532_744) + #expect(EditedCodeBlock.filename(info: info, language: .shell, at: date, timeZone: utc) + == "Edited 2026-10-09 at 07.59.04.sh") + } +} diff --git a/Tests/BloomCoreTests/FileTreeFilterTests.swift b/Tests/BloomCoreTests/FileTreeFilterTests.swift index 74532e7a9..ed9e6ae6e 100644 --- a/Tests/BloomCoreTests/FileTreeFilterTests.swift +++ b/Tests/BloomCoreTests/FileTreeFilterTests.swift @@ -4,6 +4,18 @@ import Testing /// The All files tab's filter field, which is a pure function over the listing and the needle. @Suite("File tree filter") struct FileTreeFilterTests { + @Test("The All files index keeps empty folders, including ignored ones") + func emptyFoldersRemainInTheTree() { + let index = FileTreeNode.index(["README.md", "cache/", "cache/empty/"]) + let rows = FileTreeRowItem.flatten( + children: index, expanded: ["cache", "cache/empty"] + ).map(\.node) + + #expect(rows.map(\.path) == ["cache", "cache/empty", "README.md"]) + #expect(rows[0].isDirectory) + #expect(rows[1].isDirectory) + } + private let index = FileTreeNode.index([ "README.md", "app/Http/Controllers/UserController.php", diff --git a/Tests/BloomCoreTests/GitHubCommandTests.swift b/Tests/BloomCoreTests/GitHubCommandTests.swift new file mode 100644 index 000000000..7398066e9 --- /dev/null +++ b/Tests/BloomCoreTests/GitHubCommandTests.swift @@ -0,0 +1,207 @@ +import Foundation +import Testing +@testable import BloomCore + +/// Every `gh` command run through `GitHub.run`, pinned argument by argument, before +/// `repositoryArguments` adds `--repo` (see `GitHubRepositoryResolutionTests`). `gh auth status` +/// and `gh api user` call `Shell` directly and cannot be seen from here. +@Suite("GitHub commands", .scratchDirectory) +struct GitHubCommandTests { + private static let fields = "number,title,url,state,isDraft,mergeable,baseRefName,headRefOid," + + "mergeStateStatus,reviewDecision,headRefName,statusCheckRollup,closedAt" + private static let fieldsWithoutChecks = "number,title,url,state,isDraft,mergeable,baseRefName,headRefOid," + + "mergeStateStatus,reviewDecision,headRefName,closedAt" + private static let listingFields = "number,title,author,headRefName,baseRefName," + + "isDraft,state,isCrossRepository,headRepositoryOwner" + private static let pullRequest = #"{"number":42,"title":"Work","url":"https://github.com/acme/app/pull/42","state":"OPEN","isDraft":false,"headRefName":"feature","statusCheckRollup":[]}"# + private static let listing = #"{"number":7,"title":"Fix","author":{"login":"someone"},"headRefName":"fix","baseRefName":"main","isDraft":false,"state":"OPEN","isCrossRepository":false}"# + private static let noPullRequest = ShellResult(status: 1, stdout: "", stderr: "no pull requests found for branch \"feature\"") + + @Test("a pull request is read by branch") + func viewByBranch() async throws { + let commands = try await record(answer: Self.pullRequest) { worktree in + _ = try await GitHub.snapshot(forBranch: "feature", worktree: worktree, maxAge: .zero) + } + #expect(commands == [["pr", "view", "feature", "--json", Self.fields]]) + } + + @Test("a branch with no pull request is asked again without naming it") + func viewOfCheckedOutBranch() async throws { + let commands = try await record(result: Self.noPullRequest) { worktree in + _ = try await GitHub.snapshot(forBranch: "feature", worktree: worktree, maxAge: .zero) + } + #expect(commands == [ + ["pr", "view", "feature", "--json", Self.fields], + ["pr", "view", "--json", Self.fields], + ]) + } + + @Test("a pull request is read by number") + func viewByNumber() async throws { + let commands = try await record(answer: Self.pullRequest) { worktree in + _ = try await GitHub.snapshot(forNumber: 42, worktree: worktree, maxAge: .zero) + } + #expect(commands == [["pr", "view", "42", "--json", Self.fields]]) + } + + @Test("refused check runs are asked for again without the rollup") + func viewWithoutChecks() async throws { + let refusal = ShellResult( + status: 1, stdout: "", + stderr: "GraphQL: Resource not accessible by personal access token (repository.pullRequest.statusCheckRollup)" + ) + let commands = try await record { arguments in + arguments.contains(Self.fields) ? refusal : ShellResult(status: 0, stdout: Self.pullRequest, stderr: "") + } operation: { worktree in + _ = try await GitHub.snapshot(forNumber: 42, worktree: worktree, maxAge: .zero) + } + #expect(commands == [ + ["pr", "view", "42", "--json", Self.fields], + ["pr", "view", "42", "--json", Self.fieldsWithoutChecks], + ]) + } + + @Test("the pull requests a head branch ever had are listed") + func listByHead() async throws { + let commands = try await record(answer: "[]") { worktree in + _ = try await GitHub.pullRequestsWithHead("feature", worktree: worktree) + } + #expect(commands == [[ + "pr", "list", "--head", "feature", "--state", "all", "--limit", "20", "--json", "number,closedAt", + ]]) + } + + @Test("a draft is marked ready by its address") + func markReady() async throws { + let draft = PullRequest( + number: 42, title: "Work", url: "https://github.com/acme/app/pull/42", state: "OPEN", isDraft: true + ) + let commands = try await record(answer: "") { worktree in + try await GitHub.markReadyForReview(draft, worktree: worktree) + } + #expect(commands == [["pr", "ready", "https://github.com/acme/app/pull/42"]]) + } + + @Test("a pull request is created, then read back") + func create() async throws { + let commands = try await record(answer: Self.pullRequest) { worktree in + _ = try await GitHub.createPullRequest( + worktree: worktree, base: "main", title: "Title", body: "Body", draft: true + ) + } + #expect(commands == [ + ["pr", "create", "--base", "main", "--title", "Title", "--body", "Body", "--draft"], + ["pr", "view", "--json", Self.fields], + ]) + } + + @Test("a pull request is created from its pushed commits, then read back") + func createFromCommits() async throws { + let commands = try await record(answer: Self.pullRequest) { worktree in + _ = try await GitHub.createPullRequestFromCommits(worktree: worktree, base: "main", title: "Title") + } + #expect(commands == [ + ["pr", "create", "--base", "main", "--title", "Title", "--fill"], + ["pr", "view", "--json", Self.fields], + ]) + } + + @Test("a failed run's log is read for its failed steps first, then whole", arguments: [ + (target: CheckFailureHandoff.LogTarget(runID: "123"), selector: ["123"]), + (target: CheckFailureHandoff.LogTarget(runID: "123", jobID: "456"), selector: ["--job", "456"]), + ]) + func runLog(target: CheckFailureHandoff.LogTarget, selector: [String]) async throws { + let commands = try await record { arguments in + ShellResult(status: 0, stdout: arguments.contains("--log") ? "the log" : "", stderr: "") + } operation: { worktree in + _ = try await GitHub.checkRunLog(target, worktree: worktree) + } + #expect(commands == [ + ["run", "view"] + selector + ["--log-failed"], + ["run", "view"] + selector + ["--log"], + ]) + } + + @Test("a pull request is summarised by number for the create window") + func summary() async throws { + let commands = try await record(answer: Self.listing) { repository in + _ = try await GitHub.pullRequestSummary(number: 7, repoPath: repository) + } + #expect(commands == [["pr", "view", "7", "--json", Self.listingFields]]) + } + + @Test("a repository with no readable remote asks gh for its name") + func slug() async throws { + let commands = try await record(answer: "acme/app\n") { repository in + _ = await GitHub.repositorySlug(repoPath: repository) + } + #expect(commands == [["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"]]) + } + + @Test("a pull request is checked out onto a named branch") + func checkout() async throws { + let commands = try await record(answer: "") { worktree in + try await GitHub.checkoutPullRequest(number: 7, into: worktree, localBranch: "review/7") + } + #expect(commands == [["pr", "checkout", "7", "--branch", "review/7"]]) + } + + @Test("the accounts a repository could belong to") + func owners() async throws { + let commands = try await record { arguments in + ShellResult(status: 0, stdout: arguments == ["api", "user"] ? #"{"login":"someone"}"# : "[]", stderr: "") + } operation: { _ in + _ = try await GitHub.owners() + } + #expect(commands == [["api", "user"], ["api", "user/orgs", "--paginate"]]) + } + + @Test("a repository name is checked, then created and given an address") + func repositoryCreation() async throws { + let commands = try await record(answer: "") { _ in + _ = await GitHub.repositoryAvailability(owner: "acme", name: "app") + _ = try await GitHub.createRepository(owner: "acme", name: "app", isPrivate: true) + } + #expect(commands == [ + ["api", "--silent", "repos/acme/app"], + ["repo", "create", "acme/app", "--private"], + ["config", "get", "git_protocol"], + ]) + } + + private func record( + answer stdout: String, operation: (String) async throws -> Void + ) async throws -> [[String]] { + try await record(result: ShellResult(status: 0, stdout: stdout, stderr: ""), operation: operation) + } + + private func record( + result: ShellResult, operation: (String) async throws -> Void + ) async throws -> [[String]] { + try await record({ _ in result }, operation: operation) + } + + /// The gh commands `operation` ran, in order, each answered by `respond`. + private func record( + _ respond: @escaping @Sendable ([String]) -> ShellResult, + operation: (String) async throws -> Void + ) async throws -> [[String]] { + let worktree = TestScratch.unique("gh-command") + let log = CommandLog() + try await GitHub.$commandOverride.withValue({ arguments, _ in + await log.append(arguments) + return respond(arguments) + }) { + try await operation(worktree) + } + return await log.commands + } +} + +private actor CommandLog { + private(set) var commands: [[String]] = [] + + func append(_ arguments: [String]) { + commands.append(arguments) + } +} diff --git a/Tests/BloomCoreTests/GitHubDirectMergeTests.swift b/Tests/BloomCoreTests/GitHubDirectMergeTests.swift new file mode 100644 index 000000000..804f56897 --- /dev/null +++ b/Tests/BloomCoreTests/GitHubDirectMergeTests.swift @@ -0,0 +1,101 @@ +import Foundation +import Testing +@testable import BloomCore + +@Suite("Direct GitHub merge", .tags(.git), .scratchDirectory) +struct GitHubDirectMergeTests { + @Test("uses the confirmed PR number, method and head without override flags") + func mergesTheConfirmedHead() async throws { + let repo = try await TempRepo() + defer { repo.cleanUp() } + try await Shell.check("git", ["checkout", "-q", "-b", "feature"], cwd: repo.path) + let head = try await Git.headSHA(of: repo.path) + let gh = MergeGH(head: head) + + let outcome = try await GitHub.$commandOverride.withValue({ arguments, _ in + await gh.execute(arguments) + }) { + try await GitHub.mergePullRequest(expected(head: head), into: "main", method: .squash, worktree: repo.path) + } + + #expect(outcome == .merged) + #expect(await gh.mergeArguments == [ + "pr", "merge", "42", "--squash", "--match-head-commit", head, + ]) + #expect(try await Git.headSHA(of: repo.path) == head) + } + + @Test("a newly pushed head refuses the direct merge") + func refusesChangedHead() async throws { + let repo = try await TempRepo() + defer { repo.cleanUp() } + try await Shell.check("git", ["checkout", "-q", "-b", "feature"], cwd: repo.path) + let head = try await Git.headSHA(of: repo.path) + let gh = MergeGH(head: String(repeating: "a", count: 40)) + + do { + _ = try await GitHub.$commandOverride.withValue({ arguments, _ in + await gh.execute(arguments) + }) { + try await GitHub.mergePullRequest(expected(head: head), into: "main", method: .merge, worktree: repo.path) + } + Issue.record("A changed PR head was merged") + } catch let error as GitHubError { + #expect(error.message.contains("changed")) + } + #expect(await gh.mergeArguments == nil) + } + + @Test("untracked work stops the merge before gh is called") + func refusesDirtyCheckout() async throws { + let repo = try await TempRepo() + defer { repo.cleanUp() } + try await Shell.check("git", ["checkout", "-q", "-b", "feature"], cwd: repo.path) + let head = try await Git.headSHA(of: repo.path) + try repo.write("untracked.txt", "keep\n") + let gh = MergeGH(head: head) + + do { + _ = try await GitHub.$commandOverride.withValue({ arguments, _ in + await gh.execute(arguments) + }) { + try await GitHub.mergePullRequest(expected(head: head), into: "main", method: .rebase, worktree: repo.path) + } + Issue.record("A dirty checkout was merged") + } catch let error as GitHubError { + #expect(error.message.contains("changes or commits")) + } + #expect(await gh.callCount == 0) + } + + private func expected(head: String) -> PullRequest { + PullRequest( + number: 42, title: "Feature", url: "https://github.com/org/repo/pull/42", + state: "OPEN", branch: "feature", baseBranch: "main", headRevision: head + ) + } +} + +private actor MergeGH { + let head: String + private(set) var callCount = 0 + private(set) var mergeArguments: [String]? + private var merged = false + + init(head: String) { self.head = head } + + func execute(_ arguments: [String]) -> ShellResult { + callCount += 1 + if arguments.starts(with: ["pr", "merge"]) { + mergeArguments = arguments + merged = true + return ShellResult(status: 0, stdout: "", stderr: "") + } + let state = merged ? "MERGED" : "OPEN" + return ShellResult(status: 0, stdout: """ + {"number":42,"title":"Feature","url":"https://github.com/org/repo/pull/42",\ + "state":"\(state)","isDraft":false,"mergeable":"MERGEABLE",\ + "headRefName":"feature","baseRefName":"main","headRefOid":"\(head)"} + """, stderr: "") + } +} diff --git a/Tests/BloomCoreTests/GitHubDirectRouteTests.swift b/Tests/BloomCoreTests/GitHubDirectRouteTests.swift new file mode 100644 index 000000000..8285703ba --- /dev/null +++ b/Tests/BloomCoreTests/GitHubDirectRouteTests.swift @@ -0,0 +1,157 @@ +import Foundation +import Testing +@testable import BloomCore + +/// What the direct routes say when Bloom runs gh itself, word for word: every refusal before +/// `gh pr merge` runs, the outcomes after it, and a create refused because one exists. +@Suite("GitHub direct routes", .tags(.git), .scratchDirectory) +struct GitHubDirectRouteTests { + enum Refusal: String, CaseIterable, Sendable { + case unrefreshed, otherBranch, localWork, otherHead, otherBase, changed, closed + } + + @Test("each refusal before gh merges", arguments: Refusal.allCases) + func refusal(_ refusal: Refusal) async throws { + let repo = try await featureRepo() + defer { repo.cleanUp() } + let head = try await Git.headSHA(of: repo.path) + let other = String(repeating: "a", count: 40) + if refusal == .localWork { try repo.write("untracked.txt", "keep\n") } + let gh = DirectGH( + head: refusal == .changed ? other : head, state: refusal == .closed ? "CLOSED" : "OPEN" + ) + let expected = pullRequest( + branch: refusal == .otherBranch ? "other" : "feature", + base: refusal == .otherBase ? "develop" : "main", + head: refusal == .unrefreshed ? nil : refusal == .otherHead ? other : head + ) + + do { + _ = try await GitHub.$commandOverride.withValue({ arguments, _ in + await gh.execute(arguments) + }) { + try await GitHub.mergePullRequest(expected, into: "main", method: .squash, worktree: repo.path) + } + Issue.record("\(refusal) was merged") + } catch let error as GitHubError { + #expect(error.message == Self.messages[refusal]) + } + #expect(await gh.merges == 0) + } + + private static let messages: [Refusal: String] = [ + .unrefreshed: "Refresh the pull request before merging directly.", + .otherBranch: "This worktree is no longer on the pull request's branch.", + .localWork: "This worktree has changes or commits GitHub has not got. Commit and push them, or choose the agent route after reviewing the warning.", + .otherHead: "This checkout is not at the pull request's head commit. Refresh or update it before merging directly.", + .otherBase: "The pull request targets develop, not main. Refresh it before merging.", + .changed: "The pull request changed since it was shown. Refresh it before merging.", + .closed: "This pull request was closed without merging.", + ] + + @Test("a merge gh accepted is merged, still open, or unverified, and a refused one that landed is merged") + func outcomes() async throws { + let repo = try await featureRepo() + defer { repo.cleanUp() } + let head = try await Git.headSHA(of: repo.path) + let cases: [(gh: DirectGH, outcome: DirectMergeOutcome)] = [ + (DirectGH(head: head, after: .merged), .merged), + (DirectGH(head: head, after: .stillOpen), .acceptedButOpen), + (DirectGH(head: head, after: .unreadable), .acceptedUnverified), + (DirectGH(head: head, after: .merged, mergeFails: true), .merged), + ] + for (gh, outcome) in cases { + let merged = try await GitHub.$commandOverride.withValue({ arguments, _ in + await gh.execute(arguments) + }) { + try await GitHub.mergePullRequest( + pullRequest(branch: "feature", base: "main", head: head), + into: "main", method: .merge, worktree: repo.path + ) + } + #expect(merged == outcome) + } + } + + @Test("a create stops before committing when the branch already has a pull request") + func createRefusedWhenOneExists() async throws { + let repo = try await featureRepo() + defer { repo.cleanUp() } + try repo.write("work.txt", "work\n") + let head = try await Git.headSHA(of: repo.path) + let gh = DirectGH(head: head) + let workspace = Workspace( + repoID: RepoID("repo"), name: "work", branch: "feature", path: repo.path, baseBranch: "main" + ) + + do { + _ = try await GitHub.$commandOverride.withValue({ arguments, _ in + await gh.execute(arguments) + }) { + try await DirectPullRequestCreation.create(for: workspace) + } + Issue.record("A second pull request was created") + } catch let error as GitHubError { + #expect(error.message == "Pull request #42 already exists for this branch.") + } + #expect(await gh.creates == 0) + #expect(try await Git.headSHA(of: repo.path) == head) + } + + private func featureRepo() async throws -> TempRepo { + let repo = try await TempRepo() + try await Shell.check("git", ["checkout", "-q", "-b", "feature"], cwd: repo.path) + return repo + } + + private func pullRequest(branch: String, base: String, head: String?) -> PullRequest { + PullRequest( + number: 42, title: "Feature", url: "https://github.com/org/repo/pull/42", + state: "OPEN", branch: branch, baseBranch: base, headRevision: head + ) + } +} + +/// gh for one pull request, #42 on `feature`, and what it says once a merge has been sent. +private actor DirectGH { + enum After: Sendable { case merged, stillOpen, unreadable } + + private let head: String + private let state: String + private let after: After + private let mergeFails: Bool + private var sent = false + private(set) var merges = 0 + private(set) var creates = 0 + + init(head: String, state: String = "OPEN", after: After = .merged, mergeFails: Bool = false) { + self.head = head + self.state = state + self.after = after + self.mergeFails = mergeFails + } + + func execute(_ arguments: [String]) -> ShellResult { + if arguments.starts(with: ["pr", "merge"]) { + merges += 1 + sent = true + return ShellResult(status: mergeFails ? 1 : 0, stdout: "", stderr: mergeFails ? "refused" : "") + } + if arguments.starts(with: ["pr", "create"]) { + creates += 1 + return ShellResult(status: 0, stdout: "", stderr: "") + } + if arguments.starts(with: ["pr", "list"]) { + return ShellResult(status: 0, stdout: "[]", stderr: "") + } + if sent, after == .unreadable { + return ShellResult(status: 1, stdout: "", stderr: "HTTP 502") + } + let current = sent && after == .merged ? "MERGED" : state + return ShellResult(status: 0, stdout: """ + {"number":42,"title":"Feature","url":"https://github.com/org/repo/pull/42",\ + "state":"\(current)","isDraft":false,"mergeable":"MERGEABLE",\ + "headRefName":"feature","baseRefName":"main","headRefOid":"\(head)"} + """, stderr: "") + } +} diff --git a/Tests/BloomCoreTests/GitHubRepositoryResolutionTests.swift b/Tests/BloomCoreTests/GitHubRepositoryResolutionTests.swift new file mode 100644 index 000000000..d8409db67 --- /dev/null +++ b/Tests/BloomCoreTests/GitHubRepositoryResolutionTests.swift @@ -0,0 +1,130 @@ +import Foundation +import Testing +@testable import BloomCore + +/// How a remote becomes the host and repository `gh` is pointed at, as the code does it today, +/// accidents included (a two segment gitlab.com remote is handed to gh). +@Suite("GitHub repository resolution") +struct GitHubRepositoryResolutionTests { + @Test("the host of a remote", arguments: [ + (remote: "https://github.com/acme/app.git", host: "github.com"), + (remote: "https://GitHub.com/acme/app", host: "github.com"), + (remote: "git@github.com:acme/app.git", host: "github.com"), + (remote: "ssh://git@github.com/acme/app.git", host: "github.com"), + (remote: "git@GHE.corp.example:acme/app.git", host: "ghe.corp.example"), + (remote: "git@gitlab.com:group/subgroup/app.git", host: "gitlab.com"), + (remote: "https://gitlab.example.com/group/subgroup/app.git", host: "gitlab.example.com"), + ]) + func host(remote: String, host: String) { + #expect(GitHub.repositoryHost(remote) == host) + } + + @Test("a remote with no host has none", arguments: ["/Users/someone/project", "../project"]) + func noHost(remote: String) { + #expect(GitHub.repositoryHost(remote) == nil) + } + + @Test("no remote has no host") + func missingRemote() { + #expect(GitHub.repositoryHost(nil) == nil) + } + + @Test("the repository gh is told to use", arguments: [ + (remote: "https://github.com/acme/app.git", specifier: "github.com/acme/app"), + (remote: "https://github.com/acme/app", specifier: "github.com/acme/app"), + (remote: "https://github.com/acme/app/", specifier: "github.com/acme/app"), + (remote: "git@github.com:acme/app.git", specifier: "github.com/acme/app"), + (remote: "ssh://git@github.com/acme/app.git", specifier: "github.com/acme/app"), + (remote: "git@ghe.corp.example:acme/app.git", specifier: "ghe.corp.example/acme/app"), + (remote: "git@gitlab.com:group/app.git", specifier: "gitlab.com/group/app"), + ]) + func specifier(remote: String, specifier: String) { + #expect(GitHub.repositorySpecifier(remote) == specifier) + } + + @Test("a remote that is not two segments on a host names no repository", arguments: [ + "git@gitlab.com:group/subgroup/app.git", + "https://gitlab.example.com/group/subgroup/app.git", + "https://github.com/acme", + "https://github.com/-acme/app", + "/Users/someone/project", + ]) + func noSpecifier(remote: String) { + #expect(GitHub.repositorySpecifier(remote) == nil) + } + + @Test("the host a request is rate limited under", arguments: [ + (arguments: ["pr", "view", "1", "--repo", "ghe.corp.example/acme/app"], host: "ghe.corp.example"), + (arguments: ["pr", "view", "1", "--repo=ghe.corp.example/acme/app"], host: "ghe.corp.example"), + (arguments: ["pr", "view", "1", "-Rghe.corp.example/acme/app"], host: "ghe.corp.example"), + (arguments: ["pr", "ready", "https://GHE.corp.example/acme/app/pull/1"], host: "ghe.corp.example"), + ]) + func requestHost(arguments: [String], host: String) { + #expect(GitHub.requestHost(arguments: arguments, context: nil) == host) + } + + @Test("a request with no host of its own takes the base remote's") + func requestHostFromContext() { + let context = GitRepositoryContext.resolve(config: [ + "remote.origin.url": "git@ghe.corp.example:acme/app.git", + "branch.main.remote": "origin", + ], base: "main", branch: "feature") + #expect(GitHub.requestHost(arguments: ["pr", "view", "feature"], context: context) == "ghe.corp.example") + #expect(GitHub.requestHost(arguments: ["pr", "view", "1", "--repo", "acme/app"], context: context) + == "ghe.corp.example") + } + + @Test("a request with no host anywhere falls back to GH_HOST, then github.com") + func requestHostFallback() { + let expected = ProcessInfo.processInfo.environment["GH_HOST"] ?? "github.com" + #expect(GitHub.requestHost(arguments: ["pr", "list"], context: nil) == expected) + } + + @Test("a pull request created from a fork names its head in the fork") + func forkCreate() { + let context = GitRepositoryContext.resolve(config: [ + "remote.origin.url": "git@github.com:fork/project.git", + "remote.upstream.url": "https://github.com/upstream/project.git", + "branch.main.remote": "upstream", "remote.pushdefault": "origin", + ], base: "main", branch: "feature") + #expect(GitHub.repositoryArguments( + ["pr", "create", "--base", "main", "--title", "Title", "--body", "Body"], context: context + ) == [ + "pr", "create", "--base", "main", "--title", "Title", "--body", "Body", + "--head", "fork:feature", "--repo", "github.com/upstream/project", + ]) + } + + @Test("a pull request created in its own repository names no head") + func ownCreate() { + let context = GitRepositoryContext.resolve(config: [ + "remote.origin.url": "git@github.com:acme/app.git", + "branch.main.remote": "origin", + ], base: "main", branch: "feature") + #expect(GitHub.repositoryArguments(["pr", "create", "--base", "main", "--draft"], context: context) + == ["pr", "create", "--base", "main", "--draft", "--repo", "github.com/acme/app"]) + } + + @Test("commands outside pull requests and run logs are left alone", arguments: [ + ["api", "user", "--jq", ".login"], + ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"], + ["run", "list"], + ["auth", "status"], + ]) + func untouchedFamilies(arguments: [String]) { + let context = GitRepositoryContext.resolve(config: [ + "remote.origin.url": "git@github.com:acme/app.git", + "branch.main.remote": "origin", + ], base: "main", branch: "feature") + #expect(GitHub.repositoryArguments(arguments, context: context) == arguments) + } + + @Test("a remote that names no repository adds no --repo") + func subgroupRemote() { + let context = GitRepositoryContext.resolve(config: [ + "remote.origin.url": "git@gitlab.com:group/subgroup/app.git", + "branch.main.remote": "origin", + ], base: "main", branch: "feature") + #expect(GitHub.repositoryArguments(["pr", "view", "1"], context: context) == ["pr", "view", "1"]) + } +} diff --git a/Tests/BloomCoreTests/GitHubStoredValueTests.swift b/Tests/BloomCoreTests/GitHubStoredValueTests.swift new file mode 100644 index 000000000..52d81cc69 --- /dev/null +++ b/Tests/BloomCoreTests/GitHubStoredValueTests.swift @@ -0,0 +1,70 @@ +import Foundation +import Testing +@testable import BloomCore + +/// Names and values that outlive a launch or leave the app. Renaming one is a migration. +@Suite("GitHub stored values") +struct GitHubStoredValueTests { + @Test("the keys a pull request's settings and state are stored under") + func keys() { + let workspace = WorkspaceID("workspace") + #expect(PromptOverrides.key(for: .createPullRequest) == "prompts.createPullRequest") + #expect(MergeMethodChoice.key(repoID: RepoID("repo")) == "repo.repo.mergeMethod") + #expect(MergeExecutionChoice.key(repoID: RepoID("repo")) == "repo.repo.mergeExecution") + #expect(ChecksWatch.lastSeenKey(workspaceID: workspace) == "checks.lastSeen.workspace") + #expect(OnboardingGate.completedKey == "onboarding.completed") + #expect(NotificationPreferences.key(for: .checksFinished) == "notifications.event.checksFinished") + } + + @Test("the instruction files a project commits") + func instructionPaths() { + #expect(PullRequestInstructions.projectPath == ".bloom/pr-instructions.md") + #expect(PullRequestInstructions.scratchPath == ".bloom/scratch/pr-instructions.md") + #expect(ProjectInstructions.projectPath(for: .merge) == ".bloom/merge-instructions.md") + #expect(ProjectInstructions.projectPath(for: .fixConflicts) == ".bloom/conflict-instructions.md") + #expect(ConflictInstructions.scratchPath == ".bloom/scratch/resolving-conflicts.md") + } + + @Test("the values those keys and answers hold") + func rawValues() { + #expect(PromptID.allCases.map(\.rawValue) == [ + "createPullRequest", "pushLocalWork", "mergePullRequest", "fixConflicts", + "continueAfterMerge", "carryOnArchived", "review", "nameWorkspace", + ]) + #expect(GitHub.MergeMethod.allCases.map(\.rawValue) == ["merge", "squash", "rebase"]) + #expect(MergeExecution.allCases.map(\.rawValue) == ["direct", "agent"]) + #expect(MergeExecution.allCases.map(\.label) == ["Merge with GitHub CLI", "Ask agent to merge"]) + let checks: [PullRequest.Checks] = [.none, .pending, .passing, .failing, .unavailable] + #expect(checks.map(\.rawValue) == ["none", "pending", "passing", "failing", "unavailable"]) + } + + @Test("a workspace's status, as a tool answers it and a legend names it") + func statuses() { + #expect(WorkspaceStatus.allCases.map { "\($0.rawValue): \($0.label)" } == [ + "settingUp: Setting up", + "awaitingPermission: Waiting on you", + "running: Agent running", + "setupFailed: Setup failed", + "unread: Unread", + "merged: Merged", + "closed: Pull request closed", + "conflicted: Merge conflicts", + "checksFailing: Checks failing", + "checksRunning: Checks running", + "checksPassed: Checks passed", + "draft: Draft pull request", + "pullRequestOpen: Pull request open", + "changed: Has changes", + "clean: No changes", + ]) + } + + @Test("a pull request state's summary names it by number") + func statusSummary() { + let pullRequest = PullRequest( + number: 42, title: "Work", url: "https://github.com/acme/app/pull/42", state: "OPEN" + ) + #expect(WorkspaceStatus.pullRequestOpen.summary(pullRequest: pullRequest) + == "Pull request open, pull request #42") + } +} diff --git a/Tests/BloomCoreTests/GitHubTextCharacterisationTests.swift b/Tests/BloomCoreTests/GitHubTextCharacterisationTests.swift new file mode 100644 index 000000000..d1bae7b6e --- /dev/null +++ b/Tests/BloomCoreTests/GitHubTextCharacterisationTests.swift @@ -0,0 +1,73 @@ +import Foundation +import Testing +@testable import BloomCore + +/// What Bloom tells an agent about pull requests, word for word. `isUnedited` compares defaults +/// byte for byte, so a reworded default makes every untouched copy look edited. +@Suite("GitHub texts, as they stand") +struct GitHubTextCharacterisationTests { + @Test("every default prompt", arguments: PromptRegistry.all) + func prompt(_ definition: PromptDefinition) { + let variables = definition.variables.map { "- \($0.name): \($0.summary)" }.joined(separator: "\n") + let text = """ + title: \(definition.title) + + summary: + \(definition.summary) + + variables: + \(variables) + + template: + \(definition.defaultTemplate) + """ + expectCharacterised(text, "prompt-\(definition.id.rawValue).txt") + } + + @Test("the instruction files Bloom writes") + func instructionFiles() { + expectCharacterised(PullRequestInstructions.defaultMarkdown, "pr-instructions.md") + expectCharacterised(MergeInstructions.canonical, "merge-instructions.md") + expectCharacterised(ConflictInstructions.defaultMarkdown, "conflict-instructions.md") + for (index, retired) in PullRequestInstructions.retiredDefaults.enumerated() { + expectCharacterised(retired, "pr-instructions-retired-\(index + 1).md") + } + } + + @Test("the sentences that carry a project's own instructions") + func projectInstructionSentences() { + let subjects: [ProjectInstructions.Subject] = [.merge, .fixConflicts] + let text = subjects.flatMap { subject in + [ + ProjectInstructions.sentence(for: subject, adding: .file(".bloom/scratch/instructions.md")) ?? "", + ProjectInstructions.sentence(for: subject, adding: .inline("Squash it.")) ?? "", + ] + }.joined(separator: "\n---\n") + expectCharacterised(text, "project-instruction-sentences.txt") + } + + @Test("the sentence a failed check is handed to the agent with") + func checkFailureSentence() { + let text = [ + CheckFailureHandoff.sentence( + name: "Tests", workflow: "CI", state: .failed, + detailsURL: "https://github.com/acme/app/actions/runs/1/job/2", + logPath: ".bloom/scratch/checks/tests.log" + ), + CheckFailureHandoff.sentence(name: "Lint", state: .failed), + ].joined(separator: "\n---\n") + expectCharacterised(text, "check-failure-sentence.txt") + } + + @Test("the tools an agent sees for pull requests") + func toolListings() { + let tools = [ + WorkspaceListTool().tool, + WorkspaceMergeTool(read: { _ in .noPullRequest }) { _, _, _ in .turnBegun(chat: "Merge") }.tool, + WorkspaceStartTool(start: { _, _, _, _ in throw CancellationError() }).tool, + ] + for tool in tools { + expectCharacterised(BridgeToolResult.json(tool.listing).text, "tool-\(tool.name).json") + } + } +} diff --git a/Tests/BloomCoreTests/HomeMixedListTests.swift b/Tests/BloomCoreTests/HomeMixedListTests.swift new file mode 100644 index 000000000..7a43a778d --- /dev/null +++ b/Tests/BloomCoreTests/HomeMixedListTests.swift @@ -0,0 +1,73 @@ +import Foundation +import Testing +@testable import BloomCore + +@Suite("Home conversations among workspaces") +struct HomeMixedListTests { + private let calendar: Calendar = { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(secondsFromGMT: 0)! + return calendar + }() + + private func workspace(_ name: String, at date: Date) -> HomeRow { + HomeRow(workspace: Workspace( + id: WorkspaceID(name), repoID: RepoID("repo"), name: name, + branch: "main", path: "/tmp/\(name)", baseBranch: "main", + state: .archived, lastActivityAt: date + )) + } + + private func conversation(_ name: String, activeAt date: Date) -> Session { + var session = Session( + id: SessionID(name), workspaceID: nil, title: name, updatedAt: date + ) + session.archivedAt = date.addingTimeInterval(600) + return session + } + + private func listing(_ rows: [HomeRow]) -> HomeListing { + HomeListing( + groups: [HomeGroup(id: "existing", title: "Existing", rows: rows)], + shown: rows.count, considered: rows.count, archived: rows.count, + shownArchived: rows.count + ) + } + + @Test("archived conversations share date headings and order with workspaces") + func sharesDateBuckets() { + let now = Date(timeIntervalSince1970: 1_760_000_000) + let recent = workspace("recent", at: now.addingTimeInterval(-60)) + let older = workspace("older", at: now.addingTimeInterval(-300)) + let chat = conversation("chat", activeAt: now.addingTimeInterval(-120)) + + let groups = HomeMixedList.build( + listing: listing([older, recent]), conversations: [chat], + filter: HomeFilter(scope: .archived), now: now, calendar: calendar + ) + + #expect(groups.count == 1) + #expect(groups.first?.title == "Today") + let ids = groups.first?.items.map(\.id) ?? [] + #expect(ids == [ + .workspace(WorkspaceID("recent")), + .conversation(SessionID("chat")), + .workspace(WorkspaceID("older")) + ]) + } + + @Test("a conversation in another date bucket is not pulled into a separate category") + func crossesDateBuckets() { + let now = Date(timeIntervalSince1970: 1_760_000_000) + let yesterday = calendar.date(byAdding: .day, value: -1, to: now)! + let chat = conversation("chat", activeAt: yesterday) + + let groups = HomeMixedList.build( + listing: listing([workspace("today", at: now)]), conversations: [chat], + filter: HomeFilter(), now: now, calendar: calendar + ) + + #expect(groups.map(\.title) == ["Today", "Yesterday"]) + #expect(groups[1].items.map(\.id) == [.conversation(SessionID("chat"))]) + } +} diff --git a/Tests/BloomCoreTests/HomeScopeTests.swift b/Tests/BloomCoreTests/HomeScopeTests.swift index 1a6ab83f1..6c1f4fa85 100644 --- a/Tests/BloomCoreTests/HomeScopeTests.swift +++ b/Tests/BloomCoreTests/HomeScopeTests.swift @@ -82,11 +82,12 @@ struct HomeScopeTests { /// The default leads, because a strip whose first chip is one nobody wants selected reads as a /// strip you have to correct. Its two subsets follow it, then the other half of the machine, /// then the widest net. - @Test("Live leads the browsing chips and All closes them") - func liveLeadsTheStrip() { - // Two, not five. See `offered`: the three that went were each a true fact drawn as a - // control nobody pressed, and `needsYou` is the one to bring back if it is missed. - #expect(HomeScope.offered(searching: false) == [.all, .archived]) + @Test("the browsing chips are All, Active and Archived, in that order") + func activeSitsBetweenAllAndArchived() { + // Three, not five. See `offered`: `needsYou` and `running` were each a true fact drawn as + // a control nobody pressed, and `needsYou` is the one to bring back if it is missed. + #expect(HomeScope.offered(searching: false) == [.all, .live, .archived]) + #expect(HomeScope.live.label(searching: false) == "Active") #expect(HomeScope.offered(searching: true).first == .all) } @@ -188,15 +189,16 @@ struct HomeScopeTests { var counts = HomeScopeCounts() counts.live = 3 counts.archived = 17 - // Only the two chips whose number is a size. Live's three is a state, and the list under - // the strip already says it. + // Only the chips whose number is a size. Needs you and Running are states, and the list + // under the strip already says them. #expect(counts.badge(of: .archived, searching: false) == 17) #expect(counts.badge(of: .all, searching: false) == 20) - #expect(counts.badge(of: .live, searching: false) == nil) + #expect(counts.badge(of: .live, searching: false) == 3) #expect(counts.badge(of: .needsYou, searching: false) == nil) #expect(counts.badge(of: .running, searching: false) == nil) - // And the two that do keep a number still drop it at nought. + // And the three that do keep a number still drop it at nought. #expect(HomeScopeCounts().badge(of: .all, searching: false) == nil) + #expect(HomeScopeCounts().badge(of: .live, searching: false) == nil) #expect(HomeScopeCounts().badge(of: .archived, searching: false) == nil) } diff --git a/Tests/BloomCoreTests/HomeSummaryTests.swift b/Tests/BloomCoreTests/HomeSummaryTests.swift index 7c26d4fc9..a19e43d87 100644 --- a/Tests/BloomCoreTests/HomeSummaryTests.swift +++ b/Tests/BloomCoreTests/HomeSummaryTests.swift @@ -327,4 +327,15 @@ struct HomeSummaryTests { func nothingSaysNothing() { #expect(HomeList.summary(listing: .empty, filter: HomeFilter(), projects: 0).isEmpty) } + + @Test("project-free conversations count in Home without inventing a project") + func conversationsInHome() { + #expect(HomeList.summary( + listing: .empty, filter: HomeFilter(), projects: 0, conversationCount: 2 + ) == "2 conversations") + #expect(HomeList.summary( + listing: .empty, filter: HomeFilter(scope: .archived), projects: 0, + conversationCount: 1 + ) == "1 archived conversation") + } } diff --git a/Tests/BloomCoreTests/LastModelChoiceTests.swift b/Tests/BloomCoreTests/LastModelChoiceTests.swift new file mode 100644 index 000000000..ce333a10f --- /dev/null +++ b/Tests/BloomCoreTests/LastModelChoiceTests.swift @@ -0,0 +1,102 @@ +import Testing +import Foundation +@testable import BloomCore + +/// The last footer choice feeds the next workspace, ranks below a default preset and a repository +/// pin, and never lands a chat on a Codex model the account cannot run. +@Suite("Last model choice", .scratchDirectory) +struct LastModelChoiceTests { + static let sol = LastModelChoice(model: "gpt-5.6-sol", effort: "low", backend: .codex) + + /// What the signed in account's `model/list` answered, which does not include `gpt-5.5`. + static let accountModels: [AgentKind: [AgentModel]] = [ + .codex: [AgentModel(id: "gpt-5.6-sol", displayName: "GPT-5.6 Sol", isDefault: true)], + ] + + @Test func aChoiceSurvivesTheStore() async throws { + let store = try makeTestStore("last-model-choice-round-trip") + #expect(await LastModelChoice.load(from: store) == nil) + try await Self.sol.save(to: store) + #expect(await LastModelChoice.load(from: store) == Self.sol) + } + + @Test func theLastChoiceIsWhatANewSessionStartsOn() async throws { + let store = try makeTestStore("last-model-choice-new-session") + await AppDefaults(model: "sonnet", effort: "low").save(to: store) + try await Self.sol.save(to: store) + + // Settings keeps showing its own values. + #expect(await AppDefaults.load(from: store).model == "sonnet") + + let defaults = await AppDefaults.loadForNewSessions(from: store) + let resolved = ComposerDefaults.resolve(repo: RepoSettings(), app: defaults) + #expect(resolved.model == "gpt-5.6-sol") + #expect(resolved.backend == .codex) + #expect(resolved.effort == "low") + } + + @Test func aDefaultPresetOutranksTheLastChoice() async throws { + let store = try makeTestStore("last-model-choice-preset") + try await Self.sol.save(to: store) + let opus = ModelPreset( + name: "Opus", model: "opus", effort: "high", backend: .claudeCode, + permissionMode: .bypassPermissions + ) + var list = ModelPresetList(presets: [opus]) + list.setDefault(opus.id) + try await list.save(to: store) + + #expect(await AppDefaults.loadForNewSessions(from: store).model == "opus") + } + + @Test func aRepositoryPinOutranksTheLastChoice() async throws { + let store = try makeTestStore("last-model-choice-repo") + try await Self.sol.save(to: store) + var repo = RepoSettings() + repo.defaultModel = "sonnet" + + let defaults = await AppDefaults.loadForNewSessions(from: store) + #expect(ComposerDefaults.resolve(repo: repo, app: defaults).model == "sonnet") + } + + @Test func aLastChoiceTheAccountCannotRunFallsBackToSettings() async throws { + let store = try makeTestStore("last-model-choice-unusable") + await AppDefaults(model: "sonnet", effort: "low").save(to: store) + try await LastModelChoice(model: "gpt-5.5", effort: "medium", backend: .codex).save(to: store) + + let defaults = await AppDefaults.loadForNewSessions(from: store, models: Self.accountModels) + #expect(defaults.model == "sonnet") + } + + @Test func aPinnedModelTheAccountCannotRunFallsBackToTheBuiltIn() { + var repo = RepoSettings() + repo.defaultModel = "gpt-5.5" + + let resolved = ComposerDefaults.resolve( + repo: repo, + app: AppDefaults(model: "gpt-5.5", backend: .codex), + models: Self.accountModels + ) + #expect(resolved.model == AppDefaults.fallbackModel) + #expect(resolved.backend == .claudeCode) + } + + @Test func aModelOnTheListIsKept() { + let resolved = ComposerDefaults.resolve( + repo: RepoSettings(), + app: AppDefaults(model: "gpt-5.6-sol", effort: "low", backend: .codex), + models: Self.accountModels + ) + #expect(resolved.model == "gpt-5.6-sol") + #expect(resolved.backend == .codex) + } + + /// An empty list means the fetch has not answered, which is not the same as nothing allowed. + @Test func anUnfetchedListDisqualifiesNothing() { + #expect(ModelAvailability.isUsable(model: "gpt-5.5", on: .codex, models: [:])) + #expect(ModelAvailability.isUsable(model: "gpt-5.5", on: .codex, models: [.codex: []])) + #expect(!ModelAvailability.isUsable(model: "gpt-5.5", on: .codex, models: Self.accountModels)) + // Claude Code ids cannot be checked against a list, so they are never refused. + #expect(ModelAvailability.isUsable(model: "opus-5-1m", on: .claudeCode, models: Self.accountModels)) + } +} diff --git a/Tests/BloomCoreTests/MarkdownHTMLTests.swift b/Tests/BloomCoreTests/MarkdownHTMLTests.swift index 25704e575..e49b1ce8e 100644 --- a/Tests/BloomCoreTests/MarkdownHTMLTests.swift +++ b/Tests/BloomCoreTests/MarkdownHTMLTests.swift @@ -31,6 +31,14 @@ struct MarkdownHTMLTests { #expect(html.contains("\"The")) } + @Test("transcript images draw alternative text without fetching a URL") + func transcriptImage() { + let html = MarkdownHTML.render("![The flow](https://example.com/image.png)", imagesAsAltText: true).html + #expect(html.contains("The flow")) + #expect(!html.contains(" B\n```") diff --git a/Tests/BloomCoreTests/MenuBarCatalogueTests.swift b/Tests/BloomCoreTests/MenuBarCatalogueTests.swift index 632452717..525f1b7c0 100644 --- a/Tests/BloomCoreTests/MenuBarCatalogueTests.swift +++ b/Tests/BloomCoreTests/MenuBarCatalogueTests.swift @@ -93,6 +93,14 @@ struct MenuBarCatalogueTests { #expect(!MenuBarCatalogue.commands.contains(where: { $0.title.contains("Add Project") })) } + @Test("opening the browser client is a direct File command") + func browserClientCommand() { + let item = MenuBarCatalogue[.openWebClient] + #expect(item.menu == .file) + #expect(item.title == "Open Bloom in Browser") + #expect(item.key == nil) + } + /// The two splits are the only items whose key lives on a submenu row, because they are the /// only submenus with a "same again" row for it to sit on. Anything else marked this way would /// be a key drawn beside an item AppKit never fires. diff --git a/Tests/BloomCoreTests/MergeExecutionChoiceTests.swift b/Tests/BloomCoreTests/MergeExecutionChoiceTests.swift new file mode 100644 index 000000000..88c70cb10 --- /dev/null +++ b/Tests/BloomCoreTests/MergeExecutionChoiceTests.swift @@ -0,0 +1,24 @@ +import Testing +@testable import BloomCore + +@Suite("Merge execution choice") +struct MergeExecutionChoiceTests { + @Test("the direct path is the default for a confirmed PR merge") + func fallback() { + #expect(MergeExecutionChoice.resolve(nil) == .direct) + #expect(MergeExecutionChoice.resolve("unknown") == .direct) + #expect(MergeExecutionChoice.resolve("agent") == .agent) + } + + @Test("the selection survives and is scoped to its project", .tags(.persistence), .scratchDirectory) + func perProject() async throws { + let store = try makeTestStore("merge-execution") + let first = try await store.upsert(Repo(name: "first", path: TestScratch.unique("first"))) + let second = try await store.upsert(Repo(name: "second", path: TestScratch.unique("second"))) + + await MergeExecutionChoice.save(.agent, repoID: first.id, to: store) + + #expect(await MergeExecutionChoice.load(repoID: first.id, from: store) == .agent) + #expect(await MergeExecutionChoice.load(repoID: second.id, from: store) == .direct) + } +} diff --git a/Tests/BloomCoreTests/PermissionDefaultTests.swift b/Tests/BloomCoreTests/PermissionDefaultTests.swift index f4e2a68ea..4c5af3c0b 100644 --- a/Tests/BloomCoreTests/PermissionDefaultTests.swift +++ b/Tests/BloomCoreTests/PermissionDefaultTests.swift @@ -68,19 +68,13 @@ struct PermissionDefaultTests { #expect(resolved.permissionMode == .bypassPermissions) } - /// **The one deliberate exception, named here so it does not read as the drift this suite - /// exists to stop.** Every unstated starting point above agrees with the fallback. Ask Bloom's - /// does not, and it is not an unstated starting point: it is a chat with no worktree, where - /// Full access would mean the whole machine rather than a copy of a project. See - /// `AskConversation.permissionMode`, and `modeOnOpening` for why it has to be reapplied. - @Test("the chat with no worktree is the one thing that does not take the default") - func theConversationWithNoWorktreeIsTheException() { - #expect(AskConversation.permissionMode != AppDefaults.fallbackPermissionMode) - #expect(AskConversation.newSession().permissionMode == .auto) + @Test("Ask Bloom takes the same default as other new sessions") + func askBloomTakesTheDefault() { + #expect(AskConversation.newSession().permissionMode == AppDefaults.fallbackPermissionMode) #expect( ComposerDefaults .resolve(repo: RepoSettings(), app: AppDefaults(), hasWorktree: false) - .permissionMode == AskConversation.permissionMode + .permissionMode == AppDefaults.fallbackPermissionMode ) } diff --git a/Tests/BloomCoreTests/PlanApprovalTests.swift b/Tests/BloomCoreTests/PlanApprovalTests.swift index 7d56849c2..60be882f9 100644 --- a/Tests/BloomCoreTests/PlanApprovalTests.swift +++ b/Tests/BloomCoreTests/PlanApprovalTests.swift @@ -120,10 +120,11 @@ struct PlanApprovalTests { #expect(try await store.planImplementationMode(sessionID: session.id, hasWorktree: true) == .acceptEdits) } - @Test("a chat without a worktree never inherits the app's bypass default") + @Test("a chat without a worktree uses the configured implementation mode") func noWorktree() async throws { let store = try makeTestStore("plan-no-worktree") + try await store.setSetting(AppDefaults.Key.permissionMode, PermissionMode.acceptEdits.rawValue) let session = try await store.upsert(Session(workspaceID: nil, permissionMode: .plan)) - #expect(try await store.planImplementationMode(sessionID: session.id, hasWorktree: false) == .auto) + #expect(try await store.planImplementationMode(sessionID: session.id, hasWorktree: false) == .acceptEdits) } } diff --git a/Tests/BloomCoreTests/SidebarReorderTests.swift b/Tests/BloomCoreTests/SidebarReorderTests.swift index 05f691f1a..963310f9d 100644 --- a/Tests/BloomCoreTests/SidebarReorderTests.swift +++ b/Tests/BloomCoreTests/SidebarReorderTests.swift @@ -418,6 +418,24 @@ struct SidebarReorderTests { ]) } + @Test("Project drops count only headers actually drawn in the sidebar") + func projectMoveWithOmittedEmptyProject() { + let repos = [repo("a", order: 0), repo("empty", order: 1), repo("b", order: 2)] + let rows: [SidebarReorder.Row] = [ + .project(RepoID("a")), .workspace(id: WorkspaceID("w"), projectID: RepoID("a")), + .project(RepoID("b")), + ] + let drawn = SidebarReorder.visibleProjects(in: rows) + #expect(drawn == [RepoID("a"), RepoID("b")]) + let changes = SidebarReorder.move( + projects: repos, visible: drawn, id: RepoID("a"), to: 2 + ) + #expect(changes == [ + SidebarReorder.ProjectChange(id: RepoID("b"), sortOrder: 0), + SidebarReorder.ProjectChange(id: RepoID("a"), sortOrder: 2), + ]) + } + @Test("A drop beside the same visible project does not move it past hidden projects") func projectMoveBesideItselfWithHiddenProjects() { let repos = [repo("hidden", order: 0), repo("a", order: 1), repo("b", order: 2)] diff --git a/Tests/BloomCoreTests/SidebarSectionOrderTests.swift b/Tests/BloomCoreTests/SidebarSectionOrderTests.swift new file mode 100644 index 000000000..38cd39a1b --- /dev/null +++ b/Tests/BloomCoreTests/SidebarSectionOrderTests.swift @@ -0,0 +1,28 @@ +import Testing +@testable import BloomCore + +@Suite("Sidebar section dragging") +struct SidebarSectionOrderTests { + @Test func movingFirstSectionDownSwapsAndPullingBackRestores() { + let start = SidebarSectionOrder.workFirst + + #expect(start.dragging(.work, translation: 33) == .conversationsFirst) + #expect(start.dragging(.work, translation: 20) == .workFirst) + #expect(start.dragging(.work, translation: -40) == .workFirst) + } + + @Test func movingSecondSectionUpSwapsInEitherOrder() { + #expect(SidebarSectionOrder.workFirst.dragging( + .conversations, translation: -33 + ) == .conversationsFirst) + #expect(SidebarSectionOrder.conversationsFirst.dragging( + .work, translation: -33 + ) == .workFirst) + } + + @Test func movingConversationsDownFromFirstSwaps() { + #expect(SidebarSectionOrder.conversationsFirst.dragging( + .conversations, translation: 33 + ) == .workFirst) + } +} diff --git a/Tests/BloomCoreTests/SidebarSelectionStyleTests.swift b/Tests/BloomCoreTests/SidebarSelectionStyleTests.swift index 806b1d2c7..e8136b5f3 100644 --- a/Tests/BloomCoreTests/SidebarSelectionStyleTests.swift +++ b/Tests/BloomCoreTests/SidebarSelectionStyleTests.swift @@ -2,7 +2,7 @@ import Testing @testable import BloomCore /// The sidebar's selection, which turned solid blue when the owner switched back to Bloom with the -/// list holding the keyboard. The fill must be the same in every state; only the edge may change. +/// list holding the keyboard. The fill must be the same in every state. @Suite("How the sidebar draws its selection") struct SidebarSelectionStyleTests { @Test("a row that is not selected draws nothing, whatever has the keyboard") @@ -14,7 +14,6 @@ struct SidebarSelectionStyleTests { ) #expect(style == .unselected) #expect(!style.drawsFill) - #expect(!style.drawsKeyboardEdge) } } } @@ -26,11 +25,10 @@ struct SidebarSelectionStyleTests { ) #expect(style == .resting) #expect(style.drawsFill) - #expect(!style.drawsKeyboardEdge) } /// The state the owner was in before switching back: the table still first responder, in a - /// window that was not key. The arrow keys move nothing there, so no edge. + /// window that was not key. The arrow keys move nothing there. @Test("a list holding the keyboard in a window that is not key rests") func backgroundWindow() { let style = SidebarSelectionStyle.resolve( @@ -40,8 +38,8 @@ struct SidebarSelectionStyleTests { } /// The report itself: the window becomes key again with the list holding the keyboard. The row - /// keeps the fill it had a moment earlier and gains only the edge. - @Test("switching back keeps the same fill and adds only the edge") + /// keeps the fill it had a moment earlier, with no added outline. + @Test("switching back keeps the same fill") func switchingBack() { let before = SidebarSelectionStyle.resolve( isSelected: true, listHasKeyboard: true, windowIsKey: false @@ -51,6 +49,5 @@ struct SidebarSelectionStyleTests { ) #expect(after == .keyboard) #expect(before.drawsFill == after.drawsFill) - #expect(after.drawsKeyboardEdge) } } diff --git a/Tests/BloomCoreTests/SidebarSelectionTests.swift b/Tests/BloomCoreTests/SidebarSelectionTests.swift index 70de84fb8..c8da444fd 100644 --- a/Tests/BloomCoreTests/SidebarSelectionTests.swift +++ b/Tests/BloomCoreTests/SidebarSelectionTests.swift @@ -66,6 +66,14 @@ struct SidebarSelectionTests { #expect(Set([.workspace(workspace), .archived(workspace)]).count == 2) } + @Test("a project-free conversation never acquires workspace controls") + func conversationCarriesNoWorkspace() { + let id = SessionID("loose") + #expect(SidebarSelection.conversation(id).workspaceID == nil) + #expect(SidebarSelection.archivedConversation(id).workspaceID == nil) + #expect(SidebarSelection.conversation(id) != .archivedConversation(id)) + } + /// The same claim the subagent test above makes, and it has to be made twice because these are /// two different things: a crew member is a stored chat that outlives the turn that started /// it, and the terminal, the diff, the inspector and the composer are all still about the diff --git a/Tests/BloomCoreTests/StandaloneToolTests.swift b/Tests/BloomCoreTests/StandaloneToolTests.swift index fd3ddb279..89b44c6cb 100644 --- a/Tests/BloomCoreTests/StandaloneToolTests.swift +++ b/Tests/BloomCoreTests/StandaloneToolTests.swift @@ -82,11 +82,11 @@ struct ProjectAddToolTests { MCPRequest(id: .number(1), method: "project_add", params: .object(["path": .string(path)])) } - @Test("only the owner sees it, because a workspace agent has a project already") + @Test("the owner and workspace agents can register an existing repository") func roleGate() { let toolbox = BridgeToolbox(handlers: [ProjectAddTool()]) - #expect(toolbox.tools(for: .workspace).isEmpty) + #expect(toolbox.tools(for: .workspace).map(\.name) == ["project_add"]) #expect(toolbox.tools(for: .owner).map(\.name) == ["project_add"]) } diff --git a/Tests/BloomCoreTests/TestSupport.swift b/Tests/BloomCoreTests/TestSupport.swift index b86f0f408..d54fc85ae 100644 --- a/Tests/BloomCoreTests/TestSupport.swift +++ b/Tests/BloomCoreTests/TestSupport.swift @@ -369,6 +369,13 @@ struct TempRepo { /// walking up from this file. Symlinks are resolved too, because the core suite is run from a mirrored /// package that has no app target (see Tools/test-core.sh). func bloomFixtureLines(_ name: String) throws -> [String] { + guard let fixture = bloomFixtureURL(name) else { throw CocoaError(.fileNoSuchFile) } + return try String(contentsOf: fixture, encoding: .utf8) + .components(separatedBy: "\n") + .filter { $0.isEmpty == false } +} + +func bloomFixtureURL(_ name: String) -> URL? { let starts = [ URL(fileURLWithPath: #filePath), URL(fileURLWithPath: #filePath).resolvingSymlinksInPath(), @@ -378,16 +385,11 @@ func bloomFixtureLines(_ name: String) throws -> [String] { var directory = start.deletingLastPathComponent() for _ in 0..<8 { let candidate = directory.appendingPathComponent("fixtures").appendingPathComponent(name) - if FileManager.default.fileExists(atPath: candidate.path) { - return try String(contentsOf: candidate, encoding: .utf8) - .components(separatedBy: "\n") - .filter { $0.isEmpty == false } - } + if FileManager.default.fileExists(atPath: candidate.path) { return candidate } directory = directory.deletingLastPathComponent() } } - - throw CocoaError(.fileNoSuchFile) + return nil } // MARK: - Output collection diff --git a/Tests/BloomCoreTests/TranscriptClientRowTests.swift b/Tests/BloomCoreTests/TranscriptClientRowTests.swift new file mode 100644 index 000000000..08bebbc87 --- /dev/null +++ b/Tests/BloomCoreTests/TranscriptClientRowTests.swift @@ -0,0 +1,115 @@ +import Foundation +import Testing +@testable import BloomCore + +@Suite("Client transcript rows") +struct TranscriptClientRowTests { + private func message(_ kind: MessageKind, _ payload: String, seq: Int = 1) -> Message { + Message(sessionID: .new(), seq: seq, kind: kind, payload: Data(payload.utf8)) + } + + @Test("agent prose uses Bloom's Markdown parser and escapes active markup") + func prose() { + let stored = message( + .assistantText, + #"{"type":"assistant","message":{"content":[{"type":"text","text":"**Ready** "}]}}"# + ) + let row = TranscriptClientRow.present(stored) + #expect(row?.kind == .assistant) + #expect(row?.html?.contains("Ready") == true) + #expect(row?.html?.contains(" + +