Files
nucleic/Sources/NucleicApp/NewChatComposer.swift
T
abkslmandClaude Fable 5 f8fa5c51fe mesh: Unify local and remote projects/sessions into one sidebar representation
Peer Macs' projects and sessions now render and behave identically to local
ones across the Mac app, differentiated only by a globe badge — one
abstraction instead of a parallel mesh section.

Core: new ProjectSummary (project analogue of SessionSummary) with
hostID/hostLabel origin; SessionSummary gains hostID, init(wire:hostID:), and
an origin-qualified sidebarRowID; AppStore.sidebarProjects name-sorts the mesh
union; projectSummary(_:) is the origin-agnostic project(_:); origin-keyed
summaries(for: ProjectSummary) overloads keep same-ProjectID Macs (migrated
databases) from crossing session lists; openSessionID.didSet auto-routes
remote opens (local records shadow; live copies beat moved-tombstones);
origin-aware verbs dispatch the phone's wire verbs via PeerClient.sendCommand
with a listSessions pull as the convergence backstop and surfaced errors when
the peer is unreachable.

Owner-side broadcast fixes so viewers' mirrors converge: mutateSession(+
ForRemote) and createSession broadcast sessionUpdated; a new
HostBroadcast.sessionList is pushed on deleteSession / deleteProject /
setProjectArchived / transfer-restore (deletions previously broadcast
nothing); the wire startChat handler no longer reveals on the owner's screen.

UI: RootView renders one unified tree (MeshSessionRow / remoteProjectHeader /
meshHosts deleted); RemoteProjectView is the summary-driven overview;
NewChatComposer picks projects across the mesh and starts remote chats over
the wire (fails closed when a remembered project is unresolvable; attachments
never silently dropped); detail-view unarchive/discard/approvals/header
controls route to the owner — remote approvals previously no-oped silently,
and opening a remote Control chat fired a spurious setSessionAuto at its
owner.

Docs: SYNC_PROTOCOL sessionList push semantics; MESH_TRANSFER unified-
representation section. Tests: MeshUnifiedSidebarTests (9) — 821 green.
Reviewed by an adversarial multi-agent pass; all confirmed findings fixed.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-05 03:24:23 -07:00

592 lines
31 KiB
Swift

