From afc0cc4ebeac9301a2fd92b9fca34eab7c751af1 Mon Sep 17 00:00:00 2001 From: Will Sigmon Date: Wed, 19 Aug 2026 23:33:19 -0400 Subject: [PATCH 1/2] feat(ios): universal multiplatform navigation, split view, keyboard shortcuts and haptics --- ios/App/ChatListView.swift | 340 ++++++++++++++++++++++++++--------- ios/App/ChatView.swift | 80 +++++++-- ios/App/CompanionApp.swift | 195 ++++++++++++++++++-- ios/App/PairingScanner.swift | 12 ++ ios/App/PlatformBridge.swift | 139 ++++++++++++++ ios/README.md | 10 ++ ios/project.yml | 6 +- 7 files changed, 671 insertions(+), 111 deletions(-) create mode 100644 ios/App/PlatformBridge.swift diff --git a/ios/App/ChatListView.swift b/ios/App/ChatListView.swift index 7848e35b5c..0c2bc694fb 100644 --- a/ios/App/ChatListView.swift +++ b/ios/App/ChatListView.swift @@ -9,118 +9,275 @@ import CompanionCore struct ChatListView: View { @EnvironmentObject private var session: Session + @Binding var selectedChat: Chat? + var isSidebar: Bool = false + @State private var query = "" - /// Driven so that making a bot can open it. Value-based navigation alone - /// cannot push without a tap, and a new bot appearing silently at the - /// bottom of the roster is a poor answer to pressing +. + /// Driven so that making a bot can open it in single-column mode. @State private var path = NavigationPath() @State private var searchHits: [SearchHit] = [] @State private var searching = false + @FocusState private var searchFieldFocused: Bool + @State private var showingSettings = false + + init(selectedChat: Binding? = nil, isSidebar: Bool = false) { + self._selectedChat = selectedChat ?? .constant(nil) + self.isSidebar = isSidebar + } var body: some View { - NavigationStack(path: $path) { - // A hand-built header rather than the navigation bar. Two - // reasons: `.searchable` anchors its field to the *bottom* of the - // screen on iOS 26, which is not where a roster's search belongs, - // and an empty-titled nav bar reserves a surprising amount of - // room above the first row. Drawing it here makes the top of the - // list the top of the screen on every iOS. - VStack(spacing: 0) { - header - StatusBanner() - - ScrollView { - LazyVStack(spacing: 0) { - if query.isEmpty { - ForEach(session.state.pendingApprovals, id: \.message.id) { pending in - if let chat = chat(forThread: pending.threadId) { - NavigationLink(value: chat) { - WaitingRow(chat: chat, card: pending.message.card) + if isSidebar { + sidebarContent + .task(id: query) { await performSearch() } + .sheet(isPresented: $showingSettings) { + NavigationStack { SettingsView() } + } + } else { + NavigationStack(path: $path) { + stackContent + .task(id: query) { await performSearch() } + } + } + } + + // MARK: - Sidebar Layout (for iPadOS & Mac Catalyst NavigationSplitView) + private var sidebarContent: some View { + VStack(spacing: 0) { + header(isSidebar: true) + StatusBanner() + + ScrollView { + LazyVStack(spacing: 0) { + if query.isEmpty { + ForEach(session.state.pendingApprovals, id: \.message.id) { pending in + if let chat = chat(forThread: pending.threadId) { + Button { + selectedChat = chat + Haptics.selection() + } label: { + WaitingRow( + chat: chat, + card: pending.message.card, + isSelected: selectedChat?.id == chat.id + ) + } + .buttonStyle(.plain) + } + } + } + + if !query.isEmpty, !searchHits.isEmpty { + HStack { + Text("Messages") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Color.secondary) + Spacer() + if searching { ProgressView().controlSize(.small) } + } + .padding(.top, 10) + .padding(.bottom, 4) + + ForEach(searchHits) { hit in + Button { + Task { + if let chat = await session.open(hit) { + selectedChat = chat + Haptics.selection() } - .buttonStyle(.plain) } + } label: { + SearchHitRow(hit: hit) } + .buttonStyle(.plain) } + } - if !query.isEmpty, !searchHits.isEmpty { - HStack { - Text("Messages") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(Color.secondary) - Spacer() - if searching { ProgressView().controlSize(.small) } + ForEach(chats) { summary in + Button { + selectedChat = summary.chat + Haptics.selection() + } label: { + ChatRow( + chat: summary.chat, + preview: summary.preview, + at: summary.lastActivity, + isSelected: selectedChat?.id == summary.chat.id + ) + } + .buttonStyle(.plain) + .contextMenu { + Button("Select", systemImage: "bubble.left.and.bubble.right") { + selectedChat = summary.chat } - .padding(.top, 10) - .padding(.bottom, 4) + Button("Copy Name", systemImage: "doc.on.doc") { + PlatformBridge.copyToPasteboard(summary.chat.name) + } + } + } + } + .padding(.horizontal, 16) + .padding(.bottom, 24) + } + .refreshable { await session.refresh() } + .overlay { + if chats.isEmpty && searchHits.isEmpty { + ContentUnavailableView( + query.isEmpty ? "No bots yet" : "Nothing matches", + systemImage: query.isEmpty ? "bubble.left.and.bubble.right" : "magnifyingglass", + description: Text( + query.isEmpty + ? "Bots you create on your computer show up here." + : "No chat matches \u{201C}\(query)\u{201D}." + ) + ) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background { + Group { + Button("") { searchFieldFocused = true } + .keyboardShortcut("k", modifiers: .command) + Button("") { searchFieldFocused = true } + .keyboardShortcut("f", modifiers: .command) + Button("") { showingSettings = true } + .keyboardShortcut(",", modifiers: .command) + Button("") { + Task { + if let bot = await session.createBot() { + selectedChat = Chat.bot(bot) + Haptics.impact(.medium) + } + } + } + .keyboardShortcut("n", modifiers: .command) + Button("") { + Task { await session.refresh() } + } + .keyboardShortcut("r", modifiers: .command) - ForEach(searchHits) { hit in - Button { - Task { - if let chat = await session.open(hit) { path.append(chat) } - } - } label: { - SearchHitRow(hit: hit) + ForEach(0..= 2 else { - searchHits = [] - searching = false - return + .refreshable { await session.refresh() } + .overlay { + if chats.isEmpty && searchHits.isEmpty { + ContentUnavailableView( + query.isEmpty ? "No bots yet" : "Nothing matches", + systemImage: query.isEmpty ? "bubble.left.and.bubble.right" : "magnifyingglass", + description: Text( + query.isEmpty + ? "Bots you create on your computer show up here." + : "No chat matches \u{201C}\(query)\u{201D}." + ) + ) } - searching = true - try? await Task.sleep(for: .milliseconds(250)) - guard !Task.isCancelled, query == expected else { return } - searchHits = await session.search(expected) - searching = false } } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .toolbar(.hidden, for: .navigationBar) + .navigationDestination(for: Chat.self) { ChatView(chat: $0) } + } + + /// Search debounce and execution helper + private func performSearch() async { + let expected = query + guard expected.trimmingCharacters(in: .whitespacesAndNewlines).count >= 2 else { + searchHits = [] + searching = false + return + } + searching = true + try? await Task.sleep(for: .milliseconds(250)) + guard !Task.isCancelled, query == expected else { return } + searchHits = await session.search(expected) + searching = false } /// Who you are, and how to find a chat β€” both at the top, always. - private var header: some View { + private func header(isSidebar: Bool) -> some View { HStack(spacing: 12) { - NavigationLink { SettingsView() } label: { - ProfileAvatar(name: session.connection?.name ?? "You") + if isSidebar { + Button { + showingSettings = true + } label: { + ProfileAvatar(name: session.connection?.name ?? "You") + } + .buttonStyle(.plain) + } else { + NavigationLink { SettingsView() } label: { + ProfileAvatar(name: session.connection?.name ?? "You") + } + .buttonStyle(.plain) } - .buttonStyle(.plain) HStack(spacing: 8) { Image(systemName: "magnifyingglass") @@ -131,6 +288,7 @@ struct ChatListView: View { .font(.system(size: 16)) .submitLabel(.search) .autocorrectionDisabled() + .focused($searchFieldFocused) if !query.isEmpty { Button { @@ -149,7 +307,14 @@ struct ChatListView: View { // Same place the desktop puts it, top-right of the roster. Button { Task { - if let bot = await session.createBot() { path.append(Chat.bot(bot)) } + if let bot = await session.createBot() { + if isSidebar { + selectedChat = Chat.bot(bot) + } else { + path.append(Chat.bot(bot)) + } + Haptics.impact(.medium) + } } } label: { Image(systemName: "plus") @@ -219,6 +384,7 @@ struct ChatRow: View { let chat: Chat let preview: String let at: Double + var isSelected: Bool = false var body: some View { HStack(alignment: .top, spacing: 14) { @@ -270,7 +436,12 @@ struct ChatRow: View { } } } - .padding(.vertical, 14) + .padding(.horizontal, 12) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(isSelected ? Color.accentColor.opacity(0.15) : Color.clear) + ) .contentShape(Rectangle()) } } @@ -280,6 +451,7 @@ struct ChatRow: View { struct WaitingRow: View { let chat: Chat let card: OptionCard? + var isSelected: Bool = false var body: some View { HStack(spacing: 12) { @@ -301,8 +473,14 @@ struct WaitingRow: View { .padding(14) .background( RoundedRectangle(cornerRadius: 18, style: .continuous) - .fill(Color.accentColor.opacity(0.14)) + .fill(isSelected ? Color.accentColor.opacity(0.25) : Color.accentColor.opacity(0.14)) ) + .overlay { + if isSelected { + RoundedRectangle(cornerRadius: 18, style: .continuous) + .strokeBorder(Color.accentColor, lineWidth: 1.5) + } + } .padding(.vertical, 6) .contentShape(Rectangle()) } diff --git a/ios/App/ChatView.swift b/ios/App/ChatView.swift index 33e8a5b651..05aecce5f8 100644 --- a/ios/App/ChatView.swift +++ b/ios/App/ChatView.swift @@ -19,8 +19,12 @@ struct ChatView: View { let chat: Chat @EnvironmentObject private var session: Session @Environment(\.dismiss) private var dismiss + #if os(iOS) + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + #endif @State private var draft = "" @State private var showingTasks = false + @State private var showingComputerSheet = false @State private var shareFile: ShareFile? @FocusState private var composerFocused: Bool @@ -152,17 +156,23 @@ struct ChatView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) .navigationBarTitleDisplayMode(.inline) - .navigationBarBackButtonHidden(true) + #if os(iOS) + .navigationBarBackButtonHidden(horizontalSizeClass == .compact) + #endif .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button { dismiss() } label: { - Image(systemName: "chevron.left") - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(Color.primary) - .frame(width: 32, height: 32) - .background(Circle().fill(Color.secondary.opacity(0.16))) + #if os(iOS) + if horizontalSizeClass == .compact { + ToolbarItem(placement: .topBarLeading) { + Button { dismiss() } label: { + Image(systemName: "chevron.left") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Color.primary) + .frame(width: 32, height: 32) + .background(Circle().fill(Color.secondary.opacity(0.16))) + } } } + #endif ToolbarItem(placement: .principal) { HStack(spacing: 8) { MausAvatar(color: current.color, size: 26) @@ -180,8 +190,8 @@ struct ChatView: View { // speaking owns one, and picking for the reader would be a // guess. Bots only. ToolbarItem(placement: .topBarTrailing) { - NavigationLink { - ComputerView(bot: bot) + Button { + showingComputerSheet = true } label: { Image(systemName: "display") .font(.system(size: 15, weight: .medium)) @@ -221,6 +231,23 @@ struct ChatView: View { } } } + .background { + Group { + if case let .bot(bot) = current { + Button("") { + if bot.busy != true { showingTasks = true } + } + .keyboardShortcut("t", modifiers: [.command, .shift]) + + Button("") { + showingComputerSheet = true + } + .keyboardShortcut("c", modifiers: [.command, .shift]) + } + } + .opacity(0) + .allowsHitTesting(false) + } .task { // opening a chat is what marks it read, exactly as on the desktop if current.unread { await session.markRead(current) } @@ -234,6 +261,18 @@ struct ChatView: View { .sheet(isPresented: $showingTasks) { if case let .bot(bot) = current { TaskManagerView(bot: bot) } } + .sheet(isPresented: $showingComputerSheet) { + if case let .bot(bot) = current { + NavigationStack { + ComputerView(bot: bot) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { showingComputerSheet = false } + } + } + } + } + } .sheet(item: $shareFile) { file in ActivityShareSheet(items: [file.url]) } @@ -254,6 +293,8 @@ struct ChatView: View { let text = draft.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty else { return } draft = "" + SoundEffects.playSent() + Haptics.impact(.medium) Task { await session.send(text, to: current) } } @@ -303,17 +344,17 @@ struct MessageRow: View { let chat: Chat let message: Message @EnvironmentObject private var session: Session - @State private var editingText = "" @State private var showingEdit = false + @State private var editingText = "" - private static let reactionChoices = ["πŸ‘", "❀️", "πŸ˜‚", "πŸŽ‰", "πŸ‘€"] + private static let reactionChoices = ["πŸ‘", "❀️", "πŸ”₯", "πŸŽ‰", "πŸ‘€"] private var versions: [Message] { session.state.versions(of: message, inThread: chat.threadId) } var body: some View { - VStack(alignment: message.role == .user ? .trailing : .leading, spacing: 6) { + VStack(alignment: .leading, spacing: 6) { content if let comm = message.comm { @@ -354,6 +395,11 @@ struct MessageRow: View { } } .contextMenu { + if let text = message.text, !text.isEmpty { + Button("Copy Text", systemImage: "doc.on.doc") { + PlatformBridge.copyToPasteboard(text) + } + } ForEach(Self.reactionChoices, id: \.self) { emoji in Button(emoji) { Task { await session.react(to: message, in: chat.threadId, emoji: emoji) } } } @@ -488,15 +534,13 @@ struct ActivityChip: View { /// An option card. When it still has a request behind it, this is the /// screen the companion exists for β€” a bot stopped, and only a person can -/// let it continue. +/// answer. struct CardView: View { let chat: Chat let message: Message @EnvironmentObject private var session: Session @State private var answering = false - /// The option this card offers that means "go ahead". - /// /// Deliberately not the literal string "Allow". `options` is whatever the /// harness sent, and it only falls back to ["Allow", "Deny"] when the /// provider event named no choices of its own (`server/index.ts`) β€” a card @@ -540,6 +584,8 @@ struct CardView: View { ForEach(card.options, id: \.self) { option in Button(option) { answering = true + SoundEffects.playActionSuccess() + Haptics.success() Task { await session.answer(threadId: chat.threadId, card: card, choice: option) answering = false @@ -559,6 +605,8 @@ struct CardView: View { if card.allowKey != nil, let allow = allowChoice, case let .bot(bot) = chat { Button("Always allow this tool") { answering = true + SoundEffects.playCelebration() + Haptics.success() Task { await session.alwaysAllow(bot: bot, card: card) await session.answer(threadId: chat.threadId, card: card, choice: allow) diff --git a/ios/App/CompanionApp.swift b/ios/App/CompanionApp.swift index 3a20b907b4..4cc1c2f8e2 100644 --- a/ios/App/CompanionApp.swift +++ b/ios/App/CompanionApp.swift @@ -5,34 +5,144 @@ // deliberately means the cursor is written down at a known point. Coming // back asks the harness what was missed rather than asking for everything. import SwiftUI +import CompanionCore @main struct CompanionApp: App { @StateObject private var session = Session() @Environment(\.scenePhase) private var scenePhase + @AppStorage("app_global_ui_zoom_scale") private var uiZoomScale: Double = 1.0 + @State private var showZoomHUD: Bool = false + @State private var zoomToastTimer: Timer? = nil + + private func zoomIn() { + let current = (uiZoomScale * 10).rounded() / 10 + let next = min(1.60, current + 0.10) + withAnimation(.spring(response: 0.20, dampingFraction: 0.85)) { + uiZoomScale = (next * 100).rounded() / 100 + } + triggerZoomToast() + Haptics.selection() + } + + private func zoomOut() { + let current = (uiZoomScale * 10).rounded() / 10 + let next = max(0.70, current - 0.10) + withAnimation(.spring(response: 0.20, dampingFraction: 0.85)) { + uiZoomScale = (next * 100).rounded() / 100 + } + triggerZoomToast() + Haptics.selection() + } + + private func resetZoom() { + withAnimation(.spring(response: 0.20, dampingFraction: 0.85)) { + uiZoomScale = 1.0 + } + triggerZoomToast() + Haptics.selection() + } + + private func triggerZoomToast() { + zoomToastTimer?.invalidate() + withAnimation(.spring(response: 0.20, dampingFraction: 0.8)) { + showZoomHUD = true + } + zoomToastTimer = Timer.scheduledTimer(withTimeInterval: 1.2, repeats: false) { _ in + withAnimation(.easeInOut(duration: 0.3)) { + showZoomHUD = false + } + } + } var body: some Scene { WindowGroup { - RootView() - .environmentObject(session) - .onAppear { session.connect() } - .onOpenURL { session.receivePairingURL($0) } - .onChange(of: scenePhase) { _, phase in - switch phase { - case .active: - session.connect() - Task { await session.refreshNotificationAuthorization() } - case .background: session.disconnect() - case .inactive: break - @unknown default: break + ZStack(alignment: .top) { + GeometryReader { geo in + let scale = max(0.5, CGFloat(uiZoomScale)) + RootView() + .environmentObject(session) + .frame( + width: geo.size.width / scale, + height: geo.size.height / scale + ) + .scaleEffect(scale, anchor: .topLeading) + } + + // Transient Zoom HUD Indicator + if showZoomHUD { + HStack(spacing: 6) { + Image(systemName: "magnifyingglass") + .font(.system(size: 11, weight: .bold)) + .foregroundColor(Color.accentColor) + Text("Zoom \(Int((uiZoomScale * 100).rounded()))%") + .font(.system(size: 12, weight: .bold, design: .monospaced)) + .foregroundColor(.white) } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.black.opacity(0.80)) + .background(.ultraThinMaterial) + .clipShape(Capsule()) + .overlay(Capsule().strokeBorder(Color.white.opacity(0.15), lineWidth: 0.6)) + .shadow(color: Color.black.opacity(0.35), radius: 8, y: 3) + .padding(.top, 14) + .transition(.move(edge: .top).combined(with: .opacity)) + .zIndex(999) } + } + .background { + Group { + Button("") { zoomIn() } + .keyboardShortcut("+", modifiers: .command) + Button("") { zoomIn() } + .keyboardShortcut("=", modifiers: .command) + Button("") { zoomOut() } + .keyboardShortcut("-", modifiers: .command) + Button("") { resetZoom() } + .keyboardShortcut("0", modifiers: .command) + } + .opacity(0) + .allowsHitTesting(false) + } + .onAppear { session.connect() } + .onOpenURL { session.receivePairingURL($0) } + .onChange(of: scenePhase) { _, phase in + switch phase { + case .active: + session.connect() + Task { await session.refreshNotificationAuthorization() } + case .background: session.disconnect() + case .inactive: break + @unknown default: break + } + } } + #if targetEnvironment(macCatalyst) || os(macOS) + .commands { + SidebarCommands() + TextEditingCommands() + CommandMenu("View") { + Button("Zoom In") { zoomIn() } + .keyboardShortcut("+", modifiers: .command) + Button("Zoom In (Keypad)") { zoomIn() } + .keyboardShortcut("=", modifiers: .command) + Button("Zoom Out") { zoomOut() } + .keyboardShortcut("-", modifiers: .command) + Divider() + Button("Actual Size (100%)") { resetZoom() } + .keyboardShortcut("0", modifiers: .command) + } + } + #endif } } struct RootView: View { @EnvironmentObject private var session: Session + #if os(iOS) + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + #endif var body: some View { Group { @@ -42,7 +152,15 @@ struct RootView: View { case .unauthorized: UnpairedView() default: - ChatListView() + #if os(iOS) + if horizontalSizeClass == .regular { + SplitCompanionView() + } else { + ChatListView() + } + #else + SplitCompanionView() + #endif } } .alert( @@ -60,6 +178,57 @@ struct RootView: View { } } +/// Fluid multi-column tablet & desktop workspace +struct SplitCompanionView: View { + @EnvironmentObject private var session: Session + @State private var selectedChat: Chat? = nil + @State private var columnVisibility: NavigationSplitViewVisibility = .all + + var body: some View { + NavigationSplitView(columnVisibility: $columnVisibility) { + ChatListView(selectedChat: $selectedChat, isSidebar: true) + .navigationTitle("OpenMausMobile") + } detail: { + if let chat = selectedChat { + ChatView(chat: chat) + .id(chat.threadId) + } else { + ContentUnavailableView( + "Select a Conversation", + systemImage: "bubble.left.and.bubble.right", + description: Text("Choose a bot or room from the sidebar to view transcript and approvals.") + ) + } + } + .navigationSplitViewStyle(.balanced) + .onAppear { + autoSelectFirstChat() + } + .onChange(of: session.state.pendingApprovals.count) { _, _ in + if selectedChat == nil { + autoSelectFirstChat() + } + } + } + + private func autoSelectFirstChat() { + guard selectedChat == nil else { return } + if let pending = session.state.pendingApprovals.first { + if let bot = session.state.bot(forThread: pending.threadId) { + selectedChat = .bot(bot) + return + } + if let room = session.state.room(forThread: pending.threadId) { + selectedChat = .room(room) + return + } + } + if let firstSummary = session.state.chatSummaries.first { + selectedChat = firstSummary.chat + } + } +} + /// The token stopped working. Almost always because someone revoked this /// phone on the computer β€” which is exactly what that button is for, so the /// honest thing is to say so and offer to pair again. diff --git a/ios/App/PairingScanner.swift b/ios/App/PairingScanner.swift index 2137c6a845..3725bc1000 100644 --- a/ios/App/PairingScanner.swift +++ b/ios/App/PairingScanner.swift @@ -8,7 +8,9 @@ import AVFoundation import SwiftUI import UIKit +#if canImport(VisionKit) && !targetEnvironment(macCatalyst) import VisionKit +#endif struct PairingScannerSheet: View { @Environment(\.dismiss) private var dismiss @@ -25,6 +27,13 @@ struct PairingScannerSheet: View { var body: some View { NavigationStack { Group { + #if targetEnvironment(macCatalyst) + ContentUnavailableView { + Label("Scanner unavailable", systemImage: "qrcode.viewfinder") + } description: { + Text("QR code scanning is available on iPhone and iPad. On Mac, choose your computer from the network list or enter the address manually.") + } + #else if !permissionResolved { ProgressView("Requesting camera access…") } else if !cameraAuthorized { @@ -69,6 +78,7 @@ struct PairingScannerSheet: View { .padding() } } + #endif } .navigationTitle("Scan QR Code") .navigationBarTitleDisplayMode(.inline) @@ -106,6 +116,7 @@ struct PairingScannerSheet: View { } } +#if canImport(VisionKit) && !targetEnvironment(macCatalyst) private struct PairingQRScanner: UIViewControllerRepresentable { let onPayload: (String) -> Bool @@ -171,3 +182,4 @@ private struct PairingQRScanner: UIViewControllerRepresentable { } } } +#endif diff --git a/ios/App/PlatformBridge.swift b/ios/App/PlatformBridge.swift new file mode 100644 index 0000000000..0df67828e6 --- /dev/null +++ b/ios/App/PlatformBridge.swift @@ -0,0 +1,139 @@ +import SwiftUI +import AudioToolbox + +#if canImport(UIKit) +import UIKit +public typealias PlatformColorType = UIColor +public typealias PlatformImage = UIImage +#elseif canImport(AppKit) +import AppKit +public typealias PlatformColorType = NSColor +public typealias PlatformImage = NSImage +#endif + +// MARK: - Sound Effects +public enum SoundEffects { + public static func playSent() { + #if os(iOS) + AudioServicesPlaySystemSound(1004) + #endif + } + + public static func playReceived() { + #if os(iOS) + AudioServicesPlaySystemSound(1003) + #endif + } + + public static func playTapback() { + #if os(iOS) + AudioServicesPlaySystemSound(1104) + #endif + } + + public static func playActionSuccess() { + #if os(iOS) + AudioServicesPlaySystemSound(1025) + #endif + } + + public static func playCelebration() { + #if os(iOS) + AudioServicesPlaySystemSound(1028) + #endif + } + + public static func playConnect() { + #if os(iOS) + AudioServicesPlaySystemSound(1109) + #endif + } +} + +// MARK: - Haptic Feedback +public enum Haptics { + public static func selection() { + #if os(iOS) + let generator = UISelectionFeedbackGenerator() + generator.prepare() + generator.selectionChanged() + #endif + } + + public static func impact(_ style: UIImpactFeedbackGenerator.FeedbackStyle = .medium) { + #if os(iOS) + let generator = UIImpactFeedbackGenerator(style: style) + generator.prepare() + generator.impactOccurred() + #endif + } + + public static func notification(_ type: UINotificationFeedbackGenerator.FeedbackType) { + #if os(iOS) + let generator = UINotificationFeedbackGenerator() + generator.prepare() + generator.notificationOccurred(type) + #endif + } + + public static func success() { + notification(.success) + } + + public static func warning() { + notification(.warning) + } + + public static func error() { + notification(.error) + } +} + +// MARK: - Platform Bridge +public enum PlatformBridge { + public static func copyToPasteboard(_ text: String) { + #if os(iOS) + UIPasteboard.general.string = text + #elseif os(macOS) + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(text, forType: .string) + #endif + Haptics.selection() + } +} + +// MARK: - Color Utilities & Semantic Palettes +public extension Color { + static var platformBackground: Color { + #if os(iOS) + return Color(uiColor: .systemBackground) + #elseif os(macOS) + return Color(nsColor: .windowBackgroundColor) + #endif + } + + static var platformSecondaryBackground: Color { + #if os(iOS) + return Color(uiColor: .secondarySystemBackground) + #elseif os(macOS) + return Color(nsColor: .controlBackgroundColor) + #endif + } + + static var platformTertiaryBackground: Color { + #if os(iOS) + return Color(uiColor: .tertiarySystemBackground) + #elseif os(macOS) + return Color(nsColor: .underPageBackgroundColor) + #endif + } + + static var platformSeparator: Color { + #if os(iOS) + return Color(uiColor: .separator) + #elseif os(macOS) + return Color(nsColor: .separatorColor) + #endif + } +} diff --git a/ios/README.md b/ios/README.md index ac35902ed9..cddbc2dba9 100644 --- a/ios/README.md +++ b/ios/README.md @@ -61,6 +61,7 @@ ios/ ChatView.swift transcript, approval cards, composer ComputerView.swift opt-in live view of a bot's computer MarkdownText.swift the supported Markdown presentation layer + PlatformBridge.swift pasteboard, haptics, sound effects, and color helpers SettingsView.swift status, and unpair ``` @@ -171,6 +172,15 @@ the host computer remain unreachable through the companion. drawn. Search covers the SQLite transcript store and opens the exact task, branch, and message; the roster's "+" creates the same basic bot the desktop endpoint creates, then opens it. +- **Universal Multiplatform (iOS, iPadOS, Mac Catalyst).** Uses `NavigationSplitView` + with responsive sidebar roster and detail transcript pane on iPad and Mac Catalyst, + while maintaining the streamlined single-column `NavigationStack` on iPhone. +- **Desktop & Hardware Keyboard Shortcuts.** Full keyboard navigation on Mac and + iPad Magic Keyboards: `Cmd+1`...`Cmd+9` (quick chat switching), `Cmd+K`/`Cmd+F` (search), + `Cmd+N` (new bot), `Cmd+,` (settings), `Cmd+Shift+T` (tasks), `Cmd+Shift+C` (live computer), + `Cmd+R` (refresh), `Cmd++`/`Cmd+-`/`Cmd+0` (UI zoom with floating HUD pill indicator). +- **Haptic and Audio Feedback.** Subtle tactile and sound cues for sent messages, + streaming token arrival, and answered approvals (`SoundEffects`, `Haptics`). ## Not in this version diff --git a/ios/project.yml b/ios/project.yml index 129bf89f1a..9a29727411 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -22,6 +22,7 @@ targets: OpenMausCompanion: type: application platform: iOS + supportedDestinations: [iOS, iPadOS, macOS] sources: - path: App dependencies: @@ -33,7 +34,10 @@ targets: MARKETING_VERSION: "1.0.0" CURRENT_PROJECT_VERSION: "1" SWIFT_VERSION: "5.9" - TARGETED_DEVICE_FAMILY: "1,2" + SUPPORTS_MACCATALYST: YES + TARGETED_DEVICE_FAMILY: "1,2,6" + SKIP_INSTALL: NO + INSTALL_PATH: "/Applications" # The catalog lives in App/, which is already a source path. Generated # by `node scripts/make-app-icon.mjs` from the same mascot the app # draws, so the icon cannot drift from the thing it depicts. From 347e578133e50533414384603d719545cc405008 Mon Sep 17 00:00:00 2001 From: Will Sigmon Date: Wed, 19 Aug 2026 23:51:14 -0400 Subject: [PATCH 2/2] feat(ios): rich card suite, collapsible reasoning chambers, and celebration particles --- ios/App/Cards/AgentThoughtChamberView.swift | 157 ++++++++++++ ios/App/Cards/GitPRDiffCardView.swift | 194 +++++++++++++++ ios/App/Cards/ParticleBursts.swift | 205 +++++++++++++++ ios/App/Cards/SQLResultTableView.swift | 135 ++++++++++ ios/App/Cards/SkillExecutionReceiptView.swift | 136 ++++++++++ ios/App/ChatView.swift | 234 ++++++++++-------- 6 files changed, 961 insertions(+), 100 deletions(-) create mode 100644 ios/App/Cards/AgentThoughtChamberView.swift create mode 100644 ios/App/Cards/GitPRDiffCardView.swift create mode 100644 ios/App/Cards/ParticleBursts.swift create mode 100644 ios/App/Cards/SQLResultTableView.swift create mode 100644 ios/App/Cards/SkillExecutionReceiptView.swift diff --git a/ios/App/Cards/AgentThoughtChamberView.swift b/ios/App/Cards/AgentThoughtChamberView.swift new file mode 100644 index 0000000000..77bab97fdb --- /dev/null +++ b/ios/App/Cards/AgentThoughtChamberView.swift @@ -0,0 +1,157 @@ +import SwiftUI +import CompanionCore + +public struct AgentThoughtChamberView: View { + public let reasoning: String + public let botName: String + public let mascotColor: Color + public let isStreaming: Bool + + @Environment(\.colorScheme) private var colorScheme + @State private var isExpanded: Bool = false + @State private var pulseAnimation: Bool = false + + public init( + reasoning: String, + botName: String = "Bot", + mascotColor: Color = .purple, + isStreaming: Bool = false + ) { + self.reasoning = reasoning + self.botName = botName + self.mascotColor = mascotColor + self.isStreaming = isStreaming + } + + private var steps: [String] { + reasoning.components(separatedBy: "\n").filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } + } + + public var body: some View { + let isDark = colorScheme == .dark + + VStack(alignment: .leading, spacing: 6) { + headerButton(isDark: isDark) + + if isExpanded { + expandedContent(isDark: isDark) + } + } + .padding(6) + .background( + LinearGradient( + colors: isDark ? [ + Color(hex: "#18181B").opacity(0.92), + Color(hex: "#0F172A").opacity(0.88) + ] : [ + Color.white.opacity(0.94), + Color(hex: "#F8FAFC").opacity(0.88) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .background(.ultraThinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(isDark ? mascotColor.opacity(0.3) : Color.black.opacity(0.08), lineWidth: 0.65) + ) + .shadow(color: Color.black.opacity(isDark ? 0.25 : 0.04), radius: 3, y: 1) + .onAppear { + if isStreaming { + withAnimation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true)) { + pulseAnimation = true + } + } + } + .onChange(of: isStreaming) { _, streaming in + if streaming { + withAnimation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true)) { + pulseAnimation = true + } + } else { + pulseAnimation = false + } + } + } + + @ViewBuilder + private func headerButton(isDark: Bool) -> some View { + Button { + withAnimation(.spring(response: 0.35, dampingFraction: 0.72)) { + isExpanded.toggle() + } + Haptics.selection() + } label: { + HStack(spacing: 6) { + Image(systemName: "brain.head.profile") + .font(.system(size: 11, weight: .bold)) + .foregroundColor(mascotColor) + .scaleEffect(pulseAnimation ? 1.15 : 1.0) + + Text(isStreaming ? "Thinking…" : "Thought Process") + .font(.caption2.weight(.bold)) + .foregroundColor(isDark ? Color(hex: "#F8FAFC") : Color(hex: "#334155")) + + if isStreaming { + Circle() + .fill(mascotColor) + .frame(width: 6, height: 6) + .opacity(pulseAnimation ? 1.0 : 0.3) + } + + Spacer() + + Text("\(steps.count) \(steps.count == 1 ? "step" : "steps")") + .font(.system(size: 9.5, weight: .medium, design: .monospaced)) + .foregroundColor(isDark ? Color(hex: "#94A3B8") : Color(hex: "#64748B")) + + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .font(.system(size: 9, weight: .bold)) + .foregroundColor(isDark ? Color(hex: "#94A3B8") : Color(hex: "#64748B")) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(isDark ? Color.white.opacity(0.08) : Color.black.opacity(0.04)) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } + + @ViewBuilder + private func expandedContent(isDark: Bool) -> some View { + VStack(alignment: .leading, spacing: 6) { + ScrollView { + VStack(alignment: .leading, spacing: 4) { + ForEach(Array(steps.enumerated()), id: \.offset) { idx, step in + stepRow(index: idx, step: step, isDark: isDark) + } + } + } + .frame(maxHeight: 160) + } + .padding(10) + .background(isDark ? Color.black.opacity(0.35) : Color.white.opacity(0.85)) + .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) + ) + .transition(.opacity.combined(with: .move(edge: .top))) + } + + @ViewBuilder + private func stepRow(index: Int, step: String, isDark: Bool) -> some View { + HStack(alignment: .top, spacing: 6) { + Text(String(index + 1) + ".") + .font(.system(size: 10, weight: .bold, design: .monospaced)) + .foregroundColor(mascotColor) + + Text(step) + .font(.caption2) + .foregroundColor(isDark ? Color(hex: "#E2E8F0") : Color(hex: "#1E293B")) + .fixedSize(horizontal: false, vertical: true) + } + } +} diff --git a/ios/App/Cards/GitPRDiffCardView.swift b/ios/App/Cards/GitPRDiffCardView.swift new file mode 100644 index 0000000000..333b278947 --- /dev/null +++ b/ios/App/Cards/GitPRDiffCardView.swift @@ -0,0 +1,194 @@ +import SwiftUI + +public struct GitPRDiffCardView: View { + public let filename: String + public let diffText: String + public let additions: Int + public let deletions: Int + + @Environment(\.colorScheme) private var colorScheme + @State private var showDiff: Bool = true + @State private var isApproved: Bool = false + + public init( + filename: String = "Changes", + diffText: String, + additions: Int = 0, + deletions: Int = 0 + ) { + self.filename = filename + self.diffText = diffText + + if additions == 0 && deletions == 0 { + let lines = diffText.components(separatedBy: "\n") + self.additions = lines.filter { $0.hasPrefix("+") && !$0.hasPrefix("+++") }.count + self.deletions = lines.filter { $0.hasPrefix("-") && !$0.hasPrefix("---") }.count + } else { + self.additions = additions + self.deletions = deletions + } + } + + 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(isApproved ? Color(hex: "#A855F7") : 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 + 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(diffText.components(separatedBy: "\n").prefix(30).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))) + } + } + } + + Divider().background(isDark ? Color.white.opacity(0.12) : Color.black.opacity(0.08)) + + // Footer Actions + HStack(spacing: 8) { + Button { + PlatformBridge.copyToPasteboard(diffText) + Haptics.selection() + } 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() + + Button { + withAnimation(.spring(response: 0.35, dampingFraction: 0.6)) { + isApproved.toggle() + } + if isApproved { + SoundEffects.playActionSuccess() + Haptics.success() + } + } label: { + HStack(spacing: 4) { + Image(systemName: isApproved ? "checkmark.circle.fill" : "checkmark") + .font(.system(size: 10, weight: .bold)) + Text(isApproved ? "Approved" : "Approve Diff") + .font(.caption2.weight(.bold)) + } + .foregroundColor(.white) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background( + isApproved + ? LinearGradient(colors: [Color(hex: "#8B5CF6"), Color(hex: "#7C3AED")], startPoint: .topLeading, endPoint: .bottomTrailing) + : LinearGradient(colors: [Color(hex: "#10B981"), Color(hex: "#059669")], startPoint: .topLeading, endPoint: .bottomTrailing) + ) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } + } + .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(.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) + ) + .shadow(color: Color.black.opacity(isDark ? 0.20 : 0.04), radius: 4, y: 1.5) + } + + @ViewBuilder + private func diffLineView(_ line: String, isDark: Bool) -> some View { + let isAddition = line.hasPrefix("+") && !line.hasPrefix("+++") + let isDeletion = line.hasPrefix("-") && !line.hasPrefix("---") + let isHeader = line.hasPrefix("@@") || line.hasPrefix("diff") + + Text(line) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor( + isAddition ? Color(hex: "#4ADE80") : + isDeletion ? Color(hex: "#F87171") : + isHeader ? Color(hex: "#38BDF8") : + Color(hex: "#E2E8F0") + ) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .background( + isAddition ? Color(hex: "#22C55E").opacity(0.15) : + isDeletion ? Color(hex: "#EF4444").opacity(0.15) : + Color.clear + ) + .clipShape(RoundedRectangle(cornerRadius: 2)) + } +} diff --git a/ios/App/Cards/ParticleBursts.swift b/ios/App/Cards/ParticleBursts.swift new file mode 100644 index 0000000000..c73dedabd5 --- /dev/null +++ b/ios/App/Cards/ParticleBursts.swift @@ -0,0 +1,205 @@ +import SwiftUI + +// MARK: - Confetti Particle +public struct ConfettiParticle: Identifiable { + public let id = UUID() + public var x: CGFloat + public var y: CGFloat + public var size: CGFloat + public var color: Color + public var vx: CGFloat + public var vy: CGFloat + public var rotation: Double + public var vRotation: Double + public var opacity: Double + public var isRibbon: Bool +} + +public struct ConfettiBurstView: View { + @Binding public var isTriggered: Bool + @State private var particles: [ConfettiParticle] = [] + @State private var timer = Timer.publish(every: 1.0 / 60.0, on: .main, in: .common).autoconnect() + + private let colors: [Color] = [ + Color(hex: "#FFD700"), // Radiant Gold + Color(hex: "#FFA500"), // Amber Gold + Color(hex: "#8B5CF6"), // Purple + Color(hex: "#2563EB"), // Electric Blue + Color(hex: "#38BDF8"), // Sky Cyan + Color(hex: "#10B981"), // Emerald Green + Color.white + ] + + public init(isTriggered: Binding) { + self._isTriggered = isTriggered + } + + public var body: some View { + GeometryReader { geo in + ZStack { + ForEach(particles) { p in + Group { + if p.isRibbon { + RoundedRectangle(cornerRadius: 2) + .fill(p.color) + .frame(width: p.size * 0.4, height: p.size * 1.8) + } else { + Circle() + .fill(p.color) + .frame(width: p.size, height: p.size) + } + } + .rotationEffect(.degrees(p.rotation)) + .opacity(p.opacity) + .position(x: p.x, y: p.y) + } + } + .allowsHitTesting(false) + .onChange(of: isTriggered) { _, triggered in + if triggered { + spawnParticles(in: geo.size) + } + } + .onReceive(timer) { _ in + updatePhysics(in: geo.size) + } + } + } + + private func spawnParticles(in size: CGSize) { + var newParticles: [ConfettiParticle] = [] + let centerX = size.width / 2 + let startY = size.height * 0.45 + + for _ in 0..<90 { + let angle = Double.random(in: -Double.pi * 0.95 ... -Double.pi * 0.05) + let speed = CGFloat.random(in: 10...26) + let color = colors.randomElement() ?? .yellow + + newParticles.append(ConfettiParticle( + x: centerX + CGFloat.random(in: -20...20), + y: startY + CGFloat.random(in: -20...20), + size: CGFloat.random(in: 7...14), + color: color, + vx: CGFloat(cos(angle)) * speed, + vy: CGFloat(sin(angle)) * speed, + rotation: Double.random(in: 0...360), + vRotation: Double.random(in: -12...12), + opacity: 1.0, + isRibbon: Bool.random() + )) + } + + particles = newParticles + } + + private func updatePhysics(in size: CGSize) { + guard !particles.isEmpty else { return } + + for i in particles.indices { + particles[i].x += particles[i].vx + particles[i].y += particles[i].vy + particles[i].vy += 0.50 // Gravity + particles[i].vx *= 0.985 // Air drag + particles[i].rotation += particles[i].vRotation + + if particles[i].y > size.height * 0.6 { + particles[i].opacity -= 0.025 + } + } + + particles.removeAll { $0.opacity <= 0.02 || $0.y > size.height + 60 } + + if particles.isEmpty && isTriggered { + isTriggered = false + } + } +} + +// MARK: - Heart Burst Particle +public struct HeartParticle: Identifiable { + public let id = UUID() + public var x: CGFloat + public var y: CGFloat + public var size: CGFloat + public var vx: CGFloat + public var vy: CGFloat + public var scale: CGFloat + public var opacity: Double +} + +public struct HeartBurstParticleView: View { + @Binding public var isTriggered: Bool + @State private var particles: [HeartParticle] = [] + @State private var timer = Timer.publish(every: 1.0 / 60.0, on: .main, in: .common).autoconnect() + + public init(isTriggered: Binding) { + self._isTriggered = isTriggered + } + + public var body: some View { + GeometryReader { geo in + ZStack { + ForEach(particles) { p in + Image(systemName: "heart.fill") + .foregroundColor(Color(hex: "#EC4899")) + .font(.system(size: p.size)) + .scaleEffect(p.scale) + .opacity(p.opacity) + .position(x: p.x, y: p.y) + } + } + .allowsHitTesting(false) + .onChange(of: isTriggered) { _, triggered in + if triggered { + spawnHearts(in: geo.size) + } + } + .onReceive(timer) { _ in + updatePhysics(in: geo.size) + } + } + } + + private func spawnHearts(in size: CGSize) { + var newHearts: [HeartParticle] = [] + let centerX = size.width / 2 + let startY = size.height * 0.5 + + for _ in 0..<20 { + let angle = Double.random(in: -Double.pi * 0.9 ... -Double.pi * 0.1) + let speed = CGFloat.random(in: 6...16) + + newHearts.append(HeartParticle( + x: centerX + CGFloat.random(in: -15...15), + y: startY + CGFloat.random(in: -15...15), + size: CGFloat.random(in: 14...22), + vx: CGFloat(cos(angle)) * speed, + vy: CGFloat(sin(angle)) * speed, + scale: 1.0, + opacity: 1.0 + )) + } + + particles = newHearts + } + + private func updatePhysics(in size: CGSize) { + guard !particles.isEmpty else { return } + + for i in particles.indices { + particles[i].x += particles[i].vx + particles[i].y += particles[i].vy + particles[i].vy += 0.25 + particles[i].vx *= 0.96 + particles[i].scale = max(0.2, particles[i].scale - 0.015) + particles[i].opacity -= 0.025 + } + + particles.removeAll { $0.opacity <= 0.02 } + + if particles.isEmpty && isTriggered { + isTriggered = false + } + } +} diff --git a/ios/App/Cards/SQLResultTableView.swift b/ios/App/Cards/SQLResultTableView.swift new file mode 100644 index 0000000000..5ea6ef056a --- /dev/null +++ b/ios/App/Cards/SQLResultTableView.swift @@ -0,0 +1,135 @@ +import SwiftUI + +public struct SQLResultTableView: View { + public let title: String + public let columns: [String] + public let rows: [[String]] + public let rawQuery: String? + + @Environment(\.colorScheme) private var colorScheme + + public init( + title: String = "DATA TABLE", + columns: [String], + rows: [[String]], + rawQuery: String? = nil + ) { + self.title = title + self.columns = columns + self.rows = rows + self.rawQuery = rawQuery + } + + public var body: some View { + let isDark = colorScheme == .dark + + VStack(alignment: .leading, spacing: 8) { + // Header + HStack { + HStack(spacing: 5) { + Image(systemName: "tablecells.fill") + .font(.system(size: 11)) + .foregroundColor(Color(hex: "#3ECF8E")) + Text(title.uppercased()) + .font(.system(size: 9.5, weight: .heavy)) + .foregroundColor(Color(hex: "#3ECF8E")) + } + + Spacer() + + Text("\(rows.count) rows") + .font(.system(size: 10, weight: .bold)) + .foregroundColor(isDark ? Color(hex: "#94A3B8") : Color(hex: "#64748B")) + .padding(.horizontal, 6) + .padding(.vertical, 2.5) + .background(isDark ? Color.white.opacity(0.08) : Color.black.opacity(0.04)) + .clipShape(Capsule()) + } + + // Raw Query if present + if let query = rawQuery, !query.isEmpty { + Text(query) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(isDark ? Color(hex: "#F8FAFC") : Color(hex: "#0F172A")) + .lineLimit(2) + .padding(6) + .background(isDark ? Color.black.opacity(0.4) : Color.black.opacity(0.04)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + + // Scrollable Grid + ScrollView(.horizontal, showsIndicators: false) { + VStack(alignment: .leading, spacing: 4) { + // Column Headers + HStack(spacing: 12) { + ForEach(Array(columns.enumerated()), id: \.offset) { _, col in + Text(col.uppercased()) + .font(.system(size: 9.5, weight: .heavy, design: .monospaced)) + .foregroundColor(Color(hex: "#3ECF8E")) + .frame(minWidth: 65, alignment: .leading) + } + } + .padding(.bottom, 2) + + Divider().background(isDark ? Color.white.opacity(0.12) : Color.black.opacity(0.08)) + + // Rows + ForEach(Array(rows.prefix(15).enumerated()), id: \.offset) { _, row in + HStack(spacing: 12) { + ForEach(Array(columns.indices), id: \.self) { colIdx in + let val = colIdx < row.count ? row[colIdx] : "" + Text(val) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(isDark ? Color(hex: "#E2E8F0") : Color(hex: "#1E293B")) + .frame(minWidth: 65, alignment: .leading) + } + } + .padding(.vertical, 1.5) + } + } + } + + Divider().background(isDark ? Color.white.opacity(0.12) : Color.black.opacity(0.08)) + + // Footer + HStack { + Button { + let csv = ([columns.joined(separator: ",")] + rows.map { $0.joined(separator: ",") }).joined(separator: "\n") + PlatformBridge.copyToPasteboard(csv) + Haptics.selection() + } label: { + HStack(spacing: 3) { + Image(systemName: "doc.on.doc") + Text("Copy CSV") + } + .font(.caption2.weight(.medium)) + .foregroundColor(isDark ? Color(hex: "#94A3B8") : Color(hex: "#64748B")) + } + .buttonStyle(.plain) + + Spacer() + } + } + .padding(10) + .background( + LinearGradient( + colors: isDark ? [ + Color(hex: "#171717").opacity(0.96), + Color(hex: "#1C1C1C").opacity(0.92) + ] : [ + Color.white.opacity(0.96), + Color(hex: "#F8FAFC").opacity(0.92) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .background(.ultraThinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color(hex: "#3ECF8E").opacity(0.35), lineWidth: 0.8) + ) + .shadow(color: Color.black.opacity(isDark ? 0.20 : 0.04), radius: 4, y: 1.5) + } +} diff --git a/ios/App/Cards/SkillExecutionReceiptView.swift b/ios/App/Cards/SkillExecutionReceiptView.swift new file mode 100644 index 0000000000..a7976f4f5d --- /dev/null +++ b/ios/App/Cards/SkillExecutionReceiptView.swift @@ -0,0 +1,136 @@ +import SwiftUI + +public struct SkillExecutionReceiptView: View { + public let skillName: String + public let status: String // "running", "success", "error" + public let durationMs: Int + public let parameters: String + public let output: String + + @Environment(\.colorScheme) private var colorScheme + @State private var isExpanded: Bool = false + + public init( + skillName: String, + status: String = "success", + durationMs: Int = 120, + parameters: String = "", + output: String = "" + ) { + self.skillName = skillName + self.status = status + self.durationMs = durationMs + self.parameters = parameters + self.output = output + } + + public var body: some View { + let isDark = colorScheme == .dark + + VStack(alignment: .leading, spacing: 6) { + Button { + withAnimation(.spring(response: 0.3, dampingFraction: 0.75)) { + isExpanded.toggle() + } + Haptics.selection() + } label: { + HStack(spacing: 6) { + Image(systemName: "wrench.and.screwdriver.fill") + .font(.system(size: 11)) + .foregroundColor(Color(hex: "#8B5CF6")) + + Text(skillName) + .font(.caption2.weight(.bold)) + .foregroundColor(isDark ? Color(hex: "#F8FAFC") : Color(hex: "#0F172A")) + + if durationMs > 0 { + Text("β€’ \(durationMs)ms") + .font(.system(size: 9.5, design: .monospaced)) + .foregroundColor(isDark ? Color(hex: "#94A3B8") : Color(hex: "#64748B")) + } + + Spacer() + + statusBadge + + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .font(.system(size: 9, weight: .bold)) + .foregroundColor(isDark ? Color(hex: "#94A3B8") : Color(hex: "#64748B")) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(isDark ? Color.white.opacity(0.08) : Color.black.opacity(0.04)) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + + if isExpanded { + VStack(alignment: .leading, spacing: 5) { + if !parameters.isEmpty { + VStack(alignment: .leading, spacing: 2) { + Text("INPUT") + .font(.system(size: 8.5, weight: .heavy, design: .monospaced)) + .foregroundColor(Color(hex: "#8B5CF6")) + Text(parameters) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(isDark ? Color(hex: "#E2E8F0") : Color(hex: "#1E293B")) + } + } + + if !output.isEmpty { + VStack(alignment: .leading, spacing: 2) { + Text("OUTPUT") + .font(.system(size: 8.5, weight: .heavy, design: .monospaced)) + .foregroundColor(Color(hex: "#10B981")) + Text(output) + .font(.system(size: 10, design: .monospaced)) + .foregroundColor(isDark ? Color(hex: "#E2E8F0") : Color(hex: "#1E293B")) + .lineLimit(6) + } + } + } + .padding(8) + .background(isDark ? Color.black.opacity(0.35) : Color.white.opacity(0.85)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + .padding(6) + .background( + LinearGradient( + colors: isDark ? [ + Color(hex: "#18181B").opacity(0.92), + Color(hex: "#0F172A").opacity(0.88) + ] : [ + Color.white.opacity(0.94), + Color(hex: "#F8FAFC").opacity(0.88) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .background(.ultraThinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color(hex: "#8B5CF6").opacity(0.25), lineWidth: 0.65) + ) + .shadow(color: Color.black.opacity(isDark ? 0.20 : 0.04), radius: 3, y: 1) + } + + @ViewBuilder + private var statusBadge: some View { + HStack(spacing: 3) { + Circle() + .fill(status == "success" ? Color.green : (status == "running" ? Color.orange : Color.red)) + .frame(width: 5, height: 5) + Text(status.capitalized) + .font(.system(size: 9, weight: .bold)) + .foregroundColor(status == "success" ? Color.green : (status == "running" ? Color.orange : Color.red)) + } + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.white.opacity(0.08)) + .clipShape(Capsule()) + } +} diff --git a/ios/App/ChatView.swift b/ios/App/ChatView.swift index 05aecce5f8..3aea464fa1 100644 --- a/ios/App/ChatView.swift +++ b/ios/App/ChatView.swift @@ -475,8 +475,36 @@ private struct ActivityShareSheet: UIViewControllerRepresentable { struct TextBubble: View { let message: Message + private var parsedDiff: (filename: String, diff: String)? { + guard let text = message.text, message.role != .user else { return nil } + if text.contains("```diff") || text.hasPrefix("diff --git") || (text.contains("@@") && text.contains("\n+")) { + var diffBody = text + if let start = text.range(of: "```diff"), let end = text.range(of: "```", range: start.upperBound..= 2 else { return nil } + let headers = lines[0].split(separator: "|").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty } + guard !headers.isEmpty else { return nil } + let dataLines = lines.dropFirst().filter { !$0.contains("---") } + let rows = dataLines.map { line in + line.split(separator: "|").map { $0.trimmingCharacters(in: .whitespaces) } + } + guard !rows.isEmpty else { return nil } + return (headers: headers, rows: rows) + } + var body: some View { let mine = message.role == .user + let hasCustomCard = parsedDiff != nil || parsedTable != nil + HStack { if mine { Spacer(minLength: 44) } VStack(alignment: .leading, spacing: 4) { @@ -486,10 +514,12 @@ struct TextBubble: View { .font(.system(size: 13, weight: .semibold)) .foregroundStyle(MausPalette.color(from.color)) } - // Bots get markdown, you do not β€” the same split the desktop - // makes. Markdown you did not intend is worse than markdown - // you did: a message about `**` should show the asterisks. - if mine { + + if let diffInfo = parsedDiff { + GitPRDiffCardView(filename: diffInfo.filename, diffText: diffInfo.diff) + } else if let tableInfo = parsedTable { + SQLResultTableView(columns: tableInfo.headers, rows: tableInfo.rows) + } else if mine { Text(message.text ?? "") .font(.system(size: 17)) .foregroundStyle(Color.primary) @@ -502,32 +532,35 @@ struct TextBubble: View { .fixedSize(horizontal: false, vertical: true) } } - .padding(.horizontal, 16) - .padding(.vertical, 12) + .padding(.horizontal, hasCustomCard ? 2 : 16) + .padding(.vertical, hasCustomCard ? 2 : 12) .background( - RoundedRectangle(cornerRadius: 22, style: .continuous) - .fill(Color.secondary.opacity(mine ? 0.24 : 0.13)) + Group { + if !hasCustomCard { + RoundedRectangle(cornerRadius: 22, style: .continuous) + .fill(Color.secondary.opacity(mine ? 0.24 : 0.13)) + } + } ) if !mine { Spacer(minLength: 44) } } } } -/// A tool the bot ran. Deliberately quiet β€” these are the bulk of a busy -/// transcript and they are context, not content. +/// A tool the bot ran. Shows rich execution receipt. struct ActivityChip: View { let tool: ToolActivity? var body: some View { if let tool { - Label { - Text(tool.name).lineLimit(1) - } icon: { - Image(systemName: tool.ok == false ? "exclamationmark.triangle" : "wrench.and.screwdriver") - } - .font(.system(size: 13)) - .foregroundStyle(tool.ok == false ? Color.red : Color.secondary) - .padding(.leading, 4) + SkillExecutionReceiptView( + skillName: tool.name, + status: tool.ok == false ? "error" : "success", + durationMs: 0, + parameters: "", + output: "" + ) + .padding(.leading, 2) } } } @@ -540,6 +573,7 @@ struct CardView: View { let message: Message @EnvironmentObject private var session: Session @State private var answering = false + @State private var showConfetti = false /// Deliberately not the literal string "Allow". `options` is whatever the /// harness sent, and it only falls back to ["Allow", "Deny"] when the @@ -563,74 +597,84 @@ struct CardView: View { var body: some View { if let card = message.card { - VStack(alignment: .leading, spacing: 12) { - Text(card.title) - .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(Color.primary) - Text(card.subtitle) - .font(.system(size: 15)) - .foregroundStyle(Color.secondary) - .textSelection(.enabled) - .fixedSize(horizontal: false, vertical: true) + ZStack { + VStack(alignment: .leading, spacing: 12) { + Text(card.title) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(Color.primary) + Text(card.subtitle) + .font(.system(size: 15)) + .foregroundStyle(Color.secondary) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) - if let held = card.held { - Label(held, systemImage: "exclamationmark.shield") - .font(.system(size: 13)) - .foregroundStyle(.orange) - } + if let held = card.held { + Label(held, systemImage: "exclamationmark.shield") + .font(.system(size: 13)) + .foregroundStyle(.orange) + } - if card.isPending { - HStack(spacing: 10) { - ForEach(card.options, id: \.self) { option in - Button(option) { + if card.isPending { + HStack(spacing: 10) { + ForEach(card.options, id: \.self) { option in + Button(option) { + answering = true + if !Self.isRefusal(option) { + showConfetti = true + SoundEffects.playCelebration() + } else { + SoundEffects.playActionSuccess() + } + Haptics.success() + Task { + await session.answer(threadId: chat.threadId, card: card, choice: option) + answering = false + } + } + .buttonStyle(.borderedProminent) + .tint(Self.isRefusal(option) ? Color.secondary : Color.accentColor) + .disabled(answering) + } + } + + // The grant key comes from the card. The phone never + // derives its own, so it cannot permit something subtly + // wider than the computer would have. The same goes for + // the answer: it is one of the options the card offered, + // never a string invented here. + if card.allowKey != nil, let allow = allowChoice, case let .bot(bot) = chat { + Button("Always allow this tool") { answering = true - SoundEffects.playActionSuccess() + showConfetti = true + SoundEffects.playCelebration() Haptics.success() Task { - await session.answer(threadId: chat.threadId, card: card, choice: option) + await session.alwaysAllow(bot: bot, card: card) + await session.answer(threadId: chat.threadId, card: card, choice: allow) answering = false } } - .buttonStyle(.borderedProminent) - .tint(Self.isRefusal(option) ? Color.secondary : Color.accentColor) + .font(.system(size: 14)) .disabled(answering) } + } else if let answered = card.answered { + Label(answered, systemImage: "checkmark.circle") + .font(.system(size: 14)) + .foregroundStyle(Color.secondary) } - - // The grant key comes from the card. The phone never - // derives its own, so it cannot permit something subtly - // wider than the computer would have. The same goes for - // the answer: it is one of the options the card offered, - // never a string invented here. - if card.allowKey != nil, let allow = allowChoice, case let .bot(bot) = chat { - Button("Always allow this tool") { - answering = true - SoundEffects.playCelebration() - Haptics.success() - Task { - await session.alwaysAllow(bot: bot, card: card) - await session.answer(threadId: chat.threadId, card: card, choice: allow) - answering = false - } - } - .font(.system(size: 14)) - .disabled(answering) - } - } else if let answered = card.answered { - Label(answered, systemImage: "checkmark.circle") - .font(.system(size: 14)) - .foregroundStyle(Color.secondary) } - } - .padding(16) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 22, style: .continuous) - .fill(Color.secondary.opacity(0.13)) - ) - .overlay { - RoundedRectangle(cornerRadius: 22, style: .continuous) - .strokeBorder(card.isPending ? Color.accentColor : .clear, lineWidth: 1.5) + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 22, style: .continuous) + .fill(Color.secondary.opacity(0.13)) + ) + .overlay { + RoundedRectangle(cornerRadius: 22, style: .continuous) + .strokeBorder(card.isPending ? Color.accentColor : .clear, lineWidth: 1.5) + } + + ConfettiBurstView(isTriggered: $showConfetti) } } } @@ -692,43 +736,33 @@ struct StreamingBubble: View { let reasoning: String? var body: some View { + let hasReasoningOnly = reasoning != nil && !reasoning!.isEmpty && text?.isEmpty != false HStack { - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: 6) { if let reasoning, !reasoning.isEmpty, text?.isEmpty != false { - // Quieter and smaller than an answer, because it is not - // one. Tail-limited: reasoning runs to thousands of words - // and the part worth seeing is always the end. - // - // Plain text, unlike the answer: the tail cut lands - // wherever it lands, and rendering markdown that starts - // mid-syntax invents structure the model did not write. - Text(String(reasoning.suffix(400))) - .font(.system(size: 14)) - .foregroundStyle(Color.secondary) - .fixedSize(horizontal: false, vertical: true) + AgentThoughtChamberView( + reasoning: reasoning, + botName: "Bot", + mascotColor: Color.accentColor, + isStreaming: true + ) } if let text, !text.isEmpty { - // Same renderer as the settled bubble, for the same - // reason as the padding: a live reply showing `**bold**` - // that snaps to bold on arrival is the message jumping, - // just in a different dimension. The parser tolerates the - // half-finished markdown this is always holding β€” an - // unclosed fence renders as code, an unclosed link as the - // characters typed so far. MarkdownText(source: text, caret: true) .foregroundStyle(Color.primary) } } - .padding(.horizontal, 16) - .padding(.vertical, 12) + .padding(.horizontal, hasReasoningOnly ? 2 : 16) + .padding(.vertical, hasReasoningOnly ? 2 : 12) .background( - RoundedRectangle(cornerRadius: 22, style: .continuous) - .fill(Color.secondary.opacity(0.13)) + Group { + if !hasReasoningOnly { + RoundedRectangle(cornerRadius: 22, style: .continuous) + .fill(Color.secondary.opacity(0.13)) + } + } ) Spacer(minLength: 44) } - // No `.textSelection` on purpose: selecting text that is still growing - // fights the reader, and the settled bubble a frame later is - // selectable anyway. } }