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:
2026-06-12 14:38:06 -07:00
co-authored by Claude Opus 4.8
parent 571e3e1317
commit 99b5bdaa8e
15 changed files with 363 additions and 87 deletions
+10 -3
View File
@@ -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)
+31
View File
@@ -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)
}
}
+4 -1
View File
@@ -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)
}
}
+81 -7
View File
@@ -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 {
+27 -29
View File
@@ -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)
+35 -15
View File
@@ -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
}
}
+3 -2
View File
@@ -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)
}
}
+62 -16
View File
@@ -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)
+9 -1
View File
@@ -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
+6 -5
View File
@@ -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)
}
+11
View File
@@ -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
}
+22 -2
View File
@@ -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() }