-
Notifications
You must be signed in to change notification settings - Fork 542
feat(ios): Messages-shaped companion — glass chrome, Updates bar, the desktop's mascot, tailed bubbles #232
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
Merged
Merged
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
a107d82
feat(ios): Messages-shaped companion — glass chrome, groups strip, Up…
aivsomkar 234099a
feat(ios): the composer's + opens a glass action sheet, not a system …
aivsomkar f64d10a
feat(ios): mascots move like the desktop's; bot bubbles flush left; c…
aivsomkar 8faabd7
fix(ios): pair with a Bonjour-discovered computer over IPv4
aivsomkar b044b39
docs(ios): pairing in the Simulator needs a signed build
aivsomkar 6c05c7d
feat(ios): the desktop's mascot, ported — 25 expressions, 40 states, …
aivsomkar 04de7da
fix(ios): chat header is a blur to the top, not a solid bar
aivsomkar 801a624
fix(ios): chat header blur is only the top strip; face and name float…
aivsomkar 83b4afa
feat(ios): the island — a bot grows out of the Dynamic Island
aivsomkar f016a11
feat(ios): the bot in the real Dynamic Island — a Live Activity
aivsomkar 2271169
fix(ios): island faces sit clear of the hardware island, centred
aivsomkar eb0e1dd
feat(ios): rainbow comets — the desktop's orbit trails, in the island…
aivsomkar c3ef69b
feat(ios): the island pops open for a new ask; the stream lingers aft…
aivsomkar e158370
Merge main: companion mDNS/failover work (#235–#237), BYO-VPS tightening
aivsomkar 80a2abd
chore(ios): iPhone-only for 1.0
aivsomkar File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Large diffs are not rendered by default.
Oops, something went wrong.
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| // The one material the chrome is made of. | ||
| // | ||
| // Every floating control — the header tiles, the Updates pill, the round | ||
| // buttons, the sheets, the chat's back pill and composer — is the same glass, | ||
| // so the app reads as one object rather than a collection of buttons. On | ||
| // iOS 26 it is the system's Liquid Glass, which refracts what scrolls | ||
| // beneath it; before that, a thin material with a hairline does the same | ||
| // job in the same shape, just without the light. | ||
| import SwiftUI | ||
|
|
||
| /// Something floating over content: a tile, a pill, a sheet. | ||
| struct GlassSurface<S: InsettableShape>: ViewModifier { | ||
| let shape: S | ||
| var interactive: Bool = true | ||
| var tint: Color? = nil | ||
|
|
||
| func body(content: Content) -> some View { | ||
| if #available(iOS 26.0, *) { | ||
| let base: Glass = interactive ? .regular.interactive() : .regular | ||
| content.glassEffect(tint.map { base.tint($0) } ?? base, in: shape) | ||
| } else { | ||
| content | ||
| .background(.ultraThinMaterial, in: shape) | ||
| .background(tint?.opacity(0.18) ?? .clear, in: shape) | ||
| .overlay(shape.strokeBorder(Color.primary.opacity(0.10), lineWidth: 0.5)) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| extension View { | ||
| /// A capsule of glass — pills and round buttons. | ||
| func glassCapsule(interactive: Bool = true, tint: Color? = nil) -> some View { | ||
| modifier(GlassSurface(shape: Capsule(), interactive: interactive, tint: tint)) | ||
| } | ||
|
|
||
| /// A rounded sheet of glass. | ||
| func glassSheet(cornerRadius: CGFloat = 28, tint: Color? = nil) -> some View { | ||
| modifier(GlassSurface( | ||
| shape: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous), | ||
| interactive: false, | ||
| tint: tint | ||
| )) | ||
| } | ||
| } | ||
|
|
||
| /// Neighbouring glass merges when it touches, the way it does in the | ||
| /// system's own bars. A no-op before iOS 26. | ||
| struct GlassGroup<Content: View>: View { | ||
| var spacing: CGFloat = 8 | ||
| @ViewBuilder let content: () -> Content | ||
|
|
||
| var body: some View { | ||
| if #available(iOS 26.0, *) { | ||
| GlassEffectContainer(spacing: spacing, content: content) | ||
| } else { | ||
| content() | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// A round glass button with one glyph — the shape of every action in the | ||
| /// chrome that is not a pill. | ||
| struct GlassButton: View { | ||
| let systemImage: String | ||
| var size: CGFloat = 44 | ||
| var weight: Font.Weight = .medium | ||
| let action: () -> Void | ||
|
|
||
| var body: some View { | ||
| Button(action: action) { | ||
| Image(systemName: systemImage) | ||
| .font(.system(size: size * 0.42, weight: weight)) | ||
| .foregroundStyle(Color.primary) | ||
| .frame(width: size, height: size) | ||
| .contentShape(Circle()) | ||
| } | ||
| .buttonStyle(.plain) | ||
| .glassCapsule() | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,162 @@ | ||
| // The island: a black shape at the top of the screen, sitting where the | ||
| // hardware Dynamic Island is, that grows into a square with a bot's face | ||
| // alive inside it — and shrinks back into the island when it is done. | ||
| // | ||
| // Apps cannot draw inside the real island, so this is the same trick X | ||
| // uses: a black rounded square whose collapsed state hides behind the | ||
| // island (or, on phones without one, disappears into the top edge). The | ||
| // mascot engine does the rest — it already morphs every state and | ||
| // expression, which is what makes the square worth growing. | ||
| import SwiftUI | ||
| import CompanionCore | ||
|
|
||
| /// The hardware island's frame, in points from the top of the screen, on the | ||
| /// phones that have one. Used to hide the collapsed pill behind it. | ||
| enum IslandGeometry { | ||
| static let size = CGSize(width: 126, height: 37) | ||
| static let top: CGFloat = 11 | ||
|
|
||
| /// True on Dynamic Island phones: their top safe-area inset is 59. | ||
| static func hasIsland(topInset: CGFloat) -> Bool { topInset >= 54 } | ||
|
|
||
| /// The window's top safe-area inset — the one number that places | ||
| /// anything relative to the screen's top edge. | ||
| static var topInset: CGFloat { | ||
| UIApplication.shared.connectedScenes | ||
| .compactMap { ($0 as? UIWindowScene)?.keyWindow } | ||
| .first?.safeAreaInsets.top ?? 0 | ||
| } | ||
| } | ||
|
|
||
| /// The island shape, collapsed or expanded, with whatever is inside it. | ||
| struct IslandShell<Content: View>: View { | ||
| let expanded: Bool | ||
| let hasIsland: Bool | ||
| var expandedSize = CGSize(width: 250, height: 330) | ||
| @ViewBuilder let content: () -> Content | ||
|
|
||
| var body: some View { | ||
| let size = expanded ? expandedSize : IslandGeometry.size | ||
| ZStack(alignment: .top) { | ||
| RoundedRectangle(cornerRadius: expanded ? 56 : IslandGeometry.size.height / 2, style: .continuous) | ||
| .fill(Color.black) | ||
| // a hairline, so the square still reads as a shape on a dark | ||
| // screen; invisible against the real island when collapsed | ||
| .overlay( | ||
| RoundedRectangle(cornerRadius: expanded ? 56 : IslandGeometry.size.height / 2, style: .continuous) | ||
| .strokeBorder(Color.white.opacity(expanded ? 0.12 : 0), lineWidth: 1) | ||
| ) | ||
| if expanded { | ||
| content() | ||
| .frame(width: expandedSize.width, height: expandedSize.height) | ||
| .transition(.opacity.combined(with: .scale(scale: 0.92))) | ||
| } | ||
| } | ||
| .frame(width: size.width, height: size.height) | ||
| .clipShape(RoundedRectangle(cornerRadius: expanded ? 56 : IslandGeometry.size.height / 2, style: .continuous)) | ||
| // collapsed and no hardware island to hide behind: be gone entirely | ||
| .opacity(expanded || hasIsland ? 1 : 0) | ||
| .scaleEffect(expanded || hasIsland ? 1 : 0.6, anchor: .top) | ||
| .padding(.top, IslandGeometry.top) | ||
| .shadow(color: .black.opacity(expanded ? 0.45 : 0), radius: 28, y: 12) | ||
| .ignoresSafeArea(edges: .top) | ||
| } | ||
| } | ||
|
|
||
| /// The roster's island: when a bot stops for you, it grows with that bot's | ||
| /// face, the question, and the answers. Tap the face to open the chat. | ||
| struct NeedsYouIsland: View { | ||
| let update: ChatUpdate? | ||
| let hasIsland: Bool | ||
| let open: (Chat) -> Void | ||
| @EnvironmentObject private var session: Session | ||
| @State private var shown: ChatUpdate? | ||
| @State private var dismissedCardIds = Set<String>() | ||
| @State private var answering = false | ||
|
|
||
| private var expanded: Bool { shown != nil } | ||
|
|
||
| var body: some View { | ||
| ZStack(alignment: .top) { | ||
| if expanded { | ||
| // tap anywhere else: put it away, keep the row's tag | ||
| Color.black.opacity(0.001) | ||
| .ignoresSafeArea() | ||
| .onTapGesture { dismiss() } | ||
| } | ||
| IslandShell(expanded: expanded, hasIsland: hasIsland) { | ||
| if let shown { | ||
| VStack(spacing: 10) { | ||
| // The hardware island covers the first 37pt of the | ||
| // square; the face sits clear of it, centred. | ||
| Button { open(shown.chat) } label: { | ||
| MausAvatar(color: shown.chat.color, size: 120, state: MausState.forChat(shown.chat, in: session.state), comets: true) | ||
| } | ||
| .buttonStyle(.plain) | ||
| .padding(.top, IslandGeometry.size.height + 14) | ||
|
|
||
| VStack(spacing: 4) { | ||
| Label("\(shown.chat.name) needs you", systemImage: "hand.raised.fill") | ||
| .font(.system(size: 13, weight: .semibold)) | ||
| .foregroundStyle(MausPalette.color(shown.chat.color)) | ||
| Text(shown.line.isEmpty ? (shown.card?.title ?? "") : shown.line) | ||
| .font(.system(size: 15)) | ||
| .foregroundStyle(.white) | ||
| .multilineTextAlignment(.center) | ||
| .lineLimit(3) | ||
| .padding(.horizontal, 20) | ||
| } | ||
|
|
||
| if let card = shown.card, card.isPending { | ||
| HStack(spacing: 8) { | ||
| ForEach(card.options, id: \.self) { option in | ||
| Button { | ||
| answering = true | ||
| Task { | ||
| await session.answer(threadId: shown.chat.threadId, card: card, choice: option) | ||
| answering = false | ||
| dismiss() | ||
| } | ||
| } label: { | ||
| Text(option) | ||
| .font(.system(size: 15, weight: .semibold)) | ||
| .foregroundStyle(CardStyle.isRefusal(option) ? .white : .white) | ||
| .frame(maxWidth: .infinity) | ||
| .frame(height: 40) | ||
| .background( | ||
| Capsule().fill(CardStyle.isRefusal(option) ? Color.white.opacity(0.16) : MausPalette.color(shown.chat.color)) | ||
| ) | ||
| } | ||
| .buttonStyle(.plain) | ||
| .disabled(answering) | ||
| } | ||
| } | ||
| .padding(.horizontal, 20) | ||
| .padding(.bottom, 18) | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| .frame(maxWidth: .infinity, alignment: .top) | ||
| .animation(.spring(response: 0.55, dampingFraction: 0.78), value: expanded) | ||
| .onChange(of: update?.card?.requestId) { _, _ in reconcile() } | ||
| .onAppear { reconcile() } | ||
| } | ||
|
|
||
| /// Show a new needs-you the moment it lands; never re-show one the user | ||
| /// put away; fold away when it is answered elsewhere. | ||
| private func reconcile() { | ||
| guard let update, update.kind == .needsYou, let id = update.card?.requestId else { | ||
| shown = nil | ||
| return | ||
| } | ||
| if dismissedCardIds.contains(id) { shown = nil; return } | ||
| shown = update | ||
| } | ||
|
|
||
| private func dismiss() { | ||
| if let id = shown?.card?.requestId { dismissedCardIds.insert(id) } | ||
| shown = nil | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| // Keeping the Dynamic Island in step with the bots. | ||
| // | ||
| // One Live Activity per bot that is doing something — needs you, or working | ||
| // — started, updated and ended from the same `updates` the pill reads. The | ||
| // stream is foreground-only and there is no push path yet, so the island is | ||
| // exact while the app is alive and goes quiet with it; iOS keeps the last | ||
| // state on screen for a while, then the activity is ended on the next | ||
| // launch if the bot has moved on. | ||
| import ActivityKit | ||
| import Combine | ||
| import Foundation | ||
| import CompanionCore | ||
|
|
||
| @MainActor | ||
| final class LiveActivityCoordinator { | ||
| private var cancellable: AnyCancellable? | ||
| private var lastSent: [String: BotActivityAttributes.ContentState] = [:] | ||
| /// When each bot's current kind began, so an update does not reset the clock. | ||
| private var since: [String: (kind: String, at: Date)] = [:] | ||
|
|
||
| func attach(to session: Session) { | ||
| // Answer from the island: the intent runs in this process. | ||
| AnswerApprovalIntent.handler = { [weak session] threadId, requestId, choice, isPermission in | ||
| await session?.answer(threadId: threadId, requestId: requestId, choice: choice, isPermission: isPermission) | ||
| } | ||
| cancellable = session.$state | ||
| .debounce(for: .milliseconds(400), scheduler: DispatchQueue.main) | ||
| .sink { [weak self] state in self?.sync(state) } | ||
| } | ||
|
|
||
| private func sync(_ state: CompanionState) { | ||
| guard ActivityAuthorizationInfo().areActivitiesEnabled else { return } | ||
| let wanted = state.updates.filter { $0.kind != .toReview } | ||
| var wantedIds = Set<String>() | ||
|
|
||
| for update in wanted { | ||
| guard case let .bot(bot) = update.chat else { continue } | ||
| wantedIds.insert(bot.id) | ||
| let face = MausState.forBot(bot, last: state.visibleTranscript(forThread: bot.threadId).last) | ||
| let kind = update.kind == .needsYou ? "needsYou" : "working" | ||
| if since[bot.id]?.kind != kind { since[bot.id] = (kind, Date()) } | ||
| let content = BotActivityAttributes.ContentState( | ||
| face: face.rawValue, | ||
| kind: update.kind == .needsYou ? "needsYou" : "working", | ||
| headline: update.kind == .needsYou ? "\(bot.name) needs you" : "\(bot.name) is working", | ||
| line: update.line.isEmpty ? (update.card?.title ?? "") : update.line, | ||
| requestId: update.card?.isPending == true ? update.card?.requestId : nil, | ||
| options: update.card?.isPending == true ? (update.card?.options ?? []) : [], | ||
| isPermission: update.card?.isPermission ?? false, | ||
| since: since[bot.id]?.at ?? Date() | ||
| ) | ||
| if lastSent[bot.id] == content { continue } | ||
| defer { lastSent[bot.id] = content } | ||
|
|
||
| // A bot stopping for you is worth an alert: the island pops open | ||
| // on its own and the lock screen lights up. Working is not. | ||
| let alert: AlertConfiguration? = update.kind == .needsYou | ||
| ? AlertConfiguration( | ||
| title: LocalizedStringResource(stringLiteral: content.headline), | ||
| body: LocalizedStringResource(stringLiteral: content.line), | ||
| sound: .default | ||
| ) | ||
| : nil | ||
| if let activity = Activity<BotActivityAttributes>.activities.first(where: { $0.attributes.botId == bot.id }) { | ||
| let newAsk = update.kind == .needsYou && lastSent[bot.id]?.requestId != content.requestId | ||
| Task { await activity.update(.init(state: content, staleDate: nil), alertConfiguration: newAsk ? alert : nil) } | ||
| } else { | ||
| let attributes = BotActivityAttributes(botId: bot.id, threadId: bot.threadId, name: bot.name, color: bot.color) | ||
| _ = try? Activity.request(attributes: attributes, content: .init(state: content, staleDate: nil), pushType: nil) | ||
| // a fresh activity cannot alert on request; one immediate alerting update does it | ||
| if let alert, let activity = Activity<BotActivityAttributes>.activities.first(where: { $0.attributes.botId == bot.id }) { | ||
| Task { await activity.update(.init(state: content, staleDate: nil), alertConfiguration: alert) } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // bots that went quiet: let the island go | ||
| for activity in Activity<BotActivityAttributes>.activities where !wantedIds.contains(activity.attributes.botId) { | ||
| lastSent.removeValue(forKey: activity.attributes.botId) | ||
| since.removeValue(forKey: activity.attributes.botId) | ||
| Task { await activity.end(nil, dismissalPolicy: .immediate) } | ||
| } | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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 | 🟠 Major | ⚡ Quick win
Record
lastSentonly after the activity request succeeds.Line 48 stores the content before line 54 requests the activity.
try?discards any failure, for example the per-app activity limit or a transient ActivityKit error. The state is then marked as sent, and the nextsyncwith identical content returns at line 47. A bot that stays in the "working" state produces identical content, so no activity is ever created for it.Store the entry only on success, and log the failure.
🐛 Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents