Storyboard for adding an incrementally-processed video / audio / transcript to an already-analysed project. Yesterday: 2 interviews analysed. Today: user tagged, starred, and edited themes. Tomorrow: a 3rd interview arrives.
Authored, not simulated. Icons are indicative; layout is schematic. Copy discipline: [current] exists in codebase · [PROPOSED] needs decision · [?] open question.
tertiaryLabelColor, fill controlAccentColor, clockwise from 12. Determinate for .running(fraction:)/.copying(fraction:); hover swaps to xmark.circle.fill secondary-grey (Finder/App Store idiom, not red).project.icon (auto-assigned from IconPickerPopover.palette, 99 symbols) or the default circle. Dimmed via .secondary when availability ≠ .ready. Icon column 20pt, symbol pointSize 16..imageScale(.small), tint per MessageKind.tint. Error/warning glyphs are clickable buttons that open ProjectDiagnosticPopover.ProjectRow.swift)Anchor for every frame below. Structure, fonts, and copy are lifted verbatim from ProjectRow.body (SwiftUI) + ProjectCellSpec (the AppKit port spec) + ProjectSubtitle.resolve() + SidebarSubtitleText + RunProgressSubtitle.compose().
.body, count .footnote/.tertiary, subtitle .caption/.secondary.HStack(.firstTextBaseline, spacing:6) [
leadingIcon // 20pt · SF Symbol · dimmed if !available
VStack(.leading, sp:1) [
titleLine HStack(sp:6) [
Text(name).body // .secondary if !available
Spacer(minLength:4)
titleRightSlot // scanning spinner OR count(.footnote/.tertiary)
]
subtitleLine HStack(sp:4) [
subtitleContent // prefix glyph? + text (.caption/.secondary)
Spacer(minLength:4)
subtitleRightSlot // 16pt ring/spinner OR icloud
]
]
]
.overlay( // drop-target hairline
RoundedRectangle(cr:8)
.stroke(accent, lw:2)
.opacity(isDropTarget ? 1 : 0)
.padding(.horizontal:-6, .vertical:-3)
)
.placeholder variant. Single-line collapse (32pt) for .placeholder. Native source-list pitch measured 22 Jun. Icon width 20pt, symbolConfig pointSize 16.SubtitleVariant precedence — the resolverProjectSubtitle.resolve(availability, pipelineState, isStopping, copy, lastRunAt, missingCount, unanalysedCount) → SubtitleVariant
Order (settled 18 Jun 2026):
cantFind (availability)
› failed / failedDiagnostic / completedPartial
› running (or .stopping when isStopping)
› queued(position) / stopped / partial(transcribeOnly) / unreachable(reason)
› (idle: copying › missing › unanalysed › ready)
Exhaustive with no default — an unmapped PipelineState is a compile error, not a silent fall-through to "ready".
SidebarSubtitleText.text(for:); drift-check the mockup against it if it ever falls out of sync.SubtitleDelta — the +N surface already existsenum SubtitleDelta { .unanalysed(count:), .missing(count:) }
pickDelta(): missing (data drift) wins over unanalysed (feature gap).
Rendered as a suffix on .ready(date, delta): "Just now · +3 unanalysed". The .unanalysed segment is a Button(.plain) that opens the unanalysed sheet; .missing is plain text.
i18n keys: desktop.chrome.unanalysedSubtitle, desktop.chrome.missingSubtitle — CLDR plural (cs/pl/ru have one/few/many/other).
.unanalysed(count:) delta segment. The design question is what to do at drop time, not how to render "N unanalysed."Two interviews analysed yesterday. Today the user has been renaming themes, starring quotes, editing section titles. No pipeline activity. Resolver returns .ready(date: yesterday, delta: nil), but per user decision [PROPOSED] the resting date is dropped from the row — too much visual noise. Idle rows collapse to single-line (.placeholder).
.ready(date, nil) collapsing to .placeholder (single-line, 32pt). The date returns only when the row genuinely has state to show (running, failed, cantFind, delta). Count = unanalysed?.sessionCount. [?] Do we expose "last analysed …" anywhere else (context menu / detail pane) as a fallback for anyone who wanted the date?User drags show-and-tell-40.mov from Finder onto the project row in the sidebar. Handled by ProjectSidebarOutline.acceptDrop() → handleDropOnProject() → CopyMachinery.copy().
isDropTarget = trueRoundedRectangle.stroke(.accentColor, lineWidth:2), padded -6/-3 to trace the selection pill (ProjectRow.swift:141-147). Rows are single-line at rest (per §1). No subtitle change on hover — the stroke is the affordance..copying(fraction:)desktop.chrome.pipeline.copying "Copying · N%" (ProjectRow.swift:304-309). Ring shows byte fraction (ProjectRowActivityIndicator.Kind.copying). Hover swaps ring for grey xmark.circle.fill = cancel. Count stays "2" — session isn't imported yet..scanning · pre-run.scanning in PipelineState. Title-right spinner after 250ms (ProjectRow.swift:194-195, scheduleScanIndicator) — the delay prevents flash for fast scans. The subtitle stays on the resting .ready text. The manifest walk that decides cached-vs-fresh happens here..running (with stage) · see §5 for ladderRunProgressSubtitle.compose() — stage verb desktop.chrome.pipeline.stage.transcribe + desktop.chrome.pipeline.sessionsCount + desktop.chrome.pipeline.etaUnderMinute, joined by " · ". Count remains "2" until the new session lands in the manifest (§8).User with the project selected picks File → Add Files… [PROPOSED] — new menu item.
NSOpenPanelTitle (semibold): Add sessions to project IKEA with uxfriends
Multi-select: yes [decided]
File-type filter: audio · video · transcript (.txt/.docx/.srt/.vtt) [decided]
Prompt (accept button): Add [decided]
desktop.chrome.addFilesTitle — placeholder {project}..scanning — the existing pre-run stateCorrection: this is not a state to invent. PipelineState.scanning exists, and titleRightSlot already shows a small ProgressView after a 250ms debounce (ProjectRow.swift:189-195). The subtitle stays on the resting .ready text — resolver §5 has .scanning falling through to the idle chain. The question isn't "what to invent" but "what to do differently for incremental."
.scanning · current behaviourdesktop.chrome.pipeline.scanning = "Scanning…" or "Checking…" — pick one..running/.copying shape, so the row visually "goes to work" the moment scanning starts. Requires promoting .scanning to a subtitleRightSlot activity kind (currently .none per ProjectRowActivityIndicator.Kind.from). [?] Visual continuity vs redundancy with the title-right spinner.SubtitleVariant tableEvery subtitle state the resolver can return, with the exact i18n key and the rendered string. Source: SidebarSubtitleText.text(for:) + RunProgressSubtitle.compose() + desktop.chrome.pipeline.*.
| Variant | i18n key / source | Rendered |
.ready(date, nil) | formatBareDate(date) | Just now / Today / Yesterday / 2 Jul / Jun 2025 |
.ready(date, .unanalysed(3)) | + desktop.chrome.unanalysedSubtitle | Yesterday · +3 unanalysed (clickable) |
.ready(date, .missing(1)) | + desktop.chrome.missingSubtitle | Yesterday · 1 missing |
.deltaOnly(.unanalysed(3)) | desktop.chrome.unanalysedSubtitle | +3 unanalysed (no date anchor) |
.copying(0.42) | desktop.chrome.pipeline.copying | Copying · 42% |
.copyCancelling | desktop.chrome.copyCancelling | Cancelling… |
.running (no signal) | desktop.chrome.pipeline.analysing | Analysing… |
.running (attached orphan) | desktop.chrome.pipeline.resuming | Resuming… |
.running (stage, N/M, ETA) | stage.transcribe · sessionsCount · etaMinutes | Transcribing · 7 of 8 · ~1 min left |
.running (stage, sub-minute) | … + etaUnderMinute | Identifying speakers · 2 of 3 · <1 min left |
.running (ETA only) | analysing · etaMinutes | Analysing · ~2 min left |
.stopping | desktop.chrome.pipeline.stopping | Stopping… |
.queued(position: 2) | desktop.chrome.pipeline.queuedPosition | Queued · position 2 |
.stopped | desktop.chrome.pipeline.stopped | Stopped |
.partial(transcribeOnly:true) | desktop.chrome.pipeline.transcribed | Transcribed |
.partial(transcribeOnly:false) | desktop.chrome.pipeline.partialRun | Partial run |
.unreachable(reason) | Python-supplied verbatim | <reason> |
.failed(summary) | Python-supplied verbatim (✕ prefix opens popover) | <summary> |
.failedDiagnostic | desktop.pipeline.diagnostic.header.failed | Run failed |
.completedPartial | desktop.pipeline.diagnostic.header.completed_partial | Partial completion |
.cantFind(.unmountedVolume) | desktop.availability.unmountedVolume | Samsung T7 · unmounted |
.cantFind(.networkUnreachable) | desktop.availability.networkUnreachable | server.local · unreachable |
.cantFind(.moved) | desktop.availability.missing | Can't find this project |
.unanalysed(count:) delta is the load-bearing finding — the "N new files, pending" surface already exists.| Variant | Proposed key | Rendered |
.scanning (with pending) | desktop.chrome.pipeline.scanning | Checking what's new… |
.running (re-cluster hint) | desktop.chrome.pipeline.stage.cluster | Grouping quotes · ~1 min left |
.partial (new session failed) | desktop.chrome.pipeline.newSessionFailed | 1 session failed |
.partial (added zero quotes) | desktop.chrome.pipeline.noQuotesAdded | No quotes added |
Locked decision: existing report stays live during incremental. User keeps reading, tagging, editing. Question: does the report get any signalling that new data is arriving, or does it stay silent until settle?
Settled 4 Jul 2026: auto-run on drop, no consent surface. Matches today's drop-then-auto-run policy for folder-shaped projects (ContentView.swift:1285). The drop gesture IS the consent — the user chose which project, which files. Pending-state (variant B) and modal (variant C) both rejected as friction the user doesn't need.
Analysis finished. Report has quietly updated in place. Sidebar returns to idle. Count-delta signalling is phase 2 — this frame is what phase 1 ships.
.new(count: 1) [PROPOSED]SubtitleDelta.new(count:) variant [PROPOSED]; siblings .unanalysed/.missing. CLDR plural key desktop.chrome.newSubtitle. Row temporarily two-line while the delta is active.Settled 4 Jul 2026. No banner, no modal, no reload button. A single idle flag gates when the report reflow applies. Sidebar updates freely — it's objective run state (ring, count, subtitle). Report reflow waits for the user to be idle. Both things can be true.
Idle when: no focused editable input AND no keystroke/mousedown/scroll in the report area for 8s.
Active-override: a focused text input is always active, even without keystrokes (catches "user typing slowly / mid-thought").
Absolute cap: 2–3 min. After that we apply the reflow anyway (guards abandoned focus).
Scope: app-wide (single-window today; upgrade to per-window when multi-window ships).
Sidebar is not gated. Ring, count, subtitle update in real time regardless. The lock is only on the report reflow surface.
| Signal | Effect on flag |
| Keystroke in editable field | Reset timer AND mark active |
| Focused text input (no keystrokes) | Active-override (never idle) |
| Mousedown / mouseup in report area | Reset timer |
| Wheel / scroll in report container | Reset timer |
Drag session (dirty NSDragOperation) | Active-override |
| Sidebar click, toolbar click, menu bar | No effect (not report activity) |
| Mousemove alone | No effect (too twitchy) |
| App loses key window / focus | No effect (backgrounded ≠ editing) |
| Project switch away | Apply held reflow to background project immediately |
| Absolute cap reached (2–3 min) | Apply reflow regardless |
File never made it into the folder. Pipeline never started.
[current] CopyMachinery raises an .alert sheet on the project row's window before the copy starts, if free space is insufficient. Never a toast — feedback_native_chrome_vocabulary forbids them.
Row state stays at .ready because the copy never started — nothing to say in the subtitle.
Task.checkCancellation raises per-file, so a partial copy leaves the source dir clean-ish. [?] Does a mid-copy failure land in a distinct subtitle state, or does the row silently revert to .ready?MessageKind.warning vocabulary (exclamationmark.triangle.fill, orange) — clickable to open a diagnostic explaining which files landed and which didn't. Same shape as .completedPartial. Would need a new SubtitleVariant case (e.g. .copyFailed(summary:)). [?] Persistence: until user dismisses / next successful action / timeout?Copy succeeded. Cache-scan happy. Transcription of the new file fails (Whisper error, corrupt audio). Yesterday's report is fine.
.completedPartial after failuredesktop.pipeline.diagnostic.header.completed_partial. Prefix glyph exclamationmark.triangle.fill orange, clickable (diagnosticSubtitle(kind: .warning) in ProjectRow.swift:255-257) — opens popover. Count stays "2" — session didn't land. [?] Post-TF, does the subtitle carry a hint like "1 session failed" — or does it stay short and rely on the popover?ProjectDiagnosticPopovershow-and-tell-40.movMessageKind.warning vocabulary — ProjectDiagnosticPopover.swift:172-173. Retry/Remove are per-file additions [PROPOSED]. [?] Does "Remove file" delete from disk or just from the manifest?Transcription done. Clustering stage calls Claude, hits quota / rate limit / auth error. Everything upstream is fine.
.failedDiagnosticdesktop.pipeline.diagnostic.header.failed. Glyph xmark.circle.fill red, clickable (diagnosticSubtitle(kind: .error)). Budget is ~22 EN chars — the sidebar row says "eyes needed"; the popover carries the detail. [?] Should .failed (with Python-supplied summary) render differently from .failedDiagnostic for incremental — e.g. "Quotes not grouped" as the summary line?Cause.category = QUOTACause.category (events.py:135-157). Recovery links via pipeline.py:332-375. Incremental-specific: a QUOTA hit mid-run may leave the new session transcribed but not clustered — the report is now "3 sessions, 2 clustered." [?] Does the report show the un-grouped quotes in an "Uncategorised" bin, or hide them?Run succeeds end-to-end. New session transcribed. Quote-extraction found nothing quotable (silent interview, all off-topic, moderator monologue).
.ready(date: now, delta: nil)MessageKind.info vocabulary (info.circle outline, blue) — not clickable per the existing SubtitleVariant.isDiagnostic gate (info doesn't open the popover). Would need a new resolver leaf. [?] Info-clickable to open the new session? [?] Persist how long — until user opens the new session?Transcription of new file succeeded. Re-clustering starts (global, per axis 4) and dies partway. The report was going to change; now it can't.
.completedPartial.completedPartial shape. Popover carries the "clustering failed, showing previous themes" detail. Count reflects the new session (transcribed successfully). [?] Do we show the new session's orphan quotes in an "Uncategorised" bin, or hide them?MessageKind.warning. Would need a new key or an override on .completedPartial. Value: more specific hint than the generic header. [?] Belongs in the resolver or as a detail on the popover?Ring showed as a hover-cancel × (Finder idiom, ProjectRowActivityIndicator.swift). User clicks it. What state does the project end in?
xmark.circle.fill at .secondaryLabelColor (Finder/App Store idiom — not red). Same 16pt frame as the ring, no reflow (ProjectRowActivityIndicator.swift:113-115)..stoppingdesktop.chrome.pipeline.stopping. Immediate ack — outranks progress in the resolver (ProjectSubtitle.swift:157-159). SIGINT→SIGTERM→SIGKILL escalation lives in PipelineRunner.scheduleOrphanCancelEscalation..stopped with .unanalysed?desktop.chrome.pipeline.stopped. But the file is still in the folder — under incremental world, should the resolver instead skip .stopped and go to .ready(date: prev, delta: .unanalysed(1))? [?] Does cancel prompt "keep file / remove file"? [?] Auto-analyse next launch, or wait for the click on "+1 unanalysed"?Project A analysing. User drags another file onto Project A's row.
ContentView.swift:1222-1239) refuses drops on .running. Drop-target stroke rendered red to signal rejection. [?] Does a toast/sheet accompany or is the visual enough? No toast per feedback_native_chrome_vocabulary — so probably just the stroke + sheet if anything..queued(position: 1) · new usage [PROPOSED].queued(position:) already exists (see §5.A) — currently used for the single-slot queue across projects. New usage: intra-project queue for a dropped-during-run file. [?] How is the queued file represented if the current run fails — orphaned, or auto-promoted to .unanalysed?Row construction, glyph vocabulary, and copy were reverse-engineered from ProjectRow.swift + ProjectCellSpec + ProjectSubtitle.swift + SidebarSubtitleText + SidebarActivityRing + ProjectRowActivityIndicator + RunProgressSubtitle + MessageKind + ProjectAvailability + RandomProjectIcon.
HStack(.firstTextBaseline, spacing:6) → [leadingIcon | VStack(spacing:1) [titleLine | subtitleLine]]. Icon column 20pt; title .body; count .footnote/.tertiary in the title-line right; subtitle .caption/.secondary with prefix glyph, text, and 16pt right-slot for activity/cloud.● ◐ ⚠ ✕ ⧗ ⟳ alphabet. Real vocabulary: identity SF Symbol from project.icon (dimmed if unavailable); subtitle-prefix glyphs from MessageKind.symbolName (xmark.circle.fill/exclamationmark.triangle.fill/checkmark.circle/info.circle/minus.circle.fill); availability glyphs (externaldrive.badge.xmark/network.slash/questionmark.folder) — all orange.RoundedRectangle(cornerRadius:8).stroke(.accentColor, lineWidth:2) with padding(.horizontal:-6, .vertical:-3) to trace the selection-pill shape. §16 rejection uses the same shape in red.PipelineState.scanning, an existing state with an existing debounced title-right spinner (ProjectRow.swift:194). Not something to invent. The question is now: does incremental want a subtitle change (S4.B) or a right-slot ring (S4.C) on top of the existing spinner?SubtitleVariant cases, i18n keys, and rendered strings — 22 rows. Split existing from proposed.SubtitleDelta.unanalysed(count:) rendering "+N unanalysed" (clickable button, opens sheet). The consent question becomes: (a) auto-run, (b) hold at .unanalysed and repurpose the click to trigger analysis, (c) sheet..alert sheet on the row's window (CopyMachinery). "No toasts" per memory rules out toast pattern. Mid-copy failure needs a new SubtitleVariant..completedPartial — an existing variant with MessageKind.warning glyph. Copy is desktop.pipeline.diagnostic.header.completed_partial "Partial completion." Popover header aligned to ProjectDiagnosticPopover..failedDiagnostic — desktop.pipeline.diagnostic.header.failed "Run failed." Diagnostic popover header rendered per MessageKind.error.MessageKind.info vocabulary (blue info.circle, outline, non-clickable per existing isDiagnostic gate)..completedPartial/MessageKind.warning shape..stopping intermediate frame (existing state, immediate ack, outranks progress in resolver). S15.C raises whether .stopped should give way to .unanalysed under incremental..queued(position:) — currently used for the cross-project single-slot queue; intra-project reuse is new.RunProgressSubtitle.compose() joins stage-verb · N of M · ~N min left with " · ". No ellipsis on the generic verb once detail present ("Analysing · <1 min left"), only on the indeterminate floor ("Analysing…"). "Resuming…" only when attachedFromOrphan AND no measured signal yet..copying(fraction:) renders "Copying · 42%" (byte fraction, not file count). .copyCancelling is "Cancelling…" + indeterminate spinner.formatBareDate shape (Just now / Today / Yesterday / D MMM / MMM yyyy) not "Updated 14h ago.".ready(date, nil) collapses to .placeholder (single-line, 32pt) — dates were visual noise. Date returns only when the row genuinely has state to show. [?] Last-analysed exposed elsewhere (context menu / detail pane)?desktop.chrome.addFilesTitle..unanalysed · consent sheet) to variant A. The drop gesture IS the consent. Undo via the existing hover-cancel × on the ring.SubtitleDelta.new(count:) variant [PROPOSED] alongside .unanalysed/.missing. Persists until user engagement (scrolls new quotes, opens new session) — then row fades back to single-line placeholder. "Just a little clue there's some new stuff to see" without any modal/banner/toast.SubtitleDelta.new(count:) — new [PROPOSED] variant..pendingAnalyse state — worth adding, or just leave it as an implicit "there's stuff in the folder"?cps and re-runs from terminal. Existing cascade already has "(cached)" and "(N new sessions)" — enough, or does it want a "+N creates work" preamble too?