When autoship aborts on a merge conflict or merge error it turns itself off; the chat's sidebar entry now shows an exclamation-triangle marker until the user re-enables autoship. Unlike the shipped marker (lastEventWasAutoship), this is sticky session state, not tied to the latest event — a failure needs attention and shouldn't vanish the moment something else happens. - Session + SessionSummary gain `autoShipFailed`; persisted via migration v13-autoship-failed. - SessionController.markAutoShipFailed() turns autoship off and sets the marker atomically; the merge queue's conflict/failed paths now call it instead of a plain setAutoShip(false). setAutoShip(true) clears the marker (re-opt-in). A clean user toggle-off leaves it untouched. - RootView.SessionRow renders the warning icon, taking precedence over the shipped icon. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
524 lines
26 KiB
Swift
524 lines
26 KiB
Swift
import SwiftUI
|
||
import NucleicCore
|
||
|
||
/// Sidebar (projects → sessions) + detail, the macOS information architecture
|
||
/// from UX_MACOS.
|
||
struct RootView: View {
|
||
@Environment(AppStore.self) private var store
|
||
@State private var showingAddProject = false
|
||
@State private var showingLockQueue = false
|
||
@State private var renamingProject: Project?
|
||
@State private var renameProjectDraft = ""
|
||
@State private var deletingProject: Project?
|
||
/// Global panel-layout state (left/right/bottom panels around the chat), shared
|
||
/// by every open chat. Created here so it outlives navigation between sessions.
|
||
@State private var panels = PanelLayoutStore()
|
||
/// Shared "open file" selection, written by Files panels and read by Editor
|
||
/// panels so clicking a file opens it in the editor.
|
||
@State private var fileSelection = FileSelectionStore()
|
||
/// Shared editing buffer for the Editor pane, so its header controls and body
|
||
/// drive the same state.
|
||
@State private var editor = EditorModel()
|
||
/// Current sidebar width, used to drop the second trailing swipe action (Delete)
|
||
/// when the column is too narrow to fit both buttons without clipping.
|
||
@State private var sidebarWidth: CGFloat = 0
|
||
/// Owns the AppKit sidebar item so the column opens/closes only on our toggle,
|
||
/// never on its own when the window resizes (see `SidebarColumnController`).
|
||
@State private var sidebarColumn = SidebarColumnController()
|
||
/// Projects whose "Archived" group is expanded. Tracked here (rather than a
|
||
/// `DisclosureGroup`) so the chevron aligns with the session rows' leading edge.
|
||
@State private var expandedArchive: Set<Project.ID> = []
|
||
@AppStorage(AppTextSize.storageKey) private var textSizeRaw = AppTextSize.medium.rawValue
|
||
@AppStorage(ColorVisionMode.storageKey) private var colorVisionRaw = ColorVisionMode.standard.rawValue
|
||
@AppStorage(AppAppearance.storageKey) private var appearanceRaw = AppAppearance.dark.rawValue
|
||
|
||
private var textSize: AppTextSize { AppTextSize(rawValue: textSizeRaw) ?? .medium }
|
||
private var appearance: AppAppearance { AppAppearance(rawValue: appearanceRaw) ?? .dark }
|
||
private var palette: AppPalette {
|
||
AppPalette.make(ColorVisionMode(rawValue: colorVisionRaw) ?? .standard)
|
||
}
|
||
|
||
var body: some View {
|
||
@Bindable var store = store
|
||
NavigationSplitView {
|
||
// We drive selection manually (tap sets `openSessionID`) and draw our own
|
||
// gray highlight via `.listRowBackground` below, rather than binding
|
||
// `List(selection:)`. On macOS the built-in sidebar selection paints a
|
||
// capsule in the *system* accent color (a saturated blue), which `.tint`
|
||
// cannot override and which drowns out each row's status dot and icons.
|
||
List {
|
||
if store.projects.isEmpty {
|
||
Text("Add a project to begin").foregroundStyle(.secondary)
|
||
}
|
||
// Flat rows rather than `Section { } header: { }`: the macOS `.sidebar`
|
||
// list style indents everything nested inside a Section (to reserve room
|
||
// for a section collapse triangle), which pushes session titles right
|
||
// until they clip. Rendering the header as a top-level row and the
|
||
// sessions as its siblings keeps them flush to the column edge.
|
||
ForEach(store.projects) { project in
|
||
projectHeader(project)
|
||
.listRowInsets(EdgeInsets())
|
||
.listRowBackground(Color.clear)
|
||
.listRowSeparator(.hidden)
|
||
let sessions = store.summaries(for: project.id)
|
||
if sessions.isEmpty {
|
||
Text("No chats yet").font(.caption).foregroundStyle(.secondary)
|
||
}
|
||
ForEach(sessions) { summary in
|
||
sessionRow(summary)
|
||
}
|
||
let archived = store.archivedSummaries(for: project.id)
|
||
if !archived.isEmpty {
|
||
archivedHeader(for: project, count: archived.count)
|
||
.listRowInsets(EdgeInsets())
|
||
.listRowBackground(Color.clear)
|
||
.listRowSeparator(.hidden)
|
||
if expandedArchive.contains(project.id) {
|
||
ForEach(archived) { summary in
|
||
sessionRow(summary).opacity(0.7)
|
||
.transition(.move(edge: .top).combined(with: .opacity))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Inset the row content from the column edges so text/icons aren't flush
|
||
// against the sides. Applied before `.background` so the sidebar fill stays
|
||
// full-bleed — only the scrollable content is padded.
|
||
.safeAreaPadding(.horizontal, 5)
|
||
.scrollContentBackground(.hidden)
|
||
.background(AppTheme.sidebar)
|
||
.background {
|
||
GeometryReader { proxy in
|
||
Color.clear
|
||
.onAppear { sidebarWidth = proxy.size.width }
|
||
.onChange(of: proxy.size.width) { _, new in sidebarWidth = new }
|
||
}
|
||
}
|
||
.background { SidebarColumnConfigurator(controller: sidebarColumn) }
|
||
.navigationTitle("Nucleic")
|
||
.frame(minWidth: 320)
|
||
// Replace NavigationSplitView's built-in toggle with our own as the first
|
||
// leading item, so the sidebar button sits immediately right of the traffic
|
||
// lights. We toggle the AppKit split-view item directly (see
|
||
// `SidebarColumnController`) rather than a `columnVisibility` binding on
|
||
// purpose: a SwiftUI visibility binding makes these leading toolbar items
|
||
// reflow (the cluster reorders) when the column collapses, whereas driving
|
||
// the item leaves the toolbar untouched — the button stays pinned beside the
|
||
// traffic lights through open / close / resize. Owning the item also lets us
|
||
// pin the column open against AppKit's automatic collapse-on-resize.
|
||
.toolbar(removing: .sidebarToggle)
|
||
.toolbar {
|
||
ToolbarItem {
|
||
Button(action: toggleSidebar) {
|
||
Label("Toggle Sidebar", systemImage: "sidebar.leading")
|
||
// Color the symbol explicitly: the split view's `.tint`
|
||
// doesn't reliably reach items hoisted into the window
|
||
// toolbar, so in light mode these rendered as a near-
|
||
// invisible white. `.primary` adapts to the standard
|
||
// label color — black in light mode, white in dark.
|
||
.foregroundStyle(.primary)
|
||
}
|
||
.help("Show or hide the sidebar")
|
||
}
|
||
ToolbarItem {
|
||
Button { store.goHome() } label: {
|
||
Label("Home", systemImage: "house")
|
||
.foregroundStyle(.primary)
|
||
}
|
||
.help("Back to the dashboard")
|
||
// Intentionally always enabled. A conditional `.disabled` here makes
|
||
// the icon flicker — a disabled toolbar item cross-fades between its
|
||
// dimmed and active appearance every time the toolbar re-renders
|
||
// (which is often, as the sidebar tracks live session state). On the
|
||
// dashboard goHome() is a harmless no-op, so there's nothing to guard.
|
||
}
|
||
ToolbarItem {
|
||
Button { showingAddProject = true } label: {
|
||
Label("Add Project", systemImage: "folder.badge.plus")
|
||
.foregroundStyle(.primary)
|
||
}
|
||
.help("Add a project")
|
||
}
|
||
ToolbarItem {
|
||
Button { showingLockQueue = true } label: {
|
||
// Amber while agents are queued for access, so the toolbar
|
||
// surfaces contention at a glance; the accent otherwise.
|
||
Label("File Locks", systemImage: store.sessionsWaitingForAccess.isEmpty
|
||
? "lock.doc" : "lock.doc.fill")
|
||
.foregroundStyle(store.sessionsWaitingForAccess.isEmpty
|
||
? Color.primary : palette.attention)
|
||
}
|
||
.help("See which sessions hold file locks and which are waiting for one")
|
||
}
|
||
}
|
||
} detail: {
|
||
VStack(spacing: 0) {
|
||
// Pre-prod warning header (red dev / blue beta / gold rc). Scoped to the
|
||
// detail pane so the area above the sidebar keeps the plain window chrome;
|
||
// renders nothing on a shipping release.
|
||
BuildBanner()
|
||
Group {
|
||
if store.openSessionID != nil {
|
||
PaneledChatView()
|
||
} else if let projectID = store.openProjectID, let project = store.project(projectID) {
|
||
ProjectView(project: project)
|
||
} else {
|
||
HomeView()
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
}
|
||
.background(AppTheme.background)
|
||
// Transient error bubble (e.g. "Autoship failed: …"). Scoped to the detail
|
||
// pane so it centers over the chat interface, not the full window width.
|
||
.overlay(alignment: .bottom) {
|
||
if let error = store.lastError {
|
||
let target = store.lastErrorSessionID
|
||
Text(error)
|
||
.font(.caption).padding(8)
|
||
.background(.red.opacity(0.85), in: .rect(cornerRadius: 8))
|
||
.foregroundStyle(.white).padding()
|
||
// Click the bubble to dismiss; if it names a responsible chat
|
||
// (e.g. an autoship failure), jump to that chat on the way out.
|
||
.onTapGesture {
|
||
if let target { store.openSessionID = target }
|
||
store.lastError = nil
|
||
}
|
||
.help(target == nil ? "Click to dismiss" : "Click to open the responsible chat")
|
||
// …and auto-dismiss after a few seconds so it never sticks. The
|
||
// task is keyed on the message so a new error restarts the timer.
|
||
.task(id: error) {
|
||
try? await Task.sleep(for: .seconds(6))
|
||
if store.lastError == error { store.lastError = nil }
|
||
}
|
||
.transition(.opacity)
|
||
}
|
||
}
|
||
.animation(.default, value: store.lastError)
|
||
.navigationTitle(windowTitle)
|
||
// Attach content sheets to the detail pane, not the split-view root. A sheet
|
||
// hosted at the root scopes its presentation scrim to the whole window —
|
||
// including the sidebar's title-bar region — which paints an odd overlay over
|
||
// the traffic lights. Hosting from the detail pane confines it there.
|
||
.sheet(isPresented: $showingAddProject) { AddProjectSheet() }
|
||
.sheet(isPresented: $showingLockQueue) { LockQueueView() }
|
||
.sheet(isPresented: $store.quickTodoPresented) { QuickTodoSheet() }
|
||
.sheet(item: $store.editingTodoID) { EditTodoSheet(todoID: $0) }
|
||
}
|
||
// Pin the unified toolbar's glass so it always renders its material. Left to the
|
||
// automatic scroll-edge behavior it intermittently dropped to a flat, unblurred
|
||
// fill (showing the BuildBanner color flat beneath it) until a resize or sidebar
|
||
// toggle forced it to re-materialize; pinning `.visible` keeps the glass on.
|
||
.toolbarBackground(.visible, for: .windowToolbar)
|
||
// …and re-render the glass when it goes dormant on occlusion / app idle, which
|
||
// pinning the background alone doesn't prevent (see `ToolbarGlassKeeper`).
|
||
.background { ToolbarGlassKeeper() }
|
||
.preferredColorScheme(appearance.colorScheme)
|
||
.dynamicTypeSize(textSize.dynamicTypeSize)
|
||
.tint(palette.accent)
|
||
.environment(panels)
|
||
.environment(fileSelection)
|
||
.environment(editor)
|
||
.environment(\.appPalette, palette)
|
||
// App-level: the conflicting agent may not be the foreground session, so the
|
||
// prompt isn't scoped to the open chat. `pendingConflict` is private(set), hence
|
||
// a read-only isPresented binding; the content reads the live head of the queue.
|
||
.sheet(isPresented: .init(
|
||
get: { store.pendingConflict != nil }, set: { _ in })
|
||
) {
|
||
if let prompt = store.pendingConflict { ConflictSheet(prompt: prompt) }
|
||
}
|
||
.alert("Rename project", isPresented: .init(
|
||
get: { renamingProject != nil },
|
||
set: { if !$0 { renamingProject = nil } })
|
||
) {
|
||
TextField("Name", text: $renameProjectDraft)
|
||
Button("Cancel", role: .cancel) { renamingProject = nil }
|
||
Button("Rename") {
|
||
if let project = renamingProject {
|
||
Task { await store.renameProject(project.id, to: renameProjectDraft) }
|
||
}
|
||
renamingProject = nil
|
||
}
|
||
}
|
||
.confirmationDialog(
|
||
"Delete “\(deletingProject?.name ?? "")”?",
|
||
isPresented: .init(get: { deletingProject != nil }, set: { if !$0 { deletingProject = nil } }),
|
||
titleVisibility: .visible
|
||
) {
|
||
Button("Delete Project", role: .destructive) {
|
||
if let project = deletingProject {
|
||
Task { await store.deleteProject(project.id) }
|
||
}
|
||
deletingProject = nil
|
||
}
|
||
Button("Cancel", role: .cancel) { deletingProject = nil }
|
||
} message: {
|
||
Text("Removes the project and all its chats, worktrees, and branches. Your repository itself is not affected.")
|
||
}
|
||
}
|
||
|
||
/// Toggle the sidebar through the AppKit split-view item we own (see
|
||
/// `SidebarColumnController`) rather than a SwiftUI `columnVisibility` binding —
|
||
/// see the toolbar comment for why (keeps the leading toolbar buttons from
|
||
/// reflowing when the column collapses). Falls back to the system action until
|
||
/// the configurator has located the split view.
|
||
private func toggleSidebar() {
|
||
if sidebarColumn.hasItem {
|
||
sidebarColumn.toggle()
|
||
} else {
|
||
NSApp.keyWindow?.firstResponder?.tryToPerform(
|
||
#selector(NSSplitViewController.toggleSidebar(_:)), with: nil)
|
||
}
|
||
}
|
||
|
||
/// Window title: the app name on the dashboard, the project name once a chat is
|
||
/// open within a project.
|
||
private var windowTitle: String {
|
||
guard let sessionID = store.openSessionID else {
|
||
if let projectID = store.openProjectID, let project = store.project(projectID) {
|
||
return project.name
|
||
}
|
||
return "Nucleic"
|
||
}
|
||
let projectID = store.openSession?.projectID
|
||
?? store.summaries.first(where: { $0.id == sessionID })?.projectID
|
||
if let projectID, let project = store.project(projectID) { return project.name }
|
||
return "Nucleic"
|
||
}
|
||
|
||
/// Below this sidebar width, the trailing swipe shows Archive only (no Delete),
|
||
/// so neither button clips. Above the 320 `minWidth` to leave a comfortable margin.
|
||
private let narrowSidebarThreshold: CGFloat = 360
|
||
|
||
@ViewBuilder
|
||
private func projectHeader(_ project: Project) -> some View {
|
||
HStack {
|
||
Button {
|
||
store.openProject(project.id)
|
||
} label: {
|
||
HStack(spacing: 4) {
|
||
Text(project.name)
|
||
.font(.title3.weight(.semibold))
|
||
.foregroundStyle(.primary)
|
||
.textCase(nil)
|
||
if project.sandbox?.enabled == true {
|
||
Image(systemName: "shield.lefthalf.filled")
|
||
.font(.caption.weight(.semibold))
|
||
.foregroundStyle(palette.accent)
|
||
.help("\(project.name) runs sessions in a sandbox container")
|
||
}
|
||
Image(systemName: "chevron.right")
|
||
.font(.caption.weight(.semibold))
|
||
.foregroundStyle(.tertiary)
|
||
Spacer(minLength: 0)
|
||
}
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.help("Open \(project.name)'s overview")
|
||
.contextMenu {
|
||
Button("Rename…") {
|
||
renameProjectDraft = project.name
|
||
renamingProject = project
|
||
}
|
||
Divider()
|
||
Button("Delete Project…", role: .destructive) {
|
||
deletingProject = project
|
||
}
|
||
}
|
||
Button {
|
||
Task { await store.newSession(in: project) }
|
||
} label: {
|
||
Image(systemName: "plus.circle")
|
||
.font(.title2)
|
||
.foregroundStyle(.primary.opacity(0.85))
|
||
}
|
||
.buttonStyle(.plain)
|
||
.padding(.trailing, 6)
|
||
.help("New chat in \(project.name)")
|
||
}
|
||
.padding(.top, 6)
|
||
.padding(.bottom, 6)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func archivedHeader(for project: Project, count: Int) -> some View {
|
||
let isExpanded = expandedArchive.contains(project.id)
|
||
Button {
|
||
withAnimation(.easeInOut(duration: 0.22)) {
|
||
if isExpanded { expandedArchive.remove(project.id) }
|
||
else { expandedArchive.insert(project.id) }
|
||
}
|
||
} label: {
|
||
// Chevron sized to sit at the same leading gutter as the session rows'
|
||
// status dot, so the archived group lines up with everything above it.
|
||
HStack(spacing: 6) {
|
||
Image(systemName: "chevron.right")
|
||
.font(.caption2.weight(.semibold))
|
||
.foregroundStyle(.secondary)
|
||
.rotationEffect(.degrees(isExpanded ? 90 : 0))
|
||
.frame(width: 8, alignment: .leading)
|
||
Text("Archived (\(count))")
|
||
.font(.caption)
|
||
.foregroundStyle(.secondary)
|
||
Spacer(minLength: 0)
|
||
}
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.padding(.vertical, 4)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func sessionRow(_ summary: SessionSummary) -> some View {
|
||
// The selection background lives inside SessionRow (not in
|
||
// `.listRowBackground`) so it travels with the content when swiped —
|
||
// a `.listRowBackground` is anchored to the cell and would stay fixed
|
||
// while `.swipeActions` slides only the row content.
|
||
SessionRow(summary: summary, isSelected: store.openSessionID == summary.id)
|
||
.contentShape(Rectangle())
|
||
.onTapGesture { store.openSessionID = summary.id }
|
||
.listRowInsets(EdgeInsets())
|
||
.listRowBackground(Color.clear)
|
||
.contextMenu {
|
||
Button(summary.favorite ? "Unfavorite" : "Favorite",
|
||
systemImage: summary.favorite ? "star.slash" : "star") {
|
||
Task { await store.setSessionFavorite(summary.id, !summary.favorite) }
|
||
}
|
||
Button(summary.archived ? "Unarchive" : "Archive",
|
||
systemImage: summary.archived ? "tray.and.arrow.up" : "archivebox") {
|
||
Task { await store.setSessionArchived(summary.id, !summary.archived) }
|
||
}
|
||
Divider()
|
||
Button("Delete", systemImage: "trash", role: .destructive) {
|
||
Task { await store.deleteSession(summary.id) }
|
||
}
|
||
}
|
||
.swipeActions(edge: .leading, allowsFullSwipe: true) {
|
||
Button {
|
||
Task { await store.setSessionFavorite(summary.id, !summary.favorite) }
|
||
} label: {
|
||
Label(summary.favorite ? "Unfavorite" : "Favorite",
|
||
systemImage: summary.favorite ? "star.slash" : "star")
|
||
}
|
||
.tint(.yellow)
|
||
}
|
||
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
|
||
// In a narrow sidebar both buttons can't fit, so the second one
|
||
// (Delete) clips off-screen. Keep only Archive there; Delete is
|
||
// still reachable from the context menu.
|
||
if sidebarWidth >= narrowSidebarThreshold {
|
||
Button(role: .destructive) {
|
||
Task { await store.deleteSession(summary.id) }
|
||
} label: {
|
||
Label("Delete", systemImage: "trash")
|
||
}
|
||
}
|
||
Button {
|
||
Task { await store.setSessionArchived(summary.id, !summary.archived) }
|
||
} label: {
|
||
Label(summary.archived ? "Unarchive" : "Archive",
|
||
systemImage: summary.archived ? "tray.and.arrow.up" : "archivebox")
|
||
}
|
||
.tint(.gray)
|
||
}
|
||
}
|
||
}
|
||
|
||
struct SessionRow: View {
|
||
@Environment(\.appPalette) private var palette
|
||
let summary: SessionSummary
|
||
var isSelected: Bool = false
|
||
|
||
var body: some View {
|
||
HStack(spacing: 6) {
|
||
Circle().fill(palette.status(summary.status, disposition: summary.disposition))
|
||
.frame(width: 8, height: 8)
|
||
VStack(alignment: .leading, spacing: 1) {
|
||
HStack(spacing: 4) {
|
||
if summary.favorite {
|
||
Image(systemName: "star.fill").font(.caption2).foregroundStyle(.yellow)
|
||
}
|
||
Text(summary.title).lineLimit(1)
|
||
// A completed chat the user hasn't opened reads as "unread" —
|
||
// emphasize its title alongside the trailing dot below.
|
||
.fontWeight(summary.unseenCompletion ? .semibold : .regular)
|
||
}
|
||
HStack(spacing: 4) {
|
||
Text(summary.status.label(disposition: summary.disposition))
|
||
.font(.caption2).foregroundStyle(.secondary)
|
||
if summary.auto {
|
||
// Against the mid-gray selection highlight the saturated teal
|
||
// accent nearly disappears; switch to high-contrast white when
|
||
// the row is selected so the bolt stays legible.
|
||
Image(systemName: "bolt.fill").font(.system(size: 8))
|
||
.foregroundStyle(isSelected ? Color.white : palette.accent)
|
||
}
|
||
}
|
||
}
|
||
Spacer()
|
||
if summary.autoShipFailed {
|
||
// Autoship aborted on a conflict/error and turned itself off — a sticky
|
||
// "needs attention" marker that persists until the user re-enables
|
||
// autoship. Takes precedence over the shipped marker below (a failure
|
||
// note is itself the latest autoship event, but the warning is what
|
||
// matters here).
|
||
Image(systemName: "exclamationmark.triangle.fill").font(.caption2)
|
||
.foregroundStyle(palette.attention)
|
||
.accessibilityLabel("Autoship failed — turned off")
|
||
} else if summary.lastEventWasAutoship {
|
||
// Autoship was the most recent thing to happen here (e.g. the branch
|
||
// shipped) and nothing's happened since — a glanceable marker that this
|
||
// chat reached the merge queue. Cleared once a new turn lands. White on
|
||
// the selection highlight so it stays legible.
|
||
Image(systemName: "shippingbox.fill").font(.caption2)
|
||
.foregroundStyle(isSelected ? Color.white : palette.accent)
|
||
.accessibilityLabel("Autoship ran")
|
||
}
|
||
if summary.pendingApprovalCount > 0 {
|
||
Image(systemName: "pause.circle.fill").foregroundStyle(palette.attention)
|
||
}
|
||
if let diff = summary.diffStat, diff.filesChanged > 0 {
|
||
Text("+\(diff.added) −\(diff.removed)")
|
||
.font(.caption2.monospaced()).foregroundStyle(.secondary)
|
||
}
|
||
if summary.unseenCompletion {
|
||
// Unread dot: the chat finished its work and the user hasn't opened it
|
||
// yet. Cleared the moment they do. White on the selection highlight so
|
||
// it stays legible (the row can't be both selected and unread for long,
|
||
// but selection can land before the open-driven clear).
|
||
Circle().fill(isSelected ? Color.white : palette.accent)
|
||
.frame(width: 7, height: 7)
|
||
.accessibilityLabel("Unread — completed")
|
||
}
|
||
}
|
||
.padding(.vertical, 5)
|
||
// A small leading inset keeps the status dot off the selection highlight's
|
||
// left edge; the trailing inset keeps the diff stats off the right edge.
|
||
// The selection highlight below spans the full width.
|
||
.padding(.horizontal, 8)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.background {
|
||
if isSelected {
|
||
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
||
.fill(AppTheme.selection)
|
||
}
|
||
}
|
||
.padding(.vertical, 1)
|
||
.contentShape(Rectangle())
|
||
}
|
||
}
|
||
|
||
extension SessionStatus {
|
||
var label: String { displayName }
|
||
|
||
/// Sidebar label, refined by the last turn's disposition: an idle session whose
|
||
/// agent finished the work reads as "Done" rather than "Awaiting input".
|
||
func label(disposition: TurnDisposition?) -> String {
|
||
if self == .awaitingInput, disposition == .completed { return "Done" }
|
||
return displayName
|
||
}
|
||
}
|