Auto mode, model SKUs + defaults, favorite/archive/delete, summary/title/padding
- Auto mode (per the safety question): wired to Claude's built-in --permission-mode
auto — its own classifier auto-approves safe actions and routes destructive ones
to our approval UI ("all but destructive"). Per-chat toggle (bolt) + a global
"start new chats in Auto" default. Session.auto + RunSpec/ResumeSpec.autoApprove +
DB v3 column; transcript still shows every auto-approved call.
- Model/effort: pickers list full SKUs (claude-opus-4-8, …[1m], sonnet, haiku) and
always show the effective concrete value (never "Default"); Settings adds default
model / default effort / default-auto; new chats inherit them.
- Favorite / archive / delete chats via right-click context menu AND swipe actions;
favorites sort first with a star, archived collapse into a per-project section,
archiving closes the open chat. Session.favorite/archived (DB v3).
- Summary reworked into sectioned, glanceable Markdown (Now / Done / Next) rendered
with MarkdownText; window title shows the project name (app name gone); padding
below project names.
Tests: newChatsInheritDefaults, favoriteArchiveDeleteChat. Full suite 92 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
@@ -47,9 +47,16 @@ struct AppleIntelligenceProvider: IntelligenceProviding {
|
||||
return HeuristicSummary.text(session: session, events: events)
|
||||
}
|
||||
let instructions = """
|
||||
You write a one- or two-sentence, glanceable status of a coding-agent chat \
|
||||
so someone bouncing between sessions can re-orient. Focus on what just \
|
||||
happened and the current state. Be concrete and terse. No preamble, no markdown.
|
||||
You write a glanceable status of a coding-agent chat for someone bouncing \
|
||||
between sessions. Output ONLY this Markdown, omitting any section that is empty:
|
||||
|
||||
**Now:** <one short line: current state or what it's waiting on>
|
||||
**Done:**
|
||||
- <short bullets of what was accomplished>
|
||||
**Next:**
|
||||
- <short bullets of pending or suggested next steps>
|
||||
|
||||
Keep every line under ~12 words. Be concrete. No preamble, no code fences.
|
||||
"""
|
||||
if let text = await generate(instructions: instructions, prompt: transcript) {
|
||||
return text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import Foundation
|
||||
|
||||
/// Selectable Claude model SKUs and effort levels, plus the persistence keys for
|
||||
/// the per-app defaults applied to new chats.
|
||||
enum ModelCatalog {
|
||||
/// Full model SKUs the picker offers (not friendly aliases).
|
||||
static let models: [String] = [
|
||||
"claude-opus-4-8",
|
||||
"claude-opus-4-8[1m]",
|
||||
"claude-sonnet-4-6",
|
||||
"claude-haiku-4-5",
|
||||
]
|
||||
static let efforts: [String] = ["low", "medium", "high", "xhigh", "max"]
|
||||
|
||||
static let fallbackModel = "claude-opus-4-8"
|
||||
static let fallbackEffort = "high"
|
||||
|
||||
static let defaultModelKey = "nucleic.defaultModel"
|
||||
static let defaultEffortKey = "nucleic.defaultEffort"
|
||||
static let defaultAutoKey = "nucleic.defaultAuto"
|
||||
|
||||
static var storedDefaultModel: String {
|
||||
UserDefaults.standard.string(forKey: defaultModelKey) ?? fallbackModel
|
||||
}
|
||||
static var storedDefaultEffort: String {
|
||||
UserDefaults.standard.string(forKey: defaultEffortKey) ?? fallbackEffort
|
||||
}
|
||||
static var storedDefaultAuto: Bool {
|
||||
UserDefaults.standard.bool(forKey: defaultAutoKey)
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,9 @@ struct NucleicApp: App {
|
||||
ClaudeCodeBackend(configuration: .init(closeStdinAfterPrompt: true))
|
||||
}
|
||||
store.intelligence = AppleIntelligenceProvider()
|
||||
store.defaultModel = ModelCatalog.storedDefaultModel
|
||||
store.defaultEffort = ModelCatalog.storedDefaultEffort
|
||||
store.defaultAuto = ModelCatalog.storedDefaultAuto
|
||||
_store = State(initialValue: store)
|
||||
} catch {
|
||||
fatalError("Could not open the Nucleic store at \(support.path): \(error)")
|
||||
@@ -50,7 +53,7 @@ struct NucleicApp: App {
|
||||
}
|
||||
.windowToolbarStyle(.unified)
|
||||
Settings {
|
||||
SettingsView()
|
||||
SettingsView().environment(store)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,16 @@ struct RootView: View {
|
||||
Text("No chats yet").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
ForEach(sessions) { summary in
|
||||
SessionRow(summary: summary).tag(Optional(summary.id))
|
||||
sessionRow(summary)
|
||||
}
|
||||
let archived = store.archivedSummaries(for: project.id)
|
||||
if !archived.isEmpty {
|
||||
DisclosureGroup("Archived (\(archived.count))") {
|
||||
ForEach(archived) { summary in
|
||||
sessionRow(summary).opacity(0.7)
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
} header: {
|
||||
HStack {
|
||||
@@ -51,6 +60,7 @@ struct RootView: View {
|
||||
.buttonStyle(.plain)
|
||||
.help("New chat in \(project.name)")
|
||||
}
|
||||
.padding(.bottom, 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,11 +74,14 @@ struct RootView: View {
|
||||
}
|
||||
}
|
||||
} detail: {
|
||||
if store.openSessionID != nil {
|
||||
SessionDetailView()
|
||||
} else {
|
||||
HomeView()
|
||||
Group {
|
||||
if store.openSessionID != nil {
|
||||
SessionDetailView()
|
||||
} else {
|
||||
HomeView()
|
||||
}
|
||||
}
|
||||
.navigationTitle(windowTitle)
|
||||
}
|
||||
.sheet(isPresented: $showingAddProject) { AddProjectSheet() }
|
||||
.alert("Rename project", isPresented: .init(
|
||||
@@ -108,6 +121,57 @@ struct RootView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Window title = current project's name (replaces the app/exe name).
|
||||
private var windowTitle: String {
|
||||
if let projectID = store.openSession?.projectID, let project = store.project(projectID) {
|
||||
return project.name
|
||||
}
|
||||
return store.projects.first?.name ?? "Nucleic"
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sessionRow(_ summary: SessionSummary) -> some View {
|
||||
SessionRow(summary: summary)
|
||||
.tag(Optional(summary.id))
|
||||
.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) {
|
||||
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 {
|
||||
@@ -117,8 +181,18 @@ struct SessionRow: View {
|
||||
HStack(spacing: 8) {
|
||||
Circle().fill(summary.status.color).frame(width: 8, height: 8)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(summary.title).lineLimit(1)
|
||||
Text(summary.status.label).font(.caption2).foregroundStyle(.secondary)
|
||||
HStack(spacing: 4) {
|
||||
if summary.favorite {
|
||||
Image(systemName: "star.fill").font(.caption2).foregroundStyle(.yellow)
|
||||
}
|
||||
Text(summary.title).lineLimit(1)
|
||||
}
|
||||
HStack(spacing: 4) {
|
||||
Text(summary.status.label).font(.caption2).foregroundStyle(.secondary)
|
||||
if summary.auto {
|
||||
Image(systemName: "bolt.fill").font(.system(size: 8)).foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if summary.pendingApprovalCount > 0 {
|
||||
|
||||
@@ -12,16 +12,13 @@ struct SessionDetailView: View {
|
||||
@State private var renameDraft = ""
|
||||
@State private var summaryExpanded = false
|
||||
|
||||
private static let models: [(label: String, value: String?)] = [
|
||||
("Default", nil), ("Opus", "opus"), ("Sonnet", "sonnet"), ("Haiku", "haiku"),
|
||||
]
|
||||
private static let efforts: [(label: String, value: String?)] = [
|
||||
("Default", nil), ("Low", "low"), ("Medium", "medium"),
|
||||
("High", "high"), ("XHigh", "xhigh"), ("Max", "max"),
|
||||
]
|
||||
|
||||
private var session: Session? { store.openSession }
|
||||
|
||||
// Effective concrete values (never shows "Default"): the session's override, or
|
||||
// the app default. Selecting a value sets a concrete override.
|
||||
private var effectiveModel: String { session?.model ?? store.defaultModel ?? ModelCatalog.fallbackModel }
|
||||
private var effectiveEffort: String { session?.effort ?? store.defaultEffort ?? ModelCatalog.fallbackEffort }
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
@@ -73,19 +70,15 @@ struct SessionDetailView: View {
|
||||
|
||||
private var modelMenu: some View {
|
||||
Menu {
|
||||
ForEach(Self.models, id: \.label) { option in
|
||||
ForEach(ModelCatalog.models, id: \.self) { sku in
|
||||
Button {
|
||||
Task { await store.setOpenSessionModel(option.value) }
|
||||
Task { await store.setOpenSessionModel(sku) }
|
||||
} label: {
|
||||
if session?.model == option.value {
|
||||
Label(option.label, systemImage: "checkmark")
|
||||
} else {
|
||||
Text(option.label)
|
||||
}
|
||||
if effectiveModel == sku { Label(sku, systemImage: "checkmark") } else { Text(sku) }
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Text("Model: \(modelLabel)")
|
||||
Text("Model: \(effectiveModel)")
|
||||
}
|
||||
.menuStyle(.button)
|
||||
.fixedSize()
|
||||
@@ -94,30 +87,34 @@ struct SessionDetailView: View {
|
||||
|
||||
private var effortMenu: some View {
|
||||
Menu {
|
||||
ForEach(Self.efforts, id: \.label) { option in
|
||||
ForEach(ModelCatalog.efforts, id: \.self) { level in
|
||||
Button {
|
||||
Task { await store.setOpenSessionEffort(option.value) }
|
||||
Task { await store.setOpenSessionEffort(level) }
|
||||
} label: {
|
||||
if session?.effort == option.value {
|
||||
Label(option.label, systemImage: "checkmark")
|
||||
} else {
|
||||
Text(option.label)
|
||||
}
|
||||
if effectiveEffort == level { Label(level, systemImage: "checkmark") } else { Text(level) }
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Text("Effort: \(effortLabel)")
|
||||
Text("Effort: \(effectiveEffort)")
|
||||
}
|
||||
.menuStyle(.button)
|
||||
.fixedSize()
|
||||
.help("Reasoning effort for the next turn")
|
||||
}
|
||||
|
||||
private var modelLabel: String {
|
||||
Self.models.first { $0.value == session?.model }?.label ?? (session?.model ?? "Default")
|
||||
}
|
||||
private var effortLabel: String {
|
||||
Self.efforts.first { $0.value == session?.effort }?.label ?? (session?.effort ?? "Default")
|
||||
private var autoToggle: some View {
|
||||
let on = session?.auto ?? false
|
||||
return Button {
|
||||
Task { await store.setOpenSessionAuto(!on) }
|
||||
} label: {
|
||||
Label("Auto", systemImage: on ? "bolt.fill" : "bolt.slash")
|
||||
.foregroundStyle(on ? Color.orange : Color.secondary)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.fixedSize()
|
||||
.help(on
|
||||
? "Auto-approve mode on: Claude auto-approves safe actions; destructive ones still ask."
|
||||
: "Manual approvals: every gated tool asks first.")
|
||||
}
|
||||
|
||||
private var targetBranch: String {
|
||||
@@ -196,6 +193,7 @@ struct SessionDetailView: View {
|
||||
HStack(spacing: 8) {
|
||||
modelMenu
|
||||
effortMenu
|
||||
autoToggle
|
||||
Spacer()
|
||||
}
|
||||
.font(.callout)
|
||||
|
||||
@@ -1,24 +1,38 @@
|
||||
import SwiftUI
|
||||
import NucleicCore
|
||||
|
||||
/// App settings — currently the Apple Intelligence model used for chat summaries
|
||||
/// and auto-generated session names.
|
||||
/// App settings: defaults applied to new chats, plus the Apple Intelligence model
|
||||
/// used for summaries and auto-generated names.
|
||||
struct SettingsView: View {
|
||||
@AppStorage(IntelligenceChoice.storageKey) private var choiceRaw = IntelligenceChoice.onDevice.rawValue
|
||||
@Environment(AppStore.self) private var store
|
||||
|
||||
private var choice: IntelligenceChoice {
|
||||
IntelligenceChoice(rawValue: choiceRaw) ?? .onDevice
|
||||
}
|
||||
@AppStorage(IntelligenceChoice.storageKey) private var choiceRaw = IntelligenceChoice.onDevice.rawValue
|
||||
@AppStorage(ModelCatalog.defaultModelKey) private var defaultModel = ModelCatalog.fallbackModel
|
||||
@AppStorage(ModelCatalog.defaultEffortKey) private var defaultEffort = ModelCatalog.fallbackEffort
|
||||
@AppStorage(ModelCatalog.defaultAutoKey) private var defaultAuto = false
|
||||
|
||||
private var choice: IntelligenceChoice { IntelligenceChoice(rawValue: choiceRaw) ?? .onDevice }
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("New chat defaults") {
|
||||
Picker("Default model", selection: $defaultModel) {
|
||||
ForEach(ModelCatalog.models, id: \.self) { Text($0).tag($0) }
|
||||
}
|
||||
Picker("Default effort", selection: $defaultEffort) {
|
||||
ForEach(ModelCatalog.efforts, id: \.self) { Text($0).tag($0) }
|
||||
}
|
||||
Toggle("Start new chats in Auto mode", isOn: $defaultAuto)
|
||||
Text("Auto mode lets Claude auto-approve safe actions; destructive ones still ask.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
Section("Apple Intelligence") {
|
||||
Picker("Model", selection: $choiceRaw) {
|
||||
ForEach(IntelligenceChoice.allCases) { option in
|
||||
Text(option.label).tag(option.rawValue)
|
||||
}
|
||||
ForEach(IntelligenceChoice.allCases) { Text($0.label).tag($0.rawValue) }
|
||||
}
|
||||
.pickerStyle(.radioGroup)
|
||||
|
||||
Text(choice.detail)
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
@@ -29,13 +43,19 @@ struct SettingsView: View {
|
||||
LabeledContent("Private Cloud Compute", value: IntelligenceAvailability.status(for: .privateCloudCompute))
|
||||
}
|
||||
.font(.callout)
|
||||
|
||||
Text("Used to summarize conversations and name new chats from your first message. Your code is only sent to Apple's models, never to third parties.")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.frame(width: 460)
|
||||
.frame(width: 480)
|
||||
.padding()
|
||||
.onAppear { pushDefaults() }
|
||||
.onChange(of: defaultModel) { _, _ in pushDefaults() }
|
||||
.onChange(of: defaultEffort) { _, _ in pushDefaults() }
|
||||
.onChange(of: defaultAuto) { _, _ in pushDefaults() }
|
||||
}
|
||||
|
||||
private func pushDefaults() {
|
||||
store.defaultModel = defaultModel
|
||||
store.defaultEffort = defaultEffort
|
||||
store.defaultAuto = defaultAuto
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,11 +36,12 @@ struct ConversationSummaryCard: View {
|
||||
if isExpanded {
|
||||
if summarizing && text.isEmpty {
|
||||
Text("Summarizing…").font(.caption).foregroundStyle(.secondary)
|
||||
} else if text.isEmpty {
|
||||
Text("No summary yet.").font(.caption).foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text(text.isEmpty ? "No summary yet." : text)
|
||||
MarkdownText(markdown: text)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(10)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,9 @@ public struct SessionSummary: Sendable, Identifiable, Equatable {
|
||||
public var status: SessionStatus
|
||||
public var diffStat: DiffStat?
|
||||
public var pendingApprovalCount: Int
|
||||
public var auto: Bool
|
||||
public var favorite: Bool
|
||||
public var archived: Bool
|
||||
public var updatedAt: Date
|
||||
|
||||
public init(_ session: Session, pendingApprovalCount: Int) {
|
||||
@@ -37,6 +40,9 @@ public struct SessionSummary: Sendable, Identifiable, Equatable {
|
||||
self.status = session.status
|
||||
self.diffStat = session.diffStat
|
||||
self.pendingApprovalCount = pendingApprovalCount
|
||||
self.auto = session.auto
|
||||
self.favorite = session.favorite
|
||||
self.archived = session.archived
|
||||
self.updatedAt = session.updatedAt
|
||||
}
|
||||
}
|
||||
@@ -88,6 +94,11 @@ public final class AppStore {
|
||||
private var summaryToken = 0
|
||||
private var namedSessions: Set<SessionID> = []
|
||||
|
||||
/// Defaults applied to newly-created chats (set by the app from Settings).
|
||||
public var defaultModel: String?
|
||||
public var defaultEffort: String?
|
||||
public var defaultAuto: Bool = false
|
||||
|
||||
private let database: any SessionMetadataStore
|
||||
private let worktrees: any WorktreeManaging
|
||||
private let backendFactory: BackendFactory
|
||||
@@ -259,8 +270,17 @@ public final class AppStore {
|
||||
activityByDay = activity
|
||||
}
|
||||
|
||||
/// Non-archived chats for a project, favorites first, then most-recent.
|
||||
public func summaries(for projectID: ProjectID) -> [SessionSummary] {
|
||||
summaries.filter { $0.projectID == projectID }.sorted { $0.updatedAt > $1.updatedAt }
|
||||
summaries
|
||||
.filter { $0.projectID == projectID && !$0.archived }
|
||||
.sorted { ($0.favorite ? 0 : 1, $1.updatedAt) < ($1.favorite ? 0 : 1, $0.updatedAt) }
|
||||
}
|
||||
|
||||
public func archivedSummaries(for projectID: ProjectID) -> [SessionSummary] {
|
||||
summaries
|
||||
.filter { $0.projectID == projectID && $0.archived }
|
||||
.sorted { $0.updatedAt > $1.updatedAt }
|
||||
}
|
||||
|
||||
// MARK: - Session lifecycle
|
||||
@@ -304,7 +324,9 @@ public final class AppStore {
|
||||
id: sessionID, projectID: project.id, backend: backendID,
|
||||
title: title, status: trimmedPrompt.isEmpty ? .awaitingInput : .running,
|
||||
worktreePath: worktree.path, branch: worktree.branch, baseSHA: worktree.baseSHA,
|
||||
transcriptPath: transcriptURL.path, createdAt: now(), updatedAt: now())
|
||||
model: defaultModel, effort: defaultEffort,
|
||||
transcriptPath: transcriptURL.path, auto: defaultAuto,
|
||||
createdAt: now(), updatedAt: now())
|
||||
|
||||
let controller = SessionController(
|
||||
session: session, backend: backendFactory(session), transcript: writer,
|
||||
@@ -366,11 +388,45 @@ public final class AppStore {
|
||||
await mutateOpenSession { await $0.setEffort(effort) }
|
||||
}
|
||||
|
||||
/// Toggle Claude's auto-approval mode for the open session's next turn.
|
||||
public func setOpenSessionAuto(_ auto: Bool) async {
|
||||
await mutateOpenSession { await $0.setAuto(auto) }
|
||||
}
|
||||
|
||||
public func setSessionFavorite(_ id: SessionID, _ favorite: Bool) async {
|
||||
await mutateSession(id) { await $0.setFavorite(favorite) }
|
||||
}
|
||||
|
||||
public func setSessionArchived(_ id: SessionID, _ archived: Bool) async {
|
||||
await mutateSession(id) { await $0.setArchived(archived) }
|
||||
if archived, openSessionID == id { openSessionID = nil }
|
||||
}
|
||||
|
||||
/// Delete a chat (any row, not just the open one): stop it, remove its worktree +
|
||||
/// branch + records.
|
||||
public func deleteSession(_ id: SessionID) async {
|
||||
if let controller = controllers[id] {
|
||||
do { try await controller.discard(force: true) }
|
||||
catch { lastError = "Delete failed: \(error)" }
|
||||
}
|
||||
observers[id]?.cancel()
|
||||
observers[id] = nil
|
||||
controllers[id] = nil
|
||||
try? await database.deleteSession(id: id)
|
||||
summaries.removeAll { $0.id == id }
|
||||
if openSessionID == id { openSessionID = nil }
|
||||
}
|
||||
|
||||
private func mutateOpenSession(_ body: (SessionController) async -> Void) async {
|
||||
guard let sessionID = openSessionID, let controller = controllers[sessionID] else { return }
|
||||
guard let sessionID = openSessionID else { return }
|
||||
await mutateSession(sessionID, body)
|
||||
}
|
||||
|
||||
private func mutateSession(_ id: SessionID, _ body: (SessionController) async -> Void) async {
|
||||
guard let controller = controllers[id] else { return }
|
||||
await body(controller)
|
||||
let snapshot = await controller.snapshot
|
||||
openSession = snapshot.session
|
||||
if id == openSessionID { openSession = snapshot.session }
|
||||
upsertSummary(SessionSummary(snapshot.session, pendingApprovalCount: snapshot.pendingApprovals.count))
|
||||
}
|
||||
|
||||
@@ -394,18 +450,8 @@ public final class AppStore {
|
||||
}
|
||||
|
||||
public func discardOpenSession() async {
|
||||
guard let sessionID = openSessionID, let controller = controllers[sessionID] else { return }
|
||||
do {
|
||||
try await controller.discard(force: true)
|
||||
observers[sessionID]?.cancel()
|
||||
observers[sessionID] = nil
|
||||
controllers[sessionID] = nil
|
||||
try? await database.deleteSession(id: sessionID)
|
||||
summaries.removeAll { $0.id == sessionID }
|
||||
if openSessionID == sessionID { openSessionID = nil }
|
||||
} catch {
|
||||
lastError = "Discard failed: \(error)"
|
||||
}
|
||||
guard let sessionID = openSessionID else { return }
|
||||
await deleteSession(sessionID)
|
||||
}
|
||||
|
||||
// MARK: - Observation bridge (controller → @Observable state)
|
||||
|
||||
@@ -114,6 +114,9 @@ public struct RunSpec: Sendable {
|
||||
public let prompt: AgentInput
|
||||
public let model: String?
|
||||
public let effort: String?
|
||||
/// Run with Claude's auto permission mode (its classifier auto-approves safe
|
||||
/// actions; the rest still route to our approval tool).
|
||||
public let autoApprove: Bool
|
||||
public let approvalPolicy: ApprovalPolicy
|
||||
public let sandbox: SandboxMode?
|
||||
public let mcpConfigPath: URL?
|
||||
@@ -127,6 +130,7 @@ public struct RunSpec: Sendable {
|
||||
prompt: AgentInput,
|
||||
model: String? = nil,
|
||||
effort: String? = nil,
|
||||
autoApprove: Bool = false,
|
||||
approvalPolicy: ApprovalPolicy = .interactive,
|
||||
sandbox: SandboxMode? = nil,
|
||||
mcpConfigPath: URL? = nil,
|
||||
@@ -139,6 +143,7 @@ public struct RunSpec: Sendable {
|
||||
self.prompt = prompt
|
||||
self.model = model
|
||||
self.effort = effort
|
||||
self.autoApprove = autoApprove
|
||||
self.approvalPolicy = approvalPolicy
|
||||
self.sandbox = sandbox
|
||||
self.mcpConfigPath = mcpConfigPath
|
||||
@@ -156,11 +161,13 @@ public struct ResumeSpec: Sendable {
|
||||
public let prompt: AgentInput?
|
||||
public let model: String?
|
||||
public let effort: String?
|
||||
public let autoApprove: Bool
|
||||
public let fork: Bool
|
||||
|
||||
public init(
|
||||
sessionID: SessionID, backendSessionID: String, worktree: WorktreePath,
|
||||
prompt: AgentInput? = nil, model: String? = nil, effort: String? = nil, fork: Bool = false
|
||||
prompt: AgentInput? = nil, model: String? = nil, effort: String? = nil,
|
||||
autoApprove: Bool = false, fork: Bool = false
|
||||
) {
|
||||
self.sessionID = sessionID
|
||||
self.backendSessionID = backendSessionID
|
||||
@@ -168,6 +175,7 @@ public struct ResumeSpec: Sendable {
|
||||
self.prompt = prompt
|
||||
self.model = model
|
||||
self.effort = effort
|
||||
self.autoApprove = autoApprove
|
||||
self.fork = fork
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +106,8 @@ public actor ClaudeCodeBackend: AgentBackend {
|
||||
worktree: resume.worktree,
|
||||
prompt: resume.prompt ?? AgentInput(parts: []),
|
||||
model: resume.model,
|
||||
effort: resume.effort)
|
||||
effort: resume.effort,
|
||||
autoApprove: resume.autoApprove)
|
||||
return makeStream(run: run, resumeArgs: args)
|
||||
}
|
||||
|
||||
@@ -185,12 +186,13 @@ public actor ClaudeCodeBackend: AgentBackend {
|
||||
args.append("--include-partial-messages")
|
||||
}
|
||||
args += ["--permission-prompt-tool", MCPApprovalServer.qualifiedToolName]
|
||||
// Pin the mode: the host machine's defaultMode (e.g. "auto") would
|
||||
// otherwise auto-accept edits before our permission tool is ever
|
||||
// consulted (M0 live-capture finding, 2026-06-12). Nucleic's approval
|
||||
// flow must be authoritative regardless of user-level settings.
|
||||
// Pin the mode explicitly: the host machine's defaultMode would otherwise
|
||||
// leak in (M0 live-capture finding, 2026-06-12). In auto mode, Claude's
|
||||
// own classifier auto-approves safe actions and routes the risky/
|
||||
// destructive ones to our permission tool; otherwise everything routes to
|
||||
// us (manual approvals).
|
||||
if case .interactive = run.approvalPolicy {
|
||||
args += ["--permission-mode", "default"]
|
||||
args += ["--permission-mode", run.autoApprove ? "auto" : "default"]
|
||||
}
|
||||
args += ["--mcp-config", mcpConfig]
|
||||
// Hermetic child: only our approval server, none of the host's global
|
||||
|
||||
@@ -61,25 +61,26 @@ public enum HeuristicTitle {
|
||||
|
||||
/// Builds a glanceable summary string from the transcript alone.
|
||||
public enum HeuristicSummary {
|
||||
/// A compact, sectioned (Markdown) summary so it's glanceable at a glance.
|
||||
public static func text(session: Session, events: [AgentEvent]) -> String {
|
||||
let userTurns = events.reduce(into: 0) { count, event in
|
||||
if case .userText = event.kind { count += 1 }
|
||||
}
|
||||
if userTurns == 0 {
|
||||
return "New chat on \(session.branch ?? "a fresh worktree"). Send a message to get started."
|
||||
return "**New chat** — send a message to get started."
|
||||
}
|
||||
|
||||
var lines = ["\(userTurns) message\(userTurns == 1 ? "" : "s") · \(session.status.displayName.lowercased())."]
|
||||
var lines = ["**Now:** \(session.status.displayName.lowercased()) · \(userTurns) message\(userTurns == 1 ? "" : "s")"]
|
||||
let lastAssistant = events.reversed().compactMap { event -> String? in
|
||||
if case .assistantText(let chunk) = event.kind, !chunk.isPartial { return chunk.text }
|
||||
return nil
|
||||
}.first
|
||||
if let last = lastAssistant {
|
||||
let snippet = last.replacingOccurrences(of: "\n", with: " ").prefix(140)
|
||||
lines.append("Last: “\(snippet)\(last.count > 140 ? "…" : "")”")
|
||||
let snippet = last.replacingOccurrences(of: "\n", with: " ").prefix(160)
|
||||
lines.append("**Last reply:** \(snippet)\(last.count > 160 ? "…" : "")")
|
||||
}
|
||||
if let diff = session.diffStat, diff.filesChanged > 0 {
|
||||
lines.append("\(diff.filesChanged) file\(diff.filesChanged == 1 ? "" : "s") changed (+\(diff.added) −\(diff.removed)).")
|
||||
lines.append("**Changes:** \(diff.filesChanged) file\(diff.filesChanged == 1 ? "" : "s") (+\(diff.added) −\(diff.removed))")
|
||||
}
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
@@ -83,6 +83,11 @@ public final class GRDBMetadataStore: SessionMetadataStore {
|
||||
migrator.registerMigration("v2-effort") { db in
|
||||
try db.execute(sql: "ALTER TABLE session ADD COLUMN effort TEXT;")
|
||||
}
|
||||
migrator.registerMigration("v3-flags") { db in
|
||||
try db.execute(sql: "ALTER TABLE session ADD COLUMN auto INTEGER NOT NULL DEFAULT 0;")
|
||||
try db.execute(sql: "ALTER TABLE session ADD COLUMN favorite INTEGER NOT NULL DEFAULT 0;")
|
||||
try db.execute(sql: "ALTER TABLE session ADD COLUMN archived INTEGER NOT NULL DEFAULT 0;")
|
||||
}
|
||||
return migrator
|
||||
}
|
||||
|
||||
@@ -239,6 +244,9 @@ private struct SessionRow: Codable, FetchableRecord, PersistableRecord {
|
||||
var diff_removed: Int?
|
||||
var ahead: Int?
|
||||
var behind: Int?
|
||||
var auto: Bool
|
||||
var favorite: Bool
|
||||
var archived: Bool
|
||||
var created_at: Date
|
||||
var updated_at: Date
|
||||
|
||||
@@ -262,6 +270,9 @@ private struct SessionRow: Codable, FetchableRecord, PersistableRecord {
|
||||
diff_removed = s.diffStat?.removed
|
||||
ahead = s.ahead
|
||||
behind = s.behind
|
||||
auto = s.auto
|
||||
favorite = s.favorite
|
||||
archived = s.archived
|
||||
created_at = s.createdAt
|
||||
updated_at = s.updatedAt
|
||||
}
|
||||
@@ -288,6 +299,9 @@ private struct SessionRow: Codable, FetchableRecord, PersistableRecord {
|
||||
diffStat: diffStat,
|
||||
ahead: ahead,
|
||||
behind: behind,
|
||||
auto: auto,
|
||||
favorite: favorite,
|
||||
archived: archived,
|
||||
createdAt: created_at,
|
||||
updatedAt: updated_at)
|
||||
}
|
||||
|
||||
@@ -101,6 +101,11 @@ public struct Session: Identifiable, Sendable, Codable, Equatable {
|
||||
public var diffStat: DiffStat?
|
||||
public var ahead: Int?
|
||||
public var behind: Int?
|
||||
/// Auto-approval mode: run with Claude's `--permission-mode auto` (its classifier
|
||||
/// auto-approves safe actions; destructive ones still route to our approval UI).
|
||||
public var auto: Bool
|
||||
public var favorite: Bool
|
||||
public var archived: Bool
|
||||
public let createdAt: Date
|
||||
public var updatedAt: Date
|
||||
|
||||
@@ -122,6 +127,9 @@ public struct Session: Identifiable, Sendable, Codable, Equatable {
|
||||
diffStat: DiffStat? = nil,
|
||||
ahead: Int? = nil,
|
||||
behind: Int? = nil,
|
||||
auto: Bool = false,
|
||||
favorite: Bool = false,
|
||||
archived: Bool = false,
|
||||
createdAt: Date,
|
||||
updatedAt: Date
|
||||
) {
|
||||
@@ -142,6 +150,9 @@ public struct Session: Identifiable, Sendable, Codable, Equatable {
|
||||
self.diffStat = diffStat
|
||||
self.ahead = ahead
|
||||
self.behind = behind
|
||||
self.auto = auto
|
||||
self.favorite = favorite
|
||||
self.archived = archived
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
|
||||
@@ -111,6 +111,7 @@ public actor SessionController {
|
||||
prompt: prompt,
|
||||
model: session.model,
|
||||
effort: session.effort,
|
||||
autoApprove: session.auto,
|
||||
approvalPolicy: .interactive)
|
||||
consume(backend.start(run), injectingUserText: prompt.plainText)
|
||||
}
|
||||
@@ -166,13 +167,14 @@ public actor SessionController {
|
||||
let spec = ResumeSpec(
|
||||
sessionID: session.id, backendSessionID: backendSessionID,
|
||||
worktree: worktreePath, prompt: input,
|
||||
model: session.model, effort: session.effort)
|
||||
model: session.model, effort: session.effort, autoApprove: session.auto)
|
||||
consume(backend.resume(spec), injectingUserText: nil)
|
||||
} else {
|
||||
// No turn has run yet → this message starts the session.
|
||||
let run = RunSpec(
|
||||
sessionID: session.id, worktree: worktreePath, prompt: input,
|
||||
model: session.model, effort: session.effort, approvalPolicy: .interactive)
|
||||
model: session.model, effort: session.effort,
|
||||
autoApprove: session.auto, approvalPolicy: .interactive)
|
||||
consume(backend.start(run), injectingUserText: nil)
|
||||
}
|
||||
return
|
||||
@@ -236,6 +238,24 @@ public actor SessionController {
|
||||
try? await metadataStore?.saveSession(session)
|
||||
}
|
||||
|
||||
public func setAuto(_ auto: Bool) async {
|
||||
session.auto = auto
|
||||
session.updatedAt = now()
|
||||
try? await metadataStore?.saveSession(session)
|
||||
}
|
||||
|
||||
public func setFavorite(_ favorite: Bool) async {
|
||||
session.favorite = favorite
|
||||
session.updatedAt = now()
|
||||
try? await metadataStore?.saveSession(session)
|
||||
}
|
||||
|
||||
public func setArchived(_ archived: Bool) async {
|
||||
session.archived = archived
|
||||
session.updatedAt = now()
|
||||
try? await metadataStore?.saveSession(session)
|
||||
}
|
||||
|
||||
public func shutdown() async {
|
||||
runTask?.cancel()
|
||||
await backend.shutdown()
|
||||
|
||||
@@ -227,6 +227,46 @@ struct AppStoreTests {
|
||||
#expect(!store.activityByDay.isEmpty) // at least today has activity
|
||||
}
|
||||
|
||||
@Test func newChatsInheritDefaults() async throws {
|
||||
let repo = try await GitTestRepo()
|
||||
defer { repo.cleanup() }
|
||||
let store = makeStore(repo: repo)
|
||||
store.defaultModel = "claude-sonnet-4-6"
|
||||
store.defaultEffort = "low"
|
||||
store.defaultAuto = true
|
||||
let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
|
||||
|
||||
_ = try await store.newSession(in: project)
|
||||
await waitFor { store.openSession != nil }
|
||||
#expect(store.openSession?.model == "claude-sonnet-4-6")
|
||||
#expect(store.openSession?.effort == "low")
|
||||
#expect(store.openSession?.auto == true)
|
||||
}
|
||||
|
||||
@Test func favoriteArchiveDeleteChat() async throws {
|
||||
let repo = try await GitTestRepo()
|
||||
defer { repo.cleanup() }
|
||||
let store = makeStore(repo: repo)
|
||||
let project = await store.addProject(name: "demo", rootPath: repo.root, defaultBranch: "main")!
|
||||
let id = try await store.createSession(in: project, title: "chat", prompt: "go")
|
||||
store.openSessionID = id
|
||||
await waitFor { store.summaries.first?.status == .awaitingInput }
|
||||
|
||||
await store.setSessionFavorite(id, true)
|
||||
#expect(store.summaries(for: project.id).first?.favorite == true)
|
||||
|
||||
await store.setSessionArchived(id, true)
|
||||
#expect(store.summaries(for: project.id).isEmpty) // hidden from main list
|
||||
#expect(store.archivedSummaries(for: project.id).map(\.id) == [id])
|
||||
#expect(store.openSessionID == nil) // closed on archive
|
||||
|
||||
await store.setSessionArchived(id, false)
|
||||
#expect(store.summaries(for: project.id).map(\.id) == [id])
|
||||
|
||||
await store.deleteSession(id)
|
||||
#expect(store.summaries.isEmpty)
|
||||
}
|
||||
|
||||
@Test func deleteProjectCascadesSessionsAndWorktrees() async throws {
|
||||
let repo = try await GitTestRepo()
|
||||
defer { repo.cleanup() }
|
||||
|
||||
Reference in New Issue
Block a user