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]>
592 lines
31 KiB
Swift
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)
|
|
}
|
|
}
|