From e49501fb124dd08fa7279abc83b7cb889fafe73b Mon Sep 17 00:00:00 2001 From: Andrew Blakeslee Moore Date: Sun, 5 Jul 2026 01:18:39 -0700 Subject: [PATCH] =?UTF-8?q?nvrsion:=20Add,=20refactor,=20and=20update=20iO?= =?UTF-8?q?S=20chat=20view:=20move=20diff=20viewer=20behind=20=E2=80=9C?= =?UTF-8?q?=E2=80=A6=E2=80=9D=20button,=20boost=20contrast=20on=20chat=20b?= =?UTF-8?q?ar,=20and=20remove=20floating=20keyboard-down=20button.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nucleic-Promote: 1 Co-authored-by: Nucleic --- .../Views/SessionDetailView.swift | 171 ++++++++++-------- NucleicRemote/NucleicRemote/Views/Theme.swift | 15 +- 2 files changed, 107 insertions(+), 79 deletions(-) diff --git a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift index 8068246..f4f7752 100644 --- a/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift +++ b/NucleicRemote/NucleicRemote/Views/SessionDetailView.swift @@ -1,15 +1,15 @@ import SwiftUI import NucleicProtocol -/// One session: a full-height Transcript/Diff read view (mode flip in the navigation bar) with -/// a status-driven action bar floating over it on Liquid Glass (UX_IOS §4). Subscribes on +/// One session: a full-height transcript with a status-driven action bar floating over it on +/// Liquid Glass; the diff viewer sits behind the ⋯ menu as a sheet (UX_IOS §4). Subscribes on /// appear, unsubscribes on disappear. struct SessionDetailView: View { @EnvironmentObject var store: RemoteStore let sessionID: SessionID - // Initial tab (0 = Transcript, 1 = Diff); overridable via NUCLEIC_DETAIL_TAB for - // offline UI previews, matching NUCLEIC_TAB / NUCLEIC_DEMO_SESSION. - @State private var tab = Int(ProcessInfo.processInfo.environment["NUCLEIC_DETAIL_TAB"] ?? "") ?? 0 + // Diff sheet visibility; NUCLEIC_DETAIL_TAB=1 opens it for offline UI previews, + // matching NUCLEIC_TAB / NUCLEIC_DEMO_SESSION. + @State private var showDiff = ProcessInfo.processInfo.environment["NUCLEIC_DETAIL_TAB"] == "1" @State private var draft = "" @State private var showRename = false @State private var renameDraft = "" @@ -21,39 +21,16 @@ struct SessionDetailView: View { } var body: some View { - Group { - if tab == 0 { - TranscriptList(events: store.openEvents) - } else if store.capabilities.canFetchDiff || store.openDiff != nil { - SessionDiffView(diff: store.openDiff, loading: store.diffLoading) - .onAppear { store.fetchDiff(sessionID) } - } else { - // Older host with no on-demand diff — the stat summary is all it projects. - DiffSummaryView(diffStat: summary?.diffStat) + TranscriptList(events: store.openEvents) + // The chat bar floats over the scrolling content on Liquid Glass instead of sitting + // in a boxed strip below it, so the transcript runs the full height of the screen. + .safeAreaInset(edge: .bottom) { actionArea } + .navigationTitle(summary?.title ?? "Session") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { sessionMenu } } - } - // The chat bar floats over the scrolling content on Liquid Glass instead of sitting in - // a boxed strip below it, so the transcript runs the full height of the screen. - .safeAreaInset(edge: .bottom) { actionArea } - .navigationTitle(summary?.title ?? "Session") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - // Transcript/Diff lives in the navigation bar (icon segments) rather than a - // dedicated row — it's a mode flip, not content, and shouldn't cost chat height. - ToolbarItem(placement: .topBarTrailing) { - Picker("View", selection: $tab) { - Image(systemName: "text.bubble").tag(0) - .accessibilityLabel("Transcript") - Image(systemName: "plus.forwardslash.minus").tag(1) - .accessibilityLabel("Diff") - } - .pickerStyle(.segmented) - .fixedSize() - } - if let summary, store.canControl { - ToolbarItem(placement: .topBarTrailing) { sessionMenu(summary) } - } - } + .sheet(isPresented: $showDiff) { diffSheet } .alert("Rename chat", isPresented: $showRename) { TextField("Title", text: $renameDraft) Button("Cancel", role: .cancel) {} @@ -143,53 +120,97 @@ struct SessionDetailView: View { lockWhenOrchestra: true) Spacer(minLength: 4) if let percent = contextPercent { + // Weight and primary ink (not `.secondary`) so the metric stays legible over + // whatever the Liquid Glass bar is blurring through. Label("\(percent)%", systemImage: "gauge.with.dots.needle.33percent") - .font(.caption2).foregroundStyle(.secondary) + .font(.caption2.weight(.semibold)).foregroundStyle(.primary) .help("Context window used") } Toggle(isOn: Binding(get: { summary.auto }, set: { store.setSessionAuto(sessionID, $0) })) { Label("Auto", systemImage: summary.auto ? "bolt.fill" : "bolt.slash") } - .toggleStyle(.button).tint(Palette.accent).font(.caption2) + .toggleStyle(.button).tint(Palette.accent).font(.caption2.weight(.semibold)) + } + } + + /// The ⋯ menu: the diff viewer for everyone, then the control-scope session actions. + @ViewBuilder + private var sessionMenu: some View { + Menu { + Button { showDiff = true } label: { + Label(diffLabel, systemImage: "plus.forwardslash.minus") + } + if let summary, store.canControl { + Divider() + sessionActions(summary) + } + } label: { + Image(systemName: "ellipsis.circle") + } + } + + /// "View changes" with the live +/− counts when the host projects them, so the menu hints + /// whether there's anything to look at before opening the sheet. + private var diffLabel: String { + guard let stat = summary?.diffStat, stat.filesChanged > 0 else { return "View changes" } + return "View changes · +\(stat.added) −\(stat.removed)" + } + + /// The diff, presented as a sheet from the ⋯ menu — a viewer you visit, not a mode the + /// chat lives in. + private var diffSheet: some View { + NavigationStack { + Group { + if store.capabilities.canFetchDiff || store.openDiff != nil { + SessionDiffView(diff: store.openDiff, loading: store.diffLoading) + .onAppear { store.fetchDiff(sessionID) } + } else { + // Older host with no on-demand diff — the stat summary is all it projects. + DiffSummaryView(diffStat: summary?.diffStat) + } + } + .navigationTitle("Changes") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { showDiff = false } + } + } } } @ViewBuilder - private func sessionMenu(_ summary: WireSessionSummary) -> some View { - Menu { - Button { renameDraft = summary.title; showRename = true } label: { - Label("Rename", systemImage: "pencil") + private func sessionActions(_ summary: WireSessionSummary) -> some View { + Button { renameDraft = summary.title; showRename = true } label: { + Label("Rename", systemImage: "pencil") + } + Button { store.setFavorite(sessionID, !summary.favorite) } label: { + Label(summary.favorite ? "Unfavorite" : "Favorite", + systemImage: summary.favorite ? "star.slash" : "star") + } + // Autoship is a Nucleic Control capability — offered only for Control projects, and + // the host couples it with auto-approval (enabling it turns Auto on). + if controlled { + Button { store.setSessionAutoShip(sessionID, !summary.autoShip) } label: { + Label(summary.autoShip ? "Turn off Autoship" : "Turn on Autoship", + systemImage: summary.autoShip ? "shippingbox.fill" : "shippingbox") } - Button { store.setFavorite(sessionID, !summary.favorite) } label: { - Label(summary.favorite ? "Unfavorite" : "Favorite", - systemImage: summary.favorite ? "star.slash" : "star") + } + if summary.status == .running { + Button { store.interrupt(sessionID) } label: { Label("Interrupt", systemImage: "stop.circle") } + } + Section { + Button { showIntegrate = true } label: { Label("Integrate…", systemImage: "arrow.triangle.merge") } + Button(role: .destructive) { showDiscard = true } label: { + Label("Discard…", systemImage: "xmark.bin") } - // Autoship is a Nucleic Control capability — offered only for Control projects, and - // the host couples it with auto-approval (enabling it turns Auto on). - if controlled { - Button { store.setSessionAutoShip(sessionID, !summary.autoShip) } label: { - Label(summary.autoShip ? "Turn off Autoship" : "Turn on Autoship", - systemImage: summary.autoShip ? "shippingbox.fill" : "shippingbox") - } + Button { store.setArchived(sessionID, !summary.archived) } label: { + Label(summary.archived ? "Unarchive" : "Archive", + systemImage: summary.archived ? "tray.and.arrow.up" : "archivebox") } - if summary.status == .running { - Button { store.interrupt(sessionID) } label: { Label("Interrupt", systemImage: "stop.circle") } - } - Section { - Button { showIntegrate = true } label: { Label("Integrate…", systemImage: "arrow.triangle.merge") } - Button(role: .destructive) { showDiscard = true } label: { - Label("Discard…", systemImage: "xmark.bin") - } - Button { store.setArchived(sessionID, !summary.archived) } label: { - Label(summary.archived ? "Unarchive" : "Archive", - systemImage: summary.archived ? "tray.and.arrow.up" : "archivebox") - } - } - Button(role: .destructive) { store.deleteSession(sessionID) } label: { - Label("Delete", systemImage: "trash") - } - } label: { - Image(systemName: "ellipsis.circle") + } + Button(role: .destructive) { store.deleteSession(sessionID) } label: { + Label("Delete", systemImage: "trash") } } @@ -221,7 +242,7 @@ struct SessionDetailView: View { if running { HStack(spacing: 8) { ProgressView().controlSize(.small) - Text("Agent is working…").font(.footnote).foregroundStyle(.secondary) + Text("Agent is working…").font(.footnote).foregroundStyle(.primary) Spacer() } .frame(maxWidth: .infinity, alignment: .leading) @@ -229,11 +250,13 @@ struct SessionDetailView: View { if store.canControl, let summary { controlRow(summary) } if canCompose { HStack(alignment: .bottom, spacing: 8) { + // No keyboard-accessory Done button here (it floats awkwardly + // over the glass bar on iOS 26) — a drag on the transcript + // dismisses the keyboard instead (`scrollDismissesKeyboard`). TextField(running ? "Queue a follow-up…" : "Send a follow-up…", text: $draft, axis: .vertical) .textFieldStyle(.plain) .lineLimit(1...4) - .keyboardDismissable() .padding(.vertical, 3) Button { store.sendInput(draft, to: sessionID) @@ -344,7 +367,7 @@ struct TranscriptList: View { } } -/// The Diff tab, read-only over the on-demand `WireSessionDiff`. On a phone it's a per-file +/// The diff sheet's content, read-only over the on-demand `WireSessionDiff`. On a phone it's a per-file /// summary over one scrolling unified patch; on a regular-width iPad it becomes the Mac's /// two-pane diff — a selectable file list beside the selected file's patch — exploiting the /// wide detail column. Same wire, no new protocol. diff --git a/NucleicRemote/NucleicRemote/Views/Theme.swift b/NucleicRemote/NucleicRemote/Views/Theme.swift index 5c2a21f..8406b5f 100644 --- a/NucleicRemote/NucleicRemote/Views/Theme.swift +++ b/NucleicRemote/NucleicRemote/Views/Theme.swift @@ -423,15 +423,17 @@ struct ModelMenu: View { } } } label: { + // Accent icon, primary-ink text: an all-accent caption washes out on the Liquid + // Glass chat bar, so only the glyph carries the tint. HStack(spacing: 4) { - Image(systemName: "cpu") + Image(systemName: "cpu").foregroundStyle(Palette.accent) Text(label) if let badge = model.flatMap({ catalog.contextBadge($0) }) { Text(badge).foregroundStyle(.secondary) } } - .font(.caption) - .foregroundStyle(Palette.accent) + .font(.caption.weight(.semibold)) + .foregroundStyle(.primary) } .disabled(groups.isEmpty) } @@ -495,12 +497,15 @@ struct EffortMenu: View { } .disabled(!controlled) } label: { + // Same treatment as `ModelMenu`: tinted glyph, primary-ink text for glass-bar + // legibility. Orchestra keeps its full-gold signature — that state should glow. HStack(spacing: 4) { Image(systemName: orchestraActive ? "sparkles" : "slider.horizontal.3") + .foregroundStyle(orchestraActive ? Palette.orchestra : Palette.accent) Text(display(triggerEffort)) } - .font(.caption) - .foregroundStyle(orchestraActive ? Palette.orchestra : Palette.accent) + .font(.caption.weight(.semibold)) + .foregroundStyle(orchestraActive ? AnyShapeStyle(Palette.orchestra) : AnyShapeStyle(.primary)) } } }