Merge branch 'dev' into canary
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user