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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ The two tracks are versioned independently.
- **Non-destructive friction-log hiding:** hide individual friction logs from
the tray while keeping their files on disk, then restore them individually or
all at once from Settings.
- **Unseen badges and friction-log Seen:** the Shots and Friction Logs tabs
show how many items still need review. Friction logs use the same Unseen /
History filter and mark-seen control as shots, persisted in each run's
`review.json`. A newer run makes the log unseen again.

### Changed

Expand All @@ -30,6 +34,8 @@ The two tracks are versioned independently.

### Fixed

- **Tray stays after full-screen review:** closing the large viewer restores
the menu-bar drawer that opened it, instead of leaving the tray dismissed.
- **Narrated video cutoff and crawl:** long step transcripts are now spoken in
sentence chunks, trailing TTS silence is trimmed before captions are timed,
and the voice is asked to talk at a conversational pace (temperature 0.5).
Expand Down
4 changes: 4 additions & 0 deletions macos/Astroshots.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
967A58CBD25BFB06C924D2DD /* NarrationModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = C28BEB6B07761BB8C9BE0687 /* NarrationModels.swift */; };
96FF7A41FF2B21E652A0DB9F /* AstroshotWatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 976E87A9FC5F5847878A8A64 /* AstroshotWatcher.swift */; };
9E7A5FC5E617434C14891B39 /* ImageClipboardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE183D1FC1B91442E70D281A /* ImageClipboardTests.swift */; };
A1624B4638732301C92739FB /* ReviewWindowControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE5C4C01CF0926A78558F5F1 /* ReviewWindowControllerTests.swift */; };
A1C2A497863C6149280EDF41 /* ShotIndexCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70B6A1CB8442F588999CF4B1 /* ShotIndexCacheTests.swift */; };
A9E0B90E6788FF3B7F892333 /* ShotThumbnail.swift in Sources */ = {isa = PBXBuildFile; fileRef = B56B2FBBA1C2EC2C3395C15B /* ShotThumbnail.swift */; };
AC6530122CF24B9A06DCF3E6 /* ImagePagingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4705ECE07E4CA31E16A35EDC /* ImagePagingTests.swift */; };
Expand Down Expand Up @@ -145,6 +146,7 @@
DFDC12CB9342C5E685FD8A25 /* DetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailView.swift; sourceTree = "<group>"; };
E835F1DA4729DEB7570B9FBC /* FrictionLogLoaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrictionLogLoaderTests.swift; sourceTree = "<group>"; };
E9F84D4D30E15FCC4EECC1C2 /* NarrationJobQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NarrationJobQueue.swift; sourceTree = "<group>"; };
EE5C4C01CF0926A78558F5F1 /* ReviewWindowControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewWindowControllerTests.swift; sourceTree = "<group>"; };
F217601C57C50DA926C31AF2 /* astroshots-app-icon-master.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "astroshots-app-icon-master.png"; sourceTree = "<group>"; };
F9EA16401CCC820E6AD2BC9A /* ReviewStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewStoreTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
Expand Down Expand Up @@ -175,6 +177,7 @@
C7975C24FB04A3D1A9DA4227 /* NarrationTests.swift */,
26844F440291050990A83490 /* PreferencesTests.swift */,
F9EA16401CCC820E6AD2BC9A /* ReviewStoreTests.swift */,
EE5C4C01CF0926A78558F5F1 /* ReviewWindowControllerTests.swift */,
70B6A1CB8442F588999CF4B1 /* ShotIndexCacheTests.swift */,
5089282794B5A6E6B3636886 /* ShotPathTests.swift */,
8C127AB1558EF7894B101A53 /* StreamGroupingTests.swift */,
Expand Down Expand Up @@ -492,6 +495,7 @@
3D70DEAA463D3459B886ED95 /* NarrationTests.swift in Sources */,
CA8C19581F533AF4120AD2C4 /* PreferencesTests.swift in Sources */,
FF7CB55DF3385E5F06077544 /* ReviewStoreTests.swift in Sources */,
A1624B4638732301C92739FB /* ReviewWindowControllerTests.swift in Sources */,
A1C2A497863C6149280EDF41 /* ShotIndexCacheTests.swift in Sources */,
D862C2464A53C71CC3BD45DF /* ShotPathTests.swift in Sources */,
699399301E1BE2AFEAEF81CA /* StreamGroupingTests.swift in Sources */,
Expand Down
96 changes: 96 additions & 0 deletions macos/Astroshots/App/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ final class AppState {
var selectedShotID: String?
var pane: TrayPane = .stream
var activeTab: TrayTab = .shots
/// Shared with the Friction Logs list so capture/automation can flip History.
var frictionLogFilter: StreamFilter = .toReview
/// Complete on-disk catalog across watched worktrees (newest first).
private(set) var discoveredFrictionLogs: [FrictionLog] = []
/// Stable identities excluded from the normal Friction Logs UX.
Expand Down Expand Up @@ -130,6 +132,21 @@ final class AppState {

var isFrictionLogsEmpty: Bool { frictionLogs.isEmpty }

var unseenShotCount: Int {
shots.filter { ($0.review?.state ?? .pending) != .seen }.count
}

var unseenFrictionLogCount: Int {
frictionLogs.filter { $0.reviewState != .seen }.count
}

func unseenCount(for tab: TrayTab) -> Int {
switch tab {
case .shots: return unseenShotCount
case .frictionLogs: return unseenFrictionLogCount
}
}

init(
preferences: Preferences = .shared,
watcher: AstroshotWatcher? = nil,
Expand Down Expand Up @@ -566,6 +583,85 @@ final class AppState {
)
}

func markFrictionLogSeen(_ log: FrictionLog) async throws {
let snapshot = try await persistFrictionLogSeen(log)
applyFrictionReview(snapshot, to: log.id, runID: log.latestRun?.runID)
showToast("Seen")
}

func markAllFrictionLogsSeen(_ candidates: [FrictionLog]) async {
guard !isMarkingSeen else { return }
let unseen = candidates.filter { $0.reviewState != .seen }
guard !unseen.isEmpty else {
showToast("Already seen")
return
}

isMarkingSeen = true
defer { isMarkingSeen = false }

var markedCount = 0
var failureCount = 0
for log in unseen {
do {
let snapshot = try await persistFrictionLogSeen(log)
applyFrictionReview(snapshot, to: log.id, runID: log.latestRun?.runID)
markedCount += 1
} catch {
failureCount += 1
}
}

if failureCount > 0 {
showToast(
markedCount == 0
? "Couldn’t mark logs as seen"
: "Marked \(markedCount) seen; \(failureCount) failed"
)
return
}
showToast(
markedCount == 1
? "Marked 1 log seen"
: "Marked \(markedCount) logs seen"
)
}

private func persistFrictionLogSeen(_ log: FrictionLog) async throws -> ReviewSnapshot {
guard let run = log.latestRun else {
throw ReviewStore.StoreError.invalidImageFileName
}
let logURL = URL(fileURLWithPath: run.directoryPath)
.appendingPathComponent(FrictionLogPath.logFileName)
guard FileManager.default.fileExists(atPath: logURL.path) else {
throw ReviewStore.StoreError.invalidImageFileName
}
return try await reviewStore.markSeen(
forImage: logURL,
featureDirectory: URL(fileURLWithPath: run.directoryPath, isDirectory: true),
runID: run.runID,
commentBody: nil
)
}

private func applyFrictionReview(
_ snapshot: ReviewSnapshot,
to logID: String,
runID: String?
) {
guard let index = discoveredFrictionLogs.firstIndex(where: { $0.id == logID }) else {
return
}
let log = discoveredFrictionLogs[index]
let runs = log.runs.map { run -> FrictionLogRun in
guard run.runID == runID else { return run }
var updated = run
updated.review = snapshot
return updated
}
discoveredFrictionLogs[index] = log.replacingRuns(runs)
}

private func applyReview(_ snapshot: ReviewSnapshot, to shotID: String) {
guard let index = shots.firstIndex(where: { $0.id == shotID }) else { return }
shots[index].review = snapshot
Expand Down
74 changes: 68 additions & 6 deletions macos/Astroshots/App/StatusItemController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,15 @@ final class StatusItemController: NSObject {
private var observationTask: Task<Void, Never>?
/// Sparkle controller; menu item target/action point at this when set.
private var updaterController: SPUStandardUpdaterController?
/// Tray surface that was showing when full-screen review opened, restored
/// when the viewer closes so the drawer is still there.
private var trayToRestoreAfterReview: TrayRestoreTarget = .none

private enum TrayRestoreTarget {
case none
case popover
case pinned
}

init(appState: AppState) {
self.appState = appState
Expand All @@ -38,12 +47,17 @@ final class StatusItemController: NSObject {

func install() {
let reviewWindowController = ReviewWindowController(appState: appState)
reviewWindowController.onClosed = { [weak self] in
self?.restoreTrayAfterReview()
}
self.reviewWindowController = reviewWindowController
appState.onReviewRequested = { [weak reviewWindowController] shot in
reviewWindowController?.open(shot)
appState.onReviewRequested = { [weak self] shot in
self?.rememberTrayForReviewRestore()
self?.reviewWindowController?.open(shot)
}
appState.onFrictionStepReviewRequested = { [weak reviewWindowController] step in
reviewWindowController?.openFrictionStep(step)
appState.onFrictionStepReviewRequested = { [weak self] step in
self?.rememberTrayForReviewRestore()
self?.reviewWindowController?.openFrictionStep(step)
}

let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
Expand Down Expand Up @@ -132,6 +146,7 @@ final class StatusItemController: NSObject {
observationTask = nil
closePopover()
closePinnedWindow()
reviewWindowController?.onClosed = nil
reviewWindowController?.close()
reviewWindowController = nil
appState.onReviewRequested = nil
Expand Down Expand Up @@ -252,9 +267,24 @@ final class StatusItemController: NSObject {

self.appState.backToStream()
self.appState.selectTab(.frictionLogs)
self.appState.frictionLogFilter = .toReview
try? await Task.sleep(nanoseconds: 450_000_000)
self.captureTraySurface(to: out.appendingPathComponent("0004-friction-logs-list.png"))

if let first = self.appState.frictionLogs.first(where: { $0.reviewState != .seen }) {
try? await self.appState.markFrictionLogSeen(first)
try? await Task.sleep(nanoseconds: 350_000_000)
self.captureTraySurface(
to: out.appendingPathComponent("0004b-friction-logs-unseen.png")
)
self.appState.frictionLogFilter = .history
try? await Task.sleep(nanoseconds: 350_000_000)
self.captureTraySurface(
to: out.appendingPathComponent("0004c-friction-logs-history.png")
)
self.appState.frictionLogFilter = .toReview
}

if let log = self.appState.frictionLogs.first {
self.appState.selectFrictionLog(log)
}
Expand Down Expand Up @@ -590,16 +620,48 @@ final class StatusItemController: NSObject {
private func reviewChrome() -> ReviewChrome {
ReviewChrome(
open: { [weak self] shot in
self?.closePopover()
self?.beginReviewFromTray()
self?.reviewWindowController?.open(shot)
},
openFrictionStep: { [weak self] step in
self?.closePopover()
self?.beginReviewFromTray()
self?.reviewWindowController?.openFrictionStep(step)
}
)
}

/// Snapshot the open tray, then hide the transient popover so the
/// full-screen viewer can take key. Pinned window stays put underneath.
private func beginReviewFromTray() {
rememberTrayForReviewRestore()
closePopover()
}

private func rememberTrayForReviewRestore() {
if pinnedWindow?.isVisible == true {
trayToRestoreAfterReview = .pinned
} else if popover?.isShown == true {
trayToRestoreAfterReview = .popover
} else {
trayToRestoreAfterReview = .none
}
}

private func restoreTrayAfterReview() {
let target = trayToRestoreAfterReview
trayToRestoreAfterReview = .none
switch target {
case .none:
break
case .popover:
showPopover()
case .pinned:
pinnedWindow?.makeKeyAndOrderFront(nil)
pinnedWindow?.orderFrontRegardless()
NSApp.activate(ignoringOtherApps: true)
}
}

// MARK: - Status appearance

private func startObservingState() {
Expand Down
6 changes: 6 additions & 0 deletions macos/Astroshots/Features/Review/ReviewWindowController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ final class ReviewWindowController {
private var panel: ReviewPanel?
private var currentShotID: String?
private var mode: Mode = .shot
/// Invoked after the takeover is ordered out (close button, Escape, Seen).
var onClosed: (() -> Void)?

init(appState: AppState) {
self.appState = appState
Expand Down Expand Up @@ -57,9 +59,13 @@ final class ReviewWindowController {
}

func close() {
let wasVisible = panel?.isVisible == true
panel?.orderOut(nil)
currentShotID = nil
mode = .shot
if wasVisible {
onClosed?()
}
}

private func present<Content: View>(_ root: Content) {
Expand Down
17 changes: 17 additions & 0 deletions macos/Astroshots/Features/Tray/FrictionLogDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,23 @@ struct FrictionLogDetailView: View {
}

if let log = appState.selectedFrictionLog {
if log.reviewState != .seen, log.latestRun != nil {
Button {
Task {
try? await appState.markFrictionLogSeen(log)
}
} label: {
Image(systemName: "checkmark.circle")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(Theme.purple)
.frame(width: 24, height: 22)
}
.buttonStyle(.plain)
.help("Mark this log seen")
.accessibilityLabel("Mark \(log.title) seen")
.accessibilityIdentifier("friction.detail.seen")
}

Button {
appState.hideFrictionLog(log)
} label: {
Expand Down
Loading
Loading