diff --git a/Packages/SimbiKit/Sources/SimbiUI/NoteView.swift b/Packages/SimbiKit/Sources/SimbiUI/NoteView.swift index a536c0f..e5fe895 100644 --- a/Packages/SimbiKit/Sources/SimbiUI/NoteView.swift +++ b/Packages/SimbiKit/Sources/SimbiUI/NoteView.swift @@ -162,6 +162,12 @@ struct NoteView: View { document?.saveNow() aiDocument?.saveNow() } + // An external delete of summary.md drops the held editor text; + // otherwise onDisappear's saveNow would write the file right + // back. Weak like the flush hook: a gone view holds no text. + summary.summaryFileRemovedExternally = { [weak aiDocument] in + aiDocument?.text = "" + } // Opening a note that already has AI notes lands on them // (spec §4) — unless a recording is underway. if summary.summaryExists && recorder.status == .idle { @@ -221,6 +227,13 @@ struct NoteView: View { isWorking: summary.status == .working, regenerateHelp: regenerateHelp, onRegenerate: { summary.regenerate() }) + } else if summary.canOfferFirstGeneration && recorder.status == .idle { + // Issue #3: a note with a transcript but no AI notes has no + // strip, so no generation entry point. Offer one in the exact + // spot the regenerate button occupies once the strip exists; + // pressing it flips status to .working, which swaps this row + // for the real strip mid-generation. + generateOfferRow } if tabStripVisible && selectedTab == .aiNotes { aiNotesPane @@ -234,6 +247,33 @@ struct NoteView: View { } } + /// A strip-shaped row holding only the generate button. The hidden tab + /// label is a height ghost: it gives this row the strip's exact height + /// so the working-state handoff to the real strip doesn't shift the + /// editor. + private var generateOfferRow: some View { + HStack(spacing: Design.paneInset) { + Text("AI Notes") + .font(.body.weight(.semibold)) + .padding(.vertical, Design.innerGap) + .hidden() + Spacer() + Button(action: { summary.generateFirst() }) { + Image(systemName: "sparkles") + .font(.meta) + .foregroundStyle(.secondary) + } + .buttonStyle(HoverCircleButtonStyle(inset: Design.iconGap)) + .disabled(!summary.codexAvailable) + .help( + summary.codexAvailable + ? "Generate AI notes from the recording" + : "AI notes need the ChatGPT app. See the sidebar footer.") + } + .padding(.horizontal, Design.paneInset) + .padding(.vertical, Design.stripPadding) + } + private var regenerateHelp: String { if summary.status == .working { return "Updating AI notes" } if !summary.codexAvailable { diff --git a/Packages/SimbiKit/Sources/SimbiUI/SummaryController.swift b/Packages/SimbiKit/Sources/SimbiUI/SummaryController.swift index 97b0fa9..dce68ea 100644 --- a/Packages/SimbiKit/Sources/SimbiUI/SummaryController.swift +++ b/Packages/SimbiKit/Sources/SimbiUI/SummaryController.swift @@ -48,15 +48,33 @@ public final class SummaryController { model: choice.model, effort: choice.effort) self.summaryExists = FileManager.default.fileExists( atPath: NoteLayout.summaryURL(noteFolder: noteFolderURL).path) + self.transcriptHasCues = VTT.transcriptHasCues(noteFolder: noteFolderURL) watcher = FileTreeWatcher.observing(url: noteFolderURL) { [weak self] in - self?.refreshSummaryExists() + self?.refreshFileState() } } - private func refreshSummaryExists() { + /// Internal (not private) so tests can drive the watcher path + /// synchronously. + func refreshFileState() { + let summaryExisted = summaryExists summaryExists = FileManager.default.fileExists(atPath: summaryFileURL.path) + transcriptHasCues = VTT.transcriptHasCues(noteFolder: noteFolderURL) + // Filesystem is truth: an external delete of summary.md (Finder, + // git, a chat thread) must tell the note view to drop the editor's + // held text, or closing the note resurrects the file. Idle-only: + // the fresh-regenerate delete lands here with status already + // .working (or .failed), and there the held text is the documented + // failed-run recovery. + if summaryExisted && !summaryExists && status == .idle { + summaryFileRemovedExternally?() + } } + /// The note view's hook to drop its AI-notes editor text after an + /// external delete; nil once the view is gone (nothing holds text then). + var summaryFileRemovedExternally: (() -> Void)? + var summaryFileURL: URL { NoteLayout.summaryURL(noteFolder: noteFolderURL) } /// Flushes any pending debounced editor autosaves so the summarizer @@ -71,6 +89,11 @@ public final class SummaryController { /// in-app generations. private(set) var summaryExists: Bool + /// Whether the transcript on disk has at least one cue — stored and + /// watcher-refreshed like summaryExists, so late-draining uploads and + /// external edits flip the first-generation offer live. + private(set) var transcriptHasCues: Bool + private var watcher: FileTreeWatcher? /// The shared live availability model — the same source the note @@ -89,6 +112,38 @@ public final class SummaryController { enabled && transcriptHasCues && codexAvailable && !alreadyWorking && !recordingActive } + /// 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 + /// regenerate button) doesn't exist yet. Codex availability is + /// deliberately absent: like the regenerate button, the offer shows + /// disabled with an explanatory tooltip when Codex is degraded. + nonisolated static func shouldOfferFirstGeneration( + enabled: Bool, transcriptHasCues: Bool, summaryExists: Bool, statusIdle: Bool, + recordingActive: Bool + ) -> Bool { + enabled && transcriptHasCues && !summaryExists && statusIdle && !recordingActive + } + + /// The offer predicate over this note's live state. + var canOfferFirstGeneration: Bool { + Self.shouldOfferFirstGeneration( + enabled: SimbiSettings.current().aiNotesEnabled, + transcriptHasCues: transcriptHasCues, summaryExists: summaryExists, + statusIdle: status == .idle, + recordingActive: RecordingController.isCapturing(noteFolderURL: noteFolderURL)) + } + + /// The first-generation button. Same guards as retry(); nothing to + /// delete, and beginRun() detects first-ness from disk so the + /// placeholder and tab strip appear through the existing paths. + func generateFirst() { + guard SimbiSettings.current().aiNotesEnabled, status != .working, codexAvailable, + !RecordingController.isCapturing(noteFolderURL: noteFolderURL) + else { return } + generate() + } + /// The recording controller's clean-stop hook. func recordingDidStop() { let hasCues = VTT.transcriptHasCues(noteFolder: noteFolderURL) diff --git a/Packages/SimbiKit/Tests/SimbiUITests/SummaryControllerTests.swift b/Packages/SimbiKit/Tests/SimbiUITests/SummaryControllerTests.swift index 83f6fa7..884ed67 100644 --- a/Packages/SimbiKit/Tests/SimbiUITests/SummaryControllerTests.swift +++ b/Packages/SimbiKit/Tests/SimbiUITests/SummaryControllerTests.swift @@ -1,4 +1,5 @@ import Foundation +import SimbiKit import Testing @testable import SimbiUI @@ -96,6 +97,101 @@ struct SummaryControllerTests { #expect(controller.status == .failed("AI notes couldn't be updated.")) } + @Test("first-generation offer gating") + func offerGate() { + #expect( + SummaryController.shouldOfferFirstGeneration( + enabled: true, transcriptHasCues: true, summaryExists: false, + statusIdle: true, recordingActive: false)) + #expect( + !SummaryController.shouldOfferFirstGeneration( + enabled: false, transcriptHasCues: true, summaryExists: false, + statusIdle: true, recordingActive: false)) + #expect( + !SummaryController.shouldOfferFirstGeneration( + enabled: true, transcriptHasCues: false, summaryExists: false, + statusIdle: true, recordingActive: false)) + #expect( + !SummaryController.shouldOfferFirstGeneration( + enabled: true, transcriptHasCues: true, summaryExists: true, + statusIdle: true, recordingActive: false)) + // Working or failed states already show the tab strip; the offer + // exists only for the strip-less idle state. + #expect( + !SummaryController.shouldOfferFirstGeneration( + enabled: true, transcriptHasCues: true, summaryExists: false, + statusIdle: false, recordingActive: false)) + // Spec §3: no trigger while recording, ever. + #expect( + !SummaryController.shouldOfferFirstGeneration( + enabled: true, transcriptHasCues: true, summaryExists: false, + statusIdle: true, recordingActive: true)) + } + + @Test("transcriptHasCues reads the transcript on init") + @MainActor + func transcriptHasCuesInitialRead() throws { + let url = FileManager.default.temporaryDirectory + .appending(path: "simbi-sum-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: url) } + #expect(!SummaryController(noteFolderURL: url).transcriptHasCues) + + let vtt = """ + WEBVTT + + 1 + 00:00:00.000 --> 00:00:01.000 + hello + + """ + try vtt.write( + to: VTT.fileURL(noteFolder: url), atomically: true, encoding: .utf8) + #expect(SummaryController(noteFolderURL: url).transcriptHasCues) + } + + @Test("external summary.md delete while idle drops the held editor text") + @MainActor + func externalDeleteNotifies() throws { + let url = FileManager.default.temporaryDirectory + .appending(path: "simbi-sum-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: url) } + let summaryURL = NoteLayout.summaryURL(noteFolder: url) + try "# notes".write(to: summaryURL, atomically: true, encoding: .utf8) + let controller = SummaryController(noteFolderURL: url) + var fired = 0 + controller.summaryFileRemovedExternally = { fired += 1 } + + try FileManager.default.removeItem(at: summaryURL) + controller.refreshFileState() + #expect(fired == 1) + // No transition on a repeat refresh: fires once per disappearance. + controller.refreshFileState() + #expect(fired == 1) + } + + @Test("the fresh-regenerate delete is not an external delete") + @MainActor + func freshRegenerateDeleteKeepsHeldText() throws { + let url = FileManager.default.temporaryDirectory + .appending(path: "simbi-sum-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: url) } + let summaryURL = NoteLayout.summaryURL(noteFolder: url) + try "# notes".write(to: summaryURL, atomically: true, encoding: .utf8) + let controller = SummaryController(noteFolderURL: url) + var fired = 0 + controller.summaryFileRemovedExternally = { fired += 1 } + + // generate(fresh:) deletes summary.md with status already .working; + // the held text is the failed-run recovery and must survive. + controller.markWorkingForTesting() + try FileManager.default.removeItem(at: summaryURL) + controller.refreshFileState() + #expect(fired == 0) + } + @Test("failed state clears on note close") @MainActor func failureClears() {