From 29547370dc778a1140705eac3e7a20db9cdae305 Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 05:18:42 -0700 Subject: [PATCH] nvrsion: Add Chat: Mesh Group Reconnection, Fix Mac instances not re-joining mesh groups, and Refactor LANTransport, LANBrowser, PeerClient, and PeerDialer to ensure connections form when devices are online. Nucleic-Promote: 1 Co-authored-by: Nucleic --- .../NucleicRemote/Views/Composer.swift | 4 ++ .../NucleicRemote/Views/SessionsView.swift | 45 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/NucleicRemote/NucleicRemote/Views/Composer.swift b/NucleicRemote/NucleicRemote/Views/Composer.swift index 44fef85..24fa52f 100644 --- a/NucleicRemote/NucleicRemote/Views/Composer.swift +++ b/NucleicRemote/NucleicRemote/Views/Composer.swift @@ -9,6 +9,9 @@ struct StartChatComposer: View { /// start typing a new chat immediately. Consumed (reset to false) once handled, so it never /// steals focus on a later Home appearance. `.constant(false)` for the iPhone/compact path. var focusNewChat: Binding = .constant(false) + /// Called after a chat is kicked off, so a host that presents the composer modally (e.g. the + /// Sessions tab's floating New-chat button) can dismiss itself. Home leaves it nil and stays put. + var onStart: (() -> Void)? = nil @State private var projectID: ProjectID? @State private var draft = "" @State private var auto = false @@ -93,6 +96,7 @@ struct StartChatComposer: View { baseBranch: branch.isEmpty ? nil : branch, useWorktree: useWorktree, auto: auto) draft = "" + onStart?() } } label: { Image(systemName: "arrow.up.circle.fill").font(.title) diff --git a/NucleicRemote/NucleicRemote/Views/SessionsView.swift b/NucleicRemote/NucleicRemote/Views/SessionsView.swift index 22fb067..81ad3bb 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionsView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionsView.swift @@ -6,8 +6,13 @@ import NucleicProtocol struct SessionsView: View { @EnvironmentObject var store: RemoteStore @State private var showArchived = false + @State private var showNewChat = false @State private var path = NavigationPath() + /// The floating New-chat button only makes sense when this device can control a Mac and there's + /// at least one project to start a chat in; otherwise the composer it opens would be inert. + private var canStartChat: Bool { store.canControl && !store.dashboard.projects.isEmpty } + private var grouped: [(title: String, rows: [WireSessionSummary])] { let pool = (showArchived ? store.sessions : store.liveSessions) .sorted(by: StatusStyle.attentionThenRecency) @@ -84,10 +89,50 @@ struct SessionsView: View { } } } + // A floating "new chat" affordance in the lower-right, mirroring the Mac's per-project + // "New chat" button. Picks a project + prompt in a sheet, then kicks off the chat. + .overlay(alignment: .bottomTrailing) { + if canStartChat { newChatButton } + } + .sheet(isPresented: $showNewChat) { + NavigationStack { + ScrollView { + StartChatComposer(onStart: { showNewChat = false }) + .padding() + } + .navigationTitle("New chat") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Cancel") { showNewChat = false } + } + } + } + .presentationDetents([.medium, .large]) + } .safeAreaInset(edge: .bottom) { ConnectionChip().padding(.bottom, 8) } } } + /// The circular plus button, styled to match the transcript's jump-to-bottom FAB: an accent + /// disc with a soft shadow, floated clear of the connection chip. + private var newChatButton: some View { + Button { + showNewChat = true + } label: { + Image(systemName: "plus") + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 56, height: 56) + .background(Palette.accent, in: .circle) + .shadow(color: .black.opacity(0.25), radius: 6, y: 2) + } + .buttonStyle(.plain) + .padding(.trailing, 20) + .padding(.bottom, 20) + .accessibilityLabel("New chat") + } + /// Push the session a notification tap asked for, then clear the request. private func consumeRoute() { guard let route = store.pendingRoute else { return }