From 58f0a7711da2187ba9e67bf7f2dec790eb0ca954 Mon Sep 17 00:00:00 2001 From: calvin-archastro Date: Thu, 13 Aug 2026 15:40:27 -0700 Subject: [PATCH] feat: badge unseen reviews and keep the tray after full-screen close Shots and Friction Logs tabs show how many items still need review. Friction logs now use the same Unseen/History filter and Seen control as shots, persisted in each run's review.json. Closing the large viewer restores the menu-bar drawer that opened it. --- CHANGELOG.md | 6 + macos/Astroshots.xcodeproj/project.pbxproj | 4 + macos/Astroshots/App/AppState.swift | 96 ++++++++++ .../Astroshots/App/StatusItemController.swift | 74 +++++++- .../Review/ReviewWindowController.swift | 6 + .../Features/Tray/FrictionLogDetailView.swift | 17 ++ .../Features/Tray/FrictionLogListView.swift | 132 ++++++++++--- .../Astroshots/Features/Tray/StreamView.swift | 33 ++-- macos/Astroshots/Features/Tray/TrayView.swift | 41 +++-- macos/Astroshots/Models/FrictionLog.swift | 39 +++- .../FrictionLogLoaderTests.swift | 53 ++++++ macos/AstroshotsTests/PreferencesTests.swift | 71 +++++++ .../ReviewWindowControllerTests.swift | 52 ++++++ .../FrictionLogTrayJourneyUITests.swift | 3 +- .../AstroshotsUITests/ReviewFlowUITests.swift | 24 +++ scripts/capture-unseen-review-ux.sh | 173 ++++++++++++++++++ 16 files changed, 767 insertions(+), 57 deletions(-) create mode 100644 macos/AstroshotsTests/ReviewWindowControllerTests.swift create mode 100755 scripts/capture-unseen-review-ux.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index c486248..9716b91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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). diff --git a/macos/Astroshots.xcodeproj/project.pbxproj b/macos/Astroshots.xcodeproj/project.pbxproj index 4105e40..89b5786 100644 --- a/macos/Astroshots.xcodeproj/project.pbxproj +++ b/macos/Astroshots.xcodeproj/project.pbxproj @@ -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 */; }; @@ -145,6 +146,7 @@ DFDC12CB9342C5E685FD8A25 /* DetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailView.swift; sourceTree = ""; }; E835F1DA4729DEB7570B9FBC /* FrictionLogLoaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FrictionLogLoaderTests.swift; sourceTree = ""; }; E9F84D4D30E15FCC4EECC1C2 /* NarrationJobQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NarrationJobQueue.swift; sourceTree = ""; }; + EE5C4C01CF0926A78558F5F1 /* ReviewWindowControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewWindowControllerTests.swift; sourceTree = ""; }; F217601C57C50DA926C31AF2 /* astroshots-app-icon-master.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "astroshots-app-icon-master.png"; sourceTree = ""; }; F9EA16401CCC820E6AD2BC9A /* ReviewStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewStoreTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -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 */, @@ -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 */, diff --git a/macos/Astroshots/App/AppState.swift b/macos/Astroshots/App/AppState.swift index a23d3c4..44193fd 100644 --- a/macos/Astroshots/App/AppState.swift +++ b/macos/Astroshots/App/AppState.swift @@ -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. @@ -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, @@ -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 diff --git a/macos/Astroshots/App/StatusItemController.swift b/macos/Astroshots/App/StatusItemController.swift index 449d915..b02f2d0 100644 --- a/macos/Astroshots/App/StatusItemController.swift +++ b/macos/Astroshots/App/StatusItemController.swift @@ -19,6 +19,15 @@ final class StatusItemController: NSObject { private var observationTask: Task? /// 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 @@ -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) @@ -132,6 +146,7 @@ final class StatusItemController: NSObject { observationTask = nil closePopover() closePinnedWindow() + reviewWindowController?.onClosed = nil reviewWindowController?.close() reviewWindowController = nil appState.onReviewRequested = nil @@ -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) } @@ -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() { diff --git a/macos/Astroshots/Features/Review/ReviewWindowController.swift b/macos/Astroshots/Features/Review/ReviewWindowController.swift index d5a39b2..3790184 100644 --- a/macos/Astroshots/Features/Review/ReviewWindowController.swift +++ b/macos/Astroshots/Features/Review/ReviewWindowController.swift @@ -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 @@ -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(_ root: Content) { diff --git a/macos/Astroshots/Features/Tray/FrictionLogDetailView.swift b/macos/Astroshots/Features/Tray/FrictionLogDetailView.swift index 1c44803..e69db5f 100644 --- a/macos/Astroshots/Features/Tray/FrictionLogDetailView.swift +++ b/macos/Astroshots/Features/Tray/FrictionLogDetailView.swift @@ -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: { diff --git a/macos/Astroshots/Features/Tray/FrictionLogListView.swift b/macos/Astroshots/Features/Tray/FrictionLogListView.swift index 365673a..aaccded 100644 --- a/macos/Astroshots/Features/Tray/FrictionLogListView.swift +++ b/macos/Astroshots/Features/Tray/FrictionLogListView.swift @@ -8,29 +8,110 @@ struct FrictionLogListView: View { if appState.isFrictionLogsEmpty { EmptyFrictionLogsView() } else { - ScrollView { - LazyVStack(spacing: 8) { - ForEach(appState.frictionLogs) { log in - FrictionLogRow( - log: log, - action: { appState.selectFrictionLog(log) }, - hideAction: { appState.hideFrictionLog(log) } - ) + VStack(spacing: 0) { + HStack(spacing: 8) { + Text( + appState.frictionLogFilter == .toReview + ? "Unseen (\(pendingCount))" + : "History (\(seenCount))" + ) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(Theme.ink2) + + Spacer() + + if appState.frictionLogFilter == .toReview { + SeenAllButton( + count: pendingCount, + isWorking: appState.isMarkingSeen, + accessibilityIdentifier: "friction.seen.all" + ) { + Task { + await appState.markAllFrictionLogsSeen(appState.frictionLogs) + } + } + } + + StreamFilterChip( + title: appState.frictionLogFilter == .history ? "Unseen" : "History", + systemImage: appState.frictionLogFilter == .history + ? "eye" + : "clock.arrow.circlepath", + isActive: appState.frictionLogFilter == .history, + unseenHelp: "Show seen friction-log history", + seenHelp: "Return to unseen friction logs", + accessibilityIdentifier: "friction.history" + ) { + appState.frictionLogFilter = + appState.frictionLogFilter == .history ? .toReview : .history } } - .padding(.horizontal, 10) - .padding(.top, 10) - .padding(.bottom, 8) + .padding(.horizontal, 12) + .padding(.vertical, 9) + + if displayedLogs.isEmpty { + if appState.frictionLogFilter == .history { + EmptyHistoryView( + detail: "Logs you mark Seen will appear here." + ) { + appState.frictionLogFilter = .toReview + } + } else { + ReviewedStreamView( + detail: "Every current friction log has been seen.", + accessibilityIdentifier: "friction.seen.empty" + ) { + appState.frictionLogFilter = .history + } + } + } else { + ScrollView { + LazyVStack(spacing: 8) { + ForEach(displayedLogs) { log in + FrictionLogRow( + log: log, + showsSeenAction: appState.frictionLogFilter == .toReview, + action: { appState.selectFrictionLog(log) }, + seenAction: { + Task { + try? await appState.markFrictionLogSeen(log) + } + } + ) + } + } + .padding(.horizontal, 10) + .padding(.bottom, 8) + } + .scrollIndicators(.hidden) + } } - .scrollIndicators(.hidden) } } + + private var displayedLogs: [FrictionLog] { + switch appState.frictionLogFilter { + case .toReview: + return appState.frictionLogs.filter { $0.reviewState != .seen } + case .history: + return appState.frictionLogs.filter { $0.reviewState == .seen } + } + } + + private var pendingCount: Int { + appState.frictionLogs.filter { $0.reviewState != .seen }.count + } + + private var seenCount: Int { + appState.frictionLogs.filter { $0.reviewState == .seen }.count + } } private struct FrictionLogRow: View { let log: FrictionLog + var showsSeenAction = true let action: () -> Void - let hideAction: () -> Void + let seenAction: () -> Void var body: some View { HStack(spacing: 0) { @@ -96,18 +177,21 @@ private struct FrictionLogRow: View { .buttonStyle(.plain) .accessibilityIdentifier("friction.row.\(log.slug)") - Button(action: hideAction) { - Image(systemName: "eye.slash") - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(Theme.muted) - .frame(width: 30, height: 30) - .contentShape(Rectangle()) + if showsSeenAction, log.reviewState != .seen, log.latestRun != nil { + Button(action: seenAction) { + Image(systemName: "checkmark.circle") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(Theme.purple) + .frame(width: 30, height: 30) + .background(Theme.purpleSoft, in: RoundedRectangle(cornerRadius: 6, style: .continuous)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help("Mark \(log.title) seen") + .accessibilityLabel("Mark \(log.title) seen") + .accessibilityIdentifier("friction.seen.\(log.slug)") + .padding(.trailing, 6) } - .buttonStyle(.plain) - .help("Hide friction log") - .accessibilityLabel("Hide \(log.title)") - .accessibilityIdentifier("friction.hide.\(log.slug)") - .padding(.trailing, 4) } .background( RoundedRectangle(cornerRadius: 12, style: .continuous) diff --git a/macos/Astroshots/Features/Tray/StreamView.swift b/macos/Astroshots/Features/Tray/StreamView.swift index 37bf10b..25e3d07 100644 --- a/macos/Astroshots/Features/Tray/StreamView.swift +++ b/macos/Astroshots/Features/Tray/StreamView.swift @@ -54,7 +54,8 @@ struct StreamView: View { systemImage: filter == .history ? "eye" : "clock.arrow.circlepath", - isActive: filter == .history + isActive: filter == .history, + accessibilityIdentifier: "stream.history" ) { filter = filter == .history ? .toReview : .history } @@ -200,7 +201,7 @@ enum StreamGrouping { } } -private struct SeenAllButton: View { +struct SeenAllButton: View { let count: Int let isWorking: Bool let accessibilityIdentifier: String @@ -233,10 +234,13 @@ private struct SeenAllButton: View { } } -private struct StreamFilterChip: View { +struct StreamFilterChip: View { let title: String let systemImage: String let isActive: Bool + var unseenHelp = "Show seen frame history" + var seenHelp = "Return to unseen frames" + var accessibilityIdentifier = "stream.history" let action: () -> Void var body: some View { @@ -257,12 +261,10 @@ private struct StreamFilterChip: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - .help(isActive ? "Return to unseen frames" : "Show seen frame history") - .accessibilityLabel( - isActive ? "Return to unseen frames" : "Show seen frame history" - ) + .help(isActive ? seenHelp : unseenHelp) + .accessibilityLabel(isActive ? seenHelp : unseenHelp) .accessibilityAddTraits(isActive ? .isSelected : []) - .accessibilityIdentifier("stream.history") + .accessibilityIdentifier(accessibilityIdentifier) } } @@ -486,7 +488,7 @@ struct ShotRow: View { } } -private enum StreamFilter: Hashable { +enum StreamFilter: Hashable { case toReview case history } @@ -510,7 +512,9 @@ struct MovieKindBadge: View { } } -private struct ReviewedStreamView: View { +struct ReviewedStreamView: View { + var detail = "Every current frame has been seen." + var accessibilityIdentifier = "stream.seen.empty" let showAll: () -> Void var body: some View { @@ -522,7 +526,7 @@ private struct ReviewedStreamView: View { Text("You’re all caught up") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(Theme.ink) - Text("Every current frame has been seen.") + Text(detail) .font(.system(size: 11)) .foregroundStyle(Theme.muted) Button("View history", action: showAll) @@ -533,11 +537,12 @@ private struct ReviewedStreamView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .accessibilityElement(children: .contain) - .accessibilityIdentifier("stream.seen.empty") + .accessibilityIdentifier(accessibilityIdentifier) } } -private struct EmptyHistoryView: View { +struct EmptyHistoryView: View { + var detail = "Frames you mark Seen will appear here." let showUnseen: () -> Void var body: some View { @@ -549,7 +554,7 @@ private struct EmptyHistoryView: View { Text("No history yet") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(Theme.ink) - Text("Frames you mark Seen will appear here.") + Text(detail) .font(.system(size: 11)) .foregroundStyle(Theme.muted) Button("Back to unseen", action: showUnseen) diff --git a/macos/Astroshots/Features/Tray/TrayView.swift b/macos/Astroshots/Features/Tray/TrayView.swift index 6b96be5..fee6887 100644 --- a/macos/Astroshots/Features/Tray/TrayView.swift +++ b/macos/Astroshots/Features/Tray/TrayView.swift @@ -56,17 +56,27 @@ struct TrayView: View { Button { appState.selectTab(tab) } label: { - Text(tab.label) - .font(.system(size: 10, weight: .semibold)) - .frame(width: 99) - .padding(.vertical, 6) - .foregroundStyle(appState.activeTab == tab ? Theme.ink : Theme.muted) - .background( - appState.activeTab == tab ? Theme.elevated : Color.clear, - in: RoundedRectangle(cornerRadius: 7, style: .continuous) - ) - .frame(maxWidth: .infinity) - .contentShape(Rectangle()) + HStack(spacing: 5) { + Text(tab.label) + .font(.system(size: 10, weight: .semibold)) + if appState.unseenCount(for: tab) > 0 { + Text("\(appState.unseenCount(for: tab))") + .font(.system(size: 8.5, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + .padding(.horizontal, 5) + .frame(minWidth: 16, minHeight: 16) + .background(Theme.amber, in: Capsule()) + .accessibilityHidden(true) + } + } + .padding(.vertical, 6) + .frame(maxWidth: .infinity) + .foregroundStyle(appState.activeTab == tab ? Theme.ink : Theme.muted) + .background( + appState.activeTab == tab ? Theme.elevated : Color.clear, + in: RoundedRectangle(cornerRadius: 7, style: .continuous) + ) + .contentShape(Rectangle()) } .buttonStyle(.plain) .keyboardShortcut( @@ -74,6 +84,7 @@ struct TrayView: View { modifiers: .command ) .accessibilityIdentifier("tray.tab.\(tab.rawValue)") + .accessibilityLabel(tabAccessibilityLabel(tab)) .accessibilityAddTraits(appState.activeTab == tab ? .isSelected : []) } } @@ -228,6 +239,14 @@ struct TrayView: View { .help(title) } + private func tabAccessibilityLabel(_ tab: TrayTab) -> String { + let unseen = appState.unseenCount(for: tab) + guard unseen > 0 else { return tab.label } + return unseen == 1 + ? "\(tab.label), 1 unseen" + : "\(tab.label), \(unseen) unseen" + } + private func showCurrentCaptureInFinder() { let currentPath = appState.selectedFrictionStep?.primaryScreenshotPath ?? appState.selectedShot?.path diff --git a/macos/Astroshots/Models/FrictionLog.swift b/macos/Astroshots/Models/FrictionLog.swift index a83ef00..c954ba8 100644 --- a/macos/Astroshots/Models/FrictionLog.swift +++ b/macos/Astroshots/Models/FrictionLog.swift @@ -83,6 +83,11 @@ struct FrictionLog: Identifiable, Hashable, Sendable { var latestRun: FrictionLogRun? { runs.first } + /// Latest run's review, matching how the stream treats the current capture. + var reviewState: ReviewState { + latestRun?.reviewState ?? .pending + } + var runCount: Int { runs.count } var stepCount: Int { latestRun?.steps.count ?? 0 } @@ -94,6 +99,21 @@ struct FrictionLog: Identifiable, Hashable, Sendable { var goodCount: Int { latestRun?.steps.reduce(0) { $0 + $1.good.count } ?? 0 } + + func replacingRuns(_ runs: [FrictionLogRun]) -> FrictionLog { + FrictionLog( + worktree: worktree, + worktreePath: worktreePath, + slug: slug, + title: title, + description: description, + promptPath: promptPath, + promptMarkdown: promptMarkdown, + status: status, + runs: runs, + updatedAt: updatedAt + ) + } } /// One execution of a friction log (JSONL + screenshots). @@ -105,6 +125,12 @@ struct FrictionLogRun: Identifiable, Hashable, Sendable { let steps: [FrictionLogStep] let capturedAt: Date let status: FrictionLogStatus? + /// Human review of this run (`review.json` next to `log.jsonl`). + var review: ReviewSnapshot? + + var reviewState: ReviewState { + review?.state ?? .pending + } /// Human label for chips/list — prefers a short clock from skill-style /// `YYYYMMDDTHHMMSSZ` ids, else a trimmed raw id. @@ -440,13 +466,24 @@ enum FrictionLogLoader { let metaURL = directory.appendingPathComponent(FrictionLogPath.metaFileName) let runMeta = loadMeta(from: metaURL) let status = FrictionLogStatus(raw: runMeta?.status) + let review: ReviewSnapshot? + if FileManager.default.fileExists(atPath: logURL.path) { + review = try? ReviewStore.readReview( + forImage: logURL, + featureDirectory: directory, + expectedRunID: runID + ) + } else { + review = nil + } return FrictionLogRun( runID: runID, directoryPath: directory.standardizedFileURL.path, steps: steps, capturedAt: mtime, - status: status + status: status, + review: review ) } diff --git a/macos/AstroshotsTests/FrictionLogLoaderTests.swift b/macos/AstroshotsTests/FrictionLogLoaderTests.swift index 286f545..4ee6cf5 100644 --- a/macos/AstroshotsTests/FrictionLogLoaderTests.swift +++ b/macos/AstroshotsTests/FrictionLogLoaderTests.swift @@ -104,6 +104,59 @@ struct FrictionLogLoaderTests { #expect(FrictionLogLoader.loadRun(directory: worktree) == nil) #expect(log.stepCount == 1) #expect(log.goodCount == 1) + #expect(log.reviewState == .pending) + } + + @Test func runReviewSidecarMarksTheLatestRunSeen() async throws { + let worktree = FileManager.default.temporaryDirectory + .appendingPathComponent("friction-review-\(UUID().uuidString)", isDirectory: true) + let slugDir = worktree + .appendingPathComponent(".astroshot/friction-logs/checkout", isDirectory: true) + let first = slugDir.appendingPathComponent("runs/20260807T120000Z", isDirectory: true) + try FileManager.default.createDirectory(at: first, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: worktree) } + + try "# prompt".write( + to: slugDir.appendingPathComponent("prompt.md"), + atomically: true, + encoding: .utf8 + ) + try """ + {"step":1,"id":"home","title":"Home","description":"Landed","screenshots":[],"good":[],"improve":[]} + """.write( + to: first.appendingPathComponent("log.jsonl"), + atomically: true, + encoding: .utf8 + ) + + let store = ReviewStore() + _ = try await store.markSeen( + forImage: first.appendingPathComponent("log.jsonl"), + featureDirectory: first, + runID: "20260807T120000Z" + ) + + let astroshot = worktree.appendingPathComponent(".astroshot", isDirectory: true) + let seenLogs = FrictionLogLoader.loadLogs(inAstroshot: astroshot) + #expect(seenLogs.first?.reviewState == .seen) + + let second = slugDir.appendingPathComponent("runs/20260807T130000Z", isDirectory: true) + try FileManager.default.createDirectory(at: second, withIntermediateDirectories: true) + try """ + {"step":1,"id":"home","title":"Home","description":"Again","screenshots":[],"good":[],"improve":[]} + """.write( + to: second.appendingPathComponent("log.jsonl"), + atomically: true, + encoding: .utf8 + ) + try FileManager.default.setAttributes( + [.modificationDate: Date().addingTimeInterval(60)], + ofItemAtPath: second.path + ) + + let nextLogs = FrictionLogLoader.loadLogs(inAstroshot: astroshot) + #expect(nextLogs.first?.latestRun?.runID == "20260807T130000Z") + #expect(nextLogs.first?.reviewState == .pending) } @Test func humanizesSkillStyleRunIDs() { diff --git a/macos/AstroshotsTests/PreferencesTests.swift b/macos/AstroshotsTests/PreferencesTests.swift index 4aae433..159bb75 100644 --- a/macos/AstroshotsTests/PreferencesTests.swift +++ b/macos/AstroshotsTests/PreferencesTests.swift @@ -94,6 +94,77 @@ struct PreferencesTests { #expect(relaunched.hiddenFrictionLogIDs.isEmpty) } + @Test @MainActor + func unseenCountsBadgeShotsAndFrictionLogs() async throws { + let suiteName = "astroshots-unseen-badge-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("astroshots-unseen-badge-\(UUID().uuidString)") + let runDir = root.appendingPathComponent("run", isDirectory: true) + try FileManager.default.createDirectory(at: runDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + try """ + {"step":1,"id":"home","title":"Home","description":"Landed","screenshots":[],"good":[],"improve":[]} + """.write( + to: runDir.appendingPathComponent("log.jsonl"), + atomically: true, + encoding: .utf8 + ) + let run = FrictionLogRun( + runID: "20260813T120000Z", + directoryPath: runDir.path, + steps: [ + FrictionLogStep( + step: 1, + stepID: "home", + title: "Home", + description: "Landed", + transcript: "", + screenshotPaths: [], + good: [], + improve: [], + url: nil, + capturedAt: nil + ), + ], + capturedAt: Date(), + status: .complete + ) + let log = FrictionLog( + worktree: "demo", + worktreePath: root.path, + slug: "checkout", + title: "Checkout", + description: "", + promptPath: nil, + promptMarkdown: nil, + status: .complete, + runs: [run], + updatedAt: Date() + ) + let state = AppState( + preferences: Preferences(defaults: defaults), + watcher: makeWatcher(root: root), + automaticallyStartsWatching: false + ) + state.replaceFrictionLogs([log]) + + #expect(state.unseenFrictionLogCount == 1) + #expect(state.unseenCount(for: .frictionLogs) == 1) + #expect(state.unseenShotCount == 0) + + try await state.markFrictionLogSeen(log) + #expect(state.unseenFrictionLogCount == 0) + #expect(state.frictionLogs.first?.reviewState == .seen) + #expect( + FileManager.default.fileExists( + atPath: runDir.appendingPathComponent("review.json").path + ) + ) + } + @Test @MainActor func freshInstallNeedsFirstRunStartup() { let suiteName = "astroshots-preferences-\(UUID().uuidString)" diff --git a/macos/AstroshotsTests/ReviewWindowControllerTests.swift b/macos/AstroshotsTests/ReviewWindowControllerTests.swift new file mode 100644 index 0000000..67c136e --- /dev/null +++ b/macos/AstroshotsTests/ReviewWindowControllerTests.swift @@ -0,0 +1,52 @@ +import Foundation +import Testing +@testable import Astroshots + +@MainActor +struct ReviewWindowControllerTests { + @Test + func closeNotifiesOnlyAfterTheViewerWasShown() { + let state = AppState( + preferences: isolatedPreferences(), + automaticallyStartsWatching: false + ) + let controller = ReviewWindowController(appState: state) + var closed = 0 + controller.onClosed = { closed += 1 } + + controller.close() + #expect(closed == 0) + + controller.open(makeShot()) + controller.close() + #expect(closed == 1) + + controller.close() + #expect(closed == 1) + } + + private func isolatedPreferences() -> Preferences { + let suiteName = "astroshots-review-window-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.removePersistentDomain(forName: suiteName) + return Preferences(defaults: defaults) + } + + private func makeShot() -> Shot { + Shot( + path: "/tmp/wt/.astroshot/settings/0001.png", + worktree: "wt", + worktreePath: "/tmp/wt", + feature: "settings", + fileName: "0001.png", + sequence: "0001", + slug: "0001", + title: "0001", + description: "", + url: nil, + runID: "run-1", + status: .pass, + capturedAt: Date() + ) + } +} diff --git a/macos/AstroshotsUITests/FrictionLogTrayJourneyUITests.swift b/macos/AstroshotsUITests/FrictionLogTrayJourneyUITests.swift index 90b6b91..f17742c 100644 --- a/macos/AstroshotsUITests/FrictionLogTrayJourneyUITests.swift +++ b/macos/AstroshotsUITests/FrictionLogTrayJourneyUITests.swift @@ -139,7 +139,8 @@ final class FrictionLogTrayJourneyUITests: XCTestCase { let row = app.buttons["friction.row.sample-onboarding"] XCTAssertTrue(row.waitForExistence(timeout: 5)) - let hide = app.buttons["friction.hide.sample-onboarding"] + row.click() + let hide = app.buttons["friction.detail.hide"] XCTAssertTrue(hide.waitForExistence(timeout: 3)) hide.click() diff --git a/macos/AstroshotsUITests/ReviewFlowUITests.swift b/macos/AstroshotsUITests/ReviewFlowUITests.swift index 495104f..80c5034 100644 --- a/macos/AstroshotsUITests/ReviewFlowUITests.swift +++ b/macos/AstroshotsUITests/ReviewFlowUITests.swift @@ -49,6 +49,30 @@ final class ReviewFlowUITests: XCTestCase { app.buttons["review.close"].click() XCTAssertFalse(takeover.waitForExistence(timeout: 1)) + // Closing the large viewer must leave the tray/drawer on screen. + XCTAssertTrue(thumbnail.waitForExistence(timeout: 3)) + XCTAssertTrue(app.buttons["tray.tab.shots"].exists) + } + + @MainActor + func testClosingReviewRestoresMenuBarPopover() throws { + terminateRunningAstroshots() + let app = XCUIApplication() + app.launchEnvironment["ASTROSHOTS_UI_TEST_TRAY_PATH"] = imageURL.path + app.launchEnvironment["ASTROSHOTS_UI_TEST_POPOVER"] = "1" + app.launch() + + let thumbnail = app.buttons["stream.review.0001-settings.png"] + XCTAssertTrue(thumbnail.waitForExistence(timeout: 8)) + thumbnail.click() + + let takeover = app.descendants(matching: .any)["review.takeover"] + XCTAssertTrue(takeover.waitForExistence(timeout: 5)) + app.buttons["review.close"].click() + XCTAssertFalse(takeover.waitForExistence(timeout: 1)) + + XCTAssertTrue(thumbnail.waitForExistence(timeout: 3)) + XCTAssertTrue(app.buttons["tray.tab.shots"].waitForExistence(timeout: 3)) } @MainActor diff --git a/scripts/capture-unseen-review-ux.sh b/scripts/capture-unseen-review-ux.sh new file mode 100755 index 0000000..693847e --- /dev/null +++ b/scripts/capture-unseen-review-ux.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +# Capture Shots/Friction Logs unseen badges and Seen/History into a temp dir, +# then stream them into .astroshot/unseen-review-ux for tray review. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +DERIVED_DATA="${DERIVED_DATA:-$REPO_ROOT/macos/build/DerivedData-hide-friction}" +FIXTURE_ROOT="$(mktemp -d /tmp/astroshots-unseen-ux.XXXXXX)" +CAPTURE_DIR="$FIXTURE_ROOT/captures" +PRODUCT="$FIXTURE_ROOT/demo-app" +FEATURE="$PRODUCT/.astroshot/install-wizard" +LOG_A="$PRODUCT/.astroshot/friction-logs/checkout-as-new-user" +LOG_B="$PRODUCT/.astroshot/friction-logs/invite-teammate" +RUN_ID="ux-$(date -u +%Y%m%dT%H%M%SZ)" + +cleanup() { + case "$FIXTURE_ROOT" in + /tmp/astroshots-unseen-ux.*) rm -rf "$FIXTURE_ROOT" ;; + esac +} +trap cleanup EXIT + +wait_for_file() { + local file="$1" + local attempts=0 + while [[ ! -s "$file" && "$attempts" -lt 160 ]]; do + sleep 0.25 + attempts=$((attempts + 1)) + done + [[ -s "$file" ]] || { + echo "timed out waiting for $file" >&2 + return 1 + } +} + +write_png() { + python3 - "$1" "$2" <<'PY' +import struct, sys, zlib +path, label = sys.argv[1], sys.argv[2] +w, h = 960, 540 +row = b"\x00" + bytes([40, 44, 72]) * w +raw = row * h +def chunk(tag, data): + return struct.pack(">I", len(data)) + tag + data + struct.pack(">I", zlib.crc32(tag + data) & 0xffffffff) +png = b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0)) +png += chunk(b"IDAT", zlib.compress(raw, 9)) +png += chunk(b"IEND", b"") +open(path, "wb").write(png) +PY +} + +mkdir -p "$CAPTURE_DIR" "$FEATURE" \ + "$LOG_A/runs/20260813T150000Z" \ + "$LOG_B/runs/20260813T151000Z" + +write_png "$FEATURE/0001-welcome.png" welcome +write_png "$FEATURE/0002-configure.png" configure +cat >"$FEATURE/manifest.json" <<'EOF' +{ + "version": 1, + "feature": "install-wizard", + "run_id": "fixture-run-1", + "status": "pass", + "shots": [ + {"id":"0001","file":"0001-welcome.png","slug":"welcome","title":"Welcome","description":"Marketing welcome step.","captured_at":"2026-08-13T15:00:00Z"}, + {"id":"0002","file":"0002-configure.png","slug":"configure","title":"Configure project","description":"Org and project fields.","captured_at":"2026-08-13T15:01:00Z"} + ] +} +EOF + +cat >"$LOG_A/prompt.md" <<'EOF' +# Checkout as a new user + +## Goal +Complete checkout with a clean session. + +## Persona +First-time buyer on the marketing site. +EOF +cat >"$LOG_A/meta.json" <<'EOF' +{"version":1,"slug":"checkout-as-new-user","title":"Checkout as a new user","description":"Fresh session from pricing to receipt","status":"complete"} +EOF +write_png "$LOG_A/runs/20260813T150000Z/0001-plan.png" plan +cat >"$LOG_A/runs/20260813T150000Z/log.jsonl" <<'EOF' +{"step":1,"id":"plan","title":"Choose a plan","description":"Compared plans.","transcript":"I compare the plans.","screenshots":["0001-plan.png"],"good":["Easy to scan"],"improve":["Annual savings unclear"],"url":"/pricing","captured_at":"2026-08-13T15:00:00Z"} +EOF + +cat >"$LOG_B/prompt.md" <<'EOF' +# Invite a teammate + +## Goal +Send the first invite from an empty workspace. + +## Persona +New admin on day one. +EOF +cat >"$LOG_B/meta.json" <<'EOF' +{"version":1,"slug":"invite-teammate","title":"Invite a teammate","description":"Empty workspace to first invite","status":"complete"} +EOF +write_png "$LOG_B/runs/20260813T151000Z/0001-invite.png" invite +cat >"$LOG_B/runs/20260813T151000Z/log.jsonl" <<'EOF' +{"step":1,"id":"invite","title":"Open invite","description":"Opened the invite dialog.","transcript":"I open invite.","screenshots":["0001-invite.png"],"good":["Dialog is clear"],"improve":["Role picker is hidden"],"url":"/team","captured_at":"2026-08-13T15:10:00Z"} +EOF + +source "$REPO_ROOT/macos/scripts/xcode-env.sh" +ensure_mlx_build_prereqs +( + cd "$REPO_ROOT/macos" + xcodebuild \ + -project Astroshots.xcodeproj \ + -scheme Astroshots \ + -configuration Debug \ + -destination 'platform=macOS' \ + -derivedDataPath "$DERIVED_DATA" \ + "${ASTROSHOTS_XCODEBUILD_FLAGS[@]}" \ + build +) + +APP_BINARY="$DERIVED_DATA/Build/Products/Debug/Astroshots.app/Contents/MacOS/Astroshots" +[[ -x "$APP_BINARY" ]] || { + echo "missing $APP_BINARY" >&2 + exit 1 +} + +# Don't fight a running menu-bar instance if we can avoid it; this launch is +# a fixture-only Debug process that terminates itself. +ASTROSHOTS_UI_TEST_TRAY_ROOT="$FIXTURE_ROOT" \ +ASTROSHOTS_FRICTION_CAPTURE_DIR="$CAPTURE_DIR" \ +ASTROSHOTS_UI_TEST_NARRATION_READY=1 \ + "$APP_BINARY" +wait_for_file "$CAPTURE_DIR/CAPTURE_OK" + +CAPTURE="" +for candidate in \ + "$REPO_ROOT/bin/astroshot-capture" \ + "$HOME/.grok/skills/astroshots-review/scripts/astroshot-capture" \ + "$HOME/.agents/skills/astroshots-review/scripts/astroshot-capture"; do + if [[ -x "$candidate" ]]; then + CAPTURE="$candidate" + break + fi +done +if [[ ! -x "$CAPTURE" ]]; then + echo "astroshot-capture not found; leaving raw captures in $CAPTURE_DIR" >&2 + ls -la "$CAPTURE_DIR" + trap - EXIT + echo "FIXTURE_ROOT=$FIXTURE_ROOT" + exit 0 +fi + +cd "$REPO_ROOT" +for spec in \ + "0001-open-tray-stream.png|shots-tab-badge|Shots tab with unseen badge|The Shots tab shows an unseen count and the Unseen stream header." \ + "0004-friction-logs-list.png|friction-unseen|Friction Logs unseen|Unseen friction logs with mark-seen controls and a History chip." \ + "0004b-friction-logs-unseen.png|friction-unseen-remaining|Friction Logs after one Seen|The unseen list after marking one log seen." \ + "0004c-friction-logs-history.png|friction-history|Friction Logs history|History shows the log that was marked Seen."; do + IFS='|' read -r file slug title desc <<<"$spec" + src="$CAPTURE_DIR/$file" + [[ -s "$src" ]] || { + echo "missing capture $file" >&2 + continue + } + "$CAPTURE" --feature unseen-review-ux \ + --slug "$slug" \ + --title "$title" \ + --description "$desc" \ + --run-id "$RUN_ID" \ + --source "$src" +done + +"$CAPTURE" --feature unseen-review-ux --status pass --finalize --run-id "$RUN_ID" +echo "Streamed captures under .astroshot/unseen-review-ux (run $RUN_ID)" +ls -la "$REPO_ROOT/.astroshot/unseen-review-ux"