Merge branch 'dev' into canary
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
// 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.
|
||||
// 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) }
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) { sessionMenu }
|
||||
}
|
||||
.sheet(isPresented: $showDiff) { diffSheet }
|
||||
.alert("Rename chat", isPresented: $showRename) {
|
||||
TextField("Title", text: $renameDraft)
|
||||
Button("Cancel", role: .cancel) {}
|
||||
@@ -143,20 +120,67 @@ 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 {
|
||||
private func sessionActions(_ summary: WireSessionSummary) -> some View {
|
||||
Button { renameDraft = summary.title; showRename = true } label: {
|
||||
Label("Rename", systemImage: "pencil")
|
||||
}
|
||||
@@ -188,9 +212,6 @@ struct SessionDetailView: View {
|
||||
Button(role: .destructive) { store.deleteSession(sessionID) } label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
}
|
||||
|
||||
/// The floating action layer: one Liquid Glass bar carrying the queued pills, the working
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user