Merge branch 'dev' into canary

This commit is contained in:
2026-07-05 05:21:13 -07:00
2 changed files with 49 additions and 0 deletions
@@ -9,6 +9,9 @@ struct StartChatComposer: View {
/// start typing a new chat immediately. Consumed (reset to false) once handled, so it never /// 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. /// steals focus on a later Home appearance. `.constant(false)` for the iPhone/compact path.
var focusNewChat: Binding<Bool> = .constant(false) var focusNewChat: Binding<Bool> = .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 projectID: ProjectID?
@State private var draft = "" @State private var draft = ""
@State private var auto = false @State private var auto = false
@@ -93,6 +96,7 @@ struct StartChatComposer: View {
baseBranch: branch.isEmpty ? nil : branch, baseBranch: branch.isEmpty ? nil : branch,
useWorktree: useWorktree, auto: auto) useWorktree: useWorktree, auto: auto)
draft = "" draft = ""
onStart?()
} }
} label: { } label: {
Image(systemName: "arrow.up.circle.fill").font(.title) Image(systemName: "arrow.up.circle.fill").font(.title)
@@ -6,8 +6,13 @@ import NucleicProtocol
struct SessionsView: View { struct SessionsView: View {
@EnvironmentObject var store: RemoteStore @EnvironmentObject var store: RemoteStore
@State private var showArchived = false @State private var showArchived = false
@State private var showNewChat = false
@State private var path = NavigationPath() @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])] { private var grouped: [(title: String, rows: [WireSessionSummary])] {
let pool = (showArchived ? store.sessions : store.liveSessions) let pool = (showArchived ? store.sessions : store.liveSessions)
.sorted(by: StatusStyle.attentionThenRecency) .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) } .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. /// Push the session a notification tap asked for, then clear the request.
private func consumeRoute() { private func consumeRoute() {
guard let route = store.pendingRoute else { return } guard let route = store.pendingRoute else { return }