diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b9cec833b0..28acd328de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -510,9 +510,20 @@ jobs: # Here it held three of the account's five macOS runners at a time. name: Swift tests + iOS build runs-on: macos-latest - timeout-minutes: 30 + # About five minutes end to end: package tests ~1.5, xcodegen ~1, the + # simulator build ~2. The cap is a hang guard, not room for the build + # to grow into: the two guard steps below name the slow code instead. + timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: No #available branch in a View extension + # The way the Oct 2026 build went from 5 to 25+ minutes: a + # `@ViewBuilder` View extension branching on `#available` doubles the + # caller's view type at every call, and IRGen on the batch holding + # both never finishes. The type-check flag below cannot see it and a + # build killed at the cap prints nothing, so the shape is refused at + # the source: such a shim is a ViewModifier (ios/App/BackDeployCompat.swift). + run: bash scripts/check-ios-view-shims.sh - name: Swift package tests run: swift test --package-path ios - name: Generate the Xcode project @@ -521,15 +532,53 @@ jobs: cd ios xcodegen generate - name: Build the simulator app + # One slice. The generic simulator destination builds arm64 AND + # x86_64 (ONLY_ACTIVE_ARCH is a no-op there), and nothing runs the + # x86_64 one: the runner, the UI-test simulators and every current + # Mac are arm64. Two slices doubled the compile and ran six + # swift-frontends side by side on a 3-core, 7 GB runner. The log is + # kept for the type-check step that follows. run: | + set -o pipefail cd ios xcodebuild \ -project OpenMausCompanion.xcodeproj \ -scheme OpenMausCompanion \ -sdk iphonesimulator \ -destination 'generic/platform=iOS Simulator' \ + ARCHS=arm64 \ CODE_SIGNING_ALLOWED=NO \ - build + build | tee "$RUNNER_TEMP/xcodebuild.log" + - name: No Swift expression or body over the type-check limit + # ios/project.yml (Debug) has the compiler warn when it spends more + # than 500 ms type-checking one expression or function body, the + # usual way a SwiftUI build turns from minutes into a timeout. Swift + # has no diagnostic group for those two warnings, so -Werror cannot + # single them out; the log is read instead. + # + # 500 ms is the bar on a developer Mac, where Xcode shows the + # warning at its line. It is not a stable verdict on this runner: + # the compiler measures wall-clock time, and the shared 3-core + # runner swings about 2x from one run to the next (bodies at 216 + # and 263 ms on an M5 measured 551 and 600 ms here on a loaded run, + # under 420 ms on a quiet one). The step therefore lists every + # body over the 500 ms warning limit, and fails only on one over + # three times that, which no body in the app comes near and which + # a type-check problem on its way to a timeout runs past. + run: | + fail_over_ms=1500 + warnings=$(grep -E 'took [0-9]+ms to type-check \(limit: ' "$RUNNER_TEMP/xcodebuild.log" | sed -E 's#^.*/ios/##' | sort -u || true) + if [ -n "$warnings" ]; then + echo "Over the 500 ms warning limit on this runner (fails at ${fail_over_ms} ms):" + printf '%s\n' "$warnings" + fi + hits=$(printf '%s\n' "$warnings" | awk -v limit="$fail_over_ms" 'match($0, /took [0-9]+ms/) { ms = substr($0, RSTART + 5, RLENGTH - 7) + 0; if (ms > limit) print }') + if [ -n "$hits" ]; then + printf '%s\n' "$hits" + echo "::error::The Swift type checker spent over ${fail_over_ms} ms on the expression(s) above. Split that body into smaller views or functions, and give colours chosen by a ternary an explicit Color type." + exit 1 + fi + echo "No expression or function body took the type checker over ${fail_over_ms} ms." # Selection includes ios/** as well as android/**: :core's fixtures come # from ios/Tests/CompanionCoreTests/Fixtures. diff --git a/ios/App/ActivityRunChip.swift b/ios/App/ActivityRunChip.swift index 5b7523e565..26c0214632 100644 --- a/ios/App/ActivityRunChip.swift +++ b/ios/App/ActivityRunChip.swift @@ -23,53 +23,64 @@ struct ActivityRunChip: View { running ? "Running \(items.count) steps" : "Ran \(items.count) steps" } - var body: some View { - let isDark = colorScheme == .dark + private var isDark: Bool { colorScheme == .dark } + private var chipFill: Color { isDark ? Color.white.opacity(0.08) : Color.black.opacity(0.05) } + private var chipStroke: Color { isDark ? Color.white.opacity(0.08) : Color.black.opacity(0.06) } + private var summaryColor: Color { isDark ? Color(hex: "#E2E8F0") : Color(hex: "#334155") } + var body: some View { VStack(alignment: .leading, spacing: 6) { - Button { - withAnimation(.spring(response: 0.3, dampingFraction: 0.75)) { expanded.toggle() } - Haptics.selection() - } label: { - HStack(spacing: 6) { - Image(systemName: running ? "ellipsis.circle" : "checkmark.seal.fill") - .font(.system(size: 11)) - .foregroundColor(running ? Color.secondary : Color(hex: "#22C55E")) + summaryButton + if expanded { + unfoldedSteps + } + } + .padding(.leading, 2) + } + + private var summaryButton: some View { + Button { + withAnimation(.spring(response: 0.3, dampingFraction: 0.75)) { expanded.toggle() } + Haptics.selection() + } label: { + summaryLabel + } + .buttonStyle(.plain) + .accessibilityLabel(summary) + .accessibilityHint(expanded ? "Hides the steps" : "Shows the steps") + } - Text(summary) - .font(.caption2.weight(.bold)) - .foregroundColor(isDark ? Color(hex: "#E2E8F0") : Color(hex: "#334155")) + private var summaryLabel: some View { + HStack(spacing: 6) { + Image(systemName: running ? "ellipsis.circle" : "checkmark.seal.fill") + .font(.system(size: 11)) + .foregroundColor(running ? Color.secondary : Color(hex: "#22C55E")) - Image(systemName: "chevron.right") - .font(.system(size: 8, weight: .bold)) - .foregroundColor(Color.secondary) - .rotationEffect(.degrees(expanded ? 90 : 0)) - } - .padding(.horizontal, 9) - .padding(.vertical, 4.5) - .background(isDark ? Color.white.opacity(0.08) : Color.black.opacity(0.05)) - .clipShape(Capsule()) - .overlay( - Capsule() - .stroke(isDark ? Color.white.opacity(0.08) : Color.black.opacity(0.06), lineWidth: 0.5) - ) - } - .buttonStyle(.plain) - .accessibilityLabel(summary) - .accessibilityHint(expanded ? "Hides the steps" : "Shows the steps") + Text(summary) + .font(.caption2.weight(.bold)) + .foregroundColor(summaryColor) - if expanded { - VStack(alignment: .leading, spacing: 4) { - ForEach(items, id: \.id) { item in - ActivityChip( - tool: item.tool, threadRef: item.threadRef, openThread: openThread, - outputIsProse: item.isTeammateReport - ) - } - } - .transition(.opacity.combined(with: .move(edge: .top))) + Image(systemName: "chevron.right") + .font(.system(size: 8, weight: .bold)) + .foregroundColor(Color.secondary) + .rotationEffect(.degrees(expanded ? 90 : 0)) + } + .padding(.horizontal, 9) + .padding(.vertical, 4.5) + .background(chipFill) + .clipShape(Capsule()) + .overlay(Capsule().stroke(chipStroke, lineWidth: 0.5)) + } + + private var unfoldedSteps: some View { + VStack(alignment: .leading, spacing: 4) { + ForEach(items, id: \.id) { item in + ActivityChip( + tool: item.tool, threadRef: item.threadRef, openThread: openThread, + outputIsProse: item.isTeammateReport + ) } } - .padding(.leading, 2) + .transition(.opacity.combined(with: .move(edge: .top))) } } diff --git a/ios/App/AgentProfileView.swift b/ios/App/AgentProfileView.swift index af4a16c7c4..d33812cac3 100644 --- a/ios/App/AgentProfileView.swift +++ b/ios/App/AgentProfileView.swift @@ -123,259 +123,14 @@ struct AgentProfileView: View { // Changing the model and generating avatars need the admin scope // on a server; a chat-only phone is not shown either. if session.canAdminister { - Section { - if !modelsLoaded { - HStack { - Text("Loading models") - Spacer() - ProgressView() - } - } else if instanceChoices.isEmpty { - Label("No model providers are available", systemImage: "exclamationmark.triangle") - .foregroundStyle(.secondary) - } else { - Picker("Provider", selection: $selectedInstanceID) { - if !instances.contains(where: { $0.instanceId == selectedInstanceID }) { - Text("Current provider (unavailable)") - .tag(selectedInstanceID) - .disabled(true) - } - ForEach(instanceChoices) { instance in - Text(instanceLabel(instance)) - .tag(instance.instanceId) - .disabled(!instance.snapshot.isAvailable) - } - } - .onValueChange(of: selectedInstanceID) { instanceID in - selectDefaults(for: instanceID) - } - - Picker("Model", selection: $selectedModelID) { - ForEach(selectedModelChoices) { option in - Text(option.label).tag(option.id) - } - } - .disabled(selectedInstance?.snapshot.isAvailable != true) - - if !effortLevels.isEmpty { - Picker("Reasoning effort", selection: $selectedEffort) { - Text("Default").tag(String?.none) - ForEach(effortLevels, id: \.self) { level in - Text(effortLabel(level)).tag(Optional(level)) - } - } - } - - if current.busy == true { - Label("Stop this bot before changing its model.", systemImage: "hourglass") - .font(.footnote) - .foregroundStyle(.secondary) - } else if selectedInstance?.snapshot.isAvailable != true { - Label("Choose an available provider to change this bot's model.", systemImage: "info.circle") - .font(.footnote) - .foregroundStyle(.secondary) - } - - Button("Apply model", systemImage: "checkmark") { - Task { await saveModel() } - } - .disabled(busy || !canApplyModel) - } - } header: { - Text("Model") - } footer: { - Text("Provider accounts and API keys stay on your computer. Default sends no reasoning level and lets the provider decide.") - } + modelSection } - - Section { - HStack { - Spacer() - BotAvatarView(bot: current, size: 112, state: .happy, animated: true) - Spacer() - } - .listRowBackground(Color.clear) - - Picker("Shape", selection: $crop) { - ForEach(AvatarCrop.allCases, id: \.self) { shape in - Text(shape.label).tag(shape) - } - } - .pickerStyle(.segmented) - - PhotosPicker(selection: $photo, matching: .images) { - Label("Upload image", systemImage: "photo.badge.plus") - } - .disabled(busy) - - if current.avatarUrl != nil { - Button("Use mascot", systemImage: "trash", role: .destructive) { - Task { await clearImage() } - } - .disabled(busy) - } - } header: { - Text("Avatar") - } footer: { - Text("PNG, JPEG, GIF, or WebP, up to 10 MB. Images are stored on your paired computer and loaded with this device's pairing token.") - } - + avatarSection if session.canAdminister { - Section { - TextField("Art direction", text: $prompt, axis: .vertical) - .lineLimit(2...5) - Button("Generate on computer", systemImage: "sparkles") { - Task { await generateImage() } - } - .disabled(busy || !imageGenerationReady || prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } header: { - Text("Generate an avatar") - } footer: { - Text(imageGenerationReady - ? "Generation uses the shared image provider configured on your computer. No provider key is sent to or stored on this device." - : "To generate images, configure the shared image provider in OpenMausBot on your computer. Provider keys cannot be added from this device.") - } - } - - Section("Identity") { - NavigationLink { - BotOverviewView(bot: current) - } label: { - Label("What this bot does", systemImage: "list.bullet.rectangle") - } - NavigationLink { - BotActivityView(bot: current) - } label: { - Label("Activity", systemImage: "checklist") - } - NavigationLink { - TeamMemoryView(section: current.section ?? "") - } label: { - Label("Team memory", systemImage: "brain") - } - TextField("Name", text: $name) - .textInputAutocapitalization(.words) - TextField("Title", text: $title) - TextField("What this agent does", text: $description, axis: .vertical) - .lineLimit(3...8) - Toggle("Agent notifications", isOn: $notifications) + generateSection } - - Section { - Picker("Voice engine", selection: $engine) { - Text("ElevenLabs").tag(VoiceProvider.elevenlabs) - Text("Fish Audio").tag(VoiceProvider.fish) - Text("Built-in Mac voices") - .tag(VoiceProvider.system) - .disabled(!hostIsMac) - Text("Chatterbox (local)").tag(VoiceProvider.chatterbox) - } - .disabled(switchingEngine) - - if usesChatterbox { - TextField( - "Chatterbox server address", - text: $chatterboxURL, - prompt: Text("http://127.0.0.1:4123") - ) - .keyboardType(.URL) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - TextField( - "Chatterbox model", - text: $chatterboxModel, - prompt: Text("chatterbox-turbo") - ) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - Button { - Task { await saveChatterboxServer() } - } label: { - HStack { - Text("Save server") - if savingServer { Spacer(); ProgressView() } - } - } - .disabled(savingServer || chatterboxURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - if let serverProblem { - Label(serverProblem, systemImage: "exclamationmark.triangle") - .foregroundStyle(Color.orange) - } - } - - if voiceConfigured { - Picker("Voice", selection: $voice) { - if hasWorkspaceDefaultVoice { - Text("Workspace default").tag("") - } else { - Text("Choose an agent voice").tag("").disabled(true) - } - if !voice.isEmpty, !voices.contains(where: { $0.id == voice }) { - Text("Current agent voice").tag(voice) - } - ForEach(voices) { option in - VStack(alignment: .leading) { - Text(option.label) - if let detail = option.description { Text(detail) } - } - .tag(option.id) - } - } - Toggle("Speak replies", isOn: $speakReplies) - .disabled(!selectedVoiceCanSpeak) - Button("Preview voice", systemImage: "speaker.wave.2") { - previewTask = Task { await previewVoice() } - } - .disabled(busy || !selectedVoiceCanSpeak) - - if !hasWorkspaceDefaultVoice, voice.isEmpty { - Label("Pick a voice for this agent before enabling speech.", systemImage: "info.circle") - .font(.footnote) - .foregroundStyle(.secondary) - } - } else if usesSystemVoices { - Label("Built-in Mac voices are unavailable", systemImage: "speaker.slash") - .foregroundStyle(.secondary) - } else if !usesChatterbox { - Label( - usesFishAudio ? "Fish Audio is not configured" : "ElevenLabs is not configured", - systemImage: "speaker.slash" - ) - .foregroundStyle(.secondary) - } - } header: { - Text("Voice") - } footer: { - if !voiceConfigured { - // Under the built-in engine "not configured" is not a - // missing credential — there is none — so the remedy - // cannot be a key. `providerConfigured` in - // `server/tts/index.ts` is reporting that this - // computer has no built-in voices to speak with. - if usesSystemVoices { - Text("Built-in Mac voices need no key, and this computer has none available. Switch the voice engine above to ElevenLabs to keep using voice.") - } else if usesChatterbox { - Text("Any OpenAI-compatible server running Chatterbox works, no key needed. Save its address and model id above.") - } else if usesFishAudio { - Text("Add the shared Fish Audio key in OpenMausBot on your computer. The key is never returned to iOS.") - } else { - Text("Add the shared ElevenLabs key in this agent's profile on the computer. The key is never returned to iOS.") - } - } else if !hasWorkspaceDefaultVoice { - if usesSystemVoices { - Text("No workspace default voice is selected. Choose an agent-specific voice above; synthesis still uses the built-in Mac voices on your computer.") - } else if usesChatterbox { - Text("No workspace default voice is selected. Choose an agent-specific voice above; synthesis still uses the Chatterbox server on your computer.") - } else if usesFishAudio { - Text("No workspace default voice is selected. Choose an agent-specific voice above; synthesis still uses the shared Fish Audio key on your computer.") - } else { - Text("No workspace default voice is selected. Choose an agent-specific voice above; synthesis still uses the shared ElevenLabs key on your computer.") - } - } else { - Text("The voice choice belongs to this agent. Workspace default uses the shared voice selected on your computer.") - } - } - + identitySection + voiceSection Section { Button("Save profile changes") { Task { await save() } } .disabled(busy || name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) @@ -387,24 +142,7 @@ struct AgentProfileView: View { ToolbarItem(placement: .cancellationAction) { Button("Done") { dismiss() } } } .overlay { if busy { ProgressView().controlSize(.large) } } - .task { - async let status = session.configStatus() - async let options = session.voiceOptions() - async let catalog = loadModelCatalog() - async let environment = session.serverEnvironment() - let (loadedConfig, loadedVoices, loadedInstances) = await (status, options, catalog) - config = loadedConfig - engine = loadedConfig?.voiceProvider ?? .elevenlabs - chatterboxURL = loadedConfig?.tts?.baseUrl ?? "" - chatterboxModel = loadedConfig?.tts?.model ?? "" - hostIsMac = (await environment)?.platform == "darwin" - voices = loadedVoices - instances = loadedInstances - modelsLoaded = true - if let loadedConfig, !loadedConfig.canSpeak(agentVoice: voice) { - speakReplies = false - } - } + .task { await loadProfile() } .onValueChange(of: photo) { item in guard let item else { return } Task { await upload(item) } @@ -423,6 +161,301 @@ struct AgentProfileView: View { } } + private var modelSection: some View { + Section { + if !modelsLoaded { + HStack { + Text("Loading models") + Spacer() + ProgressView() + } + } else if instanceChoices.isEmpty { + Label("No model providers are available", systemImage: "exclamationmark.triangle") + .foregroundStyle(.secondary) + } else { + modelPickers + } + } header: { + Text("Model") + } footer: { + Text("Provider accounts and API keys stay on your computer. Default sends no reasoning level and lets the provider decide.") + } + } + + @ViewBuilder private var modelPickers: some View { + Picker("Provider", selection: $selectedInstanceID) { + if !instances.contains(where: { $0.instanceId == selectedInstanceID }) { + Text("Current provider (unavailable)") + .tag(selectedInstanceID) + .disabled(true) + } + ForEach(instanceChoices) { instance in + Text(instanceLabel(instance)) + .tag(instance.instanceId) + .disabled(!instance.snapshot.isAvailable) + } + } + .onValueChange(of: selectedInstanceID) { instanceID in + selectDefaults(for: instanceID) + } + + Picker("Model", selection: $selectedModelID) { + ForEach(selectedModelChoices) { option in + Text(option.label).tag(option.id) + } + } + .disabled(selectedInstance?.snapshot.isAvailable != true) + + if !effortLevels.isEmpty { + Picker("Reasoning effort", selection: $selectedEffort) { + Text("Default").tag(String?.none) + ForEach(effortLevels, id: \.self) { level in + Text(effortLabel(level)).tag(Optional(level)) + } + } + } + + if current.busy == true { + Label("Stop this bot before changing its model.", systemImage: "hourglass") + .font(.footnote) + .foregroundStyle(.secondary) + } else if selectedInstance?.snapshot.isAvailable != true { + Label("Choose an available provider to change this bot's model.", systemImage: "info.circle") + .font(.footnote) + .foregroundStyle(.secondary) + } + + Button("Apply model", systemImage: "checkmark") { + Task { await saveModel() } + } + .disabled(busy || !canApplyModel) + } + + private var avatarSection: some View { + Section { + HStack { + Spacer() + BotAvatarView(bot: current, size: 112, state: .happy, animated: true) + Spacer() + } + .listRowBackground(Color.clear) + + Picker("Shape", selection: $crop) { + ForEach(AvatarCrop.allCases, id: \.self) { shape in + Text(shape.label).tag(shape) + } + } + .pickerStyle(.segmented) + + PhotosPicker(selection: $photo, matching: .images) { + Label("Upload image", systemImage: "photo.badge.plus") + } + .disabled(busy) + + if current.avatarUrl != nil { + Button("Use mascot", systemImage: "trash", role: .destructive) { + Task { await clearImage() } + } + .disabled(busy) + } + } header: { + Text("Avatar") + } footer: { + Text("PNG, JPEG, GIF, or WebP, up to 10 MB. Images are stored on your paired computer and loaded with this device's pairing token.") + } + } + + private var generateSection: some View { + Section { + TextField("Art direction", text: $prompt, axis: .vertical) + .lineLimit(2...5) + Button("Generate on computer", systemImage: "sparkles") { + Task { await generateImage() } + } + .disabled(busy || !imageGenerationReady || prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } header: { + Text("Generate an avatar") + } footer: { + Text(imageGenerationReady + ? "Generation uses the shared image provider configured on your computer. No provider key is sent to or stored on this device." + : "To generate images, configure the shared image provider in OpenMausBot on your computer. Provider keys cannot be added from this device.") + } + } + + private var identitySection: some View { + Section("Identity") { + NavigationLink { + BotOverviewView(bot: current) + } label: { + Label("What this bot does", systemImage: "list.bullet.rectangle") + } + NavigationLink { + BotActivityView(bot: current) + } label: { + Label("Activity", systemImage: "checklist") + } + NavigationLink { + TeamMemoryView(section: current.section ?? "") + } label: { + Label("Team memory", systemImage: "brain") + } + TextField("Name", text: $name) + .textInputAutocapitalization(.words) + TextField("Title", text: $title) + TextField("What this agent does", text: $description, axis: .vertical) + .lineLimit(3...8) + Toggle("Agent notifications", isOn: $notifications) + } + } + + private var voiceSection: some View { + Section { + Picker("Voice engine", selection: $engine) { + Text("ElevenLabs").tag(VoiceProvider.elevenlabs) + Text("Fish Audio").tag(VoiceProvider.fish) + Text("Built-in Mac voices") + .tag(VoiceProvider.system) + .disabled(!hostIsMac) + Text("Chatterbox (local)").tag(VoiceProvider.chatterbox) + } + .disabled(switchingEngine) + + if usesChatterbox { + chatterboxFields + } + + if voiceConfigured { + voiceControls + } else if usesSystemVoices { + Label("Built-in Mac voices are unavailable", systemImage: "speaker.slash") + .foregroundStyle(.secondary) + } else if !usesChatterbox { + Label( + usesFishAudio ? "Fish Audio is not configured" : "ElevenLabs is not configured", + systemImage: "speaker.slash" + ) + .foregroundStyle(.secondary) + } + } header: { + Text("Voice") + } footer: { + voiceFooter + } + } + + @ViewBuilder private var chatterboxFields: some View { + TextField( + "Chatterbox server address", + text: $chatterboxURL, + prompt: Text("http://127.0.0.1:4123") + ) + .keyboardType(.URL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + TextField( + "Chatterbox model", + text: $chatterboxModel, + prompt: Text("chatterbox-turbo") + ) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + Button { + Task { await saveChatterboxServer() } + } label: { + HStack { + Text("Save server") + if savingServer { Spacer(); ProgressView() } + } + } + .disabled(savingServer || chatterboxURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + if let serverProblem { + Label(serverProblem, systemImage: "exclamationmark.triangle") + .foregroundStyle(Color.orange) + } + } + + @ViewBuilder private var voiceControls: some View { + Picker("Voice", selection: $voice) { + if hasWorkspaceDefaultVoice { + Text("Workspace default").tag("") + } else { + Text("Choose an agent voice").tag("").disabled(true) + } + if !voice.isEmpty, !voices.contains(where: { $0.id == voice }) { + Text("Current agent voice").tag(voice) + } + ForEach(voices) { option in + VStack(alignment: .leading) { + Text(option.label) + if let detail = option.description { Text(detail) } + } + .tag(option.id) + } + } + Toggle("Speak replies", isOn: $speakReplies) + .disabled(!selectedVoiceCanSpeak) + Button("Preview voice", systemImage: "speaker.wave.2") { + previewTask = Task { await previewVoice() } + } + .disabled(busy || !selectedVoiceCanSpeak) + + if !hasWorkspaceDefaultVoice, voice.isEmpty { + Label("Pick a voice for this agent before enabling speech.", systemImage: "info.circle") + .font(.footnote) + .foregroundStyle(.secondary) + } + } + + @ViewBuilder private var voiceFooter: some View { + if !voiceConfigured { + // Under the built-in engine "not configured" is not a + // missing credential — there is none — so the remedy + // cannot be a key. `providerConfigured` in + // `server/tts/index.ts` is reporting that this + // computer has no built-in voices to speak with. + if usesSystemVoices { + Text("Built-in Mac voices need no key, and this computer has none available. Switch the voice engine above to ElevenLabs to keep using voice.") + } else if usesChatterbox { + Text("Any OpenAI-compatible server running Chatterbox works, no key needed. Save its address and model id above.") + } else if usesFishAudio { + Text("Add the shared Fish Audio key in OpenMausBot on your computer. The key is never returned to iOS.") + } else { + Text("Add the shared ElevenLabs key in this agent's profile on the computer. The key is never returned to iOS.") + } + } else if !hasWorkspaceDefaultVoice { + if usesSystemVoices { + Text("No workspace default voice is selected. Choose an agent-specific voice above; synthesis still uses the built-in Mac voices on your computer.") + } else if usesChatterbox { + Text("No workspace default voice is selected. Choose an agent-specific voice above; synthesis still uses the Chatterbox server on your computer.") + } else if usesFishAudio { + Text("No workspace default voice is selected. Choose an agent-specific voice above; synthesis still uses the shared Fish Audio key on your computer.") + } else { + Text("No workspace default voice is selected. Choose an agent-specific voice above; synthesis still uses the shared ElevenLabs key on your computer.") + } + } else { + Text("The voice choice belongs to this agent. Workspace default uses the shared voice selected on your computer.") + } + } + + private func loadProfile() async { + async let status = session.configStatus() + async let options = session.voiceOptions() + async let catalog = loadModelCatalog() + async let environment = session.serverEnvironment() + let (loadedConfig, loadedVoices, loadedInstances) = await (status, options, catalog) + config = loadedConfig + engine = loadedConfig?.voiceProvider ?? .elevenlabs + chatterboxURL = loadedConfig?.tts?.baseUrl ?? "" + chatterboxModel = loadedConfig?.tts?.model ?? "" + hostIsMac = (await environment)?.platform == "darwin" + voices = loadedVoices + instances = loadedInstances + modelsLoaded = true + if let loadedConfig, !loadedConfig.canSpeak(agentVoice: voice) { + speakReplies = false + } + } + /// Switch the workspace's voice engine. The engine is a setting, not a /// secret, so it rides the ordinary config write; the voice list reloads /// because every engine offers different voices. A failed switch snaps diff --git a/ios/App/BackDeployCompat.swift b/ios/App/BackDeployCompat.swift index 6608fb50ed..3395246ec6 100644 --- a/ios/App/BackDeployCompat.swift +++ b/ios/App/BackDeployCompat.swift @@ -4,29 +4,50 @@ // iOS 16 and can never move past it. Three SwiftUI things we lean on landed in // 17, and ActivityKit landed in 16.1/16.2, so each one is bridged here rather // than sprinkling `#available` through every view. +// +// Every shim that branches on `#available` is a `ViewModifier`, never a +// `@ViewBuilder` extension on View that returns `some View`. The builder form +// makes the result a `_ConditionalContent` whose payloads each wrap the whole +// view the shim was applied to, so a chain of N such calls builds a view type +// 2^N–3^N times the size. The compiler only sees that type when this file and +// the caller are primaries of the same batch — which they are on a 3-core CI +// runner, where ChatView's 19 `onValueChange` calls turned a 5-minute build +// into a 25-minute one (Oct 2026). A modifier wraps the view once and branches +// over a fixed placeholder, so the type grows linearly however long the chain. +// CI holds the rule: scripts/check-ios-view-shims.sh fails on an `#available` +// inside any `extension View`. import AVFoundation import SwiftUI +/// `onChange(of:_:)` with the iOS 17 two-value closure, back-ported. +/// +/// Call sites only ever read the new value, so the shim hands that over and +/// nothing else. On 16 this is the old single-value `onChange`, which fires on +/// exactly the same edges. +private struct OnValueChange: ViewModifier { + let value: V + let initial: Bool + let action: (V) -> Void + + func body(content: Content) -> some View { + if #available(iOS 17.0, *) { + content.onChange(of: value, initial: initial) { _, newValue in action(newValue) } + } else if initial { + content.onChange(of: value) { newValue in action(newValue) } + .onAppear { action(value) } + } else { + content.onChange(of: value) { newValue in action(newValue) } + } + } +} + extension View { - /// `onChange(of:_:)` with the iOS 17 two-value closure, back-ported. - /// - /// Call sites only ever read the new value, so the shim hands that over - /// and nothing else. On 16 this is the old single-value `onChange`, which - /// fires on exactly the same edges. - @ViewBuilder func onValueChange( of value: V, initial: Bool = false, perform action: @escaping (V) -> Void ) -> some View { - if #available(iOS 17.0, *) { - onChange(of: value, initial: initial) { _, newValue in action(newValue) } - } else if initial { - onChange(of: value) { newValue in action(newValue) } - .onAppear { action(value) } - } else { - onChange(of: value) { newValue in action(newValue) } - } + modifier(OnValueChange(value: value, initial: initial, action: action)) } } @@ -116,92 +137,133 @@ enum FeedbackKind { } } -extension View { - /// `sensoryFeedback(_:trigger:)` back-ported. - /// - /// Same contract: the haptic fires when `trigger` changes. Below 17 the - /// generators are driven by hand, which is what the modifier does anyway. - @ViewBuilder - func feedback(_ kind: FeedbackKind, trigger: T) -> some View { +/// `sensoryFeedback(_:trigger:)` back-ported. +/// +/// Same contract: the haptic fires when `trigger` changes. Below 17 the +/// generators are driven by hand, which is what the modifier does anyway. +private struct Feedback: ViewModifier { + let kind: FeedbackKind + let trigger: T + + func body(content: Content) -> some View { if #available(iOS 17.0, *) { switch kind { - case .selection: sensoryFeedback(.selection, trigger: trigger) - case .warning: sensoryFeedback(.warning, trigger: trigger) - case .success: sensoryFeedback(.success, trigger: trigger) + case .selection: content.sensoryFeedback(.selection, trigger: trigger) + case .warning: content.sensoryFeedback(.warning, trigger: trigger) + case .success: content.sensoryFeedback(.success, trigger: trigger) } } else { - onValueChange(of: trigger) { _ in kind.play() } + content.onValueChange(of: trigger) { _ in kind.play() } } } } -extension View { - /// `selectionDisabled(_:)` back-ported. - /// - /// It marks a List row as not selectable, which iOS 16 has no equivalent - /// for. There the row stays selectable and the screen's own guards decide - /// what a tap does, which is the pre-17 behaviour the app already had. - @ViewBuilder - func rowSelectionDisabled(_ disabled: Bool = true) -> some View { +/// `selectionDisabled(_:)` back-ported. +/// +/// It marks a List row as not selectable, which iOS 16 has no equivalent for. +/// There the row stays selectable and the screen's own guards decide what a +/// tap does, which is the pre-17 behaviour the app already had. +private struct RowSelectionDisabled: ViewModifier { + let disabled: Bool + + func body(content: Content) -> some View { if #available(iOS 17.0, *) { - selectionDisabled(disabled) + content.selectionDisabled(disabled) } else { - self + content } } } -extension View { - /// The sheet's material background and rounded corners are iOS 16.4. - /// Below that the sheet keeps the system's own chrome, which is the same - /// shape, just opaque. - @ViewBuilder - func sheetChromeCompat() -> some View { +/// The sheet's material background and rounded corners are iOS 16.4. Below +/// that the sheet keeps the system's own chrome, which is the same shape, +/// just opaque. +private struct SheetChrome: ViewModifier { + func body(content: Content) -> some View { if #available(iOS 16.4, *) { - presentationBackground(.thinMaterial) + content + .presentationBackground(.thinMaterial) .presentationCornerRadius(28) } else { - self + content } } +} - /// The repeating pulse on a symbol is iOS 17. Below that the symbol simply - /// sits still; it marks activity that the surrounding view already states - /// in words. - @ViewBuilder - func pulseCompat(isActive: Bool) -> some View { +/// The repeating pulse on a symbol is iOS 17. Below that the symbol simply +/// sits still; it marks activity that the surrounding view already states in +/// words. +private struct Pulse: ViewModifier { + let isActive: Bool + + func body(content: Content) -> some View { if #available(iOS 17.0, *) { - symbolEffect(.pulse, options: .repeating, isActive: isActive) + content.symbolEffect(.pulse, options: .repeating, isActive: isActive) } else { - self + content } } } -extension View { - /// `defaultScrollAnchor(_:)` is iOS 17. Below that the scroll view starts - /// at the top and the screens that need the bottom scroll there - /// themselves once content lands. - @ViewBuilder - func scrollAnchorCompat(_ anchor: UnitPoint) -> some View { +/// `defaultScrollAnchor(_:)` is iOS 17. Below that the scroll view starts at +/// the top and the screens that need the bottom scroll there themselves once +/// content lands. +private struct ScrollAnchor: ViewModifier { + let anchor: UnitPoint + + func body(content: Content) -> some View { if #available(iOS 17.0, *) { - defaultScrollAnchor(anchor) + content.defaultScrollAnchor(anchor) } else { - self + content } } +} - /// Runs `action` when the person starts dragging a scroll view. - /// `onScrollPhaseChange` is iOS 18; below that this is a no-op, so - /// callers must treat "never called" as "do not know". - @ViewBuilder - func onUserScrollCompat(_ action: @escaping () -> Void) -> some View { +/// Runs `action` when the person starts dragging a scroll view. +/// `onScrollPhaseChange` is iOS 18; below that this is a no-op, so callers +/// must treat "never called" as "do not know". +private struct OnUserScroll: ViewModifier { + let action: () -> Void + + func body(content: Content) -> some View { if #available(iOS 18.0, *) { - onScrollPhaseChange { _, phase in + content.onScrollPhaseChange { _, phase in if phase == .interacting { action() } } } else { - self + content + } + } +} + +/// `scrollClipDisabled()` is iOS 17. Below it the scroll view clips its +/// content to its bounds, which costs a shadow spilling past the edge. +private struct ScrollClipDisabled: ViewModifier { + func body(content: Content) -> some View { + if #available(iOS 17.0, *) { + content.scrollClipDisabled() + } else { + content + } + } +} + +/// Hardware-keyboard Return handling, which is `onKeyPress` on iOS 17. There +/// is no pre-17 equivalent for a SwiftUI text field, so on 16 a hardware +/// Return just inserts a newline like the software one. +private struct OnHardwareReturn: ViewModifier { + let action: () -> Void + + func body(content: Content) -> some View { + if #available(iOS 17.0, *) { + content.onKeyPress(.return, phases: .down) { press in + if press.modifiers.contains(.shift) { return .ignored } + action() + return .handled + } + } else { + content } } } @@ -227,37 +289,42 @@ private struct OnValueChangePair: ViewModifier { } extension View { - func onValueChangePair( - of value: V, - perform action: @escaping (V, V) -> Void - ) -> some View { - modifier(OnValueChangePair(value: value, action: action)) + func feedback(_ kind: FeedbackKind, trigger: T) -> some View { + modifier(Feedback(kind: kind, trigger: trigger)) + } + + func rowSelectionDisabled(_ disabled: Bool = true) -> some View { + modifier(RowSelectionDisabled(disabled: disabled)) + } + + func sheetChromeCompat() -> some View { + modifier(SheetChrome()) + } + + func pulseCompat(isActive: Bool) -> some View { + modifier(Pulse(isActive: isActive)) + } + + func scrollAnchorCompat(_ anchor: UnitPoint) -> some View { + modifier(ScrollAnchor(anchor: anchor)) + } + + func onUserScrollCompat(_ action: @escaping () -> Void) -> some View { + modifier(OnUserScroll(action: action)) } - /// `scrollClipDisabled()` is iOS 17. Below it the scroll view clips its - /// content to its bounds, which costs a shadow spilling past the edge. - @ViewBuilder func scrollClipDisabledCompat() -> some View { - if #available(iOS 17.0, *) { - scrollClipDisabled() - } else { - self - } + modifier(ScrollClipDisabled()) } - /// Hardware-keyboard Return handling, which is `onKeyPress` on iOS 17. - /// There is no pre-17 equivalent for a SwiftUI text field, so on 16 a - /// hardware Return just inserts a newline like the software one. - @ViewBuilder func onHardwareReturn(_ action: @escaping () -> Void) -> some View { - if #available(iOS 17.0, *) { - onKeyPress(.return, phases: .down) { press in - if press.modifiers.contains(.shift) { return .ignored } - action() - return .handled - } - } else { - self - } + modifier(OnHardwareReturn(action: action)) + } + + func onValueChangePair( + of value: V, + perform action: @escaping (V, V) -> Void + ) -> some View { + modifier(OnValueChangePair(value: value, action: action)) } } diff --git a/ios/App/Cards/AgentThoughtChamberView.swift b/ios/App/Cards/AgentThoughtChamberView.swift index de1a2ced0e..3a6b2eb783 100644 --- a/ios/App/Cards/AgentThoughtChamberView.swift +++ b/ios/App/Cards/AgentThoughtChamberView.swift @@ -103,38 +103,45 @@ public struct AgentThoughtChamberView: View, Equatable { .buttonStyle(.plain) } - @ViewBuilder private func expandedContent(isDark: Bool, steps: [ReasoningWindow.Step]) -> some View { - VStack(alignment: .leading, spacing: 6) { + let fill: Color = isDark ? Color.black.opacity(0.35) : Color.white.opacity(0.85) + let rim: Color = isDark ? Color.white.opacity(0.06) : Color.black.opacity(0.06) + return VStack(alignment: .leading, spacing: 6) { ScrollViewReader { proxy in - ScrollView { - VStack(alignment: .leading, spacing: 4) { - ForEach(steps) { step in - stepRow(number: step.number, step: step.text, isDark: isDark) - } - } - } - .frame(maxHeight: 160) - // While the bot thinks, the newest step is the news: start at - // the bottom and keep following as steps arrive, the way the - // reply bubble follows its own text. - .scrollAnchorCompat(.bottom) - .onValueChange(of: reasoning) { _ in - guard isStreaming, let newest = steps.last else { return } - withAnimation { proxy.scrollTo(newest.number, anchor: .bottom) } - } + stepList(proxy: proxy, isDark: isDark, steps: steps) } } .padding(10) - .background(isDark ? Color.black.opacity(0.35) : Color.white.opacity(0.85)) + .background(fill) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 10, style: .continuous) - .stroke(isDark ? Color.white.opacity(0.06) : Color.black.opacity(0.06), lineWidth: 0.5) + .stroke(rim, lineWidth: 0.5) ) .transition(.opacity.combined(with: .move(edge: .top))) } - + + private func stepList(proxy: ScrollViewProxy, isDark: Bool, steps: [ReasoningWindow.Step]) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: 4) { + ForEach(steps) { step in + stepRow(number: step.number, step: step.text, isDark: isDark) + } + } + } + .frame(maxHeight: 160) + // While the bot thinks, the newest step is the news: start at + // the bottom and keep following as steps arrive, the way the + // reply bubble follows its own text. + .scrollAnchorCompat(.bottom) + .onValueChange(of: reasoning) { _ in followNewestStep(proxy, steps: steps) } + } + + private func followNewestStep(_ proxy: ScrollViewProxy, steps: [ReasoningWindow.Step]) { + guard isStreaming, let newest = steps.last else { return } + withAnimation { proxy.scrollTo(newest.number, anchor: .bottom) } + } + @ViewBuilder private func stepRow(number: Int, step: String, isDark: Bool) -> some View { HStack(alignment: .top, spacing: 6) { diff --git a/ios/App/Cards/GitPRDiffCardView.swift b/ios/App/Cards/GitPRDiffCardView.swift index edb3782cdf..47b636e77e 100644 --- a/ios/App/Cards/GitPRDiffCardView.swift +++ b/ios/App/Cards/GitPRDiffCardView.swift @@ -34,127 +34,154 @@ public struct GitPRDiffCardView: View { } } + private var isDark: Bool { colorScheme == .dark } + private static let green = Color(hex: "#22C55E") + private static let red = Color(hex: "#EF4444") + private var titleColor: Color { isDark ? Color(hex: "#F8FAFC") : Color(hex: "#0F172A") } + private var mutedColor: Color { isDark ? Color(hex: "#94A3B8") : Color(hex: "#64748B") } + private var deltaFill: Color { isDark ? Color.white.opacity(0.08) : Color.black.opacity(0.04) } + private var hairline: Color { isDark ? Color.white.opacity(0.12) : Color.black.opacity(0.08) } + private var codeFill: Color { isDark ? Color.black.opacity(0.55) : Color(hex: "#0F172A") } + public var body: some View { - let isDark = colorScheme == .dark - VStack(alignment: .leading, spacing: 8) { - // Header - HStack(spacing: 6) { - Image(systemName: "arrow.triangle.pull") - .font(.system(size: 12, weight: .bold)) - .foregroundColor(Color(hex: "#22C55E")) - - Text(filename) - .font(.caption.weight(.bold)) - .foregroundColor(isDark ? Color(hex: "#F8FAFC") : Color(hex: "#0F172A")) - .lineLimit(1) - - Spacer() - - // Diff Delta (+ / -) - HStack(spacing: 4) { - Text("+\(additions)") - .font(.system(size: 10.5, weight: .bold, design: .monospaced)) - .foregroundColor(Color(hex: "#22C55E")) - Text("-\(deletions)") - .font(.system(size: 10.5, weight: .bold, design: .monospaced)) - .foregroundColor(Color(hex: "#EF4444")) - } - .padding(.horizontal, 6) - .padding(.vertical, 2.5) - .background(isDark ? Color.white.opacity(0.08) : Color.black.opacity(0.04)) - .clipShape(Capsule()) - } - - // Diff Content + header if !diffText.isEmpty { - VStack(alignment: .leading, spacing: 0) { - Button { - withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { - showDiff.toggle() - } - Haptics.selection() - } label: { - HStack { - Image(systemName: showDiff ? "chevron.down" : "chevron.right") - .font(.system(size: 9, weight: .bold)) - Text(showDiff ? "Hide Diff" : "View Diff") - .font(.caption2.weight(.semibold)) - Spacer() - } - .foregroundColor(isDark ? Color(hex: "#94A3B8") : Color(hex: "#64748B")) - .padding(.vertical, 2) - } - .buttonStyle(.plain) - - if showDiff { - ScrollView(.horizontal, showsIndicators: false) { - VStack(alignment: .leading, spacing: 1) { - ForEach(Array(visibleLines.enumerated()), id: \.offset) { _, line in - diffLineView(line, isDark: isDark) - } - } - .padding(6) - } - .background(isDark ? Color.black.opacity(0.55) : Color(hex: "#0F172A")) - .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - .transition(.opacity.combined(with: .move(edge: .top))) - - if lines.count > 80 { - Button(showAllLines ? "Show first 80 lines" : "Show all \(lines.count) lines") { - withAnimation(.easeInOut(duration: 0.2)) { showAllLines.toggle() } - Haptics.selection() - } - .font(.caption2.weight(.semibold)) - .buttonStyle(.plain) - .accessibilityHint("The copied diff always includes every line") - } - } - } - } - - Divider().background(isDark ? Color.white.opacity(0.12) : Color.black.opacity(0.08)) - - // Footer Actions - HStack(spacing: 8) { - Button { - PlatformBridge.copyToPasteboard(diffText) - } label: { - HStack(spacing: 4) { - Image(systemName: "doc.on.doc") - Text("Copy Diff") - } - .font(.caption2.weight(.medium)) - .foregroundColor(isDark ? Color(hex: "#94A3B8") : Color(hex: "#64748B")) - } - .buttonStyle(.plain) - - Spacer() + diffSection } + Divider().background(hairline) + footer } .padding(10) - .background( - LinearGradient( - colors: isDark ? [ - Color(hex: "#0D1117").opacity(0.96), - Color(hex: "#161B22").opacity(0.92) - ] : [ - Color.white.opacity(0.96), - Color(hex: "#F8FAFC").opacity(0.92) - ], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - ) + .background(cardGradient) .background(.ultraThinMaterial) .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(isDark ? Color.white.opacity(0.12) : Color.black.opacity(0.08), lineWidth: 0.75) + .stroke(hairline, lineWidth: 0.75) ) .shadow(color: Color.black.opacity(isDark ? 0.20 : 0.04), radius: 4, y: 1.5) } - + + private var header: some View { + HStack(spacing: 6) { + Image(systemName: "arrow.triangle.pull") + .font(.system(size: 12, weight: .bold)) + .foregroundColor(Self.green) + + Text(filename) + .font(.caption.weight(.bold)) + .foregroundColor(titleColor) + .lineLimit(1) + + Spacer() + + delta + } + } + + /// The + / - counts. + private var delta: some View { + HStack(spacing: 4) { + Text("+\(additions)") + .font(.system(size: 10.5, weight: .bold, design: .monospaced)) + .foregroundColor(Self.green) + Text("-\(deletions)") + .font(.system(size: 10.5, weight: .bold, design: .monospaced)) + .foregroundColor(Self.red) + } + .padding(.horizontal, 6) + .padding(.vertical, 2.5) + .background(deltaFill) + .clipShape(Capsule()) + } + + private var diffSection: some View { + VStack(alignment: .leading, spacing: 0) { + diffToggle + + if showDiff { + diffLines + + if lines.count > 80 { + Button(showAllLines ? "Show first 80 lines" : "Show all \(lines.count) lines") { + withAnimation(.easeInOut(duration: 0.2)) { showAllLines.toggle() } + Haptics.selection() + } + .font(.caption2.weight(.semibold)) + .buttonStyle(.plain) + .accessibilityHint("The copied diff always includes every line") + } + } + } + } + + private var diffToggle: some View { + Button { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + showDiff.toggle() + } + Haptics.selection() + } label: { + HStack { + Image(systemName: showDiff ? "chevron.down" : "chevron.right") + .font(.system(size: 9, weight: .bold)) + Text(showDiff ? "Hide Diff" : "View Diff") + .font(.caption2.weight(.semibold)) + Spacer() + } + .foregroundColor(mutedColor) + .padding(.vertical, 2) + } + .buttonStyle(.plain) + } + + private var diffLines: some View { + ScrollView(.horizontal, showsIndicators: false) { + VStack(alignment: .leading, spacing: 1) { + ForEach(Array(visibleLines.enumerated()), id: \.offset) { _, line in + diffLineView(line, isDark: isDark) + } + } + .padding(6) + } + .background(codeFill) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .transition(.opacity.combined(with: .move(edge: .top))) + } + + private var footer: some View { + HStack(spacing: 8) { + Button { + PlatformBridge.copyToPasteboard(diffText) + } label: { + HStack(spacing: 4) { + Image(systemName: "doc.on.doc") + Text("Copy Diff") + } + .font(.caption2.weight(.medium)) + .foregroundColor(mutedColor) + } + .buttonStyle(.plain) + + Spacer() + } + } + + private var cardGradient: LinearGradient { + LinearGradient( + colors: isDark ? [ + Color(hex: "#0D1117").opacity(0.96), + Color(hex: "#161B22").opacity(0.92) + ] : [ + Color.white.opacity(0.96), + Color(hex: "#F8FAFC").opacity(0.92) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + @ViewBuilder private func diffLineView(_ line: String, isDark: Bool) -> some View { let isAddition = line.hasPrefix("+") && !line.hasPrefix("+++") diff --git a/ios/App/ChatView.swift b/ios/App/ChatView.swift index e28e18d5d9..9817872a44 100644 --- a/ios/App/ChatView.swift +++ b/ios/App/ChatView.swift @@ -183,257 +183,7 @@ struct ChatView: View { // explicitly told to take everything the composer does not. VStack(spacing: 0) { ScrollViewReader { proxy in - ScrollView { - // VStack, not LazyVStack. A lazy stack does not know how - // tall it is until its rows have been built, so - // `.defaultScrollAnchor(.bottom)` anchors against an - // estimate and the chat opens somewhere in the middle of - // the conversation. Building all of it up front makes the - // height exact and the anchor land on the newest message. - // A thread holds 50 messages until you ask for more, so - // there is nothing here worth being lazy about. - VStack(alignment: .leading, spacing: 6) { - // room for the floating face when scrolled to the top - Color.clear.frame(height: 72) - - if session.state.hasMore[threadId] == true { - Button("Load earlier messages") { - // keep the reader where they were: after older - // messages are prepended, sit back on the one - // that used to be at the top - let anchor = transcript.first?.id - Task { - await session.loadOlder(threadId: threadId) - if let anchor { proxy.scrollTo(anchor, anchor: .top) } - } - } - .font(.footnote) - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - } - - ForEach(Array(transcript.enumerated()), id: \.element.id) { index, row in - VStack(alignment: .leading, spacing: 6) { - // a gap in time is worth marking; a timestamp - // on every message is just noise - if startsANewStretch(at: index, in: transcript) { - Text(RelativeStamp.separator(row.head.date)) - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(Color.secondary.opacity(0.7)) - .frame(maxWidth: .infinity) - .padding(.top, 10) - .padding(.bottom, 4) - } - switch row { - case let .message(message): - MessageRow( - chat: current, - message: message, - versions: message.role == .user && message.kind == .text - ? versions[message.parentId] ?? [] : [], - endsRun: endsRun(at: index, in: transcript), - openLink: openLink, - openThread: openThread - ) - case let .activityRun(items): - ActivityRunChip(items: items, openThread: openThread) - case let .assistantTurn(turn): - AssistantTurnChip( - turn: turn, chat: current, openLink: openLink, openThread: openThread, - revealedMessageId: revealedMessageId, - scrollToMessage: { proxy.scrollTo($0, anchor: .center) } - ) - } - } - .id(row.id) - } - - // The reply as it is typed. It sits after the last - // settled message and disappears the moment the real - // one arrives — the store clears it on the same frame - // that appends the message, so there is never a beat - // where both are on screen. - // At Hidden the words go to the status line above the - // composer; the transcript keeps the typing dots. - if current.busy, detail != .hidden, let live = session.state.streaming[threadId], !live.isEmpty { - StreamingBubble(text: live, reasoning: nil, color: current.color) - .id(Self.liveBubbleId) - } else if current.busy, activityDetail != ActivityDetail.hidden.rawValue, - let thinking = session.state.reasoning[threadId], !thinking.isEmpty { - // Only while there is no answer yet. Once tokens - // of the reply exist, the reasoning is behind us - // and showing both is just noise. - StreamingBubble(text: nil, reasoning: thinking, color: current.color) - .id(Self.liveBubbleId) - } else if current.busy { - TypingIndicatorView(tintColor: MausPalette.color(current.color)) - .id(Self.liveBubbleId) - .accessibilityLabel("\(current.name) is working") - } - - Color.clear - .frame(height: 1) - .id(Self.transcriptEndId) - // Only a change of answer touches state: this - // fires on every scrolled frame. - .onGeometryChange(for: CGFloat.self) { $0.frame(in: .global).maxY } action: { end in - let reading = end - viewportBottom > Self.jumpToLatestThreshold - if reading != showsJumpToLatest { - withAnimation(reduceMotion ? nil : .easeOut(duration: 0.18)) { showsJumpToLatest = reading } - } - } - .accessibilityHidden(true) - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .frame(maxWidth: CompanionLayout.chatWidth, alignment: .leading) - .frame(maxWidth: .infinity) - } - // The header lives in the scroll view's top safe area: the - // transcript starts below it and scrolls under it — that is - // what the glass is for. An inset rather than a content - // margin, because `.defaultScrollAnchor(.bottom)` anchored - // unreliably against a margin and opened chats mid-way. - // The blur is only the top strip — back, computer — the way - // a system bar is; the transcript starts on that line and - // scrolls under the face and name, which float over it. - .safeAreaInset(edge: .top, spacing: 0) { headerBar } - .overlay(alignment: .top) { headerFace } - .overlay(alignment: .top) { - // One face, in one layer, measured from the screen's top - // edge: it sits in the island while that is open and - // glides into its header slot when the island lets go. - let topInset = IslandGeometry.topInset - let islandSide: CGFloat = 220 - // centred in the part of the square the hardware island does not cover - let islandFaceCentre = IslandGeometry.top + IslandGeometry.size.height + (islandSide - IslandGeometry.size.height) / 2 - let headerFaceCentre = topInset + 26 - let faceSize = 60 + 72 * facePhase - let faceCentre = headerFaceCentre + (islandFaceCentre - headerFaceCentre) * facePhase - ZStack(alignment: .top) { - if islandVisible { - IslandShell(expanded: islandExpanded, expandedSize: CGSize(width: islandSide, height: islandSide)) { - Color.clear - } - } - ChatAvatarView(chat: current, size: faceSize, state: MausState.forChat(current, in: session.state), animated: MausState.forChat(current, in: session.state).showsActivity || islandExpanded, comets: islandExpanded) - .offset(y: faceCentre - faceSize / 2) - .allowsHitTesting(false) - } - .frame(maxWidth: .infinity, alignment: .top) - .ignoresSafeArea(edges: .top) - .allowsHitTesting(false) - } - // Reading scrollback — one tap back to the end, streaming or - // not, the same pill the desktop chat offers. - .onGeometryChange(for: CGFloat.self) { $0.frame(in: .global).maxY } action: { bottom in - viewportBottom = bottom - } - .overlay(alignment: .bottom) { - if showsJumpToLatest { - Button { - withAnimation { proxy.scrollTo(Self.transcriptEndId, anchor: .bottom) } - } label: { - Label("Jump to latest", systemImage: "arrow.down") - .font(.footnote.weight(.medium)) - .padding(.horizontal, 14) - .padding(.vertical, 8) - .background(.regularMaterial, in: Capsule()) - .overlay(Capsule().strokeBorder(Color.primary.opacity(0.08))) - .shadow(color: .black.opacity(0.12), radius: 8, y: 2) - } - .buttonStyle(.plain) - .accessibilityLabel("Jump to latest messages") - .padding(.bottom, 10) - .transition(.opacity.combined(with: .scale(scale: 0.9))) - } - } - .task { - // grow, hold a beat, shrink — the face rides along - guard CompanionLayout.supportsIslandPresentation, !reduceMotion else { return } - // The intro is a greeting, and a greeting repeated every - // time you open a chat stops being one. - let intro = IslandIntro(rawValue: islandIntro) ?? .oncePerBot - switch intro { - case .never: - return - case .oncePerBot: - guard !IslandSeen.contains(islandIntroID, in: islandSeen) else { return } - islandSeen = IslandSeen.adding(islandIntroID, to: islandSeen) - case .always: - break - } - islandVisible = true - try? await Task.sleep(for: .milliseconds(40)) - withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { islandExpanded = true; facePhase = 1 } - try? await Task.sleep(for: .milliseconds(1000)) - withAnimation(.spring(response: 0.55, dampingFraction: 0.82)) { islandExpanded = false; facePhase = 0 } - try? await Task.sleep(for: .milliseconds(600)) - islandVisible = false - } - // A conversation grows from the bottom: a transcript shorter - // than the screen rests at the bottom, and opening a chat - // starts on the newest message rather than the oldest. - .scrollAnchorCompat(.bottom) - // Tapping the transcript puts the keyboard away. The composer - // is a sibling of this scroll view rather than inside it, so - // nothing else here drops its focus — until this, the only way - // back to the whole conversation was to leave the chat. - // Simultaneous, not `.onTapGesture`: a tap that lands on a - // link, a card button or a selected word still reaches the row - // that owns it, and only also closes the keyboard. - .simultaneousGesture(TapGesture().onEnded { - if composerFocused { composerFocused = false } - }) - // And a drag down over the transcript pushes it away, the way - // it does in Mail and Messages. - .scrollDismissesKeyboard(.interactively) - // `initial: true` is what opens the chat on the newest - // message where `scrollAnchorCompat` cannot (iOS 16). On 17 the - // anchor has already put us there and this is a no-op. - .onValueChange(of: transcript.last?.id, initial: true) { _ in - guard let last = transcript.last else { return } - withAnimation { proxy.scrollTo(last.id, anchor: .bottom) } - } - // Neither of the above is enough on its own when the newest - // message holds a table or a code block. Their horizontal - // scroll views throw off the height the anchor measures on - // the first pass, so the chat opened a table's height short - // of the end; and the `initial` scroll above runs before - // there is anything to scroll. One more scroll once the first - // layout has settled lands on the end — again when the page - // arrives from the computer, which can be after the push, - // unless the reader has already scrolled away to read. - .task(id: "\(threadId)|\(session.state.hasLoadedPage(forThread: threadId))") { - try? await Task.sleep(for: .milliseconds(50)) - guard !Task.isCancelled, !readerScrolled, let last = rows.last else { return } - proxy.scrollTo(last.id, anchor: .bottom) - } - .onUserScrollCompat { readerScrolled = true } - // Follow the text as it arrives. Keyed on length rather than - // the string so this fires once per delta batch, and without - // animation — animating every token turns a smooth stream - // into a stutter, because each scroll interrupts the last. - .onValueChange(of: session.state.streaming[threadId]?.count ?? 0) { length in - guard length > 0 else { return } - proxy.scrollTo(Self.liveBubbleId, anchor: .bottom) - } - .task(id: session.focusedMessageId) { - guard let messageId = session.focusedMessageId, - messages.contains(where: { $0.id == messageId }) - else { return } - revealedMessageId = messageId - // Materialize the lazy folded row first. Its target bubble - // scrolls itself into view once expansion has laid it out. - let folded = transcript.first { row in - if case let .assistantTurn(turn) = row { - return turn.messages.contains { $0.id == messageId } - } - return false - } - proxy.scrollTo(folded?.id ?? messageId, anchor: .center) - session.consumeFocus(messageId) - } + transcriptScrollView(proxy: proxy, transcript: transcript, versions: versions) } .id(threadId) .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -443,17 +193,7 @@ struct ChatView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) .overlay(alignment: .bottom) { plusSheet } - .overlay(alignment: .bottomTrailing) { -#if DEBUG - if ProcessInfo.processInfo.arguments.contains("-busy-fleet-preview") { - Text("Offline busy-fleet fixture") - .font(.caption2) - .allowsHitTesting(false) - .accessibilityIdentifier("busy-fleet-progress") - .accessibilityValue(session.state.cursor ?? "0") - } -#endif - } + .overlay(alignment: .bottomTrailing) { busyFleetFixtureBadge } .toolbar(.hidden, for: .navigationBar) .navigationBarBackButtonHidden(true) // Hiding the bar above also disarms the system edge-swipe back @@ -464,52 +204,15 @@ struct ChatView: View { .navigationDestination(isPresented: $showingComputer) { if case let .bot(bot) = current { ComputerView(bot: bot) } } - .task(id: threadId) { - if selectedThreadWasRemoved { dismiss(); return } - let openedChat = current - session.threadSelection.rememberThread(openedChat, connectionID: session.connection?.id) - await session.loadThreadIfNeeded(openedChat.threadId) - // opening a chat is what marks it read, exactly as on the desktop - if openedChat.unread { await session.markRead(openedChat) } -#if DEBUG - // `-open-plus`: the + sheet up, for the screenshot harness - if ProcessInfo.processInfo.arguments.contains("-open-plus") { showingPlus = true } - // Profile parity screenshots without automating a tap through the - // animated island/header transition. - if ProcessInfo.processInfo.arguments.contains("-open-profile") { showingProfile = true } -#endif - } + .task(id: threadId) { await enterThread() } .onValueChange(of: selectedThreadWasRemoved) { removed in if removed { dismiss() } } .onValueChange(of: session.state.hasLoadedPage(forThread: threadId)) { loaded in - let requestedThread = threadId - if !loaded { Task { await session.loadThreadIfNeeded(requestedThread) } } - } - .onValueChange(of: current.unread) { unread in - // A message can arrive while this chat is already on screen. The - // initial task above will not run again, so clear that new unread - // bit here rather than leaving a badge on an open conversation. - let readChat = current - if unread { Task { await session.markRead(readChat) } } - } - .onValueChangePair(of: threadId) { previous, next in - dictation.stop() - threadDrafts[previous] = ComposerSnapshot(text: draft, attachments: attachments, error: attachmentError) - let restored = threadDrafts.removeValue(forKey: next) ?? ComposerSnapshot() - draft = restored.text - attachments = restored.attachments - attachmentError = restored.error - selectedPhotos = [] - showCommandHUD = false - showingPlus = false - readerScrolled = false - // The local task picker changed threads. A download - // started in the previous task must not open a sheet (or surface - // its error) in the new one when the network reply arrives late. - resetFilePreview() - cancelThreadOpen() + reloadIfPageDropped(loaded) } + .onValueChange(of: current.unread) { unread in markReadIfNeeded(unread) } + .onValueChangePair(of: threadId) { previous, next in switchThread(from: previous, to: next) } .onValueChange(of: session.connection?.id) { _ in steering = false cancelThreadOpen() @@ -517,11 +220,7 @@ struct ChatView: View { .onValueChange(of: heldSends.first?.queueId) { _ in steering = false } .onValueChange(of: current.busy) { busy in if !busy { steering = false } } .onValueChange(of: threadId) { _ in steering = false } - .task(id: steering) { - guard steering else { return } - try? await Task.sleep(for: .seconds(20)) - if !Task.isCancelled { steering = false } - } + .task(id: steering) { await expireSteering() } .onDisappear { dictation.stop() resetFilePreview() @@ -543,11 +242,7 @@ struct ChatView: View { if shown { dictation.stop() } } .onReceive(NotificationCenter.default.publisher(for: AVAudioSession.interruptionNotification)) { note in - let raw = note.userInfo?[AVAudioSessionInterruptionTypeKey] - let value = (raw as? NSNumber)?.uintValue ?? (raw as? UInt) - if value == AVAudioSession.InterruptionType.began.rawValue { - dictation.stop() - } + stopDictation(ifInterruptedBy: note) } .onValueChange(of: dictation.transcript) { spoken in // Always join against the text frozen at capture start. A newer @@ -610,6 +305,385 @@ struct ChatView: View { } } + // MARK: - Transcript + + /// The transcript: its rows, the live tail, the header and face that + /// float over it, and the scrolling that keeps the newest message in view. + private func transcriptScrollView( + proxy: ScrollViewProxy, transcript: [TranscriptRow], versions: [String?: [Message]] + ) -> some View { + ScrollView { + transcriptColumn(proxy: proxy, transcript: transcript, versions: versions) + } + // The header lives in the scroll view's top safe area: the + // transcript starts below it and scrolls under it — that is + // what the glass is for. An inset rather than a content + // margin, because `.defaultScrollAnchor(.bottom)` anchored + // unreliably against a margin and opened chats mid-way. + // The blur is only the top strip — back, computer — the way + // a system bar is; the transcript starts on that line and + // scrolls under the face and name, which float over it. + .safeAreaInset(edge: .top, spacing: 0) { headerBar } + .overlay(alignment: .top) { headerFace } + .overlay(alignment: .top) { floatingFace } + // Reading scrollback — one tap back to the end, streaming or + // not, the same pill the desktop chat offers. + .onGeometryChange(for: CGFloat.self) { $0.frame(in: .global).maxY } action: { bottom in + viewportBottom = bottom + } + .overlay(alignment: .bottom) { jumpToLatestPill(proxy) } + .task { await playIslandIntro() } + // A conversation grows from the bottom: a transcript shorter + // than the screen rests at the bottom, and opening a chat + // starts on the newest message rather than the oldest. + .scrollAnchorCompat(.bottom) + // Tapping the transcript puts the keyboard away. The composer + // is a sibling of this scroll view rather than inside it, so + // nothing else here drops its focus — until this, the only way + // back to the whole conversation was to leave the chat. + // Simultaneous, not `.onTapGesture`: a tap that lands on a + // link, a card button or a selected word still reaches the row + // that owns it, and only also closes the keyboard. + .simultaneousGesture(TapGesture().onEnded { + if composerFocused { composerFocused = false } + }) + // And a drag down over the transcript pushes it away, the way + // it does in Mail and Messages. + .scrollDismissesKeyboard(.interactively) + // `initial: true` is what opens the chat on the newest + // message where `scrollAnchorCompat` cannot (iOS 16). On 17 the + // anchor has already put us there and this is a no-op. + .onValueChange(of: transcript.last?.id, initial: true) { _ in + guard let last = transcript.last else { return } + withAnimation { proxy.scrollTo(last.id, anchor: .bottom) } + } + // Neither of the above is enough on its own when the newest + // message holds a table or a code block. Their horizontal + // scroll views throw off the height the anchor measures on + // the first pass, so the chat opened a table's height short + // of the end; and the `initial` scroll above runs before + // there is anything to scroll. One more scroll once the first + // layout has settled lands on the end — again when the page + // arrives from the computer, which can be after the push, + // unless the reader has already scrolled away to read. + .task(id: "\(threadId)|\(session.state.hasLoadedPage(forThread: threadId))") { + await settleOnEnd(proxy) + } + .onUserScrollCompat { readerScrolled = true } + // Follow the text as it arrives. Keyed on length rather than + // the string so this fires once per delta batch, and without + // animation — animating every token turns a smooth stream + // into a stutter, because each scroll interrupts the last. + .onValueChange(of: session.state.streaming[threadId]?.count ?? 0) { length in + guard length > 0 else { return } + proxy.scrollTo(Self.liveBubbleId, anchor: .bottom) + } + .task(id: session.focusedMessageId) { revealFocusedMessage(proxy, in: transcript) } + } + + /// The rows, then whatever is live, then the end marker. + private func transcriptColumn( + proxy: ScrollViewProxy, transcript: [TranscriptRow], versions: [String?: [Message]] + ) -> some View { + // VStack, not LazyVStack. A lazy stack does not know how + // tall it is until its rows have been built, so + // `.defaultScrollAnchor(.bottom)` anchors against an + // estimate and the chat opens somewhere in the middle of + // the conversation. Building all of it up front makes the + // height exact and the anchor land on the newest message. + // A thread holds 50 messages until you ask for more, so + // there is nothing here worth being lazy about. + VStack(alignment: .leading, spacing: 6) { + // room for the floating face when scrolled to the top + Color.clear.frame(height: 72) + + if session.state.hasMore[threadId] == true { + loadEarlierButton(proxy: proxy, transcript: transcript) + } + + ForEach(Array(transcript.enumerated()), id: \.element.id) { index, row in + transcriptRow(row, at: index, in: transcript, versions: versions, proxy: proxy) + } + + liveTail + transcriptEnd + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .frame(maxWidth: CompanionLayout.chatWidth, alignment: .leading) + .frame(maxWidth: .infinity) + } + + private func loadEarlierButton(proxy: ScrollViewProxy, transcript: [TranscriptRow]) -> some View { + Button("Load earlier messages") { + // keep the reader where they were: after older + // messages are prepended, sit back on the one + // that used to be at the top + let anchor = transcript.first?.id + Task { + await session.loadOlder(threadId: threadId) + if let anchor { proxy.scrollTo(anchor, anchor: .top) } + } + } + .font(.footnote) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } + + private func transcriptRow( + _ row: TranscriptRow, at index: Int, in transcript: [TranscriptRow], + versions: [String?: [Message]], proxy: ScrollViewProxy + ) -> some View { + VStack(alignment: .leading, spacing: 6) { + // a gap in time is worth marking; a timestamp + // on every message is just noise + if startsANewStretch(at: index, in: transcript) { + Text(RelativeStamp.separator(row.head.date)) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(Color.secondary.opacity(0.7)) + .frame(maxWidth: .infinity) + .padding(.top, 10) + .padding(.bottom, 4) + } + switch row { + case let .message(message): + MessageRow( + chat: current, + message: message, + versions: message.role == .user && message.kind == .text + ? versions[message.parentId] ?? [] : [], + endsRun: endsRun(at: index, in: transcript), + openLink: openLink, + openThread: openThread + ) + case let .activityRun(items): + ActivityRunChip(items: items, openThread: openThread) + case let .assistantTurn(turn): + AssistantTurnChip( + turn: turn, chat: current, openLink: openLink, openThread: openThread, + revealedMessageId: revealedMessageId, + scrollToMessage: { proxy.scrollTo($0, anchor: .center) } + ) + } + } + .id(row.id) + } + + /// The reply as it is typed. It sits after the last settled message and + /// disappears the moment the real one arrives — the store clears it on + /// the same frame that appends the message, so there is never a beat + /// where both are on screen. + /// At Hidden the words go to the status line above the composer; the + /// transcript keeps the typing dots. + @ViewBuilder private var liveTail: some View { + if current.busy, detail != .hidden, let live = session.state.streaming[threadId], !live.isEmpty { + StreamingBubble(text: live, reasoning: nil, color: current.color) + .id(Self.liveBubbleId) + } else if current.busy, activityDetail != ActivityDetail.hidden.rawValue, + let thinking = session.state.reasoning[threadId], !thinking.isEmpty { + // Only while there is no answer yet. Once tokens + // of the reply exist, the reasoning is behind us + // and showing both is just noise. + StreamingBubble(text: nil, reasoning: thinking, color: current.color) + .id(Self.liveBubbleId) + } else if current.busy { + TypingIndicatorView(tintColor: MausPalette.color(current.color)) + .id(Self.liveBubbleId) + .accessibilityLabel("\(current.name) is working") + } + } + + private var transcriptEnd: some View { + Color.clear + .frame(height: 1) + .id(Self.transcriptEndId) + // Only a change of answer touches state: this + // fires on every scrolled frame. + .onGeometryChange(for: CGFloat.self) { $0.frame(in: .global).maxY } action: { end in + noteTranscriptEnd(end) + } + .accessibilityHidden(true) + } + + private func noteTranscriptEnd(_ end: CGFloat) { + let reading = end - viewportBottom > Self.jumpToLatestThreshold + if reading != showsJumpToLatest { + withAnimation(reduceMotion ? nil : .easeOut(duration: 0.18)) { showsJumpToLatest = reading } + } + } + + /// One face, in one layer, measured from the screen's top + /// edge: it sits in the island while that is open and + /// glides into its header slot when the island lets go. + private var floatingFace: some View { + let topInset = IslandGeometry.topInset + let islandSide: CGFloat = 220 + // centred in the part of the square the hardware island does not cover + let islandFaceCentre = IslandGeometry.top + IslandGeometry.size.height + (islandSide - IslandGeometry.size.height) / 2 + let headerFaceCentre = topInset + 26 + let faceSize = 60 + 72 * facePhase + let faceCentre = headerFaceCentre + (islandFaceCentre - headerFaceCentre) * facePhase + return ZStack(alignment: .top) { + if islandVisible { + IslandShell(expanded: islandExpanded, expandedSize: CGSize(width: islandSide, height: islandSide)) { + Color.clear + } + } + ChatAvatarView(chat: current, size: faceSize, state: MausState.forChat(current, in: session.state), animated: MausState.forChat(current, in: session.state).showsActivity || islandExpanded, comets: islandExpanded) + .offset(y: faceCentre - faceSize / 2) + .allowsHitTesting(false) + } + .frame(maxWidth: .infinity, alignment: .top) + .ignoresSafeArea(edges: .top) + .allowsHitTesting(false) + } + + @ViewBuilder private func jumpToLatestPill(_ proxy: ScrollViewProxy) -> some View { + if showsJumpToLatest { + Button { + withAnimation { proxy.scrollTo(Self.transcriptEndId, anchor: .bottom) } + } label: { + Label("Jump to latest", systemImage: "arrow.down") + .font(.footnote.weight(.medium)) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(.regularMaterial, in: Capsule()) + .overlay(Capsule().strokeBorder(Color.primary.opacity(0.08))) + .shadow(color: .black.opacity(0.12), radius: 8, y: 2) + } + .buttonStyle(.plain) + .accessibilityLabel("Jump to latest messages") + .padding(.bottom, 10) + .transition(.opacity.combined(with: .scale(scale: 0.9))) + } + } + + /// grow, hold a beat, shrink — the face rides along + private func playIslandIntro() async { + guard CompanionLayout.supportsIslandPresentation, !reduceMotion else { return } + // The intro is a greeting, and a greeting repeated every + // time you open a chat stops being one. + let intro = IslandIntro(rawValue: islandIntro) ?? .oncePerBot + switch intro { + case .never: + return + case .oncePerBot: + guard !IslandSeen.contains(islandIntroID, in: islandSeen) else { return } + islandSeen = IslandSeen.adding(islandIntroID, to: islandSeen) + case .always: + break + } + islandVisible = true + try? await Task.sleep(for: .milliseconds(40)) + withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { islandExpanded = true; facePhase = 1 } + try? await Task.sleep(for: .milliseconds(1000)) + withAnimation(.spring(response: 0.55, dampingFraction: 0.82)) { islandExpanded = false; facePhase = 0 } + try? await Task.sleep(for: .milliseconds(600)) + islandVisible = false + } + + /// One more scroll to the end once the first layout has settled, unless + /// the reader has already scrolled away. + private func settleOnEnd(_ proxy: ScrollViewProxy) async { + try? await Task.sleep(for: .milliseconds(50)) + guard !Task.isCancelled, !readerScrolled, let last = rows.last else { return } + proxy.scrollTo(last.id, anchor: .bottom) + } + + private func revealFocusedMessage(_ proxy: ScrollViewProxy, in transcript: [TranscriptRow]) { + guard let messageId = session.focusedMessageId, + messages.contains(where: { $0.id == messageId }) + else { return } + revealedMessageId = messageId + // Materialize the lazy folded row first. Its target bubble + // scrolls itself into view once expansion has laid it out. + let folded = transcript.first { row in + if case let .assistantTurn(turn) = row { + return turn.messages.contains { $0.id == messageId } + } + return false + } + proxy.scrollTo(folded?.id ?? messageId, anchor: .center) + session.consumeFocus(messageId) + } + + // MARK: - Thread lifecycle + + /// Opening a chat: remember it, load it, and mark it read. + private func enterThread() async { + if selectedThreadWasRemoved { dismiss(); return } + let openedChat = current + session.threadSelection.rememberThread(openedChat, connectionID: session.connection?.id) + await session.loadThreadIfNeeded(openedChat.threadId) + // opening a chat is what marks it read, exactly as on the desktop + if openedChat.unread { await session.markRead(openedChat) } +#if DEBUG + // `-open-plus`: the + sheet up, for the screenshot harness + if ProcessInfo.processInfo.arguments.contains("-open-plus") { showingPlus = true } + // Profile parity screenshots without automating a tap through the + // animated island/header transition. + if ProcessInfo.processInfo.arguments.contains("-open-profile") { showingProfile = true } +#endif + } + + private func reloadIfPageDropped(_ loaded: Bool) { + let requestedThread = threadId + if !loaded { Task { await session.loadThreadIfNeeded(requestedThread) } } + } + + /// A message can arrive while this chat is already on screen. The + /// opening task will not run again, so clear that new unread bit here + /// rather than leaving a badge on an open conversation. + private func markReadIfNeeded(_ unread: Bool) { + let readChat = current + if unread { Task { await session.markRead(readChat) } } + } + + /// The local task picker changed threads: park this one's draft, bring + /// back the next one's, and drop what belonged to the old thread. + private func switchThread(from previous: String, to next: String) { + dictation.stop() + threadDrafts[previous] = ComposerSnapshot(text: draft, attachments: attachments, error: attachmentError) + let restored = threadDrafts.removeValue(forKey: next) ?? ComposerSnapshot() + draft = restored.text + attachments = restored.attachments + attachmentError = restored.error + selectedPhotos = [] + showCommandHUD = false + showingPlus = false + readerScrolled = false + // A download started in the previous task must not open a sheet (or + // surface its error) in the new one when the network reply arrives late. + resetFilePreview() + cancelThreadOpen() + } + + private func expireSteering() async { + guard steering else { return } + try? await Task.sleep(for: .seconds(20)) + if !Task.isCancelled { steering = false } + } + + private func stopDictation(ifInterruptedBy note: Notification) { + let raw = note.userInfo?[AVAudioSessionInterruptionTypeKey] + let value = (raw as? NSNumber)?.uintValue ?? (raw as? UInt) + if value == AVAudioSession.InterruptionType.began.rawValue { + dictation.stop() + } + } + + @ViewBuilder private var busyFleetFixtureBadge: some View { +#if DEBUG + if ProcessInfo.processInfo.arguments.contains("-busy-fleet-preview") { + Text("Offline busy-fleet fixture") + .font(.caption2) + .allowsHitTesting(false) + .accessibilityIdentifier("busy-fleet-progress") + .accessibilityValue(session.state.cursor ?? "0") + } +#endif + } + // MARK: - Live call /// Dictation lets go of the microphone first: the call takes it. @@ -2163,22 +2237,7 @@ struct CredentialRequestCardView: View { var body: some View { VStack(alignment: .leading, spacing: 12) { - HStack(alignment: .top, spacing: 11) { - Image(systemName: "key.fill") - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(tint) - .frame(width: 38, height: 38) - .background(tint.opacity(0.13), in: RoundedRectangle(cornerRadius: 11, style: .continuous)) - - VStack(alignment: .leading, spacing: 3) { - Text(label) - .font(.system(size: 16, weight: .semibold)) - Text("Requested by \(requester)") - .font(.system(size: 12.5)) - .foregroundStyle(Color.secondary) - } - Spacer(minLength: 0) - } + heading if let description = visible(secret.description) { Text(description) @@ -2187,129 +2246,7 @@ struct CredentialRequestCardView: View { .fixedSize(horizontal: false, vertical: true) } - if secret.provided == true { - VStack(alignment: .leading, spacing: 8) { - Label( - secret.resumed == true ? "Saved securely. The task resumed." : "Saved securely on your computer.", - systemImage: "checkmark.shield.fill" - ) - .foregroundStyle(.green) - - if secret.resumed != true, let preparedSubmission { - Button(action: { send(preparedSubmission) }) { - HStack(spacing: 7) { - if submitting { ProgressView() } - Image(systemName: "arrow.clockwise") - Text(submitting ? "Resuming…" : "Try resuming the task") - } - .font(.system(size: 13, weight: .semibold)) - } - .disabled(submitting || !hasProtectedTransport) - } - } - } else if secret.dismissed == true { - Label("Not provided", systemImage: "xmark.circle") - .foregroundStyle(Color.secondary) - } else if submitted { - Label("Encrypted and saved on your computer", systemImage: "checkmark.shield.fill") - .foregroundStyle(.green) - } else if canEnterOnPhone { - VStack(alignment: .leading, spacing: 5) { - Label("Enter securely on this phone", systemImage: "lock.shield.fill") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(tint) - - if preparedSubmission == nil { - SecureField(placeholder, text: $value) - .id(fieldID) - .textContentType(.password) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .privacySensitive() - .disabled(submitting) - .submitLabel(.done) - .onSubmit { submit() } - .padding(.horizontal, 12) - .frame(minHeight: 44) - .background( - Color.secondary.opacity(0.1), - in: RoundedRectangle(cornerRadius: 11, style: .continuous) - ) - .accessibilityLabel(label) - } else { - Label( - submitting ? "Encrypted and saving…" : "Encrypted and ready to retry", - systemImage: "lock.fill" - ) - .font(.system(size: 13)) - .foregroundStyle(Color.secondary) - .frame(maxWidth: .infinity, minHeight: 44, alignment: .leading) - .padding(.horizontal, 12) - .background( - Color.secondary.opacity(0.1), - in: RoundedRectangle(cornerRadius: 11, style: .continuous) - ) - } - - Button(action: submit) { - HStack(spacing: 7) { - if submitting { ProgressView().tint(.white) } - Image(systemName: "lock.fill") - Text( - submitting - ? "Saving securely…" - : preparedSubmission == nil ? "Save securely" : "Try again securely" - ) - } - .font(.system(size: 14, weight: .semibold)) - .frame(maxWidth: .infinity, minHeight: 42) - } - .buttonStyle(.borderedProminent) - .tint(tint) - .disabled(!canSubmit) - - if preparedSubmission != nil, !submitting, submissionError != nil { - Button("Enter a different value") { - discardPreparedSubmission() - } - .font(.system(size: 13, weight: .medium)) - } - - Text("Use Apple Passwords, 1Password, Bitwarden, or paste. The value is encrypted for your computer and never added to chat.") - .font(.system(size: 13)) - .foregroundStyle(Color.secondary) - .fixedSize(horizontal: false, vertical: true) - } - .padding(11) - .frame(maxWidth: .infinity, alignment: .leading) - .background(tint.opacity(0.08), in: RoundedRectangle(cornerRadius: 13, style: .continuous)) - } else if !hasSecurePairing { - VStack(alignment: .leading, spacing: 5) { - Label("Pair again to enter here", systemImage: "qrcode") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(tint) - Text("This pairing predates secure phone entry. Scan a fresh QR from OpenMausBot, or finish this request on your computer.") - .font(.system(size: 13)) - .foregroundStyle(Color.secondary) - .fixedSize(horizontal: false, vertical: true) - } - .padding(11) - .frame(maxWidth: .infinity, alignment: .leading) - .background(tint.opacity(0.08), in: RoundedRectangle(cornerRadius: 13, style: .continuous)) - } else { - VStack(alignment: .leading, spacing: 5) { - Label("Secure connection required", systemImage: "lock.shield.fill") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(tint) - Text("Switch to Secure phone access (HTTPS) or Tailscale, then try again. You can still finish this request on your computer.") - .font(.system(size: 13)) - .foregroundStyle(Color.secondary) - .fixedSize(horizontal: false, vertical: true) - } - .padding(11) - .frame(maxWidth: .infinity, alignment: .leading) - .background(tint.opacity(0.08), in: RoundedRectangle(cornerRadius: 13, style: .continuous)) - } + outcome if let submissionError = visible(submissionError) { Label(submissionError, systemImage: "exclamationmark.triangle.fill") @@ -2345,21 +2282,9 @@ struct CredentialRequestCardView: View { .accessibilityElement(children: canEnterOnPhone ? .contain : .combine) .accessibilityLabel("\(label). \(accessibilityStatus)") .onAppear { - preparedSubmission = session.preparedCredential( - chat: chat, - message: message, - secret: secret - ) - } - .onValueChange(of: requestIdentity) { _ in - resetSensitiveState(clearPrepared: true) - preparedSubmission = session.preparedCredential( - chat: chat, - message: message, - secret: secret - ) - submitted = false + preparedSubmission = session.preparedCredential(chat: chat, message: message, secret: secret) } + .onValueChange(of: requestIdentity) { _ in resetForNewRequest() } .onValueChange(of: session.credentialEntryResetGeneration) { _ in suspendSensitiveEntry() } @@ -2382,6 +2307,189 @@ struct CredentialRequestCardView: View { } } + private var heading: some View { + HStack(alignment: .top, spacing: 11) { + Image(systemName: "key.fill") + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(tint) + .frame(width: 38, height: 38) + .background(tint.opacity(0.13), in: RoundedRectangle(cornerRadius: 11, style: .continuous)) + + VStack(alignment: .leading, spacing: 3) { + Text(label) + .font(.system(size: 16, weight: .semibold)) + Text("Requested by \(requester)") + .font(.system(size: 12.5)) + .foregroundStyle(Color.secondary) + } + Spacer(minLength: 0) + } + } + + /// Where the request stands: answered, declined, waiting on this phone, + /// or waiting on a connection this phone does not have. + @ViewBuilder private var outcome: some View { + if secret.provided == true { + providedStatus + } else if secret.dismissed == true { + Label("Not provided", systemImage: "xmark.circle") + .foregroundStyle(Color.secondary) + } else if submitted { + Label("Encrypted and saved on your computer", systemImage: "checkmark.shield.fill") + .foregroundStyle(.green) + } else if canEnterOnPhone { + phoneEntry + } else if !hasSecurePairing { + pairAgainNotice + } else { + secureConnectionNotice + } + } + + private var providedStatus: some View { + VStack(alignment: .leading, spacing: 8) { + Label( + secret.resumed == true ? "Saved securely. The task resumed." : "Saved securely on your computer.", + systemImage: "checkmark.shield.fill" + ) + .foregroundStyle(.green) + + if secret.resumed != true, let preparedSubmission { + Button(action: { send(preparedSubmission) }) { + HStack(spacing: 7) { + if submitting { ProgressView() } + Image(systemName: "arrow.clockwise") + Text(submitting ? "Resuming…" : "Try resuming the task") + } + .font(.system(size: 13, weight: .semibold)) + } + .disabled(submitting || !hasProtectedTransport) + } + } + } + + private var phoneEntry: some View { + VStack(alignment: .leading, spacing: 5) { + Label("Enter securely on this phone", systemImage: "lock.shield.fill") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(tint) + + if preparedSubmission == nil { + secureField + } else { + preparedNotice + } + + submitButton + + if preparedSubmission != nil, !submitting, submissionError != nil { + Button("Enter a different value") { + discardPreparedSubmission() + } + .font(.system(size: 13, weight: .medium)) + } + + Text("Use Apple Passwords, 1Password, Bitwarden, or paste. The value is encrypted for your computer and never added to chat.") + .font(.system(size: 13)) + .foregroundStyle(Color.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(11) + .frame(maxWidth: .infinity, alignment: .leading) + .background(tint.opacity(0.08), in: RoundedRectangle(cornerRadius: 13, style: .continuous)) + } + + private var secureField: some View { + SecureField(placeholder, text: $value) + .id(fieldID) + .textContentType(.password) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .privacySensitive() + .disabled(submitting) + .submitLabel(.done) + .onSubmit { submit() } + .padding(.horizontal, 12) + .frame(minHeight: 44) + .background( + Color.secondary.opacity(0.1), + in: RoundedRectangle(cornerRadius: 11, style: .continuous) + ) + .accessibilityLabel(label) + } + + private var preparedNotice: some View { + Label( + submitting ? "Encrypted and saving…" : "Encrypted and ready to retry", + systemImage: "lock.fill" + ) + .font(.system(size: 13)) + .foregroundStyle(Color.secondary) + .frame(maxWidth: .infinity, minHeight: 44, alignment: .leading) + .padding(.horizontal, 12) + .background( + Color.secondary.opacity(0.1), + in: RoundedRectangle(cornerRadius: 11, style: .continuous) + ) + } + + private var submitButton: some View { + Button(action: submit) { + HStack(spacing: 7) { + if submitting { ProgressView().tint(.white) } + Image(systemName: "lock.fill") + Text( + submitting + ? "Saving securely…" + : preparedSubmission == nil ? "Save securely" : "Try again securely" + ) + } + .font(.system(size: 14, weight: .semibold)) + .frame(maxWidth: .infinity, minHeight: 42) + } + .buttonStyle(.borderedProminent) + .tint(tint) + .disabled(!canSubmit) + } + + private var pairAgainNotice: some View { + VStack(alignment: .leading, spacing: 5) { + Label("Pair again to enter here", systemImage: "qrcode") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(tint) + Text("This pairing predates secure phone entry. Scan a fresh QR from OpenMausBot, or finish this request on your computer.") + .font(.system(size: 13)) + .foregroundStyle(Color.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(11) + .frame(maxWidth: .infinity, alignment: .leading) + .background(tint.opacity(0.08), in: RoundedRectangle(cornerRadius: 13, style: .continuous)) + } + + private var secureConnectionNotice: some View { + VStack(alignment: .leading, spacing: 5) { + Label("Secure connection required", systemImage: "lock.shield.fill") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(tint) + Text("Switch to Secure phone access (HTTPS) or Tailscale, then try again. You can still finish this request on your computer.") + .font(.system(size: 13)) + .foregroundStyle(Color.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(11) + .frame(maxWidth: .infinity, alignment: .leading) + .background(tint.opacity(0.08), in: RoundedRectangle(cornerRadius: 13, style: .continuous)) + } + + /// A different request landed in this card: start clean, with whatever + /// envelope the session already holds for it. + private func resetForNewRequest() { + resetSensitiveState(clearPrepared: true) + preparedSubmission = session.preparedCredential(chat: chat, message: message, secret: secret) + submitted = false + } + private func submit() { guard canSubmit else { return } submissionError = nil diff --git a/ios/App/NewSectionSheet.swift b/ios/App/NewSectionSheet.swift index 2e27be4bd7..685c86aac7 100644 --- a/ios/App/NewSectionSheet.swift +++ b/ios/App/NewSectionSheet.swift @@ -272,55 +272,7 @@ struct NewSectionSheet: View { warningFeedback += 1 } } label: { - VStack(spacing: 9) { - ZStack(alignment: .topTrailing) { - BotAvatarView(bot: bot, size: 52, animated: false) - if let order { - Text("\(order)") - .font(.system(size: 11, weight: .bold, design: .rounded)) - .foregroundStyle(Color.white) - .frame(minWidth: 21, minHeight: 21) - .background(tint, in: Circle()) - .overlay(Circle().stroke(Color(uiColor: .systemBackground), lineWidth: 2)) - .offset(x: 5, y: -5) - } - } - - VStack(spacing: 3) { - Text(bot.name) - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(Color.primary) - .lineLimit(1) - Text(botContext(bot)) - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(Color.secondary) - .lineLimit(1) - } - } - .frame(maxWidth: .infinity) - .frame(height: 116) - .background( - selected ? tint.opacity(0.13) : Color.secondary.opacity(0.07), - in: RoundedRectangle(cornerRadius: 20, style: .continuous) - ) - .overlay { - RoundedRectangle(cornerRadius: 20, style: .continuous) - .stroke( - candidate ? tint : (selected ? tint.opacity(0.75) : Color.secondary.opacity(0.12)), - lineWidth: candidate ? 3 : 1 - ) - } - .overlay(alignment: .bottom) { - if candidate { - Capsule() - .fill(tint) - .frame(height: 4) - .padding(.horizontal, 18) - .padding(.bottom, 7) - .transition(.opacity) - } - } - .contentShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + botTile(bot, selected: selected, candidate: candidate, order: order, tint: tint) } .buttonStyle(.plain) .accessibilityLabel("\(bot.name), \(botContext(bot))") @@ -331,6 +283,64 @@ struct NewSectionSheet: View { .animation(reduceMotion ? nil : .easeInOut(duration: 0.12), value: candidate) } + /// The tile: face with its order badge, name and context, the selection + /// ring, and the underline on the tile the trail is about to add. + private func botTile(_ bot: Bot, selected: Bool, candidate: Bool, order: Int?, tint: Color) -> some View { + VStack(spacing: 9) { + ZStack(alignment: .topTrailing) { + BotAvatarView(bot: bot, size: 52, animated: false) + if let order { + orderBadge(order, tint: tint) + } + } + + VStack(spacing: 3) { + Text(bot.name) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Color.primary) + .lineLimit(1) + Text(botContext(bot)) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(Color.secondary) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity) + .frame(height: 116) + .background( + selected ? tint.opacity(0.13) : Color.secondary.opacity(0.07), + in: RoundedRectangle(cornerRadius: 20, style: .continuous) + ) + .overlay { + RoundedRectangle(cornerRadius: 20, style: .continuous) + .stroke( + candidate ? tint : (selected ? tint.opacity(0.75) : Color.secondary.opacity(0.12)), + lineWidth: candidate ? 3 : 1 + ) + } + .overlay(alignment: .bottom) { + if candidate { + Capsule() + .fill(tint) + .frame(height: 4) + .padding(.horizontal, 18) + .padding(.bottom, 7) + .transition(.opacity) + } + } + .contentShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + } + + private func orderBadge(_ order: Int, tint: Color) -> some View { + Text("\(order)") + .font(.system(size: 11, weight: .bold, design: .rounded)) + .foregroundStyle(Color.white) + .frame(minWidth: 21, minHeight: 21) + .background(tint, in: Circle()) + .overlay(Circle().stroke(Color(uiColor: .systemBackground), lineWidth: 2)) + .offset(x: 5, y: -5) + } + private var trail: some View { Canvas { context, _ in guard selection.isDragging else { return } diff --git a/ios/Widgets/UpdatesSnapshotProvider.swift b/ios/Widgets/UpdatesSnapshotProvider.swift index b4f8cff25b..00be2fc7eb 100644 --- a/ios/Widgets/UpdatesSnapshotProvider.swift +++ b/ios/Widgets/UpdatesSnapshotProvider.swift @@ -146,29 +146,34 @@ enum WidgetChatLink { ) } -/// The background every home-screen widget in this extension draws. iOS -/// 17 requires a widget to opt in to its container background or render -/// with none at all; below 17 the system draws it for us. -extension View { - @ViewBuilder - func widgetContainerBackground() -> some View { +/// iOS 17 requires a widget to opt in to its container background or render +/// with none at all; below 17 the system draws it for us. A ViewModifier, not +/// a `@ViewBuilder` extension, for the reason given in +/// App/BackDeployCompat.swift: the builder form doubles the view type at +/// every call, and scripts/check-ios-view-shims.sh fails CI on that shape. +private struct WidgetContainerBackground: ViewModifier { + let color: Color + + func body(content: Content) -> some View { if #available(iOS 17.0, *) { - containerBackground(for: .widget) { Color(uiColor: .systemBackground) } + content.containerBackground(for: .widget) { color } } else { - self + content } } +} + +extension View { + /// The background every home-screen widget in this extension draws. + func widgetContainerBackground() -> some View { + modifier(WidgetContainerBackground(color: Color(uiColor: .systemBackground))) + } /// The background a lock-screen accessory draws: nothing of its own, so /// the system's accessory material shows through. Accessories opt in /// explicitly on iOS 17 for the same reason home-screen widgets do — /// below it the system already draws one. - @ViewBuilder func widgetAccessoryBackground() -> some View { - if #available(iOS 17.0, *) { - containerBackground(for: .widget) { Color.clear } - } else { - self - } + modifier(WidgetContainerBackground(color: .clear)) } } diff --git a/ios/project.yml b/ios/project.yml index 528c66813a..d7b6bbc906 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -15,6 +15,25 @@ options: iOS: "16.0" createIntermediateGroups: true +# Build settings every target inherits. +settings: + configs: + Debug: + # The type checker warns, at the line, when it spends more than half a + # second on one expression or function body. That is the common way a + # SwiftUI build turns from minutes into a CI timeout: a `body` that + # chains enough views, modifiers, colour ternaries and closures becomes + # one constraint problem the solver searches for seconds. (The other + # way, a view type IRGen cannot finish, is what the `ViewModifier` rule + # in App/BackDeployCompat.swift is for; this flag does not see it, so + # CI checks that rule at the source: scripts/check-ios-view-shims.sh.) + # CI reads these warnings out of the build log and fails on any + # (ci.yml, the step after "Build the simulator app"), so a new slow + # body is a file:line in a two-minute build, not a 30-minute timeout. + # Keep bodies small: a section or a row is its own property or + # function, and a colour chosen by a ternary is a typed `let`. + OTHER_SWIFT_FLAGS: "$(inherited) -Xfrontend -warn-long-expression-type-checking=500 -Xfrontend -warn-long-function-bodies=500" + packages: CompanionCore: path: . diff --git a/scripts/check-ios-view-shims.sh b/scripts/check-ios-view-shims.sh new file mode 100755 index 0000000000..eb9346b823 --- /dev/null +++ b/scripts/check-ios-view-shims.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Every shim that branches on `#available` is a ViewModifier, never a +# `@ViewBuilder` extension on View — the rule at the top of +# ios/App/BackDeployCompat.swift. The builder form returns a +# `_ConditionalContent` whose payloads each hold the whole view it was +# applied to, so a chain of N calls builds a view type 2^N–3^N times the +# size, and IRGen on the batch that holds both the shim and its caller runs +# for as long as the job cap allows (5 → 25+ minutes on CI, Oct 2026). The +# type checker's -warn-long-* flags in ios/project.yml do not see that, and +# a build killed at the cap prints nothing, so the shape is caught here, at +# the source, in a second: any `#available(` inside an `extension View { … }` +# block fails with its file:line. A ViewModifier branches over its +# placeholder Content and never trips this. +cd "$(dirname "$0")/.." + +hits=$(find ios -name '*.swift' -not -path '*/.build/*' -print0 | xargs -0 awk ' + FNR == 1 { inside = 0; depth = 0 } + { + line = $0 + gsub(/"([^"\\]|\\.)*"/, "\"\"", line) # string literals: a brace or // inside one is text + sub(/(^|[[:space:]])\/\/.*$/, "", line) # line comments; "https://" survives + if (!inside && line ~ /^[[:space:]]*((public|internal|fileprivate|private)[[:space:]]+)?extension[[:space:]]+View([[:space:]]|\{|$)/) inside = 1 + if (!inside) next + if (line ~ /#available\(/) { shown = $0; sub(/^[[:space:]]+/, "", shown); print FILENAME ":" FNR ": " shown } + opens = gsub(/\{/, "{", line); closes = gsub(/\}/, "}", line) + depth += opens - closes + if (opens + closes > 0 && depth <= 0) { inside = 0; depth = 0 } + } +') + +if [ -n "$hits" ]; then + printf '%s\n' "$hits" + echo "::error::An #available branch inside a View extension doubles the caller's view type at every call. Make it a ViewModifier that branches over its content, as in ios/App/BackDeployCompat.swift." + exit 1 +fi +echo "No #available branch inside a View extension."