-
Notifications
You must be signed in to change notification settings - Fork 383
feat(ios): universal multiplatform build, adaptive split navigation, keyboard shortcuts & haptics #283
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(ios): universal multiplatform build, adaptive split navigation, keyboard shortcuts & haptics #283
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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() | ||
|
Comment on lines
+587
to
+588
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Success feedback plays for a refusal, and before the request settles.
♻️ Proposed change Button(option) {
answering = true
- SoundEffects.playActionSuccess()
- Haptics.success()
Task {
await session.answer(threadId: chat.threadId, card: card, choice: option)
+ if !Self.isRefusal(option) {
+ SoundEffects.playActionSuccess()
+ Haptics.success()
+ }
answering = false
}
}🤖 Prompt for AI Agents |
||
| 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) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: milind-soni/OpenMausBot
Length of output: 6238
🏁 Script executed:
Repository: milind-soni/OpenMausBot
Length of output: 10251
🌐 Web query:
XcodeGen project.yml supportedDestinations macOS platform iOS SUPPORTS_MACCATALYST semantics💡 Result:
In XcodeGen, the supportedDestinations key is used to define multi-platform support for a target, effectively replacing the older single-platform definition [1]. When using this feature, you should set the target platform to auto, which is specifically designed to work with supportedDestinations [2][3][4]. Regarding Mac Catalyst: 1. Supported Destinations: You can include macCatalyst in your supportedDestinations list [2][5]. 2. Implicit Requirements: XcodeGen automatically adds iOS to the supportedDestinations list if it contains only macCatalyst but lacks iOS, ensuring compatibility with the underlying requirements for Mac Catalyst targets [6]. 3. Semantic Settings: While supportedDestinations handles the structural platform support, specific Mac Catalyst build settings like SUPPORTS_MACCATALYST and DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER are not automatically generated by the destination key alone [7]. You must explicitly define these in the target's settings block if they are required for your project [7]. Example configuration: targets: MyApp: type: application platform: auto supportedDestinations: [iOS, macCatalyst] settings: base: SUPPORTS_MACCATALYST: YES DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER: YES
Citations:
🏁 Script executed:
Repository: milind-soni/OpenMausBot
Length of output: 400
Align the target destinations with the UIKit-only app.
ios/project.ymldeclaresmacOS, butChatView.swiftuses unguarded UIKit APIs. If native macOS support is required, guard or replace these APIs. Otherwise, removemacOSfromsupportedDestinations.🤖 Prompt for AI Agents