diff --git a/ios/App/ChatListView.swift b/ios/App/ChatListView.swift index 7848e35b5..0c2bc694f 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 33e8a5b65..05aecce5f 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 3a20b907b..4cc1c2f8e 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 2137c6a84..3725bc100 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 000000000..0df67828e --- /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 ac35902ed..cddbc2dba 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 129bf89f1..9a2972741 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.