Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions Packages/SimbiKit/Sources/SimbiUI/NoteView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -221,6 +227,13 @@ struct NoteView: View {
isWorking: summary.status == .working,
regenerateHelp: regenerateHelp,
onRegenerate: { summary.regenerate() })
} else if summary.canOfferFirstGeneration && recorder.status == .idle {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Suppress first generation while an import is running

When a media import is transcribing or fixing, recorder.status remains .idle while cues are progressively written, so this condition exposes the new generate button during the import. generateFirst() likewise has no ImportController.isImporting guard, allowing generation from a partial transcript; if the import finishes while that run is still working, the completion hook skips its corrective auto-generation because alreadyWorking is true, leaving incomplete AI notes. Gate both the offer and action on the import being idle.

Useful? React with 👍 / 👎.

// 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
Expand All @@ -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 {
Expand Down
59 changes: 57 additions & 2 deletions Packages/SimbiKit/Sources/SimbiUI/SummaryController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?()
Comment on lines +69 to +70

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor external deletes in the failed state

If an in-place generation fails while an existing summary remains loaded, status is .failed; deleting summary.md externally then bypasses this callback solely because the controller is not idle. The editor retains its nonempty text, and NoteView.onDisappear calls saveNow(), recreating the file the user deleted. Fresh-regeneration recovery needs special handling, but external deletes following ordinary failed updates should still clear the held document.

Useful? React with 👍 / 👎.

}
}

/// 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
Expand All @@ -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
Expand All @@ -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)
Expand Down
96 changes: 96 additions & 0 deletions Packages/SimbiKit/Tests/SimbiUITests/SummaryControllerTests.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import SimbiKit
import Testing

@testable import SimbiUI
Expand Down Expand Up @@ -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
<v Speaker 1>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() {
Expand Down
Loading