nvrsion: Add, refactor, and update iOS chat view: move diff viewer behind “…” button, boost contrast on chat bar, and remove floating keyboard-down button.

Nucleic-Promote: 1
Co-authored-by: Nucleic <[email protected]>
This commit is contained in:
2026-07-05 01:18:39 -07:00
co-authored by Nucleic
parent 8b37b8576f
commit e49501fb12
2 changed files with 107 additions and 79 deletions
@@ -1,15 +1,15 @@
import SwiftUI import SwiftUI
import NucleicProtocol import NucleicProtocol
/// One session: a full-height Transcript/Diff read view (mode flip in the navigation bar) with /// One session: a full-height transcript with a status-driven action bar floating over it on
/// a status-driven action bar floating over it on Liquid Glass (UX_IOS §4). Subscribes on /// Liquid Glass; the diff viewer sits behind the ⋯ menu as a sheet (UX_IOS §4). Subscribes on
/// appear, unsubscribes on disappear. /// appear, unsubscribes on disappear.
struct SessionDetailView: View { struct SessionDetailView: View {
@EnvironmentObject var store: RemoteStore @EnvironmentObject var store: RemoteStore
let sessionID: SessionID let sessionID: SessionID
// Initial tab (0 = Transcript, 1 = Diff); overridable via NUCLEIC_DETAIL_TAB for // Diff sheet visibility; NUCLEIC_DETAIL_TAB=1 opens it for offline UI previews,
// offline UI previews, matching NUCLEIC_TAB / NUCLEIC_DEMO_SESSION. // matching NUCLEIC_TAB / NUCLEIC_DEMO_SESSION.
@State private var tab = Int(ProcessInfo.processInfo.environment["NUCLEIC_DETAIL_TAB"] ?? "") ?? 0 @State private var showDiff = ProcessInfo.processInfo.environment["NUCLEIC_DETAIL_TAB"] == "1"
@State private var draft = "" @State private var draft = ""
@State private var showRename = false @State private var showRename = false
@State private var renameDraft = "" @State private var renameDraft = ""
@@ -21,39 +21,16 @@ struct SessionDetailView: View {
} }
var body: some View { var body: some View {
Group { TranscriptList(events: store.openEvents)
if tab == 0 { // The chat bar floats over the scrolling content on Liquid Glass instead of sitting
TranscriptList(events: store.openEvents) // in a boxed strip below it, so the transcript runs the full height of the screen.
} else if store.capabilities.canFetchDiff || store.openDiff != nil { .safeAreaInset(edge: .bottom) { actionArea }
SessionDiffView(diff: store.openDiff, loading: store.diffLoading) .navigationTitle(summary?.title ?? "Session")
.onAppear { store.fetchDiff(sessionID) } .navigationBarTitleDisplayMode(.inline)
} else { .toolbar {
// Older host with no on-demand diff — the stat summary is all it projects. ToolbarItem(placement: .topBarTrailing) { sessionMenu }
DiffSummaryView(diffStat: summary?.diffStat)
} }
} .sheet(isPresented: $showDiff) { diffSheet }
// 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) }
}
}
.alert("Rename chat", isPresented: $showRename) { .alert("Rename chat", isPresented: $showRename) {
TextField("Title", text: $renameDraft) TextField("Title", text: $renameDraft)
Button("Cancel", role: .cancel) {} Button("Cancel", role: .cancel) {}
@@ -143,53 +120,97 @@ struct SessionDetailView: View {
lockWhenOrchestra: true) lockWhenOrchestra: true)
Spacer(minLength: 4) Spacer(minLength: 4)
if let percent = contextPercent { 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") Label("\(percent)%", systemImage: "gauge.with.dots.needle.33percent")
.font(.caption2).foregroundStyle(.secondary) .font(.caption2.weight(.semibold)).foregroundStyle(.primary)
.help("Context window used") .help("Context window used")
} }
Toggle(isOn: Binding(get: { summary.auto }, set: { store.setSessionAuto(sessionID, $0) })) { Toggle(isOn: Binding(get: { summary.auto }, set: { store.setSessionAuto(sessionID, $0) })) {
Label("Auto", systemImage: summary.auto ? "bolt.fill" : "bolt.slash") 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 @ViewBuilder
private func sessionMenu(_ summary: WireSessionSummary) -> some View { private func sessionActions(_ summary: WireSessionSummary) -> some View {
Menu { Button { renameDraft = summary.title; showRename = true } label: {
Button { renameDraft = summary.title; showRename = true } label: { Label("Rename", systemImage: "pencil")
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", if summary.status == .running {
systemImage: summary.favorite ? "star.slash" : "star") 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 Button { store.setArchived(sessionID, !summary.archived) } label: {
// the host couples it with auto-approval (enabling it turns Auto on). Label(summary.archived ? "Unarchive" : "Archive",
if controlled { systemImage: summary.archived ? "tray.and.arrow.up" : "archivebox")
Button { store.setSessionAutoShip(sessionID, !summary.autoShip) } label: {
Label(summary.autoShip ? "Turn off Autoship" : "Turn on Autoship",
systemImage: summary.autoShip ? "shippingbox.fill" : "shippingbox")
}
} }
if summary.status == .running { }
Button { store.interrupt(sessionID) } label: { Label("Interrupt", systemImage: "stop.circle") } Button(role: .destructive) { store.deleteSession(sessionID) } label: {
} Label("Delete", systemImage: "trash")
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")
} }
} }
@@ -221,7 +242,7 @@ struct SessionDetailView: View {
if running { if running {
HStack(spacing: 8) { HStack(spacing: 8) {
ProgressView().controlSize(.small) ProgressView().controlSize(.small)
Text("Agent is working…").font(.footnote).foregroundStyle(.secondary) Text("Agent is working…").font(.footnote).foregroundStyle(.primary)
Spacer() Spacer()
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -229,11 +250,13 @@ struct SessionDetailView: View {
if store.canControl, let summary { controlRow(summary) } if store.canControl, let summary { controlRow(summary) }
if canCompose { if canCompose {
HStack(alignment: .bottom, spacing: 8) { 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…", TextField(running ? "Queue a follow-up…" : "Send a follow-up…",
text: $draft, axis: .vertical) text: $draft, axis: .vertical)
.textFieldStyle(.plain) .textFieldStyle(.plain)
.lineLimit(1...4) .lineLimit(1...4)
.keyboardDismissable()
.padding(.vertical, 3) .padding(.vertical, 3)
Button { Button {
store.sendInput(draft, to: sessionID) 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 /// 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 /// two-pane diff — a selectable file list beside the selected file's patch — exploiting the
/// wide detail column. Same wire, no new protocol. /// wide detail column. Same wire, no new protocol.
+10 -5
View File
@@ -423,15 +423,17 @@ struct ModelMenu: View {
} }
} }
} label: { } 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) { HStack(spacing: 4) {
Image(systemName: "cpu") Image(systemName: "cpu").foregroundStyle(Palette.accent)
Text(label) Text(label)
if let badge = model.flatMap({ catalog.contextBadge($0) }) { if let badge = model.flatMap({ catalog.contextBadge($0) }) {
Text(badge).foregroundStyle(.secondary) Text(badge).foregroundStyle(.secondary)
} }
} }
.font(.caption) .font(.caption.weight(.semibold))
.foregroundStyle(Palette.accent) .foregroundStyle(.primary)
} }
.disabled(groups.isEmpty) .disabled(groups.isEmpty)
} }
@@ -495,12 +497,15 @@ struct EffortMenu: View {
} }
.disabled(!controlled) .disabled(!controlled)
} label: { } 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) { HStack(spacing: 4) {
Image(systemName: orchestraActive ? "sparkles" : "slider.horizontal.3") Image(systemName: orchestraActive ? "sparkles" : "slider.horizontal.3")
.foregroundStyle(orchestraActive ? Palette.orchestra : Palette.accent)
Text(display(triggerEffort)) Text(display(triggerEffort))
} }
.font(.caption) .font(.caption.weight(.semibold))
.foregroundStyle(orchestraActive ? Palette.orchestra : Palette.accent) .foregroundStyle(orchestraActive ? AnyShapeStyle(Palette.orchestra) : AnyShapeStyle(.primary))
} }
} }
} }