From 42bcc859f1cfb2920b0098f21cf5c99dfd5f3b45 Mon Sep 17 00:00:00 2001 From: 13v13reddy Date: Sat, 19 Sep 2026 13:40:12 -0500 Subject: [PATCH 01/14] build: fix anydoc release compilation --- scripts/fetch-anydoc.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/fetch-anydoc.sh b/scripts/fetch-anydoc.sh index 83dc822..ccfa447 100755 --- a/scripts/fetch-anydoc.sh +++ b/scripts/fetch-anydoc.sh @@ -44,6 +44,7 @@ cd "$SRC/anydoc-$VERSION" SLICES="" for TARGET in aarch64-apple-darwin x86_64-apple-darwin; do if cargo build --locked --release --example convert --target "$TARGET" \ + --config profile.release.strip=false \ >"$OUT/build-$TARGET.log" 2>&1; then SLICES="$SLICES target/$TARGET/release/examples/convert" else From 79301a0e749ae0c63596384063c8f82cd035fb98 Mon Sep 17 00:00:00 2001 From: 13v13reddy Date: Sat, 19 Sep 2026 13:40:18 -0500 Subject: [PATCH 02/14] feat(capture): add menubar quick recording --- App/SimbiApp.swift | 6 + .../Sources/SimbiKit/NoteOperations.swift | 7 +- .../Sources/SimbiUI/QuickCaptureMenu.swift | 287 ++++++++++++++++++ .../Sources/SimbiUI/RecordingController.swift | 13 + .../Sources/SimbiUI/UpdateModel.swift | 4 + .../SimbiKitTests/NoteOperationsTests.swift | 4 + 6 files changed, 319 insertions(+), 2 deletions(-) create mode 100644 Packages/SimbiKit/Sources/SimbiUI/QuickCaptureMenu.swift diff --git a/App/SimbiApp.swift b/App/SimbiApp.swift index 81c476f..272ad2d 100644 --- a/App/SimbiApp.swift +++ b/App/SimbiApp.swift @@ -54,5 +54,11 @@ struct SimbiApp: App { Settings { SettingsView() } + MenuBarExtra { + QuickCaptureMenuContent() + } label: { + QuickCaptureMenuBarLabel() + } + .menuBarExtraStyle(.menu) } } diff --git a/Packages/SimbiKit/Sources/SimbiKit/NoteOperations.swift b/Packages/SimbiKit/Sources/SimbiKit/NoteOperations.swift index 0945d84..e4a4739 100644 --- a/Packages/SimbiKit/Sources/SimbiKit/NoteOperations.swift +++ b/Packages/SimbiKit/Sources/SimbiKit/NoteOperations.swift @@ -89,9 +89,12 @@ public enum NoteOperations { availableName("New Note", in: parent) } - /// True exactly for the names `availableNoteName` produces — the - /// auto-titler renames only notes whose title is still this default. + /// True for names generated by either the regular New Note action or + /// menubar quick capture. The auto-titler renames only notes whose title + /// is still one of these defaults. public static func isDefaultNoteName(_ name: String) -> Bool { name.wholeMatch(of: /New Note( [0-9]+)?/) != nil + || name.wholeMatch(of: /[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}-[0-9]{2}( [0-9]+)?/) + != nil } } diff --git a/Packages/SimbiKit/Sources/SimbiUI/QuickCaptureMenu.swift b/Packages/SimbiKit/Sources/SimbiUI/QuickCaptureMenu.swift new file mode 100644 index 0000000..970f34c --- /dev/null +++ b/Packages/SimbiKit/Sources/SimbiUI/QuickCaptureMenu.swift @@ -0,0 +1,287 @@ +import AppKit +import Observation +import SimbiKit +import SwiftUI + +/// Menubar-first recording: choose any normal Simbi folder, create a +/// timestamped note there, and start the per-note recorder immediately. +@MainActor @Observable +public final class QuickCaptureModel { + public static let shared = QuickCaptureModel() + + public let home: SimbiHome + public private(set) var nodes: [FileTreeNode] = [] + public private(set) var lastError: String? + + private var watcher: FileTreeWatcher? + private static let lastFolderDefaultsKey = "quickCapture.lastFolderURL" + + private static let timestampFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = .current + formatter.dateFormat = "yyyy-MM-dd HH-mm" + return formatter + }() + + private init() { + home = SimbiHome() + guard !OnboardingState.isNeeded() else { return } + refresh() + watcher = FileTreeWatcher.observing(url: home.rootURL) { [weak self] in + self?.refresh() + } + } + + public var rootFolders: [FileTreeNode] { + nodes.filter { $0.kind == .folder } + } + + /// The common path is one click: keep using the last folder the user + /// chose. A single available folder is an obvious first-run default; + /// otherwise the Simbi root remains the safe fallback. + public var quickStartParent: URL { + if let lastFolderURL { return lastFolderURL } + if rootFolders.count == 1, let onlyFolder = rootFolders.first { + return onlyFolder.url + } + return home.rootURL + } + + public var quickStartLabel: String { + if quickStartParent == home.rootURL { + return "Start Recording in Simbi" + } + return "Start Recording in \(quickStartParent.lastPathComponent)" + } + + private var lastFolderURL: URL? { + guard let path = UserDefaults.standard.string(forKey: Self.lastFolderDefaultsKey) else { + return nil + } + let url = URL(fileURLWithPath: path).standardizedFileURL + var isDirectory = ObjCBool(false) + guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory), + isDirectory.boolValue, + isValidFolderDestination(url) + else { return nil } + return url + } + + public func refresh() { + guard !OnboardingState.isNeeded() else { + nodes = [] + return + } + do { + try home.bootstrap() + } catch { + lastError = "Could not open the Simbi folder: \(error.localizedDescription)" + return + } + nodes = FileTreeScanner.scan(root: home.rootURL) + } + + /// Creates the note before starting capture, so the recording pipeline + /// owns a real, durable folder from its first byte. + public func start(in parent: URL) { + guard !RecordingActivity.shared.isRecording else { return } + lastError = nil + + let parent = parent.standardizedFileURL + guard parent == home.rootURL || !NoteOperations.isInsideNoteFolder(parent) else { + lastError = "Choose a Simbi folder, not an existing note." + return + } + guard isValidFolderDestination(parent) else { + lastError = "Choose a folder inside Simbi." + return + } + + do { + UserDefaults.standard.set(parent.path, forKey: Self.lastFolderDefaultsKey) + let timestamp = Self.timestampFormatter.string(from: .now) + let name = NoteOperations.availableName(timestamp, in: parent) + let noteURL = try NoteOperations.createNote(named: name, in: parent) + let controller = RecordingController.shared(noteFolderURL: noteURL) + let summary = SummaryController.shared(noteFolderURL: noteURL) + let title = TitleController.shared(noteFolderURL: noteURL) + + // Quick capture has no NoteView yet, so install the same + // post-recording AI hooks that NoteView normally wires up. + controller.onRecordingStopped = { [weak summary, weak title] in + summary?.recordingDidStop() + title?.recordingDidStop() + } + title.noteIsQuiet = { [weak controller, weak summary] in + TitleController.isQuiet( + fixerStatus: controller?.fixerActivity.status ?? .off, + summaryWorking: summary?.status == .working) + } + title.renameNote = { [weak self] newName in + do { + let renamed = try NoteOperations.rename(noteURL, to: newName) + SidebarOrder.renamed( + from: noteURL.lastPathComponent, + to: renamed.lastPathComponent, + in: noteURL.deletingLastPathComponent()) + self?.refresh() + } catch { + Log.ui.error("quick capture title rename failed: \(error)") + } + } + controller.startRecording() + refresh() + + // Surface a startup failure in the menubar instead of leaving an + // empty timestamped note with no explanation. + Task { @MainActor [weak self, weak controller] in + try? await Task.sleep(for: .seconds(1)) + guard let controller, case .failed(let message) = controller.status else { return } + self?.lastError = message + } + } catch { + lastError = "Could not create the recording note: \(error.localizedDescription)" + } + } + + public func stop() { + guard let noteURL = RecordingActivity.shared.activeNoteURL else { return } + RecordingController.shared(noteFolderURL: noteURL).toggle() + } + + private func isValidFolderDestination(_ url: URL) -> Bool { + let root = home.rootURL.standardizedFileURL + let path = url.standardizedFileURL.path + guard path == root.path || path.hasPrefix(root.path + "/") else { return false } + return path == root.path || !NoteOperations.isInsideNoteFolder(url) + } + + public func openSimbi() { + NSApp.activate(ignoringOtherApps: true) + NSApp.windows.first?.makeKeyAndOrderFront(nil) + } + +} + +/// The app-level menubar content. The model is resolved only after onboarding +/// is dismissed, so the menubar never latches SimbiHome to the default folder +/// before the user chooses a notes location. +public struct QuickCaptureMenuContent: View { + @State private var onboarding = OnboardingPresenter.shared + @State private var activity = RecordingActivity.shared + + public init() {} + + public var body: some View { + if onboarding.isActive { + Text("Finish Simbi setup to start recording") + Button("Open Simbi") { + NSApp.activate(ignoringOtherApps: true) + NSApp.windows.first?.makeKeyAndOrderFront(nil) + } + } else { + QuickCaptureMenu(model: QuickCaptureModel.shared, activity: activity) + } + } +} + +/// The compact label stays visible even when the main window is closed. +public struct QuickCaptureMenuBarLabel: View { + @State private var activity = RecordingActivity.shared + + public init() {} + + public var body: some View { + Image(systemName: activity.isRecording ? "record.circle.fill" : "waveform") + .symbolRenderingMode(.hierarchical) + .accessibilityLabel(activity.isRecording ? "Recording" : "Start recording") + } +} + +private struct QuickCaptureMenu: View { + @Bindable private var model: QuickCaptureModel + @Bindable private var activity: RecordingActivity + + init(model: QuickCaptureModel, activity: RecordingActivity) { + self.model = model + self.activity = activity + } + + var body: some View { + Group { + if OnboardingState.isNeeded() { + Text("Finish Simbi setup to start recording") + Button("Open Simbi") { model.openSimbi() } + } else if activity.isRecording { + Section { + Label( + model.recordingLabel, + systemImage: "record.circle.fill" + ) + .foregroundStyle(.red) + Button("Stop Recording", systemImage: "stop.circle") { + model.stop() + } + Button("Open Simbi") { model.openSimbi() } + } + } else { + ForEach(folderDestinations) { destination in + Button { + model.start(in: destination.url) + } label: { + Label("Start Recording in \(destination.path)", systemImage: "record.circle") + } + } + } + + if let error = model.lastError { + Divider() + Label(error, systemImage: "exclamationmark.triangle") + .foregroundStyle(.orange) + } + + Divider() + Button("Open Simbi") { model.openSimbi() } + SettingsLink { + Text("Settings…") + } + } + .onAppear { model.refresh() } + } + + private var folderDestinations: [FolderDestination] { + [FolderDestination(url: model.home.rootURL, path: "Simbi")] + + model.rootFolders.flatMap { allFolderDestinations(for: $0, prefix: "") } + } + + private func allFolderDestinations( + for node: FileTreeNode, + prefix: String + ) -> [FolderDestination] { + let path = prefix.isEmpty ? node.name : "\(prefix) / \(node.name)" + var destinations = [FolderDestination(url: node.url, path: path)] + for child in (node.children ?? []).filter({ $0.kind == .folder }) { + destinations.append(contentsOf: allFolderDestinations(for: child, prefix: path)) + } + return destinations + } +} + +private struct FolderDestination: Identifiable { + let url: URL + let path: String + + var id: String { + url.standardizedFileURL.path + } +} + +private extension QuickCaptureModel { + var recordingLabel: String { + if let noteURL = RecordingActivity.shared.activeNoteURL { + return "Recording in \(noteURL.deletingLastPathComponent().lastPathComponent)/\(noteURL.lastPathComponent)…" + } + return "Recording…" + } +} diff --git a/Packages/SimbiKit/Sources/SimbiUI/RecordingController.swift b/Packages/SimbiKit/Sources/SimbiUI/RecordingController.swift index c42f1ca..e435057 100644 --- a/Packages/SimbiKit/Sources/SimbiUI/RecordingController.swift +++ b/Packages/SimbiKit/Sources/SimbiUI/RecordingController.swift @@ -26,6 +26,12 @@ public final class RecordingController { controllers.all.contains { $0.status.isCapturing } } + /// The note currently capturing, if any. The menubar recorder uses this + /// to stop a recording without opening the main window. + public static var activeNoteFolderURL: URL? { + controllers.all.first(where: { $0.status.isCapturing })?.noteFolderURL + } + /// True while THIS note is capturing. Read-only — never creates a /// controller as a side effect. The summary controller uses it to /// refuse generation triggers mid-recording (AI Notes spec §3). @@ -181,6 +187,13 @@ public final class RecordingController { } } + /// Starts this note's recording without requiring a visible NoteView. + /// Quick capture uses this entry point after creating the note folder. + public func startRecording() { + guard status == .idle else { return } + Task { await start() } + } + private func start() async { status = .preparing // An import owns the note's timeline (state.json sessionCount / diff --git a/Packages/SimbiKit/Sources/SimbiUI/UpdateModel.swift b/Packages/SimbiKit/Sources/SimbiUI/UpdateModel.swift index 80163b9..c9c4114 100644 --- a/Packages/SimbiKit/Sources/SimbiUI/UpdateModel.swift +++ b/Packages/SimbiKit/Sources/SimbiUI/UpdateModel.swift @@ -20,11 +20,15 @@ public final class RecordingActivity { public static let shared = RecordingActivity() public private(set) var isRecording = false + /// The note currently capturing, if any. Used by the menubar control to + /// offer a one-click stop and to show where the recording is going. + public private(set) var activeNoteURL: URL? private init() {} func refresh() { let live = RecordingController.isAnyRecording + activeNoteURL = RecordingController.activeNoteFolderURL guard live != isRecording else { return } isRecording = live if !live { diff --git a/Packages/SimbiKit/Tests/SimbiKitTests/NoteOperationsTests.swift b/Packages/SimbiKit/Tests/SimbiKitTests/NoteOperationsTests.swift index 39d7a25..5d741d1 100644 --- a/Packages/SimbiKit/Tests/SimbiKitTests/NoteOperationsTests.swift +++ b/Packages/SimbiKit/Tests/SimbiKitTests/NoteOperationsTests.swift @@ -110,5 +110,9 @@ struct NoteOperationsTests { #expect(!NoteOperations.isDefaultNoteName("Design sync")) #expect(!NoteOperations.isDefaultNoteName("New Note 2b")) #expect(!NoteOperations.isDefaultNoteName("2026-08-15 Note")) + #expect(NoteOperations.isDefaultNoteName("2026-08-15 09-30")) + #expect(NoteOperations.isDefaultNoteName("2026-08-15 09-30 2")) + #expect(!NoteOperations.isDefaultNoteName("2026-8-15 09-30")) + #expect(!NoteOperations.isDefaultNoteName("2026-08-15 9-30")) } } From eeb690b7268e0ca32bd418586c0a0f06d8671666 Mon Sep 17 00:00:00 2001 From: 13v13reddy Date: Sat, 19 Sep 2026 13:40:23 -0500 Subject: [PATCH 03/14] feat(settings): add launch and recording controls --- .../Sources/SimbiKit/LaunchAtLogin.swift | 21 +++++++ .../SimbiKit/Sources/SimbiKit/Settings.swift | 10 +++- .../Sources/SimbiUI/SettingsView.swift | 57 ++++++++++--------- .../Sources/SimbiUI/SimbiRootView.swift | 47 +++++++++------ 4 files changed, 89 insertions(+), 46 deletions(-) create mode 100644 Packages/SimbiKit/Sources/SimbiKit/LaunchAtLogin.swift diff --git a/Packages/SimbiKit/Sources/SimbiKit/LaunchAtLogin.swift b/Packages/SimbiKit/Sources/SimbiKit/LaunchAtLogin.swift new file mode 100644 index 0000000..e053b19 --- /dev/null +++ b/Packages/SimbiKit/Sources/SimbiKit/LaunchAtLogin.swift @@ -0,0 +1,21 @@ +import Foundation +import ServiceManagement + +/// Keeps the app's menubar capture control ready before a meeting starts. +public enum LaunchAtLogin { + /// Applies the user's preference to the app's login item registration. + /// Registration failures are logged but never prevent Simbi from opening. + public static func apply(enabled: Bool) { + do { + if enabled { + if SMAppService.mainApp.status != .enabled { + try SMAppService.mainApp.register() + } + } else if SMAppService.mainApp.status == .enabled { + try SMAppService.mainApp.unregister() + } + } catch { + Log.ui.error("updating launch-at-login registration failed: \(error)") + } + } +} diff --git a/Packages/SimbiKit/Sources/SimbiKit/Settings.swift b/Packages/SimbiKit/Sources/SimbiKit/Settings.swift index c3ee285..d83c567 100644 --- a/Packages/SimbiKit/Sources/SimbiKit/Settings.swift +++ b/Packages/SimbiKit/Sources/SimbiKit/Settings.swift @@ -61,6 +61,8 @@ public struct SimbiSettings: Codable, Equatable, Sendable { /// Specific input device UID; nil follows the system default. public var micDeviceUID: String? public var systemAudioEnabled: Bool + /// Keeps Simbi ready in the menubar before a call starts. + public var launchAtLogin: Bool public static let `default` = SimbiSettings() @@ -78,7 +80,8 @@ public struct SimbiSettings: Codable, Equatable, Sendable { titleEffort: String? = nil, micEnabled: Bool = true, micDeviceUID: String? = nil, - systemAudioEnabled: Bool = true + systemAudioEnabled: Bool = true, + launchAtLogin: Bool = true ) { self.transcriptFixerEnabled = transcriptFixerEnabled self.aiNotesEnabled = aiNotesEnabled @@ -94,6 +97,7 @@ public struct SimbiSettings: Codable, Equatable, Sendable { self.micEnabled = micEnabled self.micDeviceUID = micDeviceUID self.systemAudioEnabled = systemAudioEnabled + self.launchAtLogin = launchAtLogin } private enum CodingKeys: String, CodingKey { @@ -102,7 +106,7 @@ public struct SimbiSettings: Codable, Equatable, Sendable { case transcriptFixerEnabled, aiNotesEnabled, noteTitleEnabled case fixerModel, converterModel, summaryModel, titleModel case fixerEffort, converterEffort, summaryEffort, titleEffort - case micEnabled, micDeviceUID, systemAudioEnabled + case micEnabled, micDeviceUID, systemAudioEnabled, launchAtLogin /// Pre-mic-picker files stored `"mic"` / `"micAndSystem"` here. case audioSource } @@ -128,6 +132,7 @@ public struct SimbiSettings: Codable, Equatable, Sendable { systemAudioEnabled = try container.decodeIfPresent(Bool.self, forKey: .systemAudioEnabled) ?? (legacySource != "mic") + launchAtLogin = try container.decodeIfPresent(Bool.self, forKey: .launchAtLogin) ?? true if !micEnabled && !systemAudioEnabled { micEnabled = true } @@ -149,6 +154,7 @@ public struct SimbiSettings: Codable, Equatable, Sendable { try container.encode(micEnabled, forKey: .micEnabled) try container.encodeIfPresent(micDeviceUID, forKey: .micDeviceUID) try container.encode(systemAudioEnabled, forKey: .systemAudioEnabled) + try container.encode(launchAtLogin, forKey: .launchAtLogin) } public static func load(from url: URL) throws -> SimbiSettings { diff --git a/Packages/SimbiKit/Sources/SimbiUI/SettingsView.swift b/Packages/SimbiKit/Sources/SimbiUI/SettingsView.swift index 01f3633..c6b6c35 100644 --- a/Packages/SimbiKit/Sources/SimbiUI/SettingsView.swift +++ b/Packages/SimbiKit/Sources/SimbiUI/SettingsView.swift @@ -117,6 +117,13 @@ final class SettingsDocument { /// window: a TabView inside the `Settings` scene renders as native toolbar /// tabs. This parent owns the single settings value (save-on-change) and /// the app-server model list; the panes are private views over bindings. +private enum SettingsLayout { + static let width: CGFloat = 460 + /// Keep the native Settings window compact and stable when switching + /// between panes. The Codex pane scrolls within this shared height. + static let height: CGFloat = 440 +} + public struct SettingsView: View { @State private var document = SettingsDocument() @State private var models: [CodexModels.Model] = [] @@ -141,13 +148,12 @@ public struct SettingsView: View { modelsUnavailable: modelsUnavailable ) .tabItem { Label("Codex", systemImage: "sparkles") } - RecordingSettingsPane(settings: $document.settings) - .tabItem { Label("Recording", systemImage: "mic") } } } - .frame(width: 460) + .frame(width: SettingsLayout.width, height: SettingsLayout.height) .onChange(of: document.settings) { document.save() + LaunchAtLogin.apply(enabled: document.settings.launchAtLogin) } .task { models = await CodexModels.availableModels(client: CodexServices.appServer) @@ -193,7 +199,9 @@ private struct GeneralSettingsPane: View { Button("Change…") { chooseHomeFolder() } } } + Toggle("Launch Simbi at login", isOn: $settings.launchAtLogin) } + RecordingSettingsSection(settings: $settings) Section("Updates") { Picker("Updates", selection: $updates.mode) { ForEach(UpdateMode.allCases, id: \.self) { mode in @@ -217,7 +225,6 @@ private struct GeneralSettingsPane: View { } } .formStyle(.grouped) - .fixedSize(horizontal: false, vertical: true) .alert( "Relaunch Simbi to switch the notes folder?", isPresented: Binding( @@ -305,7 +312,6 @@ private struct CodexSettingsPane: View { } } .formStyle(.grouped) - .fixedSize(horizontal: false, vertical: true) .alert( "Reset to default?", isPresented: Binding( @@ -361,34 +367,31 @@ private struct CodexSettingsPane: View { } } -/// Default recording sources (SPEC.md §3.1). -private struct RecordingSettingsPane: View { +/// Default recording sources (SPEC.md §3.1), kept with the general settings +/// because they are device-level preferences rather than Codex behavior. +private struct RecordingSettingsSection: View { @Binding var settings: SimbiSettings var body: some View { - Form { - Section("Recording") { - Picker("Microphone", selection: micSelection) { - Text("Off").tag(MicChoice.off) - Text("System default").tag(MicChoice.systemDefault) - ForEach(microphones) { mic in - Text(mic.name).tag(MicChoice.device(mic.id)) - } - // A saved-but-unplugged mic stays selectable rather - // than silently snapping to another option. - if case .device(let uid) = micSelection.wrappedValue, - !microphones.contains(where: { $0.id == uid }) - { - Text("Saved microphone (not connected)").tag(MicChoice.device(uid)) - } + Section("Recording") { + Picker("Microphone", selection: micSelection) { + Text("Off").tag(MicChoice.off) + Text("System default").tag(MicChoice.systemDefault) + ForEach(microphones) { mic in + Text(mic.name).tag(MicChoice.device(mic.id)) + } + // A saved-but-unplugged mic stays selectable rather + // than silently snapping to another option. + if case .device(let uid) = micSelection.wrappedValue, + !microphones.contains(where: { $0.id == uid }) + { + Text("Saved microphone (not connected)").tag(MicChoice.device(uid)) } - Toggle("Capture system audio", isOn: $settings.systemAudioEnabled) - // The last active source can't be turned off. - .disabled(!settings.micEnabled) } + Toggle("Capture system audio", isOn: $settings.systemAudioEnabled) + // The last active source can't be turned off. + .disabled(!settings.micEnabled) } - .formStyle(.grouped) - .fixedSize(horizontal: false, vertical: true) } private enum MicChoice: Hashable { diff --git a/Packages/SimbiKit/Sources/SimbiUI/SimbiRootView.swift b/Packages/SimbiKit/Sources/SimbiUI/SimbiRootView.swift index f24401f..efca946 100644 --- a/Packages/SimbiKit/Sources/SimbiUI/SimbiRootView.swift +++ b/Packages/SimbiKit/Sources/SimbiUI/SimbiRootView.swift @@ -117,6 +117,8 @@ public struct SimbiRootView: View { } .task { model.start() + LaunchAtLogin.apply( + enabled: SimbiSettings.current(home: model.home).launchAtLogin) // Load the diarizer + VAD models now so Record never waits on // them (screenshot mode stays offline). if !Flags.uiPreview { @@ -231,24 +233,35 @@ private struct SidebarScrollProbe: NSViewRepresentable { private struct CodexStatusFooter: View { var body: some View { let available = CodexSetupModel.shared.state == .connected - Button { - CodexStatusWindowManager.shared.open() - } label: { - HStack(spacing: Design.iconGap) { - StatusDot(color: available ? .statusOK : .statusWarning) - Text(available ? "Codex connected" : "Codex unavailable: transcription off") - .font(.meta) - .foregroundStyle( - available ? AnyShapeStyle(.secondary) : AnyShapeStyle(Color.statusWarning) - ) - .lineLimit(1) + HStack(spacing: 0) { + Button { + CodexStatusWindowManager.shared.open() + } label: { + HStack(spacing: Design.iconGap) { + StatusDot(color: available ? .statusOK : .statusWarning) + Text(available ? "Codex connected" : "Codex unavailable: transcription off") + .font(.meta) + .foregroundStyle( + available ? AnyShapeStyle(.secondary) : AnyShapeStyle(Color.statusWarning) + ) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, Design.footerInset) + .padding(.vertical, Design.stripPadding) + .contentShape(.rect) + } + .buttonStyle(.plain) + .help("Show Codex account status and usage") + + SettingsLink { + Image(systemName: "gearshape") + .accessibilityLabel("Settings") } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, Design.footerInset) - .padding(.vertical, Design.stripPadding) - .contentShape(.rect) + .buttonStyle(.borderless) + .foregroundStyle(.secondary) + .padding(.trailing, Design.footerInset) + .help("Settings…") } - .buttonStyle(.plain) - .help("Show Codex account status and usage") } } From 3d24ab53cb2eab1dbac9bb17490d9eb85f420cb8 Mon Sep 17 00:00:00 2001 From: 13v13reddy Date: Sat, 19 Sep 2026 13:40:28 -0500 Subject: [PATCH 04/14] feat(sidebar): add folders, deletion, and pinning --- .../OutlineViewKit/MenuOutlineView.swift | 18 ++- .../SimbiKit/Sources/SimbiKit/FileTree.swift | 2 +- .../Sources/SimbiKit/NoteOperations.swift | 2 +- .../Sources/SimbiKit/SidebarOrder.swift | 127 ++++++++++++++--- .../Sources/SimbiUI/FileTreeModel.swift | 50 ++++++- .../SimbiUI/SidebarOutlineSupport.swift | 129 +++++++++++++++++- .../Sources/SimbiUI/SidebarView.swift | 90 ++++++++---- .../SimbiKitTests/SidebarOrderTests.swift | 29 +++- 8 files changed, 387 insertions(+), 60 deletions(-) diff --git a/Packages/SimbiKit/Sources/OutlineViewKit/MenuOutlineView.swift b/Packages/SimbiKit/Sources/OutlineViewKit/MenuOutlineView.swift index b928385..1a0d3e8 100644 --- a/Packages/SimbiKit/Sources/OutlineViewKit/MenuOutlineView.swift +++ b/Packages/SimbiKit/Sources/OutlineViewKit/MenuOutlineView.swift @@ -4,6 +4,14 @@ import SwiftUI // Local addition to the vendored package (see VENDORED.md): per-row context // menus, which upstream OutlineView does not support. +/// A row cell that wants to follow the outline's single source of truth for +/// pointer hover. The outline owns tracking so recycled rows cannot retain a +/// stale hover state. +@MainActor +public protocol OutlineViewRowHoverable: AnyObject { + func setOutlineViewHovered(_ hovered: Bool) +} + /// `NSOutlineView` subclass that vends a context menu for the clicked row /// (or for the empty area below the rows, passing `nil`). final class MenuOutlineView: NSOutlineView { @@ -121,7 +129,15 @@ final class MenuOutlineView: NSOutlineView { private func updateHover(to point: NSPoint?) { let target = point.map { row(at: $0) } ?? -1 enumerateAvailableRowViews { rowView, row in - (rowView as? HoverHighlightRowView)?.isHovered = row == target + let hovered = row == target + (rowView as? HoverHighlightRowView)?.isHovered = hovered + if let cell = view(atColumn: 0, row: row, makeIfNecessary: false) + as? OutlineViewRowHoverable + { + MainActor.assumeIsolated { + cell.setOutlineViewHovered(hovered) + } + } } } } diff --git a/Packages/SimbiKit/Sources/SimbiKit/FileTree.swift b/Packages/SimbiKit/Sources/SimbiKit/FileTree.swift index 6bc2446..c658098 100644 --- a/Packages/SimbiKit/Sources/SimbiKit/FileTree.swift +++ b/Packages/SimbiKit/Sources/SimbiKit/FileTree.swift @@ -78,7 +78,7 @@ public struct FileTreeNode: Identifiable, Hashable, Sendable { /// internals (`files/`, `context/`, `.simbi/`, …) are never children; /// - hidden entries are skipped; /// - folders sort before loose files, each group alphabetically — -/// unless the folder has a manual `SidebarOrder`, which wins. +/// unless the folder has a manual `SidebarOrder` or pins, which win. public enum FileTreeScanner { public static let noteMarkerName = "note.md" diff --git a/Packages/SimbiKit/Sources/SimbiKit/NoteOperations.swift b/Packages/SimbiKit/Sources/SimbiKit/NoteOperations.swift index e4a4739..2729b44 100644 --- a/Packages/SimbiKit/Sources/SimbiKit/NoteOperations.swift +++ b/Packages/SimbiKit/Sources/SimbiKit/NoteOperations.swift @@ -9,7 +9,7 @@ public enum NoteOperationError: Error, Equatable { /// Plain file-system operations behind the sidebar's context menu (SPEC.md §6). public enum NoteOperations { /// Creates a note folder: a plain folder plus the empty `note.md` - /// marker that makes it a note. New notes are pinned to the top of the + /// marker that makes it a note. New notes are placed at the top of the /// parent's sidebar order rather than landing at their alphabetical slot. @discardableResult public static func createNote(named name: String, in parent: URL) throws -> URL { diff --git a/Packages/SimbiKit/Sources/SimbiKit/SidebarOrder.swift b/Packages/SimbiKit/Sources/SimbiKit/SidebarOrder.swift index d7e4ba6..dee971f 100644 --- a/Packages/SimbiKit/Sources/SimbiKit/SidebarOrder.swift +++ b/Packages/SimbiKit/Sources/SimbiKit/SidebarOrder.swift @@ -1,6 +1,6 @@ import Foundation -/// Persisted manual ordering of a folder's sidebar children. +/// Persisted manual ordering and pin state for a folder's sidebar children. /// /// Each organizational folder (and the home root) may hold a hidden /// `.simbi-order.json` — a JSON array of child names in display order. @@ -8,13 +8,20 @@ import Foundation /// follow the listed ones; names that no longer exist are simply ignored /// and get dropped on the next write. No file → pure default sort, so /// ordering stays opt-in per folder and travels with the folder on moves. +/// Pin state is kept separately in `.simbi-pins.json`, so pinning an item +/// never overwrites its manual position. public enum SidebarOrder { public static let fileName = ".simbi-order.json" + public static let pinsFileName = ".simbi-pins.json" static func fileURL(in folder: URL) -> URL { folder.appending(path: fileName) } + static func pinsFileURL(in folder: URL) -> URL { + folder.appending(path: pinsFileName) + } + public static func read(in folder: URL) -> [String] { guard let data = try? Data(contentsOf: fileURL(in: folder)), let names = try? JSONDecoder().decode([String].self, from: data) @@ -24,6 +31,15 @@ public enum SidebarOrder { return names } + static func readPinned(in folder: URL) -> [String] { + guard let data = try? Data(contentsOf: pinsFileURL(in: folder)), + let names = try? JSONDecoder().decode([String].self, from: data) + else { + return [] + } + return names + } + public static func write(_ names: [String], in folder: URL) { let url = fileURL(in: folder) guard !names.isEmpty else { @@ -44,24 +60,59 @@ public enum SidebarOrder { } } - /// Reorders `nodes` (already in default order) by the stored order: - /// listed names first in stored order, unlisted ones after, unchanged. + private static func writePinned(_ names: [String], in folder: URL) { + let url = pinsFileURL(in: folder) + guard !names.isEmpty else { + if FileManager.default.fileExists(atPath: url.path) { + do { + try FileManager.default.removeItem(at: url) + } catch { + Log.files.error("SidebarOrder: removing \(url.path) failed: \(error)") + } + } + return + } + do { + let data = try JSONEncoder().encode(names) + try data.write(to: url, options: .atomic) + } catch { + Log.files.error("SidebarOrder: writing \(url.path) failed: \(error)") + } + } + + /// Reorders `nodes` (already in default order) by manual order, then + /// overlays pinned names at the top. Unlisted and unpinned items keep + /// their current relative order. public static func apply(to nodes: [FileTreeNode], in folder: URL) -> [FileTreeNode] { let order = read(in: folder) - guard !order.isEmpty else { return nodes } - var rank: [String: Int] = [:] - for (index, name) in order.enumerated() where rank[name] == nil { - rank[name] = index + let ordered: [FileTreeNode] + if order.isEmpty { + ordered = nodes + } else { + var rank: [String: Int] = [:] + for (index, name) in order.enumerated() where rank[name] == nil { + rank[name] = index + } + var listed = nodes.filter { rank[$0.name] != nil } + listed.sort { rank[$0.name] ?? 0 < rank[$1.name] ?? 0 } + ordered = listed + nodes.filter { rank[$0.name] == nil } } - var listed = nodes.filter { rank[$0.name] != nil } - listed.sort { rank[$0.name] ?? 0 < rank[$1.name] ?? 0 } - return listed + nodes.filter { rank[$0.name] == nil } + + let pinned = readPinned(in: folder) + guard !pinned.isEmpty else { return ordered } + var pinnedRank: [String: Int] = [:] + for (index, name) in pinned.enumerated() where pinnedRank[name] == nil { + pinnedRank[name] = index + } + var pinnedNodes = ordered.filter { pinnedRank[$0.name] != nil } + pinnedNodes.sort { pinnedRank[$0.name] ?? 0 < pinnedRank[$1.name] ?? 0 } + let pinnedNames = Set(pinned) + return pinnedNodes + ordered.filter { !pinnedNames.contains($0.name) } } - /// Pins `name` to the top of its folder's order, creating the order - /// file when the folder had none — unlisted siblings keep their - /// alphabetical order below (`apply`). New notes go through this so - /// they appear first instead of at their alphabetical slot. + /// Prepends `name` to its folder's manual order, creating the order file + /// when the folder had none. New notes use this so they appear first + /// instead of at their alphabetical slot. public static func prepend(_ name: String, in folder: URL) { var names = read(in: folder) names.removeAll { $0 == name } @@ -69,19 +120,55 @@ public enum SidebarOrder { write(names, in: folder) } + /// Returns whether `name` is pinned within its immediate parent folder. + public static func isPinned(_ name: String, in folder: URL) -> Bool { + readPinned(in: folder).contains(name) + } + + /// Pins `name` to the top of its immediate parent folder. Pin order is + /// independent from manual drag order and newest pins appear first. + public static func pin(_ name: String, in folder: URL) { + var names = readPinned(in: folder) + names.removeAll { $0 == name } + names.insert(name, at: 0) + writePinned(names, in: folder) + } + + /// Removes a pin without changing the item's manual order. + public static func unpin(_ name: String, in folder: URL) { + var names = readPinned(in: folder) + guard names.contains(name) else { return } + names.removeAll { $0 == name } + writePinned(names, in: folder) + } + /// Keeps a renamed item's manual position, if it had one. public static func renamed(from oldName: String, to newName: String, in folder: URL) { var names = read(in: folder) - guard let index = names.firstIndex(of: oldName) else { return } - names[index] = newName - write(names, in: folder) + if let index = names.firstIndex(of: oldName) { + names[index] = newName + write(names, in: folder) + } + + var pinned = readPinned(in: folder) + if let index = pinned.firstIndex(of: oldName) { + pinned[index] = newName + writePinned(pinned, in: folder) + } } /// Drops a removed item from its folder's stored order, if present. public static func removed(_ name: String, in folder: URL) { var names = read(in: folder) - guard names.contains(name) else { return } - names.removeAll { $0 == name } - write(names, in: folder) + if names.contains(name) { + names.removeAll { $0 == name } + write(names, in: folder) + } + + var pinned = readPinned(in: folder) + if pinned.contains(name) { + pinned.removeAll { $0 == name } + writePinned(pinned, in: folder) + } } } diff --git a/Packages/SimbiKit/Sources/SimbiUI/FileTreeModel.swift b/Packages/SimbiKit/Sources/SimbiUI/FileTreeModel.swift index 2c4c2c5..4c8461c 100644 --- a/Packages/SimbiKit/Sources/SimbiUI/FileTreeModel.swift +++ b/Packages/SimbiKit/Sources/SimbiUI/FileTreeModel.swift @@ -87,13 +87,33 @@ public final class FileTreeModel { } } + /// Whether a sidebar item is pinned within its immediate parent folder. + public func isPinned(_ node: FileTreeNode) -> Bool { + SidebarOrder.isPinned(node.name, in: node.url.deletingLastPathComponent()) + } + + /// Toggles an item's pin without changing its filesystem location or + /// manual drag order. Pins are scoped to the item's current parent. + public func togglePinned(_ url: URL) { + let parent = url.deletingLastPathComponent() + let name = url.lastPathComponent + if SidebarOrder.isPinned(name, in: parent) { + SidebarOrder.unpin(name, in: parent) + } else { + SidebarOrder.pin(name, in: parent) + } + refresh() + } + /// "New Note" with a name prompt (SPEC.md §6): the dialog opens with /// the default name prefilled and selected, so typing replaces it; /// Return creates, Esc cancels. public func promptForNewNote(in folder: URL? = nil) { let parent = folder ?? targetFolderForNewItems let suggested = NoteOperations.availableNoteName(in: parent) - guard let entered = NoteNamePrompt.run(suggestedName: suggested) else { return } + guard let entered = NamePrompt.run(title: "New Note", suggestedName: suggested) else { + return + } // "/" would silently nest the note; an emptied field falls back to // the default name rather than failing. var name = @@ -113,6 +133,28 @@ public final class FileTreeModel { } } + public func promptForNewFolder(in folder: URL? = nil) { + let parent = folder ?? home.rootURL + let suggested = NoteOperations.availableName("New Folder", in: parent) + guard let entered = NamePrompt.run(title: "New Folder", suggestedName: suggested) else { + return + } + var name = + entered + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "/", with: "-") + if name.isEmpty { + name = suggested + } + name = NoteOperations.availableName(name, in: parent) + do { + _ = try NoteOperations.createFolder(named: name, in: parent) + refresh() + } catch { + Log.files.error("creating folder \\(name) failed: \\(error)") + } + } + public func createFolder(in folder: URL? = nil) { let parent = folder ?? targetFolderForNewItems let name = NoteOperations.availableName("New Folder", in: parent) @@ -221,11 +263,11 @@ public final class FileTreeModel { /// behaviors the dialog relies on — initial first responder selects the /// whole prefill, Return fires the default button, Esc cancels. @MainActor -enum NoteNamePrompt { +enum NamePrompt { /// Returns the entered name, or nil on cancel. - static func run(suggestedName: String) -> String? { + static func run(title: String, suggestedName: String) -> String? { let alert = NSAlert() - alert.messageText = "New Note" + alert.messageText = title alert.addButton(withTitle: "Create") alert.addButton(withTitle: "Cancel") let field = NSTextField(string: suggestedName) diff --git a/Packages/SimbiKit/Sources/SimbiUI/SidebarOutlineSupport.swift b/Packages/SimbiKit/Sources/SimbiUI/SidebarOutlineSupport.swift index 2af0b24..a0ed3da 100644 --- a/Packages/SimbiKit/Sources/SimbiUI/SidebarOutlineSupport.swift +++ b/Packages/SimbiKit/Sources/SimbiUI/SidebarOutlineSupport.swift @@ -7,11 +7,26 @@ extension NSPasteboard.PasteboardType { static let simbiSidebarItem = NSPasteboard.PasteboardType("com.simbi.sidebar-item") } -/// Sidebar row for OutlineViewKit: a truncating name label (no icon). +/// Sidebar row for OutlineViewKit: a truncating name label with compact +/// hover actions. Pinned rows keep the pin action visible as a state marker. /// An `NSTableCellView` (not SwiftUI) because the outline view uses the /// cell's `textField` outlet for selection tinting. -final class SidebarCellView: NSTableCellView { - init(node: FileTreeNode) { +@MainActor +final class SidebarCellView: NSTableCellView, OutlineViewRowHoverable { + private let deleteButton: SidebarDeleteButton + private let pinButton: SidebarPinButton + private let actions: NSStackView + + init( + node: FileTreeNode, + isPinned: Bool, + onTogglePin: @escaping @MainActor () -> Void, + onDelete: @escaping @MainActor () -> Void + ) { + deleteButton = SidebarDeleteButton(nodeName: node.name, handler: onDelete) + pinButton = SidebarPinButton( + nodeName: node.name, isPinned: isPinned, handler: onTogglePin) + actions = NSStackView(views: [pinButton, deleteButton]) super.init(frame: .zero) let label = NSTextField(labelWithString: node.name) @@ -26,16 +41,122 @@ final class SidebarCellView: NSTableCellView { addSubview(label) textField = label + actions.orientation = .horizontal + actions.alignment = .centerY + actions.spacing = 2 + actions.setContentHuggingPriority(.required, for: .horizontal) + actions.setContentCompressionResistancePriority(.required, for: .horizontal) + addSubview(actions) + label.translatesAutoresizingMaskIntoConstraints = false + actions.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 2), - label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -2), + label.trailingAnchor.constraint(lessThanOrEqualTo: actions.leadingAnchor, constant: -4), label.topAnchor.constraint(equalTo: topAnchor, constant: 4), label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4), + actions.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -2), + actions.centerYAnchor.constraint(equalTo: centerYAnchor), ]) } required init?(coder: NSCoder) { nil } + + func setOutlineViewHovered(_ hovered: Bool) { + pinButton.setHovered(hovered) + deleteButton.setHovered(hovered) + } +} + +/// A compact pin/unpin affordance. Unpinned rows reveal it on hover; pinned +/// rows keep it visible so the pin state is clear without relying on color. +@MainActor +private final class SidebarPinButton: NSButton { + private let handler: @MainActor () -> Void + private let nodeName: String + private var pinned: Bool + private var hovered = false + + init(nodeName: String, isPinned: Bool, handler: @escaping @MainActor () -> Void) { + self.handler = handler + self.nodeName = nodeName + self.pinned = isPinned + super.init(frame: .zero) + imagePosition = .imageOnly + imageScaling = .scaleProportionallyDown + isBordered = false + bezelStyle = .inline + focusRingType = .none + contentTintColor = .secondaryLabelColor + setContentHuggingPriority(.required, for: .horizontal) + setContentCompressionResistancePriority(.required, for: .horizontal) + target = self + action = #selector(invoke) + updateAppearance() + } + + required init?(coder: NSCoder) { nil } + + func setHovered(_ hovered: Bool) { + self.hovered = hovered + updateVisibility() + } + + private func updateAppearance() { + image = NSImage( + systemSymbolName: pinned ? "pin.fill" : "pin", + accessibilityDescription: pinned ? "Unpin from top" : "Pin to top") + let action = pinned ? "Unpin" : "Pin" + toolTip = "\(action) \(nodeName) \(pinned ? "from" : "to") top" + setAccessibilityLabel("\(action) \(nodeName) \(pinned ? "from" : "to") top") + updateVisibility() + } + + private func updateVisibility() { + isHidden = !pinned && !hovered + } + + @objc private func invoke() { + pinned.toggle() + updateAppearance() + handler() + } +} + +/// A compact, visible delete affordance for every sidebar row. Deletion uses +/// FileManager.trashItem, so users can recover recordings from macOS Trash. +@MainActor +private final class SidebarDeleteButton: NSButton { + private let handler: @MainActor () -> Void + + init(nodeName: String, handler: @escaping @MainActor () -> Void) { + self.handler = handler + super.init(frame: .zero) + image = NSImage( + systemSymbolName: "trash.fill", + accessibilityDescription: "Move to Trash") + imagePosition = .imageOnly + imageScaling = .scaleProportionallyDown + isBordered = false + bezelStyle = .inline + focusRingType = .none + contentTintColor = .systemRed + isHidden = true + setContentHuggingPriority(.required, for: .horizontal) + setContentCompressionResistancePriority(.required, for: .horizontal) + toolTip = "Move \(nodeName) to Trash" + setAccessibilityLabel("Move \(nodeName) to Trash") + target = self + action = #selector(invoke) + } + + required init?(coder: NSCoder) { nil } + + func setHovered(_ hovered: Bool) { + isHidden = !hovered + } + + @objc private func invoke() { handler() } } /// `NSMenuItem` that runs a closure, so sidebar context menus can be built diff --git a/Packages/SimbiKit/Sources/SimbiUI/SidebarView.swift b/Packages/SimbiKit/Sources/SimbiUI/SidebarView.swift index 62449f5..d59d3b9 100644 --- a/Packages/SimbiKit/Sources/SimbiUI/SidebarView.swift +++ b/Packages/SimbiKit/Sources/SimbiUI/SidebarView.swift @@ -17,33 +17,56 @@ struct SidebarView: View { @State private var renameText = "" var body: some View { - OutlineView( - model.nodes, - children: \.children, - selection: selectedNode - ) { node in - SidebarCellView(node: node) - } - .outlineViewStyle(.sourceList) - .quietRowSelection() - // Folders are containers, not documents: clicking one toggles its - // expansion, and the selection only ever points at notes and files. - .selectableRows { $0.kind != .folder } - .hoverHighlight(color: NSColor(Color.hoverFill), cornerRadius: Design.Radius.row) - .dragDataSource { node in - let item = NSPasteboardItem() - item.setString(node.url.standardizedFileURL.path, forType: .simbiSidebarItem) - return item - } - .onDrop(of: [.simbiSidebarItem], receiver: SidebarDropReceiver(model: model)) - .contextMenu { node in - contextMenu(for: node) - } - .renameAlert(isPresented: renameAlertPresented, name: $renameText) { name in - if let target = renameTarget { - model.rename(target.url, to: name) + VStack(spacing: 0) { + HStack(spacing: Design.innerGap) { + Text("Folders") + .font(.headline) + Spacer() + Button("New Folder", systemImage: "folder.badge.plus") { + model.promptForNewFolder(in: model.home.rootURL) + } + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + .controlSize(.small) + .help("Create a new folder") + } + .padding(.horizontal, Design.paneInset) + .padding(.vertical, Design.stripPadding) + + Divider() + + OutlineView( + model.nodes, + children: \.children, + selection: selectedNode + ) { node in + SidebarCellView( + node: node, + isPinned: model.isPinned(node), + onTogglePin: { model.togglePinned(node.url) }, + onDelete: { model.trash(node.url) }) + } + .outlineViewStyle(.sourceList) + .quietRowSelection() + // Folders are containers, not documents: clicking one toggles its + // expansion, and the selection only ever points at notes and files. + .selectableRows { $0.kind != .folder } + .hoverHighlight(color: NSColor(Color.hoverFill), cornerRadius: Design.Radius.row) + .dragDataSource { node in + let item = NSPasteboardItem() + item.setString(node.url.standardizedFileURL.path, forType: .simbiSidebarItem) + return item + } + .onDrop(of: [.simbiSidebarItem], receiver: SidebarDropReceiver(model: model)) + .contextMenu { node in + contextMenu(for: node) + } + .renameAlert(isPresented: renameAlertPresented, name: $renameText) { name in + if let target = renameTarget { + model.rename(target.url, to: name) + } + renameTarget = nil } - renameTarget = nil } } @@ -71,7 +94,9 @@ struct SidebarView: View { menu.addItem( HandlerMenuItem("New Note") { model.promptForNewNote(in: model.home.rootURL) }) menu.addItem( - HandlerMenuItem("New Folder") { model.createFolder(in: model.home.rootURL) }) + HandlerMenuItem("New Folder") { + model.promptForNewFolder(in: model.home.rootURL) + }) return menu } if node.kind == .folder { @@ -81,10 +106,19 @@ struct SidebarView: View { }) menu.addItem( HandlerMenuItem("New Folder") { - model.createFolder(in: node.url) + model.promptForNewFolder(in: node.url) }) menu.addItem(.separator()) } + let isPinned = model.isPinned(node) + let pinItem = HandlerMenuItem(isPinned ? "Unpin from Top" : "Pin to Top") { + model.togglePinned(node.url) + } + pinItem.image = NSImage( + systemSymbolName: isPinned ? "pin.slash" : "pin", + accessibilityDescription: isPinned ? "Unpin" : "Pin") + menu.addItem(pinItem) + menu.addItem(.separator()) menu.addItem( HandlerMenuItem("Rename…") { renameText = node.name diff --git a/Packages/SimbiKit/Tests/SimbiKitTests/SidebarOrderTests.swift b/Packages/SimbiKit/Tests/SimbiKitTests/SidebarOrderTests.swift index 481b156..6efcdd6 100644 --- a/Packages/SimbiKit/Tests/SimbiKitTests/SidebarOrderTests.swift +++ b/Packages/SimbiKit/Tests/SimbiKitTests/SidebarOrderTests.swift @@ -43,7 +43,30 @@ struct SidebarOrderTests { #expect(SidebarOrder.apply(to: defaultOrder, in: folder).map(\.name) == ["c", "a", "b", "d"]) } - @Test("prepend pins a name first, creating the order file if needed") + @Test("pins overlay manual order without changing it") + func pinOrder() throws { + let folder = try makeTempFolder() + defer { try? FileManager.default.removeItem(at: folder) } + + func node(_ name: String) -> FileTreeNode { + FileTreeNode(url: folder.appending(path: name), name: name, kind: .note, children: nil) + } + let defaultOrder = [node("a"), node("b"), node("c"), node("d")] + + SidebarOrder.write(["c", "a", "b"], in: folder) + SidebarOrder.pin("b", in: folder) + SidebarOrder.pin("a", in: folder) + #expect(SidebarOrder.readPinned(in: folder) == ["a", "b"]) + #expect(SidebarOrder.apply(to: defaultOrder, in: folder).map(\.name) == ["a", "b", "c", "d"]) + + SidebarOrder.unpin("a", in: folder) + #expect(SidebarOrder.apply(to: defaultOrder, in: folder).map(\.name) == ["b", "c", "a", "d"]) + SidebarOrder.unpin("b", in: folder) + #expect(SidebarOrder.apply(to: defaultOrder, in: folder).map(\.name) == ["c", "a", "b", "d"]) + #expect(!FileManager.default.fileExists(atPath: SidebarOrder.pinsFileURL(in: folder).path)) + } + + @Test("prepend places a name first, creating the order file if needed") func prepend() throws { let folder = try makeTempFolder() defer { try? FileManager.default.removeItem(at: folder) } @@ -67,8 +90,10 @@ struct SidebarOrderTests { defer { try? FileManager.default.removeItem(at: folder) } SidebarOrder.write(["c", "a", "b"], in: folder) + SidebarOrder.pin("a", in: folder) SidebarOrder.renamed(from: "a", to: "renamed", in: folder) #expect(SidebarOrder.read(in: folder) == ["c", "renamed", "b"]) + #expect(SidebarOrder.readPinned(in: folder) == ["renamed"]) // Renaming something with no manual position is a no-op. SidebarOrder.renamed(from: "unknown", to: "other", in: folder) @@ -76,6 +101,8 @@ struct SidebarOrderTests { SidebarOrder.removed("c", in: folder) #expect(SidebarOrder.read(in: folder) == ["renamed", "b"]) + SidebarOrder.removed("renamed", in: folder) + #expect(SidebarOrder.readPinned(in: folder) == []) } @Test("scanner applies the stored order over the alphabetical default") From 56f60a9d8e8143f054e88a4ebf61734730fba792 Mon Sep 17 00:00:00 2001 From: 13v13reddy Date: Sat, 19 Sep 2026 13:40:34 -0500 Subject: [PATCH 05/14] feat(titles): prefix AI names with date --- .../Sources/SimbiKit/AgentInstructions.swift | 2 +- .../Sources/SimbiUI/TitleController.swift | 17 ++++++++++++++++- .../SimbiUITests/TitleControllerTests.swift | 16 +++++++++++++++- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/Packages/SimbiKit/Sources/SimbiKit/AgentInstructions.swift b/Packages/SimbiKit/Sources/SimbiKit/AgentInstructions.swift index dc26758..01fc7a0 100644 --- a/Packages/SimbiKit/Sources/SimbiKit/AgentInstructions.swift +++ b/Packages/SimbiKit/Sources/SimbiKit/AgentInstructions.swift @@ -266,7 +266,7 @@ public enum AgentInstructions: String, CaseIterable, Identifiable, Sendable { Rules: - 2 to 6 words, plain text: no quotes, no trailing period, no date, \ - no "Meeting about" filler. + no "Meeting about" filler. Simbi adds the note date as a prefix. - Name the concrete subject, not the format: "Q3 Budget Review", not \ "Team Meeting Notes". - The title becomes a folder name, so never use "/" or ":". diff --git a/Packages/SimbiKit/Sources/SimbiUI/TitleController.swift b/Packages/SimbiKit/Sources/SimbiUI/TitleController.swift index 83b3546..f9e9bc5 100644 --- a/Packages/SimbiKit/Sources/SimbiUI/TitleController.swift +++ b/Packages/SimbiKit/Sources/SimbiUI/TitleController.swift @@ -15,6 +15,14 @@ import SimbiKit public final class TitleController { private static let controllers = PerNoteRegistry() + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = .current + formatter.dateFormat = "yyyy-MM-dd" + return formatter + }() + public static func shared(noteFolderURL: URL) -> TitleController { controllers.value(for: noteFolderURL, make: TitleController.init(noteFolderURL:)) } @@ -131,8 +139,15 @@ public final class TitleController { !RecordingController.isCapturing(noteFolderURL: noteFolderURL), let renameNote else { return } + let resourceValues = try? noteFolderURL.resourceValues(forKeys: [.creationDateKey]) + let noteDate = resourceValues?.creationDate ?? .now + let datedTitle = Self.datedTitle(title, date: noteDate) let unique = NoteOperations.availableName( - title, in: noteFolderURL.deletingLastPathComponent()) + datedTitle, in: noteFolderURL.deletingLastPathComponent()) renameNote(unique) } + + static func datedTitle(_ title: String, date: Date) -> String { + "\(dateFormatter.string(from: date)) - \(title)" + } } diff --git a/Packages/SimbiKit/Tests/SimbiUITests/TitleControllerTests.swift b/Packages/SimbiKit/Tests/SimbiUITests/TitleControllerTests.swift index e0a3dd5..bb6d8a8 100644 --- a/Packages/SimbiKit/Tests/SimbiUITests/TitleControllerTests.swift +++ b/Packages/SimbiKit/Tests/SimbiUITests/TitleControllerTests.swift @@ -54,6 +54,20 @@ struct TitleControllerTests { #expect(!TitleController.isQuiet(fixerStatus: .off, summaryWorking: true)) } + @Test("generated titles receive the note date prefix") + @MainActor + func datedTitle() { + var components = DateComponents() + components.calendar = Calendar(identifier: .gregorian) + components.timeZone = .current + components.year = 2026 + components.month = 9 + components.day = 19 + let date = components.calendar!.date(from: components)! + + #expect(TitleController.datedTitle("Design Sync", date: date) == "2026-09-19 - Design Sync") + } + @Test("a note that never goes quiet is not renamed") @MainActor func renameSkippedWhenNeverQuiet() async { @@ -87,7 +101,7 @@ struct TitleControllerTests { } await controller.awaitQuietThenApply("Design Sync") - #expect(renamedTo == "Design Sync") + #expect(renamedTo?.hasSuffix(" - Design Sync") == true) #expect(polls >= 3) } From 9fc070da51ddbe9e5728ba764b50f145080d72e6 Mon Sep 17 00:00:00 2001 From: 13v13reddy Date: Sat, 19 Sep 2026 13:40:39 -0500 Subject: [PATCH 06/14] docs: add product context --- PRODUCT.md | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 PRODUCT.md diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..1581599 --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,52 @@ +# Product + +## Register + +product + +## Users + +People who use Simbi for DSU calls, meetings, interviews, lectures, and other +conversations. They often need to start recording quickly, before opening a +full app window, and may be distracted by the call itself. + +## Product Purpose + +Simbi turns live conversations into local, editable notes with speaker-labelled +transcripts and AI-generated summaries. The most important workflow is reliable +capture: a user should be able to start a recording in an existing Simbi folder +with minimal interruption and without risking that they forget to press Record. + +Quick capture creates a timestamped note in a user-selected existing folder, +starts recording immediately, and leaves naming and organization refinements +for later. Simbi should be ready from the menubar before a call begins. + +## Brand Personality + +Calm, trustworthy, native to macOS. The product should feel prepared and +quietly protective of the user's meetings rather than demanding attention. + +## Anti-references + +Avoid setup-heavy workflows, mandatory naming before capture, notification-heavy +recording controls, separate proprietary storage areas, and generic dashboard +patterns that make the user hunt for the primary action. + +## Design Principles + +- Capture first: starting a recording should be the shortest reliable path. +- Organize without interruption: reuse the user's normal Simbi folders and defer + optional naming work. +- Make state unmistakable: the menubar must clearly show when recording is live. +- Stay native: use familiar macOS menubar, settings, permissions, and launch-at- + login conventions. +- Preserve agency: stopping is always explicit, and quick capture never hides + where the note was saved. + +## Accessibility & Inclusion + +Use system controls and labels, support VoiceOver through explicit menu and +button labels, preserve keyboard access in the main app, and do not rely on color +alone to communicate recording state. Respect reduced-motion preferences and +provide clear error feedback if permissions, folder creation, or recording +startup fails. From 5350be390d3454dcf657d4a3c87d68d3ce0d16ac Mon Sep 17 00:00:00 2001 From: 13v13reddy Date: Sat, 19 Sep 2026 16:40:13 -0500 Subject: [PATCH 07/14] fix(menubar): streamline folder quick capture --- .../Sources/SimbiUI/QuickCaptureMenu.swift | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/Packages/SimbiKit/Sources/SimbiUI/QuickCaptureMenu.swift b/Packages/SimbiKit/Sources/SimbiUI/QuickCaptureMenu.swift index 970f34c..06bb22f 100644 --- a/Packages/SimbiKit/Sources/SimbiUI/QuickCaptureMenu.swift +++ b/Packages/SimbiKit/Sources/SimbiUI/QuickCaptureMenu.swift @@ -37,8 +37,8 @@ public final class QuickCaptureModel { nodes.filter { $0.kind == .folder } } - /// The common path is one click: keep using the last folder the user - /// chose. A single available folder is an obvious first-run default; + /// The checked destination in the menubar: keep using the last folder + /// chosen. A single available folder is an obvious first-run default; /// otherwise the Simbi root remains the safe fallback. public var quickStartParent: URL { if let lastFolderURL { return lastFolderURL } @@ -48,13 +48,6 @@ public final class QuickCaptureModel { return home.rootURL } - public var quickStartLabel: String { - if quickStartParent == home.rootURL { - return "Start Recording in Simbi" - } - return "Start Recording in \(quickStartParent.lastPathComponent)" - } - private var lastFolderURL: URL? { guard let path = UserDefaults.standard.string(forKey: Self.lastFolderDefaultsKey) else { return nil @@ -223,15 +216,13 @@ private struct QuickCaptureMenu: View { Button("Stop Recording", systemImage: "stop.circle") { model.stop() } - Button("Open Simbi") { model.openSimbi() } } } else { ForEach(folderDestinations) { destination in - Button { - model.start(in: destination.url) - } label: { - Label("Start Recording in \(destination.path)", systemImage: "record.circle") + Toggle(isOn: destinationBinding(for: destination)) { + Text(destination.path) } + .accessibilityLabel("Record in \(destination.path)") } } @@ -255,6 +246,18 @@ private struct QuickCaptureMenu: View { + model.rootFolders.flatMap { allFolderDestinations(for: $0, prefix: "") } } + private func destinationBinding(for destination: FolderDestination) -> Binding { + Binding( + get: { + model.quickStartParent.standardizedFileURL + == destination.url.standardizedFileURL + }, + // A folder row is an action, not a persistent on/off setting: + // clicking either the checked or unchecked row starts capture. + set: { _ in model.start(in: destination.url) } + ) + } + private func allFolderDestinations( for node: FileTreeNode, prefix: String From 84bf5fc90a2cfc8b38333810fd535218f1787dd4 Mon Sep 17 00:00:00 2001 From: 13v13reddy Date: Sat, 19 Sep 2026 17:12:11 -0500 Subject: [PATCH 08/14] fix(audio): recover from input changes --- .../SimbiAudio/AudioInputDevices.swift | 15 ++ .../Sources/SimbiAudio/MicCapture.swift | 238 ++++++++++++++++-- .../SimbiAudioTests/MicCaptureTests.swift | 169 +++++++++++++ 3 files changed, 398 insertions(+), 24 deletions(-) create mode 100644 Packages/SimbiKit/Tests/SimbiAudioTests/MicCaptureTests.swift diff --git a/Packages/SimbiKit/Sources/SimbiAudio/AudioInputDevices.swift b/Packages/SimbiKit/Sources/SimbiAudio/AudioInputDevices.swift index 279434e..a84caed 100644 --- a/Packages/SimbiKit/Sources/SimbiAudio/AudioInputDevices.swift +++ b/Packages/SimbiKit/Sources/SimbiAudio/AudioInputDevices.swift @@ -40,6 +40,21 @@ public enum AudioInputDevices { } } + /// The current system-default input. Re-read during route recovery so a + /// recording moves from unplugged headphones to the built-in microphone. + static func defaultDeviceID() -> AudioDeviceID? { + var address = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultInputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + var deviceID = AudioDeviceID(kAudioObjectUnknown) + var size = UInt32(MemoryLayout.size) + let status = AudioObjectGetPropertyData( + AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &deviceID) + guard status == noErr, deviceID != kAudioObjectUnknown else { return nil } + return deviceID + } + private static func allDeviceIDs() -> [AudioDeviceID] { var address = AudioObjectPropertyAddress( mSelector: kAudioHardwarePropertyDevices, diff --git a/Packages/SimbiKit/Sources/SimbiAudio/MicCapture.swift b/Packages/SimbiKit/Sources/SimbiAudio/MicCapture.swift index 4cf502c..e31681f 100644 --- a/Packages/SimbiKit/Sources/SimbiAudio/MicCapture.swift +++ b/Packages/SimbiKit/Sources/SimbiAudio/MicCapture.swift @@ -1,71 +1,261 @@ import AVFoundation import Foundation +import SimbiKit + +/// The hardware-facing seam behind `MicCapture`. Keeping route recovery in +/// `MicCapture` lets tests simulate a device disappearing without touching +/// the machine's real microphone. +protocol MicInputEngine: AnyObject { + var configurationChangeObject: AnyObject { get } + var isRunning: Bool { get } + + func start( + deviceUID: String?, onSamples: @escaping @Sendable ([Float]) -> Void + ) throws + func stop() +} /// Microphone capture (SPEC.md §3.1): AVAudioEngine input-node tap, /// converted to the pipeline format (16 kHz mono Float32) and delivered as /// an AsyncStream of sample batches. `MixedCapture` mixes this stream with /// the system-audio tap's. public final class MicCapture: @unchecked Sendable { + private static let recoveryFrameCount = 1_024 + private static let recoveryFrameInterval: DispatchTimeInterval = .milliseconds(64) + private static let recoverySilence = [Float](repeating: 0, count: recoveryFrameCount) + public enum CaptureError: Error { case converterUnavailable + case inputUnavailable /// The requested device UID isn't connected (or refused selection). case deviceUnavailable } - private let engine = AVAudioEngine() + private let engine: any MicInputEngine + private let notificationCenter: NotificationCenter + private let recoveryQueue = DispatchQueue(label: "app.getsimbi.mac.mic-recovery") private var continuation: AsyncStream<[Float]>.Continuation? + private var configurationObserver: NSObjectProtocol? + private var recoveryClock: DispatchSourceTimer? + private var retryWorkItem: DispatchWorkItem? + private var retryAttempt = 0 + private var requestedDeviceUID: String? + private var active = false + + public convenience init() { + self.init(engine: AVAudioMicInputEngine(), notificationCenter: .default) + } - public init() {} + init(engine: any MicInputEngine, notificationCenter: NotificationCenter) { + self.engine = engine + self.notificationCenter = notificationCenter + } /// Starts the tap and returns the 16 kHz mono batch stream. The stream /// finishes when `stop()` is called. `deviceUID` selects a specific /// input device; nil follows the system default. public func start(deviceUID: String? = nil) throws -> AsyncStream<[Float]> { + let (stream, continuation) = AsyncStream.makeStream(of: [Float].self) + do { + try recoveryQueue.sync { + self.continuation = continuation + requestedDeviceUID = deviceUID + active = true + observeConfigurationChanges() + do { + try startEngine() + } catch { + tearDown() + throw error + } + } + } catch { + continuation.finish() + throw error + } + return stream + } + + /// Stops the tap and finishes the stream (delivering everything already + /// yielded first — the pipeline drains before its stop sequence). + public func stop() { + recoveryQueue.sync { tearDown() } + } + + private func observeConfigurationChanges() { + configurationObserver = notificationCenter.addObserver( + forName: .AVAudioEngineConfigurationChange, + object: engine.configurationChangeObject, + queue: nil + ) { [weak self] _ in + // Apple posts this on an internal audio queue and warns against + // tearing the engine down there. Hop to our serial queue first. + self?.recoverAfterConfigurationChange() + } + } + + private func recoverAfterConfigurationChange() { + recoveryQueue.async { [weak self] in + guard let self, active, !engine.isRunning else { return } + startRecoveryClock() + retryWorkItem?.cancel() + retryWorkItem = nil + retryAttempt = 0 + attemptRecovery() + } + } + + private func attemptRecovery() { + guard active, !engine.isRunning else { return } + engine.stop() + do { + try startEngine() + stopRecoveryClock() + retryAttempt = 0 + retryWorkItem = nil + Log.recording.info("microphone capture recovered after audio device change") + } catch { + Log.recording.warning( + "microphone input is not ready after audio device change; retrying: \(error)") + scheduleRecoveryRetry() + } + } + + private func scheduleRecoveryRetry() { + let exponent = min(retryAttempt, 4) + let delayMilliseconds = min(100 * (1 << exponent), 1_600) + retryAttempt += 1 + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + retryWorkItem = nil + attemptRecovery() + } + retryWorkItem?.cancel() + retryWorkItem = workItem + recoveryQueue.asyncAfter( + deadline: .now() + .milliseconds(delayMilliseconds), execute: workItem) + } + + /// `MixedCapture` uses microphone batches as its clock. Keep that clock + /// advancing with silence while the hardware input is unavailable so the + /// system-audio FIFO continues flowing and the note timeline stays intact. + private func startRecoveryClock() { + guard recoveryClock == nil else { return } + let timer = DispatchSource.makeTimerSource(queue: recoveryQueue) + timer.schedule( + deadline: .now() + Self.recoveryFrameInterval, + repeating: Self.recoveryFrameInterval, + leeway: .milliseconds(4)) + timer.setEventHandler { [weak self] in + guard let self, active, !engine.isRunning else { return } + continuation?.yield(Self.recoverySilence) + } + recoveryClock = timer + timer.resume() + } + + private func stopRecoveryClock() { + recoveryClock?.cancel() + recoveryClock = nil + } + + private func startEngine() throws { + guard let continuation else { return } + try engine.start(deviceUID: requestedDeviceUID) { samples in + continuation.yield(samples) + } + } + + private func tearDown() { + active = false + stopRecoveryClock() + retryWorkItem?.cancel() + retryWorkItem = nil + retryAttempt = 0 + if let configurationObserver { + notificationCenter.removeObserver(configurationObserver) + self.configurationObserver = nil + } + engine.stop() + continuation?.finish() + continuation = nil + requestedDeviceUID = nil + } +} + +/// AVAudioEngine implementation used in production. Every start reads the +/// current hardware format again, so a recovered engine gets a fresh tap and +/// converter for the replacement input device. +private final class AVAudioMicInputEngine: MicInputEngine, @unchecked Sendable { + private let engine = AVAudioEngine() + private var tapInstalled = false + + var configurationChangeObject: AnyObject { engine } + var isRunning: Bool { engine.isRunning } + + func start( + deviceUID: String?, onSamples: @escaping @Sendable ([Float]) -> Void + ) throws { let input = engine.inputNode - if let deviceUID { - // Must happen before the first format query — selecting the - // device changes the input node's hardware format. - guard var deviceID = AudioInputDevices.deviceID(forUID: deviceUID), - let audioUnit = input.audioUnit, + // Must happen before the first format query — selecting the device + // changes the input node's hardware format. Resolve the default on + // every start so unplugging headphones moves capture to the built-in + // microphone instead of retaining the vanished device. + let deviceID: AudioDeviceID? + if let deviceUID, let selected = AudioInputDevices.deviceID(forUID: deviceUID) { + deviceID = selected + } else { + deviceID = AudioInputDevices.defaultDeviceID() + } + if var deviceID { + guard let audioUnit = input.audioUnit, AudioUnitSetProperty( audioUnit, kAudioOutputUnitProperty_CurrentDevice, kAudioUnitScope_Global, 0, &deviceID, UInt32(MemoryLayout.size)) == noErr else { - throw CaptureError.deviceUnavailable + throw MicCapture.CaptureError.deviceUnavailable } } + let inputFormat = input.outputFormat(forBus: 0) + guard inputFormat.sampleRate > 0, inputFormat.channelCount > 0 else { + throw MicCapture.CaptureError.inputUnavailable + } guard let targetFormat = AudioResampling.pipelineFormat, let converter = AVAudioConverter(from: inputFormat, to: targetFormat) else { - throw CaptureError.converterUnavailable + throw MicCapture.CaptureError.converterUnavailable } - let (stream, continuation) = AsyncStream.makeStream(of: [Float].self) - self.continuation = continuation - input.installTap( onBus: 0, bufferSize: 1024, format: inputFormat - ) { [weak self] buffer, _ in - guard let self, - let samples = AudioResampling.convert(buffer, using: converter, to: targetFormat) + ) { buffer, _ in + guard + let samples = AudioResampling.convert( + buffer, using: converter, to: targetFormat) else { return } - self.continuation?.yield(samples) + onSamples(samples) } + tapInstalled = true engine.prepare() - try engine.start() - return stream + do { + try engine.start() + } catch { + input.removeTap(onBus: 0) + tapInstalled = false + throw error + } } - /// Stops the tap and finishes the stream (delivering everything already - /// yielded first — the pipeline drains before its stop sequence). - public func stop() { - engine.inputNode.removeTap(onBus: 0) + func stop() { + if tapInstalled { + engine.inputNode.removeTap(onBus: 0) + tapInstalled = false + } engine.stop() - continuation?.finish() - continuation = nil + engine.reset() } } diff --git a/Packages/SimbiKit/Tests/SimbiAudioTests/MicCaptureTests.swift b/Packages/SimbiKit/Tests/SimbiAudioTests/MicCaptureTests.swift new file mode 100644 index 0000000..1c9f235 --- /dev/null +++ b/Packages/SimbiKit/Tests/SimbiAudioTests/MicCaptureTests.swift @@ -0,0 +1,169 @@ +import AVFoundation +import Foundation +import Testing + +@testable import SimbiAudio + +@Suite("MicCapture") +struct MicCaptureTests { + @Test("continues streaming after the input device changes") + func recoversAfterInputDeviceChange() async throws { + let notifications = NotificationCenter() + let engine = FakeMicInputEngine() + let capture = MicCapture(engine: engine, notificationCenter: notifications) + let stream = try capture.start() + var iterator = stream.makeAsyncIterator() + + engine.emit([0.1]) + #expect(await iterator.next() == [0.1]) + + engine.simulateConfigurationChange(on: notifications) + for _ in 0..<100 where engine.startCount < 2 { + try await Task.sleep(for: .milliseconds(10)) + } + + #expect(engine.startCount == 2) + #expect(engine.isRunning) + + engine.emit([0.2]) + #expect(await iterator.next() == [0.2]) + capture.stop() + } + + @Test("retries while the replacement input is becoming available") + func retriesTransientRecoveryFailure() async throws { + let notifications = NotificationCenter() + let engine = FakeMicInputEngine() + let capture = MicCapture(engine: engine, notificationCenter: notifications) + let stream = try capture.start() + var iterator = stream.makeAsyncIterator() + + engine.failNextStarts(1) + engine.simulateConfigurationChange(on: notifications) + for _ in 0..<150 where engine.startCount < 3 { + try await Task.sleep(for: .milliseconds(10)) + } + + #expect(engine.startCount == 3) + #expect(engine.isRunning) + + engine.emit([0.3]) + var receivedReplacementInput = false + for _ in 0..<4 { + guard let batch = await iterator.next() else { break } + if batch == [0.3] { + receivedReplacementInput = true + break + } + } + #expect(receivedReplacementInput) + capture.stop() + } + + @Test("keeps the recording clock moving while the input reconnects") + func emitsTimingSilenceDuringRecovery() async throws { + let notifications = NotificationCenter() + let engine = FakeMicInputEngine() + let capture = MicCapture(engine: engine, notificationCenter: notifications) + let stream = try capture.start() + let collector = BatchCollector() + let collectionTask = Task { + for await batch in stream { + await collector.append(batch) + } + } + + engine.failNextStarts(10) + engine.simulateConfigurationChange(on: notifications) + for _ in 0..<100 { + if await collector.count > 0 { break } + try await Task.sleep(for: .milliseconds(10)) + } + + let firstBatch = await collector.first + #expect(firstBatch?.count == 1_024) + #expect(firstBatch?.allSatisfy { $0 == 0 } == true) + + capture.stop() + await collectionTask.value + } +} + +private actor BatchCollector { + private var batches: [[Float]] = [] + + var count: Int { batches.count } + var first: [Float]? { batches.first } + + func append(_ batch: [Float]) { + batches.append(batch) + } +} + +private final class FakeMicInputEngine: MicInputEngine, @unchecked Sendable { + let configurationChangeObject: AnyObject = NSObject() + + private let lock = NSLock() + private var running = false + private var starts = 0 + private var failuresRemaining = 0 + private var sampleHandler: (@Sendable ([Float]) -> Void)? + + var isRunning: Bool { + lock.lock() + defer { lock.unlock() } + return running + } + + var startCount: Int { + lock.lock() + defer { lock.unlock() } + return starts + } + + func start( + deviceUID: String?, onSamples: @escaping @Sendable ([Float]) -> Void + ) throws { + lock.lock() + starts += 1 + if failuresRemaining > 0 { + failuresRemaining -= 1 + running = false + lock.unlock() + throw FakeError.startFailed + } + running = true + sampleHandler = onSamples + lock.unlock() + } + + func stop() { + lock.lock() + running = false + lock.unlock() + } + + func emit(_ samples: [Float]) { + lock.lock() + let handler = sampleHandler + lock.unlock() + handler?(samples) + } + + func failNextStarts(_ count: Int) { + lock.lock() + failuresRemaining = count + lock.unlock() + } + + func simulateConfigurationChange(on notifications: NotificationCenter) { + stop() + notifications.post( + name: .AVAudioEngineConfigurationChange, + object: configurationChangeObject) + } + + private enum FakeError: Error { + case startFailed + } +} From 0a40b4aa0fb8aeff23ea8226fe642bff34d332bf Mon Sep 17 00:00:00 2001 From: 13v13reddy Date: Sat, 19 Sep 2026 17:51:34 -0500 Subject: [PATCH 09/14] feat(files): auto-refresh converted context Coalesce parallel conversions into one AI Notes update and make chat turns rescan converted files. --- .../Sources/CodexKit/TerminalChatLaunch.swift | 11 ++- .../SimbiKit/Sources/SimbiUI/FilesModel.swift | 56 ++++++++++++- .../SimbiKit/Sources/SimbiUI/NoteView.swift | 12 +++ .../Sources/SimbiUI/SummaryController.swift | 84 +++++++++++++++++++ .../TerminalChatLaunchTests.swift | 15 +++- .../ContextConversionBatchTests.swift | 31 +++++++ .../SimbiUITests/SummaryControllerTests.swift | 42 ++++++++++ 7 files changed, 247 insertions(+), 4 deletions(-) create mode 100644 Packages/SimbiKit/Tests/SimbiUITests/ContextConversionBatchTests.swift diff --git a/Packages/SimbiKit/Sources/CodexKit/TerminalChatLaunch.swift b/Packages/SimbiKit/Sources/CodexKit/TerminalChatLaunch.swift index a170aa5..e1752fc 100644 --- a/Packages/SimbiKit/Sources/CodexKit/TerminalChatLaunch.swift +++ b/Packages/SimbiKit/Sources/CodexKit/TerminalChatLaunch.swift @@ -11,6 +11,14 @@ import SimbiKit /// note titles, paths, and the context blurb out of shell-quoting territory /// entirely. public struct TerminalChatLaunch: Sendable, Equatable { + /// App-owned live-context contract. It is appended outside CHAT.md so + /// existing installs with a bootstrapped or customized template gain the + /// behavior too; user instructions still control every other chat detail. + private static let liveContextInstructions = """ + Before every answer, re-list `context/` rather than relying only on the \ + launch-time inventory. Read every newly added or changed markdown file \ + there before responding. + """ /// Ghostty runs this via `bash -c "exec -l "` (`command =` /// config key), with two parsing traps verified against Ghostty 1.3: /// it prepends the `exec` itself (a leading `exec` here becomes @@ -61,9 +69,10 @@ public struct TerminalChatLaunch: Sendable, Equatable { ? "The note has no files yet." : "The note currently contains: " + files.map { "`\($0)`" }.joined(separator: ", ") + "." - return AgentInstructions.chat.resolve( + let chatInstructions = AgentInstructions.chat.resolve( homeRootURL: homeRootURL, variables: ["note_path": notePath, "files": contents]) + return chatInstructions + "\n\n" + liveContextInstructions } /// Top-level note files plus one level of `context/` and `files/`, diff --git a/Packages/SimbiKit/Sources/SimbiUI/FilesModel.swift b/Packages/SimbiKit/Sources/SimbiUI/FilesModel.swift index 716ac3a..2ca47ba 100644 --- a/Packages/SimbiKit/Sources/SimbiUI/FilesModel.swift +++ b/Packages/SimbiKit/Sources/SimbiUI/FilesModel.swift @@ -3,6 +3,28 @@ import Foundation import Observation import SimbiKit +/// Coalesces parallel file conversions into one context-ready event. A picker +/// selection dispatches all of its files synchronously, so the transition back +/// to zero active jobs is the batch boundary the rest of the app cares about. +struct ContextConversionBatch { + private var active = 0 + private var successful = 0 + + mutating func started() { + active += 1 + } + + /// Returns the successful-file count exactly once when the batch settles. + mutating func finished(successfully: Bool) -> Int? { + precondition(active > 0) + active -= 1 + if successfully { successful += 1 } + guard active == 0 else { return nil } + defer { successful = 0 } + return successful > 0 ? successful : nil + } +} + /// Owns file import + conversion for one note (SPEC.md §5.3): copies /// dropped/picked files into `files/`, dispatches one converter thread per /// file, and exposes per-row status for the UI. Shared per note (like @@ -33,9 +55,19 @@ final class FilesModel { private(set) var rows: [Row] = [] private(set) var importError: String? + /// Fired once after all conversions in an import batch settle, carrying + /// the number that produced usable context. If the note view has not + /// attached its handler yet, the completed count is retained and delivered + /// when it does (fast conversions must not miss the AI-notes trigger). + var onContextBatchCompleted: ((Int) -> Void)? { + didSet { deliverPendingContextBatchIfPossible() } + } + private let noteFolderURL: URL private let converter: FileConverter private var activeJobs: Set = [] + private var conversionBatch = ContextConversionBatch() + private var pendingCompletedContextFiles = 0 /// Files whose conversion thread is running a turn the app did not /// start (typed in a viewer terminal). Suppresses refresh()'s /// stale-record re-dispatch while the turn runs; empty after a @@ -109,16 +141,19 @@ final class FilesModel { else { return } switch effect { case .turnBegan(let file): - externalTurns.insert(file) + if externalTurns.insert(file).inserted { + conversionBatch.started() + } Self.updateState(noteFolder: noteFolderURL) { $0.conversions[file]?.status = .converting } case .turnEnded(let file): - externalTurns.remove(file) let done = WorkerOutput.exists(at: contextURL(for: file)) + let wasTracked = externalTurns.remove(file) != nil Self.updateState(noteFolder: noteFolderURL) { $0.conversions[file]?.status = done ? .done : .failed } + if wasTracked { conversionFinished(successfully: done) } } refresh() } @@ -228,11 +263,13 @@ final class FilesModel { private func dispatch(_ name: String) { activeJobs.insert(name) + conversionBatch.started() Self.updateState(noteFolder: noteFolderURL) { $0.conversions[name] = .init(status: .converting) } Task { let folder = noteFolderURL + var converted = false do { try await converter.convert(fileName: name) { threadId in Self.updateState(noteFolder: folder) { @@ -243,6 +280,7 @@ final class FilesModel { $0.conversions[name] = .init( status: .done, threadId: $0.conversions[name]?.threadId) } + converted = true } catch { Log.files.error("converting \(name) failed: \(error)") Self.updateState(noteFolder: folder) { @@ -251,7 +289,21 @@ final class FilesModel { } } activeJobs.remove(name) + conversionFinished(successfully: converted) refresh() } } + + private func conversionFinished(successfully: Bool) { + guard let completed = conversionBatch.finished(successfully: successfully) else { return } + pendingCompletedContextFiles += completed + deliverPendingContextBatchIfPossible() + } + + private func deliverPendingContextBatchIfPossible() { + guard let onContextBatchCompleted, pendingCompletedContextFiles > 0 else { return } + let completed = pendingCompletedContextFiles + pendingCompletedContextFiles = 0 + onContextBatchCompleted(completed) + } } diff --git a/Packages/SimbiKit/Sources/SimbiUI/NoteView.swift b/Packages/SimbiKit/Sources/SimbiUI/NoteView.swift index f8d3b97..93762ec 100644 --- a/Packages/SimbiKit/Sources/SimbiUI/NoteView.swift +++ b/Packages/SimbiKit/Sources/SimbiUI/NoteView.swift @@ -162,6 +162,15 @@ struct NoteView: View { document?.saveNow() aiDocument?.saveNow() } + // File conversions are parallel, but FilesModel emits only after + // the whole picker/drop batch settles. One callback therefore + // means one in-place AI Notes update, never one run per file. + files.onContextBatchCompleted = { [weak summary] _ in + summary?.contextDidChange() + } + // A deferred refresh may have outlived the previous view (for + // example Codex reconnected while this note was closed). + summary.resumePendingContextRefresh() // Opening a note that already has AI notes lands on them // (spec §4) — unless a recording is underway. if summary.summaryExists && recorder.status == .idle { @@ -184,6 +193,9 @@ struct NoteView: View { selectedTab = .aiNotes } } + .onChange(of: summary.codexAvailable) { _, available in + if available { summary.resumePendingContextRefresh() } + } .onChange(of: summary.generationCount) { // The summarizer thread just rewrote summary.md on disk; // force an immediate refresh in addition to the live document's diff --git a/Packages/SimbiKit/Sources/SimbiUI/SummaryController.swift b/Packages/SimbiKit/Sources/SimbiUI/SummaryController.swift index 80e8d7c..eabc247 100644 --- a/Packages/SimbiKit/Sources/SimbiUI/SummaryController.swift +++ b/Packages/SimbiKit/Sources/SimbiUI/SummaryController.swift @@ -18,6 +18,43 @@ public final class SummaryController { case failed(String) } + enum ContextRefreshAction: Equatable { + case ignore + case `defer` + case generate + } + + struct ContextRefreshCoordinator { + private var pending = false + + /// Records a newly converted batch and returns whether generation + /// should begin now. + mutating func request(_ action: ContextRefreshAction) -> Bool { + switch action { + case .ignore: + pending = false + return false + case .defer: + pending = true + return false + case .generate: + pending = false + return true + } + } + + /// Re-evaluates the one coalesced pending request after conditions + /// change. Once consumed, repeated resumes are no-ops. + mutating func resume(_ action: ContextRefreshAction) -> Bool { + guard pending else { return false } + return request(action) + } + + mutating func satisfy() { + pending = false + } + } + private static let controllers = PerNoteRegistry() public static func shared(noteFolderURL: URL) -> SummaryController { @@ -36,6 +73,9 @@ public final class SummaryController { /// confirms the editor refresh, so a placeholder keyed to the file's /// existence dropped early and flashed an empty editor across that gap. private(set) var firstGenerationInFlight = false + /// Coalesces any number of converted-file batches that arrive while a + /// generation, recording, or temporary Codex outage prevents an update. + private var contextRefresh = ContextRefreshCoordinator() let noteFolderURL: URL private let summarizer: NoteSummarizer @@ -98,6 +138,18 @@ public final class SummaryController { enabled && transcriptHasCues && codexAvailable && !alreadyWorking && !recordingActive } + /// Added files are useful only once their markdown conversion exists. + /// Generate immediately when the note is quiet; otherwise retain one + /// pending refresh regardless of how many batches arrive meanwhile. + nonisolated static func contextRefreshAction( + enabled: Bool, transcriptHasCues: Bool, codexAvailable: Bool, + alreadyWorking: Bool, recordingActive: Bool + ) -> ContextRefreshAction { + guard enabled, transcriptHasCues else { return .ignore } + if !codexAvailable || alreadyWorking || recordingActive { return .defer } + return .generate + } + /// Whether the note view offers a first-generation button (issue #3): /// the note has a transcript worth summarizing but no AI notes and no /// run in flight — the states where the tab strip (and so the @@ -140,9 +192,40 @@ public final class SummaryController { alreadyWorking: status == .working, recordingActive: RecordingController.isCapturing(noteFolderURL: noteFolderURL)) else { return } + // This generation reads all converted context, so it also satisfies + // anything queued while the recording was active. + contextRefresh.satisfy() generate() } + /// File conversion completion hook. The update is deliberately in-place + /// (`fresh: false`) so user edits in summary.md survive and the new context + /// is woven into the existing AI Notes. + func contextDidChange() { + refreshFileState() + let action = Self.contextRefreshAction( + enabled: SimbiSettings.current().aiNotesEnabled, + transcriptHasCues: transcriptHasCues, + codexAvailable: codexAvailable, + alreadyWorking: status == .working, + recordingActive: RecordingController.isCapturing(noteFolderURL: noteFolderURL)) + if contextRefresh.request(action) { generate() } + } + + /// Called when a condition that may have deferred a context refresh clears + /// (generation completion or Codex reconnect). A still-active recording + /// simply leaves the single pending bit set until its stop hook runs. + func resumePendingContextRefresh() { + refreshFileState() + let action = Self.contextRefreshAction( + enabled: SimbiSettings.current().aiNotesEnabled, + transcriptHasCues: transcriptHasCues, + codexAvailable: codexAvailable, + alreadyWorking: status == .working, + recordingActive: RecordingController.isCapturing(noteFolderURL: noteFolderURL)) + if contextRefresh.resume(action) { generate() } + } + /// The tab strip's regenerate button and the failed banner's Try /// Again. Refuses while this note is recording (spec §3) — the view /// gates its buttons too, but the guard here holds regardless. @@ -229,6 +312,7 @@ public final class SummaryController { generationCount += 1 status = .idle } + resumePendingContextRefresh() } // Test seams: status transitions without a live app-server. diff --git a/Packages/SimbiKit/Tests/CodexKitTests/TerminalChatLaunchTests.swift b/Packages/SimbiKit/Tests/CodexKitTests/TerminalChatLaunchTests.swift index b9f4c57..3fe164c 100644 --- a/Packages/SimbiKit/Tests/CodexKitTests/TerminalChatLaunchTests.swift +++ b/Packages/SimbiKit/Tests/CodexKitTests/TerminalChatLaunchTests.swift @@ -82,6 +82,16 @@ struct TerminalChatLaunchTests { #expect(text.contains("files/slides.pdf")) } + @Test("default instructions refresh converted context on every turn") + func instructionsRefreshLiveContext() throws { + let temp = try makeNote(files: ["note.md": "hello"]) + defer { temp.cleanup() } + let text = TerminalChatLaunch.developerInstructions( + noteFolderURL: temp.note, homeRootURL: temp.home) + #expect(text.contains("Before every answer, re-list `context/`")) + #expect(text.contains("newly added or changed")) + } + @Test("a CHAT.md at the home root replaces the default template") func instructionsUseUserTemplate() throws { let temp = try makeNote(files: ["note.md": "hi"]) @@ -90,7 +100,10 @@ struct TerminalChatLaunchTests { to: temp.home.appending(path: "CHAT.md"), atomically: true, encoding: .utf8) let text = TerminalChatLaunch.developerInstructions( noteFolderURL: temp.note, homeRootURL: temp.home) - #expect(text == "You are helping with `Work/Standup`. The note currently contains: `note.md`.") + #expect( + text.hasPrefix( + "You are helping with `Work/Standup`. The note currently contains: `note.md`.")) + #expect(text.contains("Before every answer, re-list `context/`")) } @Test("instructions say so when the note has no files yet") diff --git a/Packages/SimbiKit/Tests/SimbiUITests/ContextConversionBatchTests.swift b/Packages/SimbiKit/Tests/SimbiUITests/ContextConversionBatchTests.swift new file mode 100644 index 0000000..623ddbf --- /dev/null +++ b/Packages/SimbiKit/Tests/SimbiUITests/ContextConversionBatchTests.swift @@ -0,0 +1,31 @@ +import Testing + +@testable import SimbiUI + +@Suite("Context conversion batch") +struct ContextConversionBatchTests { + @Test("multiple successful conversions emit one settled batch") + func multipleFilesEmitOnce() { + var batch = ContextConversionBatch() + batch.started() + batch.started() + batch.started() + + #expect(batch.finished(successfully: true) == nil) + #expect(batch.finished(successfully: true) == nil) + #expect(batch.finished(successfully: true) == 3) + } + + @Test("failed conversions are excluded and the next batch starts clean") + func failuresAreExcluded() { + var batch = ContextConversionBatch() + batch.started() + batch.started() + + #expect(batch.finished(successfully: false) == nil) + #expect(batch.finished(successfully: true) == 1) + + batch.started() + #expect(batch.finished(successfully: false) == nil) + } +} diff --git a/Packages/SimbiKit/Tests/SimbiUITests/SummaryControllerTests.swift b/Packages/SimbiKit/Tests/SimbiUITests/SummaryControllerTests.swift index e217c25..ca36fdb 100644 --- a/Packages/SimbiKit/Tests/SimbiUITests/SummaryControllerTests.swift +++ b/Packages/SimbiKit/Tests/SimbiUITests/SummaryControllerTests.swift @@ -6,6 +6,48 @@ import Testing @Suite("SummaryController") struct SummaryControllerTests { + @Test("converted context generates now or defers until the note is quiet") + func contextRefreshGate() { + #expect( + SummaryController.contextRefreshAction( + enabled: true, transcriptHasCues: true, codexAvailable: true, + alreadyWorking: false, recordingActive: false) == .generate) + #expect( + SummaryController.contextRefreshAction( + enabled: true, transcriptHasCues: true, codexAvailable: true, + alreadyWorking: true, recordingActive: false) == .defer) + #expect( + SummaryController.contextRefreshAction( + enabled: true, transcriptHasCues: true, codexAvailable: true, + alreadyWorking: false, recordingActive: true) == .defer) + #expect( + SummaryController.contextRefreshAction( + enabled: true, transcriptHasCues: true, codexAvailable: false, + alreadyWorking: false, recordingActive: false) == .defer) + #expect( + SummaryController.contextRefreshAction( + enabled: false, transcriptHasCues: true, codexAvailable: true, + alreadyWorking: false, recordingActive: false) == .ignore) + #expect( + SummaryController.contextRefreshAction( + enabled: true, transcriptHasCues: false, codexAvailable: true, + alreadyWorking: false, recordingActive: false) == .ignore) + } + + @Test("deferred context changes coalesce into one follow-up generation") + func contextRefreshCoalesces() { + var refresh = SummaryController.ContextRefreshCoordinator() + + let first = refresh.request(.defer) + let second = refresh.request(.defer) + let resumed = refresh.resume(.generate) + let repeatedResume = refresh.resume(.generate) + #expect(!first) + #expect(!second) + #expect(resumed) + #expect(!repeatedResume) + } + @Test("auto-generation gating") func autoGate() { #expect( From 51992642550386390453c238a270deb8d26a7cec Mon Sep 17 00:00:00 2001 From: 13v13reddy Date: Sat, 19 Sep 2026 18:09:56 -0500 Subject: [PATCH 10/14] feat(codex): group Simbi threads by project Use the Simbi home as the shared Codex working root while preserving note-scoped task directories and sandboxes. Archive legacy fixer threads when their grouped replacements start. --- .../Sources/CodexKit/CodexTrust.swift | 12 ++--- .../Sources/CodexKit/CodexWorker.swift | 17 +++++-- .../Sources/CodexKit/FileConverter.swift | 13 ++++-- .../Sources/CodexKit/NoteSummarizer.swift | 17 ++++--- .../Sources/CodexKit/NoteTitler.swift | 11 +++-- .../Sources/CodexKit/SimbiCodexProject.swift | 43 +++++++++++++++++ .../Sources/CodexKit/TerminalChatLaunch.swift | 10 ++-- .../Sources/CodexKit/TranscriptFixer.swift | 46 ++++++++++++++----- .../Sources/SimbiKit/AgentInstructions.swift | 20 ++++---- .../Sources/SimbiUI/ImportController.swift | 12 +++-- .../Sources/SimbiUI/RecordingController.swift | 12 +++-- .../Sources/SimbiUI/TerminalChatWindow.swift | 9 ++-- .../SimbiCodexProjectTests.swift | 36 +++++++++++++++ .../TerminalChatLaunchTests.swift | 7 +-- 14 files changed, 198 insertions(+), 67 deletions(-) create mode 100644 Packages/SimbiKit/Sources/CodexKit/SimbiCodexProject.swift create mode 100644 Packages/SimbiKit/Tests/CodexKitTests/SimbiCodexProjectTests.swift diff --git a/Packages/SimbiKit/Sources/CodexKit/CodexTrust.swift b/Packages/SimbiKit/Sources/CodexKit/CodexTrust.swift index 5cf442b..71ec2d7 100644 --- a/Packages/SimbiKit/Sources/CodexKit/CodexTrust.swift +++ b/Packages/SimbiKit/Sources/CodexKit/CodexTrust.swift @@ -1,13 +1,13 @@ import Foundation import SimbiKit -/// Pre-trusts a note folder in `~/.codex/config.toml` so the chat TUI -/// skips its "Do you trust the contents of this directory?" gate on every -/// note. Appends the same `[projects.""]` entry codex writes when +/// Pre-trusts the Simbi project folder in `~/.codex/config.toml` so the chat +/// TUI skips its "Do you trust the contents of this directory?" gate. +/// Appends the same `[projects.""]` entry codex writes when /// the user picks "Yes, continue"; trust is per exact path — a parent -/// entry does not cover children (verified against codex 0.147), so each -/// note folder needs its own entry. `-c` overrides on the command line -/// are ignored for the trust decision, hence the config file. +/// entry does not cover children (verified against codex 0.147). `-c` +/// overrides on the command line are ignored for the trust decision, hence +/// the config file. public enum CodexTrust { public static func ensureTrusted( directory: URL, installation: CodexInstallation = .standard diff --git a/Packages/SimbiKit/Sources/CodexKit/CodexWorker.swift b/Packages/SimbiKit/Sources/CodexKit/CodexWorker.swift index bff287e..77db9cf 100644 --- a/Packages/SimbiKit/Sources/CodexKit/CodexWorker.swift +++ b/Packages/SimbiKit/Sources/CodexKit/CodexWorker.swift @@ -96,7 +96,9 @@ public enum WorkerOutput { /// bookkeeping is per-thread. actor CodexWorkerTurnRunner { struct Spec: Sendable { - var cwd: URL + var project: SimbiCodexProject + var noteFolderURL: URL + var taskDirectoryURL: URL /// `thread/start` sandbox ("workspace-write" / "read-only"). var sandbox: String /// Turn sandbox scope; nil inherits the thread's sandbox (titler). @@ -160,7 +162,8 @@ actor CodexWorkerTurnRunner { /// §5.1: state.json records thread ids). func run( instructions: String, - threadName: String, + role: String, + detail: String? = nil, onThreadStarted: @Sendable (String) async -> Void = { _ in } ) async throws -> String? { if !bound { @@ -183,7 +186,9 @@ actor CodexWorkerTurnRunner { } let threadId = try await CodexTurn.startThread( - client: client, cwd: spec.cwd, sandbox: spec.sandbox, name: threadName) + client: client, cwd: spec.project.rootURL, sandbox: spec.sandbox, + name: spec.project.threadName( + for: spec.noteFolderURL, role: role, detail: detail)) activeThreads.insert(threadId) await onThreadStarted(threadId) @@ -207,7 +212,11 @@ actor CodexWorkerTurnRunner { _ = try await client.request( method: "turn/start", params: CodexTurn.startParams( - threadId: threadId, text: instructions, writableRoot: spec.writableRoot, + threadId: threadId, + text: spec.project.instructions( + for: spec.noteFolderURL, taskDirectoryURL: spec.taskDirectoryURL, + task: instructions), + writableRoot: spec.writableRoot, model: spec.model, effort: spec.effort)) try await awaitTurnCompletion(threadId: threadId) return messages[threadId] diff --git a/Packages/SimbiKit/Sources/CodexKit/FileConverter.swift b/Packages/SimbiKit/Sources/CodexKit/FileConverter.swift index 8a23f26..a312ee2 100644 --- a/Packages/SimbiKit/Sources/CodexKit/FileConverter.swift +++ b/Packages/SimbiKit/Sources/CodexKit/FileConverter.swift @@ -2,9 +2,9 @@ import Foundation import SimbiKit /// Runs the per-file converter jobs for one note (SPEC.md §5.3): each -/// imported file gets its own Codex thread (cwd = note folder, -/// workspace-write sandbox) with one turn that converts `files/` to -/// `context/.md`; the thread is archived when the job ends. +/// imported file gets its own thread in the shared Simbi Codex project, with +/// a workspace-write sandbox restricted to the note folder. One turn converts +/// `files/` to `context/.md`; the thread is archived when it ends. public actor FileConverter { private let noteFolderURL: URL private let runner: CodexWorkerTurnRunner @@ -21,6 +21,7 @@ public actor FileConverter { public init( noteFolderURL: URL, client: AppServerClient, model: String? = nil, effort: String? = nil, + projectRootURL: URL = SimbiHome().rootURL, turnTimeout: Duration = .seconds(900), // generous — odd formats send the agent exploring anydocPath: String? = nil, shouldArchiveOnJobEnd: @escaping @Sendable (String) async -> Bool = { _ in true }, @@ -34,7 +35,9 @@ public actor FileConverter { self.runner = CodexWorkerTurnRunner( client: client, spec: .init( - cwd: noteFolderURL, sandbox: "workspace-write", writableRoot: noteFolderURL, + project: SimbiCodexProject(rootURL: projectRootURL), + noteFolderURL: noteFolderURL, taskDirectoryURL: noteFolderURL, + sandbox: "workspace-write", writableRoot: noteFolderURL, model: model, effort: effort, turnTimeout: turnTimeout, shouldArchiveOnEnd: shouldArchiveOnJobEnd)) } @@ -69,7 +72,7 @@ public actor FileConverter { ) async throws { let message = try await runner.run( instructions: instructions(fileName: fileName), - threadName: "[simbi] convert: \(fileName)", + role: "Convert", detail: fileName, onThreadStarted: onThreadStarted) if let message, let reason = CodexWorkerTurnRunner.reportedFailure(in: message) { throw CodexWorkerError.reportedFailure(reason) diff --git a/Packages/SimbiKit/Sources/CodexKit/NoteSummarizer.swift b/Packages/SimbiKit/Sources/CodexKit/NoteSummarizer.swift index aedfc43..958fba6 100644 --- a/Packages/SimbiKit/Sources/CodexKit/NoteSummarizer.swift +++ b/Packages/SimbiKit/Sources/CodexKit/NoteSummarizer.swift @@ -1,11 +1,11 @@ import Foundation import SimbiKit -/// Generates a note's AI notes (AI Notes spec §3): one fresh thread per -/// generation (cwd = note folder, workspace-write sandbox), one turn whose -/// input is the SUMMARY.md instructions. The thread reads note.md, -/// transcript.vtt, context/*.md, and any current summary.md from its own -/// cwd and writes summary.md itself by design (user decision 2026-08-10), +/// Generates a note's AI notes (AI Notes spec §3): one fresh thread in the +/// shared Simbi Codex project per generation, with its writable sandbox scoped +/// to the note folder. The turn reads note.md, +/// transcript.vtt, context/*.md, and any current summary.md from its task +/// directory and writes summary.md itself by design (user decision 2026-08-10), /// converter-style; its final message is only a DONE/FAILED status reply. public actor NoteSummarizer { private let noteFolderURL: URL @@ -17,6 +17,7 @@ public actor NoteSummarizer { public init( noteFolderURL: URL, client: AppServerClient, model: String? = nil, effort: String? = nil, + projectRootURL: URL = SimbiHome().rootURL, turnTimeout: Duration = .seconds(600), instructionsProvider: @escaping @Sendable () -> String = { AgentInstructions.summary.contents(homeRootURL: SimbiHome().rootURL) @@ -29,7 +30,9 @@ public actor NoteSummarizer { self.runner = CodexWorkerTurnRunner( client: client, spec: .init( - cwd: noteFolderURL, sandbox: "workspace-write", writableRoot: noteFolderURL, + project: SimbiCodexProject(rootURL: projectRootURL), + noteFolderURL: noteFolderURL, taskDirectoryURL: noteFolderURL, + sandbox: "workspace-write", writableRoot: noteFolderURL, model: model, effort: effort, turnTimeout: turnTimeout)) } @@ -39,7 +42,7 @@ public actor NoteSummarizer { public func generate() async throws { let message = try await runner.run( instructions: instructionsProvider(), - threadName: "[simbi] summary: \(noteFolderURL.lastPathComponent)") + role: "AI Notes") if let message, let reason = CodexWorkerTurnRunner.reportedFailure(in: message) { throw CodexWorkerError.reportedFailure(reason) diff --git a/Packages/SimbiKit/Sources/CodexKit/NoteTitler.swift b/Packages/SimbiKit/Sources/CodexKit/NoteTitler.swift index 93eec8e..1ac7519 100644 --- a/Packages/SimbiKit/Sources/CodexKit/NoteTitler.swift +++ b/Packages/SimbiKit/Sources/CodexKit/NoteTitler.swift @@ -2,8 +2,8 @@ import Foundation import SimbiKit /// Names a note whose title is still the default: one fresh read-only -/// thread per attempt (cwd = note folder, `sandbox: read-only`), one turn -/// whose input is the TITLE.md instructions. Unlike the summarizer the +/// thread in the shared Simbi Codex project per attempt. Its task directory is +/// the note folder and its sandbox is read-only. Unlike the summarizer the /// thread writes nothing — its final agent message IS the result, which /// `sanitizedTitle` turns into a folder-safe name. public actor NoteTitler { @@ -14,6 +14,7 @@ public actor NoteTitler { public init( noteFolderURL: URL, client: AppServerClient, model: String? = nil, effort: String? = nil, + projectRootURL: URL = SimbiHome().rootURL, turnTimeout: Duration = .seconds(180), instructionsProvider: @escaping @Sendable () -> String = { AgentInstructions.title.contents(homeRootURL: SimbiHome().rootURL) @@ -27,7 +28,9 @@ public actor NoteTitler { self.runner = CodexWorkerTurnRunner( client: client, spec: .init( - cwd: noteFolderURL, sandbox: "read-only", writableRoot: nil, + project: SimbiCodexProject(rootURL: projectRootURL), + noteFolderURL: noteFolderURL, taskDirectoryURL: noteFolderURL, + sandbox: "read-only", writableRoot: nil, model: model, effort: effort, turnTimeout: turnTimeout)) } @@ -88,7 +91,7 @@ public actor NoteTitler { public func generateTitle() async throws -> String { let message = try await runner.run( instructions: instructionsProvider(), - threadName: "[simbi] title: \(noteFolderURL.lastPathComponent)") + role: "Note Title") guard let message else { throw CodexWorkerError.noOutput } if let reason = CodexWorkerTurnRunner.reportedFailure(in: message) { diff --git a/Packages/SimbiKit/Sources/CodexKit/SimbiCodexProject.swift b/Packages/SimbiKit/Sources/CodexKit/SimbiCodexProject.swift new file mode 100644 index 0000000..c4e33dd --- /dev/null +++ b/Packages/SimbiKit/Sources/CodexKit/SimbiCodexProject.swift @@ -0,0 +1,43 @@ +import Foundation + +/// One local Codex project for every thread Simbi creates. The shared root +/// keeps threads together in Codex, while each task still receives an explicit +/// note directory and a note-scoped sandbox from its caller. +struct SimbiCodexProject: Sendable, Equatable { + let rootURL: URL + + init(rootURL: URL) { + self.rootURL = rootURL.standardizedFileURL + } + + /// App-owned scope placed ahead of user-editable role instructions. This + /// lets existing templates keep using paths such as `note.md` even though + /// the thread's Codex project now starts at the shared Simbi root. + func instructions(for noteFolderURL: URL, taskDirectoryURL: URL, task: String) -> String { + let notePath = CodexChat.notePath( + noteFolderURL: noteFolderURL, homeRootURL: rootURL) + let taskPath = CodexChat.notePath( + noteFolderURL: taskDirectoryURL, homeRootURL: rootURL) + return """ + The Codex project working directory is the shared Simbi home. The active Simbi note is \ + `\(notePath)`. The task directory is `\(taskPath)`. Change to that task directory before \ + running commands or editing files. For the task instructions below, resolve every \ + relative file path from there, not from the project root. Work only inside the active \ + note unless the user explicitly asks otherwise. + + \(task) + """ + } + + /// A consistent user-visible name that remains useful when all Simbi + /// threads appear together in one Codex project. + func threadName( + for noteFolderURL: URL, role: String, detail: String? = nil + ) -> String { + let notePath = CodexChat.notePath( + noteFolderURL: noteFolderURL, homeRootURL: rootURL) + let base = "[Simbi · \(notePath)] \(role)" + guard let detail, !detail.isEmpty else { return base } + return base + " — " + detail + } +} diff --git a/Packages/SimbiKit/Sources/CodexKit/TerminalChatLaunch.swift b/Packages/SimbiKit/Sources/CodexKit/TerminalChatLaunch.swift index e1752fc..cfee51d 100644 --- a/Packages/SimbiKit/Sources/CodexKit/TerminalChatLaunch.swift +++ b/Packages/SimbiKit/Sources/CodexKit/TerminalChatLaunch.swift @@ -29,12 +29,13 @@ public struct TerminalChatLaunch: Sendable, Equatable { /// vars keep their quotes. /// /// Flags mirror the retired app-server chat: workspace-write sandbox, - /// on-request approvals, the whole Simbi home writable. Note context + /// on-request approvals, and the Simbi home as the shared Codex project. + /// Note context /// is injected as a developer message; a `-c` value that fails TOML /// parsing is taken as a literal string (codex --help), so no /// escaping is needed. public static let commandLine = - "$SIMBI_CODEX_BIN -C \"$SIMBI_NOTE_DIR\" --add-dir \"$SIMBI_HOME_ROOT\" " + "$SIMBI_CODEX_BIN -C \"$SIMBI_HOME_ROOT\" " + "-c developer_instructions=\"$SIMBI_CHAT_CONTEXT\" " + "-s workspace-write -a on-request" @@ -72,7 +73,10 @@ public struct TerminalChatLaunch: Sendable, Equatable { let chatInstructions = AgentInstructions.chat.resolve( homeRootURL: homeRootURL, variables: ["note_path": notePath, "files": contents]) - return chatInstructions + "\n\n" + liveContextInstructions + let project = SimbiCodexProject(rootURL: homeRootURL) + return project.instructions( + for: noteFolderURL, taskDirectoryURL: noteFolderURL, + task: chatInstructions + "\n\n" + liveContextInstructions) } /// Top-level note files plus one level of `context/` and `files/`, diff --git a/Packages/SimbiKit/Sources/CodexKit/TranscriptFixer.swift b/Packages/SimbiKit/Sources/CodexKit/TranscriptFixer.swift index 8660f60..de5aab7 100644 --- a/Packages/SimbiKit/Sources/CodexKit/TranscriptFixer.swift +++ b/Packages/SimbiKit/Sources/CodexKit/TranscriptFixer.swift @@ -27,30 +27,33 @@ public enum FixerEvent: Equatable, Sendable { /// The per-note transcript-fixer thread (SPEC.md §5.2): a Codex worker /// thread that fixes ASR errors in newly appended cues and unifies speaker /// names. It works on a snapshot COPY of the transcript in -/// `.simbi/fixer-worktree/` (its cwd and sole writable root) with plain -/// apply_patch edits; the host diffs and merges each pass into the live -/// file (see `TranscriptFixerHost`). +/// `.simbi/fixer-worktree/` (its task directory and sole writable root) with +/// plain apply_patch edits; the host diffs and merges each pass into the live +/// file (see `TranscriptFixerHost`). Its thread belongs to the shared Simbi +/// Codex project. /// /// Ping policy: ping when ≥ 1 new cue has been appended since the last pass /// AND the thread has no active turn; pings are coalesced. A final ping /// fires at recording stop. public actor TranscriptFixer { - /// The fixer's working directory: holds its refreshed working copy of + /// The fixer's task directory: holds its refreshed working copy of /// transcript.vtt, and nothing else. public static func worktreeURL(noteFolder: URL) -> URL { NoteLayout.fixerWorktreeURL(noteFolder: noteFolder) } - /// Bumped whenever the thread's *contract* (worktree cwd, merge + /// Bumped whenever the thread's *contract* (worktree task directory, merge /// behavior, ping protocol) changes incompatibly. A note's saved /// thread carries the instructions it was created with, so threads /// from an older version are retired and recreated instead of /// resumed; the instruction *text* (user-editable FIXER.md) is /// tracked separately via `instructionsFingerprint`. History: 1 = - /// snapshot-and-replay worktree, 2 = per-cue speaker-attribution fixes. - public static let instructionsVersion = 2 + /// snapshot-and-replay worktree, 2 = per-cue speaker-attribution fixes, + /// 3 = shared Simbi Codex project with an explicit fixer task directory. + public static let instructionsVersion = 3 private let noteFolderURL: URL + private let project: SimbiCodexProject private let client: AppServerClient /// Model override for fixer turns (SPEC.md §5.5); nil = thread default. private let model: String? @@ -61,6 +64,9 @@ public actor TranscriptFixer { private let instructions: String /// Persisted by the caller in .simbi/state.json across app restarts. public private(set) var threadId: String? + /// A saved fixer from an older contract. It cannot be moved to the shared + /// project, so archive it once before creating its replacement. + private var retiredThreadId: String? private var turnActive = false private var lastPingedCue = 0 @@ -74,12 +80,16 @@ public actor TranscriptFixer { public init( noteFolderURL: URL, client: AppServerClient, savedThreadId: String?, + retiredThreadId: String? = nil, model: String? = nil, effort: String? = nil, + projectRootURL: URL = SimbiHome().rootURL, instructions: String = AgentInstructions.fixer.defaultContents ) { self.noteFolderURL = noteFolderURL + self.project = SimbiCodexProject(rootURL: projectRootURL) self.client = client self.threadId = savedThreadId + self.retiredThreadId = retiredThreadId self.model = model self.effort = effort self.instructions = instructions @@ -107,7 +117,7 @@ public actor TranscriptFixer { /// resumes the note's existing thread (archive → unarchive → resume). public func recordingStarted() async throws { stopping = false - // The thread's cwd; must exist before thread/start. + // The task directory must exist before its first turn. do { try FileManager.default.createDirectory( at: Self.worktreeURL(noteFolder: noteFolderURL), withIntermediateDirectories: true) @@ -143,11 +153,21 @@ public actor TranscriptFixer { return } + if let retiredThreadId { + self.retiredThreadId = nil + do { + _ = try await client.request( + method: "thread/archive", params: ["threadId": retiredThreadId]) + } catch { + Log.codex.warning("archiving retired fixer thread failed: \(error)") + } + } + threadId = try await CodexTurn.startThread( client: client, - cwd: Self.worktreeURL(noteFolder: noteFolderURL), + cwd: project.rootURL, sandbox: "workspace-write", - name: "[simbi] fixer: \(noteFolderURL.lastPathComponent)") + name: project.threadName(for: noteFolderURL, role: "Transcript Fixer")) try await startTurn(text: instructions) } @@ -211,7 +231,11 @@ public actor TranscriptFixer { _ = try await client.request( method: "turn/start", params: CodexTurn.startParams( - threadId: threadId, text: text, + threadId: threadId, + text: project.instructions( + for: noteFolderURL, + taskDirectoryURL: Self.worktreeURL(noteFolder: noteFolderURL), + task: text), writableRoot: Self.worktreeURL(noteFolder: noteFolderURL), model: model, effort: effort)) } catch { diff --git a/Packages/SimbiKit/Sources/SimbiKit/AgentInstructions.swift b/Packages/SimbiKit/Sources/SimbiKit/AgentInstructions.swift index 01fc7a0..2f2f4ef 100644 --- a/Packages/SimbiKit/Sources/SimbiKit/AgentInstructions.swift +++ b/Packages/SimbiKit/Sources/SimbiKit/AgentInstructions.swift @@ -22,12 +22,12 @@ public enum AgentInstructions: String, CaseIterable, Identifiable, Sendable { /// `{{ note_path }}` — the note's home-relative path; `{{ files }}` — /// a sentence inventorying the note's current files. case chat = "CHAT.md" - /// AI-notes summarizer instructions. No variables: the thread runs - /// with the note folder as cwd, reads the note, transcript, and any + /// AI-notes summarizer instructions. No variables: the thread uses + /// the note folder as its task directory, reads the note, transcript, and any /// current AI notes itself, and writes summary.md itself. case summary = "SUMMARY.md" - /// Note auto-titler instructions. No variables: the thread runs with - /// the note folder as cwd (read-only) and replies with the title as + /// Note auto-titler instructions. No variables: the thread uses + /// the note folder as its read-only task directory and replies with the title as /// its final message — nothing is written to disk. case title = "TITLE.md" /// Ground rules for any agent working in the Simbi home. No variables; @@ -115,10 +115,10 @@ public enum AgentInstructions: String, CaseIterable, Identifiable, Sendable { private static let defaultFixer = """ You are the transcript fixer for this note. The file transcript.vtt in your \ - working directory is YOUR WORKING COPY of the note's live WebVTT transcript — \ + task directory is YOUR WORKING COPY of the note's live WebVTT transcript — \ it is refreshed from the live file before every ping, and after each of your \ turns I diff your copy against what you were given and merge the changed cue \ - payloads into the live transcript myself. Never write outside your working \ + payloads into the live transcript myself. Never write outside your task \ directory. The note's own files are read-only ground truth for names and \ jargon: ../../note.md, and ../../context/*.md if present. @@ -176,8 +176,8 @@ public enum AgentInstructions: String, CaseIterable, Identifiable, Sendable { """ private static let defaultChat = """ - This session is attached to the Simbi note at {{ note_path }} (your working \ - directory). Simbi notes keep a fixed layout: `note.md` is the user's note, \ + This session is attached to the Simbi note at {{ note_path }} inside the shared \ + Simbi project. Simbi notes keep a fixed layout: `note.md` is the user's note, \ `transcript.vtt` is the meeting transcript with speaker labels, `context/` \ holds markdown conversions of attached files, and `files/` holds the \ original attachments. @@ -192,7 +192,7 @@ public enum AgentInstructions: String, CaseIterable, Identifiable, Sendable { private static let defaultSummary = """ You write the AI notes for a Simbi note: a clean, scannable summary that \ - fuses the user's own notes with the meeting transcript. Your working \ + fuses the user's own notes with the meeting transcript. Your task \ directory is the note folder. Read note.md (the user's own notes), \ transcript.vtt (the speaker-labeled WebVTT transcript), and context/*.md \ if present. @@ -258,7 +258,7 @@ public enum AgentInstructions: String, CaseIterable, Identifiable, Sendable { """ private static let defaultTitle = """ - You name a Simbi note that still has its default title. Your working \ + You name a Simbi note that still has its default title. Your task \ directory is the note folder; everything is read-only. Read note.md \ (the user's own notes), transcript.vtt (the speaker-labeled WebVTT \ transcript), and context/*.md if present, then choose a short, clean, \ diff --git a/Packages/SimbiKit/Sources/SimbiUI/ImportController.swift b/Packages/SimbiKit/Sources/SimbiUI/ImportController.swift index 0b73a85..71f5918 100644 --- a/Packages/SimbiKit/Sources/SimbiUI/ImportController.swift +++ b/Packages/SimbiKit/Sources/SimbiUI/ImportController.swift @@ -144,16 +144,18 @@ public final class ImportController { let noteState = NoteRecordingState.current(noteFolder: noteFolderURL) let fixerInstructions = AgentInstructions.fixer.resolve( homeRootURL: SimbiHome().rootURL) - let savedThreadId = + let canResumeSavedThread = noteState.fixerInstructionsVersion == TranscriptFixer.instructionsVersion - && noteState.fixerInstructionsHash - == AgentInstructions.fingerprint(fixerInstructions) - ? noteState.fixerThreadId : nil + && noteState.fixerInstructionsHash + == AgentInstructions.fingerprint(fixerInstructions) + let savedThreadId = canResumeSavedThread ? noteState.fixerThreadId : nil + let retiredThreadId = canResumeSavedThread ? nil : noteState.fixerThreadId let choice = settings[.fixer] await pipeline.attachFixer( TranscriptFixer( noteFolderURL: noteFolderURL, client: CodexServices.appServer, - savedThreadId: savedThreadId, model: choice.model, + savedThreadId: savedThreadId, retiredThreadId: retiredThreadId, + model: choice.model, effort: choice.effort, instructions: fixerInstructions)) } else { await pipeline.attachFixer(nil) diff --git a/Packages/SimbiKit/Sources/SimbiUI/RecordingController.swift b/Packages/SimbiKit/Sources/SimbiUI/RecordingController.swift index e435057..bae6d86 100644 --- a/Packages/SimbiKit/Sources/SimbiUI/RecordingController.swift +++ b/Packages/SimbiKit/Sources/SimbiUI/RecordingController.swift @@ -221,15 +221,17 @@ public final class RecordingController { let noteState = NoteRecordingState.current(noteFolder: noteFolderURL) let fixerInstructions = AgentInstructions.fixer.resolve( homeRootURL: SimbiHome().rootURL) - let savedThreadId = + let canResumeSavedThread = noteState.fixerInstructionsVersion == TranscriptFixer.instructionsVersion - && noteState.fixerInstructionsHash - == AgentInstructions.fingerprint(fixerInstructions) - ? noteState.fixerThreadId : nil + && noteState.fixerInstructionsHash + == AgentInstructions.fingerprint(fixerInstructions) + let savedThreadId = canResumeSavedThread ? noteState.fixerThreadId : nil + let retiredThreadId = canResumeSavedThread ? nil : noteState.fixerThreadId let choice = settings[.fixer] let fixer = TranscriptFixer( noteFolderURL: noteFolderURL, client: CodexServices.appServer, - savedThreadId: savedThreadId, model: choice.model, + savedThreadId: savedThreadId, retiredThreadId: retiredThreadId, + model: choice.model, effort: choice.effort, instructions: fixerInstructions) let activity = fixerActivity diff --git a/Packages/SimbiKit/Sources/SimbiUI/TerminalChatWindow.swift b/Packages/SimbiKit/Sources/SimbiUI/TerminalChatWindow.swift index 05c604a..f7637fc 100644 --- a/Packages/SimbiKit/Sources/SimbiUI/TerminalChatWindow.swift +++ b/Packages/SimbiKit/Sources/SimbiUI/TerminalChatWindow.swift @@ -109,15 +109,16 @@ final class ChatWindow: NSWindow, NSWindowDelegate { if CodexInstallation.standard.isBinaryInstalled { let terminal = TerminalView(frame: contentRect(forFrameRect: frame)) - // Pre-trust the note folder so the TUI opens on the composer + let homeRootURL = SimbiHome().rootURL + // Pre-trust the shared project so the TUI opens on the composer // instead of the per-directory trust prompt. - CodexTrust.ensureTrusted(directory: noteFolderURL) + CodexTrust.ensureTrusted(directory: homeRootURL) let launch = TerminalChatLaunch.forNote( - noteFolderURL: noteFolderURL, homeRootURL: SimbiHome().rootURL) + noteFolderURL: noteFolderURL, homeRootURL: homeRootURL) terminal.delegate = self terminal.configuration = TerminalSurfaceOptions( backend: .exec, - workingDirectory: noteFolderURL.path, + workingDirectory: homeRootURL.path, envVars: launch.envVars) terminal.controller = TerminalChatServices.controller self.terminal = terminal diff --git a/Packages/SimbiKit/Tests/CodexKitTests/SimbiCodexProjectTests.swift b/Packages/SimbiKit/Tests/CodexKitTests/SimbiCodexProjectTests.swift new file mode 100644 index 0000000..ad1f7dc --- /dev/null +++ b/Packages/SimbiKit/Tests/CodexKitTests/SimbiCodexProjectTests.swift @@ -0,0 +1,36 @@ +import Foundation +import Testing + +@testable import CodexKit + +@Suite("Simbi Codex project") +struct SimbiCodexProjectTests { + @Test("task instructions keep the project root shared and scope relative paths to one note") + func scopesTaskToNote() { + let root = URL(filePath: "/Users/test/Simbi") + let note = root.appending(path: "Work/Standup") + let project = SimbiCodexProject(rootURL: root) + + let text = project.instructions( + for: note, taskDirectoryURL: note, + task: "Read note.md and write summary.md.") + + #expect(project.rootURL == root.standardizedFileURL) + #expect(text.contains("active Simbi note is `Work/Standup`")) + #expect(text.contains("task directory is `Work/Standup`")) + #expect(text.contains("Change to that task directory before running commands")) + #expect(text.hasSuffix("Read note.md and write summary.md.")) + } + + @Test("thread names identify Simbi, the note path, role, and optional detail") + func namesThread() { + let root = URL(filePath: "/Users/test/Simbi") + let note = root.appending(path: "Work/Standup") + let project = SimbiCodexProject(rootURL: root) + + #expect(project.threadName(for: note, role: "AI Notes") == "[Simbi · Work/Standup] AI Notes") + #expect( + project.threadName(for: note, role: "Convert", detail: "agenda.pdf") + == "[Simbi · Work/Standup] Convert — agenda.pdf") + } +} diff --git a/Packages/SimbiKit/Tests/CodexKitTests/TerminalChatLaunchTests.swift b/Packages/SimbiKit/Tests/CodexKitTests/TerminalChatLaunchTests.swift index 3fe164c..9dc7d15 100644 --- a/Packages/SimbiKit/Tests/CodexKitTests/TerminalChatLaunchTests.swift +++ b/Packages/SimbiKit/Tests/CodexKitTests/TerminalChatLaunchTests.swift @@ -41,8 +41,8 @@ struct TerminalChatLaunchTests { #expect(!command.hasPrefix("exec ")) #expect(!command.hasPrefix("\"")) #expect(!command.hasSuffix("\"")) - #expect(command.contains("-C \"$SIMBI_NOTE_DIR\"")) - #expect(command.contains("--add-dir \"$SIMBI_HOME_ROOT\"")) + #expect(command.contains("-C \"$SIMBI_HOME_ROOT\"")) + #expect(!command.contains("-C \"$SIMBI_NOTE_DIR\"")) #expect(command.contains("-s workspace-write")) #expect(command.contains("-a on-request")) #expect(command.contains("-c developer_instructions=\"$SIMBI_CHAT_CONTEXT\"")) @@ -101,8 +101,9 @@ struct TerminalChatLaunchTests { let text = TerminalChatLaunch.developerInstructions( noteFolderURL: temp.note, homeRootURL: temp.home) #expect( - text.hasPrefix( + text.contains( "You are helping with `Work/Standup`. The note currently contains: `note.md`.")) + #expect(text.contains("active Simbi note is `Work/Standup`")) #expect(text.contains("Before every answer, re-list `context/`")) } From 5bf3ca8f5b683c82204a9a35ca4a6c19fa452b62 Mon Sep 17 00:00:00 2001 From: 13v13reddy Date: Sat, 19 Sep 2026 19:28:57 -0500 Subject: [PATCH 11/14] fix(codex): restore Simbi thread visibility Register the Simbi home as a local Codex project and migrate existing sessions because cwd alone does not assign project membership. Assign future worker and terminal threads explicitly. --- .../Sources/CodexKit/AppServerClient.swift | 24 ++- .../Sources/CodexKit/CodexWorker.swift | 23 ++- .../CodexKit/SimbiCodexProjectOrganizer.swift | 166 ++++++++++++++++++ .../Sources/SimbiUI/SimbiRootView.swift | 18 +- .../Sources/SimbiUI/TerminalChatWindow.swift | 12 ++ .../AppServerEndpointTests.swift | 7 + .../Tests/CodexKitTests/CodexTurnTests.swift | 10 ++ .../SimbiCodexProjectTests.swift | 23 +++ 8 files changed, 271 insertions(+), 12 deletions(-) create mode 100644 Packages/SimbiKit/Sources/CodexKit/SimbiCodexProjectOrganizer.swift diff --git a/Packages/SimbiKit/Sources/CodexKit/AppServerClient.swift b/Packages/SimbiKit/Sources/CodexKit/AppServerClient.swift index cfe060b..26fbf30 100644 --- a/Packages/SimbiKit/Sources/CodexKit/AppServerClient.swift +++ b/Packages/SimbiKit/Sources/CodexKit/AppServerClient.swift @@ -139,6 +139,18 @@ public actor AppServerClient { AppServerJanitor.shared.prepare(binaryPath: installation.binaryURL.path) } + /// Project methods and `thread/start.projectId` are capability-gated by + /// app-server. Keep the handshake shape testable because silently + /// dropping this flag makes every Simbi thread unassigned again. + nonisolated static func initializeParams(version: String) -> [String: any Sendable] { + [ + "clientInfo": [ + "name": "simbi", "title": "Simbi", "version": version, + ], + "capabilities": ["experimentalApi": true], + ] + } + public func addNotificationHandler( _ handler: @escaping @Sendable (String, Data) -> Void ) { @@ -287,15 +299,11 @@ public actor AppServerClient { await self?.readLoop(socketTask) } + let version = + Bundle.main.object( + forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "dev" _ = try await send( - method: "initialize", - params: [ - "clientInfo": [ - "name": "simbi", "title": "Simbi", - "version": Bundle.main.object( - forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "dev", - ] - ]) + method: "initialize", params: Self.initializeParams(version: version)) try await write(["jsonrpc": "2.0", "method": "initialized"]) let auth = try await send( diff --git a/Packages/SimbiKit/Sources/CodexKit/CodexWorker.swift b/Packages/SimbiKit/Sources/CodexKit/CodexWorker.swift index 77db9cf..54f0583 100644 --- a/Packages/SimbiKit/Sources/CodexKit/CodexWorker.swift +++ b/Packages/SimbiKit/Sources/CodexKit/CodexWorker.swift @@ -18,6 +18,16 @@ public enum CodexWorkerError: Error { /// Wire shapes shared by every worker turn (verified in /// Sources/simbi-appserver-spike/README.md). enum CodexTurn { + static func threadStartParams( + cwd: URL, sandbox: String, projectId: String? + ) -> [String: any Sendable] { + var params: [String: any Sendable] = [ + "cwd": cwd.path, "approvalPolicy": "never", "sandbox": sandbox, + ] + if let projectId { params["projectId"] = projectId } + return params + } + /// A single text input item for `turn/start`. static func textInput(_ text: String) -> [[String: any Sendable]] { [["type": "text", "text": text, "text_elements": [String]()]] @@ -67,9 +77,20 @@ enum CodexTurn { static func startThread( client: AppServerClient, cwd: URL, sandbox: String, name: String ) async throws -> String { + let projectId: String? + do { + projectId = try await SimbiCodexProjectOrganizer.ensureProject( + client: client, rootURL: cwd) + } catch { + // Project organization is additive. A Codex app update must not + // be allowed to disable transcription or AI notes if its + // experimental project API changes. + Log.codex.warning("organizing Codex project failed; continuing unassigned: \(error)") + projectId = nil + } let resultData = try await client.request( method: "thread/start", - params: ["cwd": cwd.path, "approvalPolicy": "never", "sandbox": sandbox]) + params: threadStartParams(cwd: cwd, sandbox: sandbox, projectId: projectId)) let id = try threadId(fromStartResult: resultData) _ = try await client.request( method: "thread/name/set", params: ["threadId": id, "name": name]) diff --git a/Packages/SimbiKit/Sources/CodexKit/SimbiCodexProjectOrganizer.swift b/Packages/SimbiKit/Sources/CodexKit/SimbiCodexProjectOrganizer.swift new file mode 100644 index 0000000..04aeb28 --- /dev/null +++ b/Packages/SimbiKit/Sources/CodexKit/SimbiCodexProjectOrganizer.swift @@ -0,0 +1,166 @@ +import Foundation + +/// Wire parsing kept separate from the organizer so project membership has +/// deterministic unit coverage without launching the real app-server. +enum SimbiCodexProjectAPI { + static func projectID(in data: Data, rootURL: URL) -> String? { + let rootPath = rootURL.standardizedFileURL.path + return rows(in: data).first { row in + let roots = row["roots"] as? [[String: Any]] ?? [] + return roots.contains { root in + guard let path = root["path"] as? String else { return false } + return URL(filePath: path).standardizedFileURL.path == rootPath + } + }?["id"] as? String + } + + static func importedProjectID(in data: Data) -> String? { + let object = jsonObject(in: data) + return (object?["project"] as? [String: Any])?["id"] as? String + } + + static func threadIDsToAssign( + in data: Data, projectID: String, rootURL: URL + ) -> [String] { + let rootPath = rootURL.standardizedFileURL.path + let rootPrefix = rootPath.hasSuffix("/") ? rootPath : rootPath + "/" + return rows(in: data).compactMap { row in + guard let id = row["id"] as? String, + row["projectId"] as? String != projectID + else { return nil } + let isOwned = row["originator"] as? String == "simbi" + let cwdPath = (row["cwd"] as? String).map { + URL(filePath: $0).standardizedFileURL.path + } + let isInsideRoot = cwdPath == rootPath || cwdPath?.hasPrefix(rootPrefix) == true + return isOwned || isInsideRoot ? id : nil + } + } + + static func nextCursor(in data: Data) -> String? { + jsonObject(in: data)?["nextCursor"] as? String + } + + private static func rows(in data: Data) -> [[String: Any]] { + jsonObject(in: data)?["data"] as? [[String: Any]] ?? [] + } + + private static func jsonObject(in data: Data) -> [String: Any]? { + try? JSONSerialization.jsonObject(with: data) as? [String: Any] + } +} + +/// Creates one local Codex project rooted at the Simbi home and keeps every +/// Simbi-created thread assigned to it. Codex does not infer project +/// membership from `cwd`, even when a matching local project already exists. +public enum SimbiCodexProjectOrganizer { + private static let registry = Registry() + + public static func ensureProject( + client: AppServerClient, rootURL: URL + ) async throws -> String { + try await registry.ensureProject(client: client, rootURL: rootURL, reconcile: false) + } + + /// Imports historical threads and catches sessions created by the + /// embedded Codex CLI, which has no project-id launch option. + public static func reconcile( + client: AppServerClient, rootURL: URL + ) async throws { + _ = try await registry.ensureProject(client: client, rootURL: rootURL, reconcile: true) + } + + private actor Registry { + private var projectIDs: [String: String] = [:] + + func ensureProject( + client: AppServerClient, rootURL: URL, reconcile: Bool + ) async throws -> String { + let rootURL = rootURL.standardizedFileURL + let rootPath = rootURL.path + if let projectID = projectIDs[rootPath] { + if reconcile { + try await assignExistingThreads( + client: client, rootURL: rootURL, projectID: projectID) + } + return projectID + } + + if let projectID = try await findProject(client: client, rootURL: rootURL) { + projectIDs[rootPath] = projectID + if reconcile { + try await assignExistingThreads( + client: client, rootURL: rootURL, projectID: projectID) + } + return projectID + } + + let threadIDs = try await matchingThreadIDs( + client: client, rootURL: rootURL, projectID: "") + let result = try await client.request( + method: "project/import", + params: [ + "name": "Simbi", + "roots": [["path": rootPath]], + "threads": threadIDs, + "idempotencyKey": "simbi:\(rootPath)", + "metadata": ["originator": "simbi"], + ]) + guard let projectID = SimbiCodexProjectAPI.importedProjectID(in: result) else { + throw AppServerClient.ClientError.serverError( + code: -1, message: "project/import returned no project id") + } + projectIDs[rootPath] = projectID + return projectID + } + + private func findProject( + client: AppServerClient, rootURL: URL + ) async throws -> String? { + var cursor: String? + repeat { + var params: [String: any Sendable] = ["limit": 100] + if let cursor { params["cursor"] = cursor } + let result = try await client.request(method: "project/list", params: params) + if let id = SimbiCodexProjectAPI.projectID(in: result, rootURL: rootURL) { + return id + } + cursor = SimbiCodexProjectAPI.nextCursor(in: result) + } while cursor != nil + return nil + } + + private func assignExistingThreads( + client: AppServerClient, rootURL: URL, projectID: String + ) async throws { + let threadIDs = try await matchingThreadIDs( + client: client, rootURL: rootURL, projectID: projectID) + for threadID in threadIDs { + _ = try await client.request( + method: "thread/metadata/update", + params: ["threadId": threadID, "projectId": projectID]) + } + } + + private func matchingThreadIDs( + client: AppServerClient, rootURL: URL, projectID: String + ) async throws -> [String] { + var ids: [String] = [] + for archived in [false, true] { + var cursor: String? + repeat { + var params: [String: any Sendable] = [ + "limit": 100, "archived": archived, + ] + if let cursor { params["cursor"] = cursor } + let result = try await client.request(method: "thread/list", params: params) + ids.append( + contentsOf: SimbiCodexProjectAPI.threadIDsToAssign( + in: result, projectID: projectID, rootURL: rootURL)) + cursor = SimbiCodexProjectAPI.nextCursor(in: result) + } while cursor != nil + } + return Array(Set(ids)).sorted() + } + } +} diff --git a/Packages/SimbiKit/Sources/SimbiUI/SimbiRootView.swift b/Packages/SimbiKit/Sources/SimbiUI/SimbiRootView.swift index efca946..029b89d 100644 --- a/Packages/SimbiKit/Sources/SimbiUI/SimbiRootView.swift +++ b/Packages/SimbiKit/Sources/SimbiUI/SimbiRootView.swift @@ -94,9 +94,21 @@ public struct SimbiRootView: View { } // Creating the shared client at launch arms AppServerJanitor's // quit cleanup: quitting must kill every running codex server — - // including older sessions' orphans — even if this session never - // talks to codex. (No server is spawned by this; that stays lazy.) - .task { _ = CodexServices.appServer } + // including older sessions' orphans. Project reconciliation also + // starts the server here so existing Simbi threads become visible in + // Codex as soon as the app opens. + .task { + _ = CodexServices.appServer + do { + try await SimbiCodexProjectOrganizer.reconcile( + client: CodexServices.appServer, rootURL: model.home.rootURL) + } catch { + // Project organization is cosmetic; recording and + // transcription must remain available if Codex changes the + // experimental project API. + Log.codex.warning("organizing Codex threads failed: \(error)") + } + } // Decouple opening a note from clicking it: building NoteView (five // models, file reads, the markdown parse) is the expensive part, so // it runs a beat after the selection change. The short sleep lets the diff --git a/Packages/SimbiKit/Sources/SimbiUI/TerminalChatWindow.swift b/Packages/SimbiKit/Sources/SimbiUI/TerminalChatWindow.swift index f7637fc..431d201 100644 --- a/Packages/SimbiKit/Sources/SimbiUI/TerminalChatWindow.swift +++ b/Packages/SimbiKit/Sources/SimbiUI/TerminalChatWindow.swift @@ -124,6 +124,18 @@ final class ChatWindow: NSWindow, NSWindowDelegate { self.terminal = terminal contentView = terminal makeFirstResponder(terminal) + + // The Codex CLI exposes no project-id flag and app-server does + // not infer membership from cwd. Give it time to persist the new + // session, then attach that session to the registered Simbi + // project. The second pass covers slower first launches. + Task { + for delay in [Duration.seconds(1), .seconds(5)] { + try? await Task.sleep(for: delay) + try? await SimbiCodexProjectOrganizer.reconcile( + client: CodexServices.appServer, rootURL: homeRootURL) + } + } } else { contentView = NSHostingView( rootView: StatusBanner( diff --git a/Packages/SimbiKit/Tests/CodexKitTests/AppServerEndpointTests.swift b/Packages/SimbiKit/Tests/CodexKitTests/AppServerEndpointTests.swift index 1760974..55cdf08 100644 --- a/Packages/SimbiKit/Tests/CodexKitTests/AppServerEndpointTests.swift +++ b/Packages/SimbiKit/Tests/CodexKitTests/AppServerEndpointTests.swift @@ -5,6 +5,13 @@ import Testing @Suite("AppServerClient endpoint parsing") struct AppServerEndpointTests { + @Test("initialize opts into the project API") + func enablesProjectAPI() { + let params = AppServerClient.initializeParams(version: "1.2.1") + let capabilities = params["capabilities"] as? [String: any Sendable] + #expect(capabilities?["experimentalApi"] as? Bool == true) + } + @Test("extracts the ws endpoint from the listen line") func parsesListenLine() { #expect( diff --git a/Packages/SimbiKit/Tests/CodexKitTests/CodexTurnTests.swift b/Packages/SimbiKit/Tests/CodexKitTests/CodexTurnTests.swift index 6418ea1..048f154 100644 --- a/Packages/SimbiKit/Tests/CodexKitTests/CodexTurnTests.swift +++ b/Packages/SimbiKit/Tests/CodexKitTests/CodexTurnTests.swift @@ -5,6 +5,16 @@ import Testing @Suite("CodexTurn helpers") struct CodexTurnTests { + @Test("thread/start assigns the thread to the Simbi project") + func threadStartProjectShape() { + let params = CodexTurn.threadStartParams( + cwd: URL(filePath: "/Users/test/Simbi"), sandbox: "workspace-write", + projectId: "project-simbi") + #expect(params["cwd"] as? String == "/Users/test/Simbi") + #expect(params["sandbox"] as? String == "workspace-write") + #expect(params["projectId"] as? String == "project-simbi") + } + @Test("startParams builds the shared turn/start shape") func startParamsShape() { let params = CodexTurn.startParams( diff --git a/Packages/SimbiKit/Tests/CodexKitTests/SimbiCodexProjectTests.swift b/Packages/SimbiKit/Tests/CodexKitTests/SimbiCodexProjectTests.swift index ad1f7dc..74f2260 100644 --- a/Packages/SimbiKit/Tests/CodexKitTests/SimbiCodexProjectTests.swift +++ b/Packages/SimbiKit/Tests/CodexKitTests/SimbiCodexProjectTests.swift @@ -5,6 +5,29 @@ import Testing @Suite("Simbi Codex project") struct SimbiCodexProjectTests { + @Test("finds the Codex project registered for the exact Simbi root") + func findsRegisteredProject() { + let result = Data( + #"{"data":[{"id":"other","roots":[{"path":"/tmp/Other"}]},{"id":"simbi","roots":[{"path":"/Users/test/Simbi"}]}],"nextCursor":null}"# + .utf8) + + #expect( + SimbiCodexProjectAPI.projectID( + in: result, rootURL: URL(filePath: "/Users/test/Simbi")) == "simbi") + } + + @Test("selects only Simbi threads not already assigned to the project") + func selectsThreadsForMigration() { + let result = Data( + #"{"data":[{"id":"move","originator":"simbi","cwd":"/tmp","projectId":null},{"id":"chat","originator":"codex_cli_rs","cwd":"/Users/test/Simbi/Work/Standup","projectId":null},{"id":"done","originator":"simbi","cwd":"/Users/test/Simbi","projectId":"simbi"},{"id":"foreign","originator":"other","cwd":"/tmp/Other","projectId":null}],"nextCursor":null}"# + .utf8) + + #expect( + SimbiCodexProjectAPI.threadIDsToAssign( + in: result, projectID: "simbi", + rootURL: URL(filePath: "/Users/test/Simbi")) == ["move", "chat"]) + } + @Test("task instructions keep the project root shared and scope relative paths to one note") func scopesTaskToNote() { let root = URL(filePath: "/Users/test/Simbi") From c29a0e809750404da878454e134dbd3c8a243e0e Mon Sep 17 00:00:00 2001 From: 13v13reddy Date: Sat, 19 Sep 2026 20:08:42 -0500 Subject: [PATCH 12/14] fix(codex): register visible Simbi project Codex keeps a separate desktop project catalog that app-server imports do not update. Register the Simbi root through Codex's folder-open flow and prefer its mapped server project. --- .../SimbiCodexDesktopProjectCatalog.swift | 101 ++++++++++++++++++ .../CodexKit/SimbiCodexProjectOrganizer.swift | 20 +++- .../Sources/SimbiUI/SimbiRootView.swift | 8 ++ .../SimbiCodexProjectTests.swift | 26 +++++ 4 files changed, 151 insertions(+), 4 deletions(-) create mode 100644 Packages/SimbiKit/Sources/CodexKit/SimbiCodexDesktopProjectCatalog.swift diff --git a/Packages/SimbiKit/Sources/CodexKit/SimbiCodexDesktopProjectCatalog.swift b/Packages/SimbiKit/Sources/CodexKit/SimbiCodexDesktopProjectCatalog.swift new file mode 100644 index 0000000..aa8d2c8 --- /dev/null +++ b/Packages/SimbiKit/Sources/CodexKit/SimbiCodexDesktopProjectCatalog.swift @@ -0,0 +1,101 @@ +import AppKit +import Foundation + +/// The local-project catalog rendered by the Codex desktop Projects screen. +/// +/// App-server projects alone are not enough: Codex keeps the UI catalog in +/// `.codex-global-state.json` and only its desktop directory-open flow updates +/// that catalog and broadcasts the change to already-open windows. +enum SimbiCodexDesktopProjectCatalog { + struct Project: Equatable { + let desktopID: String + let serverID: String? + } + + static func project( + in data: Data, rootURL: URL, codexHomeURL: URL + ) -> Project? { + guard + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let projects = object["local-projects"] as? [String: [String: Any]] + else { return nil } + + let rootPath = rootURL.standardizedFileURL.path + guard let (desktopID, _) = projects.first(where: { _, project in + let roots = project["rootPaths"] as? [String] ?? [] + return roots.contains { + URL(filePath: $0).standardizedFileURL.path == rootPath + } + }) else { return nil } + + let hostKey = "local:\(codexHomeURL.standardizedFileURL.path)" + let mappings = object["app-server-project-id-by-legacy-project-id-by-host"] + as? [String: [String: String]] + return Project(desktopID: desktopID, serverID: mappings?[hostKey]?[desktopID]) + } + + static func project( + rootURL: URL, installation: CodexInstallation = .standard + ) -> Project? { + let stateURL = installation.codexHomeURL.appending( + path: ".codex-global-state.json") + guard let data = try? Data(contentsOf: stateURL) else { return nil } + return project( + in: data, rootURL: rootURL, codexHomeURL: installation.codexHomeURL) + } +} + +/// Registers the Simbi root through Codex's own macOS directory-open path. +/// That is the only external entry point that updates both the app-server +/// project store and the live desktop UI catalog without restarting Codex. +public enum SimbiCodexDesktopProjectRegistrar { + public enum RegistrationError: Error { + case appMissing + case timedOut + } + + @MainActor + public static func ensureProject( + rootURL: URL, installation: CodexInstallation = .standard + ) async throws { + let rootURL = rootURL.standardizedFileURL + if SimbiCodexDesktopProjectCatalog.project( + rootURL: rootURL, installation: installation)?.serverID != nil + { + return + } + + guard FileManager.default.fileExists(atPath: installation.appBundleURL.path) else { + throw RegistrationError.appMissing + } + + let configuration = NSWorkspace.OpenConfiguration() + configuration.activates = false + configuration.addsToRecentItems = false + try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + NSWorkspace.shared.open( + [rootURL], withApplicationAt: installation.appBundleURL, + configuration: configuration + ) { _, error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } + } + } + + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(10)) + while clock.now < deadline { + if SimbiCodexDesktopProjectCatalog.project( + rootURL: rootURL, installation: installation)?.serverID != nil + { + return + } + try await Task.sleep(for: .milliseconds(100)) + } + throw RegistrationError.timedOut + } +} diff --git a/Packages/SimbiKit/Sources/CodexKit/SimbiCodexProjectOrganizer.swift b/Packages/SimbiKit/Sources/CodexKit/SimbiCodexProjectOrganizer.swift index 04aeb28..b44fb96 100644 --- a/Packages/SimbiKit/Sources/CodexKit/SimbiCodexProjectOrganizer.swift +++ b/Packages/SimbiKit/Sources/CodexKit/SimbiCodexProjectOrganizer.swift @@ -3,15 +3,23 @@ import Foundation /// Wire parsing kept separate from the organizer so project membership has /// deterministic unit coverage without launching the real app-server. enum SimbiCodexProjectAPI { - static func projectID(in data: Data, rootURL: URL) -> String? { + static func projectID( + in data: Data, rootURL: URL, preferredID: String? = nil + ) -> String? { let rootPath = rootURL.standardizedFileURL.path - return rows(in: data).first { row in + let matches = rows(in: data).filter { row in let roots = row["roots"] as? [[String: Any]] ?? [] return roots.contains { root in guard let path = root["path"] as? String else { return false } return URL(filePath: path).standardizedFileURL.path == rootPath } - }?["id"] as? String + } + if let preferredID, + matches.contains(where: { $0["id"] as? String == preferredID }) + { + return preferredID + } + return matches.first?["id"] as? String } static func importedProjectID(in data: Data) -> String? { @@ -117,12 +125,16 @@ public enum SimbiCodexProjectOrganizer { private func findProject( client: AppServerClient, rootURL: URL ) async throws -> String? { + let preferredID = SimbiCodexDesktopProjectCatalog.project( + rootURL: rootURL)?.serverID var cursor: String? repeat { var params: [String: any Sendable] = ["limit": 100] if let cursor { params["cursor"] = cursor } let result = try await client.request(method: "project/list", params: params) - if let id = SimbiCodexProjectAPI.projectID(in: result, rootURL: rootURL) { + if let id = SimbiCodexProjectAPI.projectID( + in: result, rootURL: rootURL, preferredID: preferredID) + { return id } cursor = SimbiCodexProjectAPI.nextCursor(in: result) diff --git a/Packages/SimbiKit/Sources/SimbiUI/SimbiRootView.swift b/Packages/SimbiKit/Sources/SimbiUI/SimbiRootView.swift index 029b89d..56a1ab8 100644 --- a/Packages/SimbiKit/Sources/SimbiUI/SimbiRootView.swift +++ b/Packages/SimbiKit/Sources/SimbiUI/SimbiRootView.swift @@ -99,6 +99,14 @@ public struct SimbiRootView: View { // Codex as soon as the app opens. .task { _ = CodexServices.appServer + do { + try await SimbiCodexDesktopProjectRegistrar.ensureProject( + rootURL: model.home.rootURL) + } catch { + // A desktop-catalog failure must not prevent app-server + // assignment, which still keeps Simbi's worker threads usable. + Log.codex.warning("registering Codex desktop project failed: \(error)") + } do { try await SimbiCodexProjectOrganizer.reconcile( client: CodexServices.appServer, rootURL: model.home.rootURL) diff --git a/Packages/SimbiKit/Tests/CodexKitTests/SimbiCodexProjectTests.swift b/Packages/SimbiKit/Tests/CodexKitTests/SimbiCodexProjectTests.swift index 74f2260..08444d8 100644 --- a/Packages/SimbiKit/Tests/CodexKitTests/SimbiCodexProjectTests.swift +++ b/Packages/SimbiKit/Tests/CodexKitTests/SimbiCodexProjectTests.swift @@ -16,6 +16,32 @@ struct SimbiCodexProjectTests { in: result, rootURL: URL(filePath: "/Users/test/Simbi")) == "simbi") } + @Test("prefers the app-server project linked to the visible desktop project") + func prefersVisibleDesktopProject() { + let result = Data( + #"{"data":[{"id":"hidden","roots":[{"path":"/Users/test/Simbi"}]},{"id":"visible","roots":[{"path":"/Users/test/Simbi"}]}],"nextCursor":null}"# + .utf8) + + #expect( + SimbiCodexProjectAPI.projectID( + in: result, rootURL: URL(filePath: "/Users/test/Simbi"), + preferredID: "visible") == "visible") + } + + @Test("reads the visible project and server mapping from Codex desktop state") + func readsDesktopProjectCatalog() { + let state = Data( + #"{"local-projects":{"desktop-simbi":{"id":"desktop-simbi","name":"Simbi","rootPaths":["/Users/test/Simbi"],"createdAt":1,"updatedAt":1}},"app-server-project-id-by-legacy-project-id-by-host":{"local:/Users/test/.codex":{"desktop-simbi":"server-simbi"}}}"# + .utf8) + + let project = SimbiCodexDesktopProjectCatalog.project( + in: state, rootURL: URL(filePath: "/Users/test/Simbi"), + codexHomeURL: URL(filePath: "/Users/test/.codex")) + + #expect(project?.desktopID == "desktop-simbi") + #expect(project?.serverID == "server-simbi") + } + @Test("selects only Simbi threads not already assigned to the project") func selectsThreadsForMigration() { let result = Data( From fcdaf004efa324b512990f6c689d54a9af9f6345 Mon Sep 17 00:00:00 2001 From: 13v13reddy Date: Sun, 20 Sep 2026 18:20:44 -0500 Subject: [PATCH 13/14] fix(menubar): make window controls reliable Address the main SwiftUI scene by ID instead of relying on unstable AppKit window ordering. Use a toggleable menubar panel so repeated icon clicks close it. --- App/SimbiApp.swift | 4 +- .../Sources/SimbiUI/QuickCaptureMenu.swift | 42 ++++++++++++------- 2 files changed, 30 insertions(+), 16 deletions(-) diff --git a/App/SimbiApp.swift b/App/SimbiApp.swift index 272ad2d..c206c8a 100644 --- a/App/SimbiApp.swift +++ b/App/SimbiApp.swift @@ -24,7 +24,7 @@ struct SimbiApp: App { } var body: some Scene { - WindowGroup { + Window("Simbi", id: SimbiWindow.mainID) { SimbiRootView() } .commands { @@ -59,6 +59,6 @@ struct SimbiApp: App { } label: { QuickCaptureMenuBarLabel() } - .menuBarExtraStyle(.menu) + .menuBarExtraStyle(.window) } } diff --git a/Packages/SimbiKit/Sources/SimbiUI/QuickCaptureMenu.swift b/Packages/SimbiKit/Sources/SimbiUI/QuickCaptureMenu.swift index 06bb22f..914d260 100644 --- a/Packages/SimbiKit/Sources/SimbiUI/QuickCaptureMenu.swift +++ b/Packages/SimbiKit/Sources/SimbiUI/QuickCaptureMenu.swift @@ -3,6 +3,11 @@ import Observation import SimbiKit import SwiftUI +/// Stable scene identity for the app's single main window. +public enum SimbiWindow { + public static let mainID = "simbi-main" +} + /// Menubar-first recording: choose any normal Simbi folder, create a /// timestamped note there, and start the per-note recorder immediately. @MainActor @Observable @@ -150,11 +155,6 @@ public final class QuickCaptureModel { return path == root.path || !NoteOperations.isInsideNoteFolder(url) } - public func openSimbi() { - NSApp.activate(ignoringOtherApps: true) - NSApp.windows.first?.makeKeyAndOrderFront(nil) - } - } /// The app-level menubar content. The model is resolved only after onboarding @@ -167,15 +167,17 @@ public struct QuickCaptureMenuContent: View { public init() {} public var body: some View { - if onboarding.isActive { - Text("Finish Simbi setup to start recording") - Button("Open Simbi") { - NSApp.activate(ignoringOtherApps: true) - NSApp.windows.first?.makeKeyAndOrderFront(nil) + VStack(alignment: .leading, spacing: 6) { + if onboarding.isActive { + Text("Finish Simbi setup to start recording") + OpenSimbiButton() + } else { + QuickCaptureMenu(model: QuickCaptureModel.shared, activity: activity) } - } else { - QuickCaptureMenu(model: QuickCaptureModel.shared, activity: activity) } + .buttonStyle(.borderless) + .padding(10) + .frame(width: 280, alignment: .leading) } } @@ -205,7 +207,7 @@ private struct QuickCaptureMenu: View { Group { if OnboardingState.isNeeded() { Text("Finish Simbi setup to start recording") - Button("Open Simbi") { model.openSimbi() } + OpenSimbiButton() } else if activity.isRecording { Section { Label( @@ -222,6 +224,7 @@ private struct QuickCaptureMenu: View { Toggle(isOn: destinationBinding(for: destination)) { Text(destination.path) } + .toggleStyle(.checkbox) .accessibilityLabel("Record in \(destination.path)") } } @@ -233,7 +236,7 @@ private struct QuickCaptureMenu: View { } Divider() - Button("Open Simbi") { model.openSimbi() } + OpenSimbiButton() SettingsLink { Text("Settings…") } @@ -271,6 +274,17 @@ private struct QuickCaptureMenu: View { } } +private struct OpenSimbiButton: View { + @Environment(\.openWindow) private var openWindow + + var body: some View { + Button("Open Simbi") { + openWindow(id: SimbiWindow.mainID) + NSApp.activate(ignoringOtherApps: true) + } + } +} + private struct FolderDestination: Identifiable { let url: URL let path: String From 2a931ccbf66bbdfca755c2482622cc3005ac52af Mon Sep 17 00:00:00 2001 From: 13v13reddy Date: Sun, 20 Sep 2026 21:19:34 -0500 Subject: [PATCH 14/14] feat(menubar): add quit action --- Packages/SimbiKit/Sources/SimbiUI/QuickCaptureMenu.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Packages/SimbiKit/Sources/SimbiUI/QuickCaptureMenu.swift b/Packages/SimbiKit/Sources/SimbiUI/QuickCaptureMenu.swift index 914d260..576fef5 100644 --- a/Packages/SimbiKit/Sources/SimbiUI/QuickCaptureMenu.swift +++ b/Packages/SimbiKit/Sources/SimbiUI/QuickCaptureMenu.swift @@ -174,6 +174,11 @@ public struct QuickCaptureMenuContent: View { } else { QuickCaptureMenu(model: QuickCaptureModel.shared, activity: activity) } + Divider() + Button("Quit Simbi") { + NSApplication.shared.terminate(nil) + } + .keyboardShortcut("q", modifiers: .command) } .buttonStyle(.borderless) .padding(10)