import SwiftUI
import NucleicCore
/// The "start a new chat" composer: pick a project (with its model, effort, auto/ship,
/// and base branch/worktree), type the first message, and start a chat. Shared by two
/// hosts — pinned inline at the bottom of the home dashboard, and floated over the
/// window as the ⌘⇧N quick-launch panel (`NewChatComposerSheet`). Both drive the same
/// state and `start()`, so the two presentations can't drift.
struct NewChatComposer: View {
@Environment(AppStore.self) private var store
@Environment(\.dismiss) private var dismiss
/// Persisted so the composer reopens on the last project you picked — shared by the
/// home bar and the floating panel so they agree on the selection.
@AppStorage("nucleic.home.lastProjectID") private var lastProjectID = ""
@AppStorage(SubmitKeyMode.storageKey) private var submitKeyRaw = SubmitKeyMode.modifierSends.rawValue
/// Drives the composer's accent: lavender for a Nucleic Control project, matching how
/// that project's own session is themed (read straight from the same setting `RootView` uses).
@AppStorage(ColorVisionMode.storageKey) private var colorVisionRaw = ColorVisionMode.standard.rawValue
/// The first-message draft. The home bar binds this to `store.homeDraft` (persists across
/// navigation); the floating panel binds a transient local draft that clears on dismiss.
@Binding var draft: String
/// Floated over the window (⌘N / ⌘⇧N) rather than pinned inline: adds a titled header with a
/// Cancel action, sizes to a fixed panel width, and dismisses itself once a chat starts.
var floating = false
/// When floating, whether starting the chat leaves the user on their current view (⌘N)
/// rather than jumping to the new session (⌘⇧N). Ignored for the inline home bar.
var background = false
@State private var composerHeight = ChatInputField.restingHeight
/// Files/images staged in the composer (attach button, paste, or drag) to send with the
/// first message of the new chat; materialized into its worktree once it's created.
@State private var attachments: [ComposerAttachment] = []
/// True while a file/image drag hovers the composer, to light up the drop zone.
@State private var composerDropTargeted = false
@State private var showingAddProject = false
@State private var modelOverride: String?
@State private var effortOverride: String?
@State private var branches: [String] = []
@State private var selectedBranch: String?
@State private var useWorktree = true
/// nil until the user explicitly toggles, so the button reflects the app default.
@State private var autoOverride: Bool?
@State private var shipOverride: Bool?
/// False until the composer's initial async state (project resolution, branch load) has
/// settled, so the rows lay out in their final positions on open instead of animating in
/// from a looser spacing and snapping into place. Flipped true after the first load so
/// genuine interactions afterward (e.g. the Ship toggle) still animate.
@State private var layoutAnimationsEnabled = false
/// Width reserved for the send button, so the bottom controls row can be
/// inset to line up with the text field's trailing edge.
private let sendButtonWidth: CGFloat = 28
private var submitMode: SubmitKeyMode { SubmitKeyMode(rawValue: submitKeyRaw) ?? .modifierSends }
var body: some View {
content
// Hold animations off while the composer's opening state settles (see
// `layoutAnimationsEnabled`); a value-based `.animation` lower in the tree would
// otherwise re-animate that first settle, so it's gated on the same flag.
.transaction { if !layoutAnimationsEnabled { $0.animation = nil } }
.task(id: selectedSummary?.id) {
await loadBranches()
layoutAnimationsEnabled = true
}
.sheet(isPresented: $showingAddProject) { AddProjectSheet() }
}
@ViewBuilder
private var content: some View {
if floating {
VStack(alignment: .leading, spacing: 14) {
HStack {
Text("New chat").font(.headline)
Spacer()
Text("\(startHint) to start & open · \(backgroundHint) in background")
.font(.caption2).foregroundStyle(.tertiary)
Button("Cancel", role: .cancel) { dismiss() }
.keyboardShortcut(.cancelAction)
.tint(nil) // drop the inherited app accent; dimmed teal is hard to read in dark mode
}
composerCore
}
.padding(20)
.frame(width: 640)
} else {
composerCore.padding(12)
}
}
/// The composer proper — the project/branch row, the text field with its send button,
/// and the mode/model/effort row. Identical in both presentations.
private var composerCore: some View {
VStack(spacing: 8) {
HStack(spacing: 8) {
Menu {
// One picker across the mesh (mesh session sync): local and peer-Mac
// projects interleave name-sorted, a globe marking the remote ones —
// matching the sidebar exactly.
ForEach(store.sidebarProjects, id: \.rowID) { project in
Button {
lastProjectID = project.id.rawValue
} label: {
if project.isRemote {
Label(project.name, systemImage: "globe")
} else {
Text(project.name)
}
}
}
if !store.sidebarProjects.isEmpty { Divider() }
Button("New Project…", systemImage: "folder.badge.plus") {
showingAddProject = true
}
} label: {
HStack(spacing: 4) {
Image(systemName: "folder")
Text(selectedSummary?.name ?? "Select a project")
if isRemoteProject {
Image(systemName: "globe").font(.caption2).foregroundStyle(.secondary)
}
Image(systemName: "chevron.down").font(.caption2)
}
}
.menuStyle(.button)
// The button menu style draws its own disclosure chevron; we supply a
// custom-styled one in the label above, so hide the built-in to avoid
// a double chevron.
.menuIndicator(.hidden)
.fixedSize()
// nvrsion projects have no per-session base branch and no per-session worktree —
// every session runs on the one shared workspace — so neither the branch picker
// nor the worktree toggle has anything to control; hide both. A remote project
// hides them too: the base branch and worktree are the owner's affairs (the
// wire start uses the owner's defaults).
if selectedProject?.nvrsionActive != true && !isRemoteProject {
branchMenu
Toggle(isOn: $useWorktree) {
Label("Worktree", systemImage: "arrow.triangle.branch")
}
.toggleStyle(.checkbox)
.help(useWorktree
? "Run on an isolated worktree branched from \(effectiveBranch)."
: "Run directly in the main checkout on \(effectiveBranch) — no isolation.")
}
Spacer()
// Provider service status (Claude / OpenAI / xAI) — a glanceable health pill,
// expanding to the current incidents on click/hover. Trailing-inset so its right
// edge lines up with the text field's trailing edge (matching the controls row).
StatusFeedIndicator(palette: composerPalette)
}
.font(.callout)
// Past the text field's trailing edge (sendButtonWidth + 8), plus an optical nudge
// left so the pill doesn't read as hugging the right edge.
.padding(.trailing, sendButtonWidth + 20)
// Staged attachments (attach button / paste / drag), shown above the field.
if !attachments.isEmpty {
ComposerAttachmentBar(attachments: $attachments)
.padding(.trailing, sendButtonWidth + 8)
}
HStack(alignment: .bottom, spacing: 8) {
ChatInputField(
text: $draft, placeholder: "Start a new chat…",
submitMode: submitMode, isEnabled: selectedSummary != nil,
height: $composerHeight, onSend: { start(background: $0) },
// Paste/drag can't stage files for a remote project — they don't ride
// the mesh start verb (the attach button is already disabled).
onAttach: { if !isRemoteProject { attachments.append(contentsOf: $0) } },
onDropTargetedChanged: { composerDropTargeted = $0 })
.frame(height: composerHeight)
.padding(8)
.background(.quaternary.opacity(0.5), in: .rect(cornerRadius: 8))
// A persistent lavender ring cues a Nucleic Control project. When Orchestra
// is on, its own purple glow owns the ring, so don't double it up.
.overlay {
if isControlProject && !isOrchestra {
RoundedRectangle(cornerRadius: 8)
.strokeBorder(composerPalette.accent.opacity(0.55), lineWidth: 1)
.allowsHitTesting(false)
}
}
// Light up the field's border while a file/image drag hovers it.
.overlay {
if composerDropTargeted {
RoundedRectangle(cornerRadius: 8)
.strokeBorder(composerPalette.accent, lineWidth: 2)
.allowsHitTesting(false)
}
}
.orchestraGlow(active: isOrchestra)
Button { start(background: background) } label: {
SubmitKeyIcon(mode: submitMode)
}
.buttonStyle(.plain)
.frame(width: sendButtonWidth)
.help(submitMode.detail)
.disabled(selectedSummary == nil || !canStart)
}
HStack(spacing: 8) {
autoToggle
// Shipping implies Auto, so the Ship toggle is only meaningful when Auto
// is on. It slides out from behind the Auto button when Auto is enabled
// and tucks back under it when Auto is turned off. Under nvrsion the trunk is
// promoted at the project level, so a per-chat Merge toggle is meaningless
// and hidden entirely.
// Hidden for a remote project too: autoship is configured on the owner (the
// wire start carries no ship flag), so a toggle here would promise a merge
// the owner may not run.
if effectiveAuto && !nvrsionActive && !isRemoteProject {
shipToggle
.transition(.move(edge: .leading).combined(with: .opacity))
}
// Attach files/images. Sits with the left-hand controls (not beside the field) so
// it never indents the composer. Disabled for a remote project — attachments
// materialize into a local worktree, and they don't ride the mesh start verb.
ComposerAttachButton(isEnabled: selectedProject != nil) { picked in
attachments.append(contentsOf: picked)
}
Spacer()
modelMenu
effortMenu
}
.animation(layoutAnimationsEnabled ? .easeInOut(duration: 0.25) : nil, value: effectiveAuto)
.font(.callout)
.padding(.trailing, sendButtonWidth + 8)
}
}
/// The keystroke that starts the chat and follows into it, mirrored in the floating header hint.
private var startHint: String {
switch submitMode {
case .returnSends: "↵"
case .modifierSends: "⌘↵"
}
}
/// The keystroke that starts the chat but stays put (fires it off in the background). Always
/// the ⌘⇧Return variant regardless of submit mode.
private var backgroundHint: String { "⌘⇧↵" }
private func loadBranches() async {
guard let project = selectedProject else { branches = []; return }
branches = await store.branches(in: project)
if let current = selectedBranch, !branches.contains(current) { selectedBranch = nil }
}
/// Base branch a new chat would start from: the explicit pick, else the
/// project's default branch.
private var effectiveBranch: String {
selectedBranch ?? selectedProject?.defaultBranch.value ?? "main"
}
/// The project a new chat would start in — anywhere in the mesh (mesh session sync): the
/// last-picked project (persisted), resolved over the unified list so a peer Mac's project
/// is pickable exactly like a local one. A remembered pick that can't be resolved (its
/// peer is disconnected, or the project is gone) fails CLOSED to no selection rather than
/// silently retargeting an unrelated project — a typed prompt must never start an agent
/// in a repo it wasn't written for. Only a fresh composer (nothing remembered) defaults
/// to the store's current/first local project.
private var selectedSummary: ProjectSummary? {
guard !lastProjectID.isEmpty else { return store.currentProject.map(ProjectSummary.init) }
return store.sidebarProjects.first { $0.id.rawValue == lastProjectID }
}
/// Whether the selected project lives on a peer Mac. Gates the local-only affordances
/// (base branch, worktree toggle, attachments, ship) — the chat itself starts on the
/// owner over the mesh.
private var isRemoteProject: Bool { selectedSummary?.isRemote == true }
/// The full local record behind the selection, when it lives on this Mac — branch
/// loading, attachments, and ship act on it. `nil` for a peer Mac's project.
private var selectedProject: Project? {
guard let summary = selectedSummary, !summary.isRemote else { return nil }
return store.project(summary.id)
}
// Effective concrete values for the chat about to be started: the user's
// override, or the app default (never shows "Default").
private var effectiveModel: String { modelOverride ?? store.defaultModel ?? ModelCatalog.fallbackModel }
// Clamp to what the chosen model supports, so a stored "max" can't survive onto a Codex model.
private var effectiveEffort: String {
let raw = ModelCatalog.clampedEffort(
effortOverride ?? store.defaultEffort ?? ModelCatalog.fallbackEffort, for: effectiveModel)
// Orchestra requires Nucleic Control; off-control (e.g. an override carried over when the
// project picker changes) it falls back to its underlying level, so the menu never shows
// it active — and a started chat never carries it — where it can't run.
if ModelCatalog.isOrchestra(raw), !isControlProject {
return OrchestrationMode.resolvedEffort(raw) ?? ModelCatalog.fallbackEffort
}
return raw
}
private var defaultModel: String { store.defaultModel ?? ModelCatalog.fallbackModel }
private var defaultEffort: String { store.defaultEffort ?? ModelCatalog.fallbackEffort }
/// Menu item: checkmark on the selected value, a "house" on the app default.
@ViewBuilder
private func choiceLabel(_ display: String, badge: String? = nil, isSelected: Bool, isDefault: Bool) -> some View {
let title = Text(display)
+ (badge.map { Text(" \($0)").foregroundColor(.secondary) } ?? Text(""))
+ (isDefault ? Text(" (default)") : Text(""))
if isSelected {
Label { title } icon: { Image(systemName: "checkmark") }
} else if isDefault {
Label { title } icon: { Image(systemName: "house") }
} else {
title
}
}
private var modelMenu: some View {
Menu {
// Group the SKUs by provider (Claude, GPT, Grok), set apart with a divider.
ForEach(Array(ModelCatalog.modelGroups.enumerated()), id: \.offset) { index, group in
if index > 0 { Divider() }
ForEach(group, id: \.self) { sku in
Button { modelOverride = sku } label: {
choiceLabel(ModelCatalog.displayName(sku),
badge: ModelCatalog.contextBadge(for: sku),
isSelected: sku == effectiveModel, isDefault: sku == defaultModel)
}
}
}
} label: {
Text("Model: \(ModelCatalog.displayName(effectiveModel))")
+ (ModelCatalog.contextBadge(for: effectiveModel).map { Text(" \($0)").foregroundColor(.secondary) } ?? Text(""))
}
.menuStyle(.button)
.fixedSize()
.help("Model for the new chat")
}
/// Whether the chat about to start runs in orchestra (orchestration) mode.
private var isOrchestra: Bool { ModelCatalog.isOrchestra(effectiveEffort) }
/// Whether the selected project is under Nucleic Control — the gate for Orchestra and the
/// cue for the composer's lavender accent. Resolved from the summary so a Control project
/// on a peer Mac themes and gates identically to a local one (its owner applies the same
/// control gate when the chat actually starts).
private var isControlProject: Bool { selectedSummary?.isNucleicControlled == true }
/// Whether the selected project is governed by nvrsion (shared trunk). When it is, the new
/// chat lands on the shared trunk and merges by project-level promotion, so a per-chat Merge
/// toggle is meaningless and hidden — mirrors the in-session composer.
private var nvrsionActive: Bool { selectedProject?.nvrsionActive == true }
/// The accent palette for the composer: lavender when the selected project is Nucleic
/// Control (matching how that project's own session is themed), the app default (teal)
/// otherwise. Recomputed as the project picker changes, so the accent tracks the selection.
private var composerPalette: AppPalette {
AppPalette.make(ColorVisionMode(rawValue: colorVisionRaw) ?? .standard, controlled: isControlProject)
}
private var effortMenu: some View {
Menu {
ForEach(ModelCatalog.efforts(for: effectiveModel), id: \.self) { level in
Button { effortOverride = level } label: {
choiceLabel(ModelCatalog.effortDisplayName(level),
isSelected: level == effectiveEffort, isDefault: level == defaultEffort)
}
}
// Orchestra sits below the API levels, set apart: it isn't an effort level but an
// orchestration mode (xhigh + standing consent to fan out to subagents). It's a
// Nucleic Control capability, so it's only selectable for a Control project — shown
// but disabled (with the reason) otherwise so the requirement is discoverable.
Divider()
Button { effortOverride = ModelCatalog.orchestraEffort } label: {
orchestraMenuItem(available: isControlProject)
}
.disabled(!isControlProject)
.help(isControlProject ? ModelCatalog.orchestraBlurb : ModelCatalog.orchestraRequiresControlHelp)
} label: {
if isOrchestra {
OrchestraEffortLabel()
} else {
Text("\(ModelCatalog.effortNoun(for: effectiveModel)): \(ModelCatalog.effortDisplayName(effectiveEffort))")
}
}
.menuStyle(.button)
.fixedSize()
.help(isOrchestra ? ModelCatalog.orchestraBlurb : "Reasoning effort for the new chat")
}
/// The Orchestra row in an effort menu: a sparkles glyph (a checkmark when it's the selected
/// mode), "Orchestra", and either the "(default)" suffix, or — when unavailable because the
/// project isn't under Nucleic Control — a grayed "Requires Nucleic Control" note.
@ViewBuilder
private func orchestraMenuItem(available: Bool) -> some View {
let selected = isOrchestra
let isDefault = ModelCatalog.isOrchestra(defaultEffort)
Label {
Text("Orchestra")
+ (available
? (isDefault ? Text(" (default)") : Text(""))
: Text(" — \(ModelCatalog.orchestraRequiresControlNote)").foregroundColor(.secondary))
} icon: {
Image(systemName: selected ? "checkmark" : OrchestraStyle.symbol)
}
}
/// Auto-approve mode for the chat about to be started; defaults to the app-wide setting
/// until the user toggles it, but is forced on for a Nucleic Control project — those chats
/// run autonomously and Auto is locked on there. Mirrors the in-session button.
private var effectiveAuto: Bool { isControlProject || (autoOverride ?? store.defaultAuto) }
private var autoToggle: some View {
// Auto is locked on for Nucleic Control projects (nvrsion can't function without it), so
// disable the toggle there. Off-control it stays a normal toggle.
let locked = isControlProject
let lockedHelp = "Auto-approve is always on for Nucleic Control chats — they run autonomously."
return Button {
autoOverride = !effectiveAuto
} label: {
Label("Auto", systemImage: effectiveAuto ? "bolt.fill" : "bolt.slash")
.foregroundStyle(effectiveAuto ? composerPalette.accent : Color.secondary)
}
.buttonStyle(.bordered)
.fixedSize()
.disabled(locked)
.help(locked
? lockedHelp
: (effectiveAuto
? "Auto-approve mode on: the new chat auto-approves safe actions; destructive ones still ask."
: "Manual approvals: every gated tool in the new chat asks first."))
// macOS suppresses .help on disabled controls, so overlay a transparent hit area to
// carry the explanation when Auto is locked on under Nucleic Control.
.overlay {
if locked {
Color.clear
.contentShape(Rectangle())
.help(lockedHelp)
}
}
}
/// Whether autoship can be enabled for the new chat: only Nucleic Control projects may
/// autoship (the hardened, sandboxed path). Mirrors the in-session gate.
private var shipAvailable: Bool { selectedProject?.isNucleicControlled == true }
/// Autoship for the chat about to be started; defaults to the app-wide setting, but is
/// clamped off for a non-Control project so the UI never promises a merge Core won't run.
private var effectiveShip: Bool { (shipOverride ?? store.defaultAutoShip) && shipAvailable }
private var shipToggle: some View {
Button {
guard shipAvailable else { return }
shipOverride = !effectiveShip
} label: {
Label("Ship", systemImage: effectiveShip ? "shippingbox.fill" : "shippingbox")
.foregroundStyle(effectiveShip ? composerPalette.accent : Color.secondary)
}
.buttonStyle(.bordered)
.fixedSize()
.disabled(!shipAvailable)
.help(shipAvailable
? (effectiveShip
? "Autoship on: when the new chat's agent finishes, squash-merge its branch into the destination branch via the merge queue (keeps Auto on)."
: "Autoship off: finished work waits for a manual merge.")
: "Autoship requires Nucleic Control — clone or move this project under Nucleic Control to enable it.")
// macOS suppresses .help tooltips on disabled controls, so overlay an enabled,
// transparent hit area to carry the explanation when Merge is grayed out off-control.
.overlay {
if !shipAvailable {
Color.clear
.contentShape(Rectangle())
.help("Autoship/automerge is only available for Nucleic Control projects — clone or move this project under Nucleic Control to enable it.")
}
}
}
private var branchMenu: some View {
Menu {
ForEach(branches, id: \.self) { name in
Button { selectedBranch = name } label: {
choiceLabel(name, isSelected: name == effectiveBranch,
isDefault: name == selectedProject?.defaultBranch.value)
}
}
} label: {
// Mirror the project selector to its left (icon + name + chevron) so the
// two read as the same control; a plain Text label rendered as a near-
// invisible white field against the chat bar.
HStack(spacing: 4) {
Image(systemName: "arrow.triangle.branch")
Text(effectiveBranch)
Image(systemName: "chevron.down").font(.caption2)
}
}
.menuStyle(.button)
// See the project selector above: hide the built-in disclosure chevron so
// the custom one in the label isn't doubled up.
.menuIndicator(.hidden)
.fixedSize()
.disabled(branches.isEmpty)
.help("Base branch the new chat starts from")
}
/// Whether the new chat can start: a typed prompt, or staged attachments on their own (an
/// attachment-only first message is allowed — the references become its body). A remote
/// project needs text: attachments can't ride the mesh start verb, so they must never be
/// the thing that lights the send button.
private var canStart: Bool {
let hasText = !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
return isRemoteProject ? hasText : hasText || !attachments.isEmpty
}
/// Start the chat. `background` (⌘⇧Return, or the ⌘N button default) fires it off without
/// navigating; the default (⌘Return) follows into the new session.
private func start(background: Bool) {
let trimmed = draft.trimmingCharacters(in: .whitespacesAndNewlines)
// A project on a peer Mac (mesh session sync): start the chat on its owner over the
// mesh — the same wire verb a phone's composer uses. The local-only affordances
// (branch, worktree, attachments, ship) are hidden for a remote selection; the new
// chat appears in the sidebar through the owner's broadcasts. Attachments staged
// before the picker moved to a remote project stay staged (they can't ride the wire
// and must not be silently thrown away) — the user is told, and they're still there
// if the picker returns to a local project.
if isRemoteProject {
guard let summary = selectedSummary, !trimmed.isEmpty else { return }
draft = ""
if !attachments.isEmpty {
store.lastError = "Attachments can't be sent to a project on another Mac yet — "
+ "the chat started without them."
}
let text = SingularityPreparation.prepare(trimmed)
let model = effectiveModel
let effort = effectiveEffort
let auto = effectiveAuto
Task {
await store.startRemoteChat(
in: summary, message: text, model: model, effort: effort, auto: auto)
}
if floating { dismiss() }
return
}
guard let project = selectedProject, !trimmed.isEmpty || !attachments.isEmpty else { return }
// Hand the staged attachments to the start Task and clear the composer immediately.
let staged = attachments
draft = ""
attachments = []
let text = SingularityPreparation.prepare(trimmed)
let base = GitRef(effectiveBranch)
let worktree = useWorktree
let auto = effectiveAuto
let ship = effectiveShip
// The inline home bar always reveals the new chat; the floating panel follows into the new
// session on ⌘Return and stays put (background) on ⌘⇧Return.
let reveal = !floating || !background
Task {
// Read each attachment's bytes off the main thread — media can be large.
let pending = await Task.detached { staged.compactMap { try? $0.pending() } }.value
await store.startChat(
in: project, message: text, model: effectiveModel, effort: effectiveEffort,
base: base, useWorktree: worktree, auto: auto, autoShip: ship, attachments: pending,
reveal: reveal)
}
// Close the floating panel once started. In reveal mode startChat opens the new chat so
// it's revealed underneath; in background mode it stays on the current view.
if floating { dismiss() }
}
}
/// Floating ⌘⇧N presentation of the new-chat composer. Owns a transient draft (so a
/// quick-launch message never bleeds into the home bar's persisted draft) and hands
/// `NewChatComposer` the `floating` flag that adds its titled header and self-dismiss.
struct NewChatComposerSheet: View {
@Environment(AppStore.self) private var store
@Environment(\.dismiss) private var dismiss
@State private var draft = ""
var body: some View {
NewChatComposer(draft: $draft, floating: true, background: store.newChatComposerStartsInBackground)
// The panel keeps its own rounded card chrome; the sheet's own background is
// cleared below so the window shows through the empty space around it.
.background(.regularMaterial, in: .rect(cornerRadius: 16))
// Fill the window so a click in the empty space around the panel lands on the dismiss
// backdrop below — the click-outside-to-close feel of a popover, which a plain modal
// sheet doesn't offer. The panel's own material intercepts its clicks, so only the
// surrounding space dismisses. Center it (no top padding) — an added top inset renders
// as a blank "forehead" band above the header once the sheet dims the window behind it.
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background {
Rectangle().fill(.black.opacity(0.001))
.contentShape(Rectangle())
.onTapGesture { dismiss() }
}
// Drop the sheet's opaque material so the backdrop (and the window behind it) shows
// around the floating panel instead of a full-window sheet fill.
.presentationBackground(.clear)
}
}