diff --git a/docs-ai/063-agent-workflows/000-plan.md b/docs-ai/063-agent-workflows/000-plan.md index c2e514055..8a64f900e 100644 --- a/docs-ai/063-agent-workflows/000-plan.md +++ b/docs-ai/063-agent-workflows/000-plan.md @@ -2,9 +2,9 @@ | | | | --- | --- | -| **Status** | In progress — R1 shipped in v2026.8.29; R2a under way: B1 (#740, [006](006-b1-definitions.md)), #733 (#741), #726 T0 (#739), B2 (#743, [007](007-b2-runner-core.md)), and B3 (#744, [008](008-b3-runner-wiring.md)) merged; C1 is next | +| **Status** | In progress — R1 shipped in v2026.8.29; R2a under way: B1 (#740, [006](006-b1-definitions.md)), #733 (#741), #726 T0 (#739), B2 (#743, [007](007-b2-runner-core.md)), and B3 (#744, [008](008-b3-runner-wiring.md)) merged; C1 is in review as #747 ([010](010-c1-workflow-status-center.md)) | | **Anchor date** | 2026-08-21 | -| **Primary PRs** | R1: #709 (C0), #710 (A1), #713 (A1b), #714 (A2) — shipped in v2026.8.29; R2a: #740 (B1), #743 (B2, [007](007-b2-runner-core.md)); #744 (B3, [008](008-b3-runner-wiring.md)); C1–D3 TBD | +| **Primary PRs** | R1: #709 (C0), #710 (A1), #713 (A1b), #714 (A2) — shipped in v2026.8.29; R2a: #740 (B1), #743 (B2, [007](007-b2-runner-core.md)); #744 (B3, [008](008-b3-runner-wiring.md)); #747 (C1, [010](010-c1-workflow-status-center.md)); C2–D3 TBD | | **Related** | [047 cross-agent-handoff](../047-cross-agent-handoff/000-plan.md), [049 agents-toolbar-entry](../049-agents-toolbar-entry/000-plan.md), [053 agent-profiles](../053-agent-profiles/000-plan.md), [055 agent-profile-runtimes](../055-agent-profile-runtimes/000-plan.md), [059 agent-transcript-snapshots](../059-agent-transcript-snapshots/000-plan.md), [060 cli-targeting-and-contract-governance](../060-prowl-cli-targeting-and-contract-governance/000-plan.md), [061 native-toolbar-controls](../061-native-toolbar-controls/toolbar-controls.md), [064 agent-completion-signals](../064-agent-completion-signals/000-plan.md) (signal bus, `agents signal` / `agents wait`), [#699 `prowl create pane`](https://github.com/onevcat/Prowl/issues/699), [PR #651 (direction reference, not merged)](https://github.com/onevcat/Prowl/pull/651), [DSL spec (living)](dsl-spec.md), [release plan (living)](release-plan.md), `docs/components/handoff.md`, `docs/components/agent-profiles.md`, `docs/components/cli.md` | ## Background @@ -596,6 +596,12 @@ attaches hooks through A2's launch boundary. ## Amendments +- Updated 2026-08-31 (C1 kickoff, grilled): the workflow status center is the selected + worktree's runtime indicator/control only; it lists active runs without history, renders every + machine-authorized attention action, and keeps R2a CLI-first. C1 is one PR with exhaustive cheap + tests, two or more neighboring-agent adversarial review rounds, and reviewed-head live E2E as its + merge gate. Display sleep relies on #746 and does not add a headless/fail policy. Plan and evidence: + [010-c1-workflow-status-center.md](010-c1-workflow-status-center.md). - Updated 2026-08-30: a two-round display-sleep spike located the `CREATE_FAILED` cause in the pinned GhosttyKit, not in Prowl's launch ordering: with zero active displays the renderer's eager CoreVideo display link (`window-vsync = true`) aborts `ghostty_surface_new`, while diff --git a/docs-ai/063-agent-workflows/010-c1-workflow-status-center.md b/docs-ai/063-agent-workflows/010-c1-workflow-status-center.md new file mode 100644 index 000000000..f982d0839 --- /dev/null +++ b/docs-ai/063-agent-workflows/010-c1-workflow-status-center.md @@ -0,0 +1,195 @@ +# 063.010 — Workflow Status Center (C1) + +## Status + +Implementation, adversarial review, and reviewed-head live verification are complete in +[#747](https://github.com/onevcat/Prowl/pull/747), after B3 merged as +[#744](https://github.com/onevcat/Prowl/pull/744). C1 is the last implementation slice in R2a and +the PR is merge-ready. Merging #747 completes the R2a implementation scope; release packaging and +publishing remain the normal post-merge release operation. + +## Product contract + +C1 makes the already-live B3 runner understandable and recoverable without changing how a run +starts. The toolbar's principal status item is a runtime indicator/control for the selected +worktree: + +- priority stays `toast > active workflow run > pull request > palette hint`; +- the compact item shows the most recently started active run's current step, an animated running + indicator or orange attention symbol, and an active-run count when the worktree has more than + one run; +- hover opens the run panel temporarily; click pins it; +- the panel lists every active run in the selected worktree and shows workflow/worktree/elapsed + state, role panes, a capped scrolling step history, repeat rounds, the current instruction, + every machine-authorized attention action, and Cancel / Reveal Run Folder / Open Log; +- role and Focus Pane controls select the worktree and focus the exact bound pane; +- successful completion reuses the toolbar success toast. Attention and terminal run transitions + enter the existing notification pipeline with a pane target. When the user is already viewing + that worktree, the existing `muteNotificationsForActiveSurface` setting decides whether external + delivery is suppressed; the sidebar notification event is still emitted. + +The runner remains the authority for action availability. The presentation layer must render the +`WorkflowAttention.actions` list exhaustively and must not reconstruct recovery policy from the +attention reason. + +## Decisions frozen before implementation + +1. C1 is one atomic PR. Internal commits may separate tests, implementation, and review fixes. +2. R2a stays CLI-first. Runs start through `prowl workflow run` and authored YAML; the C2 start + sheet/pickers, D1 Settings/authoring skill, and D2 built-in workflow remain later slices. +3. The run panel is only current runtime state: active and needs-attention runs, not history or a + recent-completions surface. +4. Every currently legal recovery/termination action is a release gate. This includes Focus Pane, + Nudge Again, Keep Waiting, Retry, Relaunch, Accept as Delivered, Accept with every declared + verdict, Ask Again, Skip, Cancel, plus role-pane focus and run-folder/log access. +5. Skip copy shows its consequence before confirmation: either the workflow continues with an + optional input absent, or the run ends because a named downstream step depends on the output. +6. The merged Ghostty fix in #746 is the display-sleep solution. C1 adds no headless/fail fallback + and no `window-vsync` override; headless remains an independent V2 topic. +7. The surface inherits the native-toolbar rules in 061: one `.principal` display item, native + controls and popover, no new Liquid Glass exception, and visual verification in Normal, Shelf, + and Canvas at normal and constrained widths. + +## Implementation shape + +- Add a pure, equatable workflow status-center presentation model derived from + `WorkflowRunsFeature.State`, selected worktree id, and the current date. It owns deterministic + run ordering, toolbar priority inputs, role/step/round rows, current instruction copy, skip + consequence copy, and the exhaustive attention-control mapping. +- Keep SwiftUI declarative: `ToolbarStatusView` selects the priority state; a dedicated workflow + popover button owns hover/pin behavior; the panel renders the model and sends typed panel intents. +- Route machine actions through `WorkflowRunsFeature.Action.userAction`. Focus, reveal, and open-log + remain presentation intents at the app/view boundary. +- Emit typed workflow status-edge delegates from `WorkflowRunsFeature`, then let `AppFeature` + coordinate selected-worktree toast and the existing terminal notification pipeline. Edge + comparison prevents persistence/bookkeeping events from duplicating alerts. + +## Automated verification matrix + +- Presentation projection: selected-worktree filtering, terminal exclusion, deterministic recency, + multiple-run count, running/attention summary, empty/current-step fallback, roles with and without + panes, top-level and repeated step records, full current instruction, elapsed formatting, and skip + consequence. +- Exhaustive controls: every `WorkflowAttentionAction` maps to one usable panel intent; provisional + verdict choices produce one action per declared verdict; unavailable focus targets are represented + honestly; Cancel remains available outside attention. +- Reducer edges: no duplicate notice for unchanged status, attention transitions (including a changed + attention), completed/skipped/max-rounds transitions, no completion notice for explicit cancel, + user-action routing, selected-worktree completion toast, and foreground/background notification + behavior. +- Existing workflow machine/reducer suites, full app tests, formatting/lint, and app build stay green. + +## Review and release gate + +After local self-review and the first PR push: + +1. Direct a neighboring Claude agent through `prowl-cli` for at least two adversarial review rounds. + Reviews focus on plan drift and material UX/correctness risks, avoid speculative nitpicks, and use + the main review session rather than a fleet of subagents. +2. Verify every finding locally. Accepted fixes start with a failing regression test where the seam + is testable, then update the PR and leave a PR comment recording disposition and evidence. +3. Continue while any P0/P1 or serious P2 remains. +4. Run the high-risk live E2E only on the reviewed PR head: happy path, provisional delivery and + verdict acceptance/ask-again, watchdog recovery, gone-role relaunch, multiple runs/background + notification, focus/reveal/log controls, toolbar width/mode/accessibility checks, and a display- + sleep launch smoke. Any live-path fix gets another adversarial review before merge readiness. + +## Non-goals + +- GUI workflow start/binding selection, workflow authoring or Settings management. +- Built-in workflows or migration of the shipped handoff. +- Workflow history, restart resume, retention, headless execution, or new DSL semantics. +- A second status item, custom toolbar glass, or changes to the existing PR/status priorities beyond + inserting the active workflow state at the planned position. + +## Delivery, review, and live verification + +### Implementation and cheap gates + +- The toolbar priority projection, run/role/step/round presentation, exhaustive attention-control + mapping, skip consequence, status-edge notices, selected-worktree completion toast, terminal + notification routing, and the native run panel are implemented. +- `make check`: passed (format, strict SwiftFormat, strict SwiftLint, 76 repository checks). +- Focused workflow/notification regression group: 107 tests passed. +- Final `make test`: both result bundles passed; the primary bundle contained 2,915 tests and the + secondary bundle 2 tests, with zero failures. +- `make build-app`: passed with zero errors and zero warnings. +- CLI release gates passed: `make build-cli`, `make test-cli-smoke`, 233 CLI unit tests, and 110 + CLI socket integration tests. +- `make agent-versions`: completed; `copilot`, `pi` are attested, while the installed `claude`, + `codex`, `droid`, `qodercli`, `omp`, and `opencode` are newer than the R2a T0 ledger. This is the + expected #726 T1 follow-up scheduled before D2, not a C1 behavior failure. + +### Pre-review Debug visual verification + +An isolated Debug app and CLI socket ran a real local workflow against a detected Codex pane. The +running toolbar item and pinned run panel were inspected in Normal, Shelf, and Canvas modes at the +normal window size and at macOS half-width. The current title, full instruction, role chip, +document-order steps, elapsed state, and footer controls remained legible and usable; the fixed +580-point single-run panel fit the constrained window without clipping. The workflow also completed +through `prowl workflow done -`, after which the active item disappeared as designed. Temporary +workflow input was removed; persisted local run records remain under the self-ignored run store. + +### Adversarial review record + +- A first neighboring-agent adversarial review found five material interaction/notification gaps; + all were accepted and fixed test-first: + - selected-worktree workflow notices now use the existing active-surface mute preference instead + of treating selection alone as proof that the run is viewed; + - the toolbar reports attention from any active run while retaining the newest run as the primary + summary; + - interacting with panel controls pins the hover-open panel so confirmation menus cannot vanish + on pointer exit; + - the workflow popover stays mounted while a toast overlays it, preserving pinned panel state; + - selected-worktree `skipped` and `maxRoundsReached` outcomes now receive warning toasts, while + successful completion keeps the success toast. +- The duplicate-edge reducer test now keeps exhaustive TestStore checking enabled through the next + action, so an unexpected duplicate notice cannot be discarded before the assertion boundary. +- The toolbar still summarizes the newest run, but a run needing attention is now the default panel + selection and VoiceOver label target, avoiding an extra hunt in multi-run panels. +- Round 2 independently traced all five fixes and ran 68 relevant tests plus strict SwiftLint. It + found no P0, P1, or serious P2 and recommended merge after reviewed-head live E2E. +- Round 3 reviewed the attention/default-selection and exhaustive-test follow-up. Its two P3 + findings were accepted: closing the popover now clears sticky selection, and notification docs + distinguish pane-level ordinary notices from worktree-level workflow notices. +- Round 4 reviewed only that final delta, found no P0/P1/P2, and approved `7c235988` as the safe + reviewed E2E head. +- Live E2E then exposed an inert-toolbar regression after a pinned last run completed and a later + run started in the same selected worktree. `e301696e` keeps the workflow status control mounted + through the zero-run transition so its local popover state closes and resets reliably. +- Round 5 reviewed only that live-found fix. It found no P0/P1/material P2 and confirmed the hidden + zero-run control is zero-size, hit-test-disabled, and Accessibility-hidden without regressing + toast/pin identity, selection retention, worktree/mode transitions, or task cleanup. + +### Reviewed-head live E2E + +- With the macOS session locked, the reviewed `7c235988` Debug build launched, created a new terminal + surface, ran a captured shell command successfully, and launched a real Codex profile. This + directly covers the display-sleep/locked-session surface path; the later final delta only changes + toolbar view lifetime and received its own focused review and rebuilt-app E2E. +- A real launch-role happy run completed through the generated `prowl workflow done -` command and + persisted its output with verdict `clean`. +- Concurrent provisional runs exercised all delivery decisions: `Ask Again` injected the generated + remediation prompt and accepted a corrected re-delivery; `Accept as Delivered` persisted a valid + delivery with a missing required section; and the hover-open `Accept with Verdict` menu pinned the + panel and persisted the selected `clean` verdict. +- Gone-role recovery replaced the dead p22 binding with a new p30 pane and resumed the step. A + separate run exercised the destructive Cancel confirmation and finished `cancelled` while keeping + its pane and outputs. +- The watchdog reached attention through its real automatic nudge and idle grace. `Nudge Again` + delivered another completion reminder, `Keep Waiting` re-armed the grace period, and the next + attention state was actually skipped after confirming the displayed consequence. +- Hover preview opened and closed without pinning; interacting with Skip and the verdict menu pinned + it across pointer exit and native confirmation/menu presentation. A real happy-path completion + showed its green toolbar toast while the pinned watchdog panel remained visible and retained its + selection, then restored the run indicator after the toast dismissed. +- Role-chip focus selected the exact bound pane. Reveal Run Folder selected the exact persisted run + directory in Finder, and Open Log opened that run's `log.md`. The sidebar notification list showed + background attention/completion events, and selecting the watchdog notice focused its p26 pane. +- Shelf, Default, and Canvas modes were inspected on the final build at the approximately 768-point + constrained width. The native toolbar item and panel remained legible and unclipped. Accessibility + exposed the attention-aware run title/count plus named recovery and footer controls. +- The live-found zero-run regression was reproduced and then retested in a fresh installed Debug + process: pinned provisional run -> completed -> zero active runs -> toast dismissed -> new run in + the same worktree -> the first toolbar click opened the new panel. No worktree reselection was + required after `e301696e`. diff --git a/docs-ai/063-agent-workflows/release-plan.md b/docs-ai/063-agent-workflows/release-plan.md index 98f4fc4bf..67793adc8 100644 --- a/docs-ai/063-agent-workflows/release-plan.md +++ b/docs-ai/063-agent-workflows/release-plan.md @@ -29,12 +29,12 @@ user-facing surface may merge before "their" release and stay dormant. Four rele R2a, R2b, R3 (R2 was split on 2026-08-29); the [cadence rules](#cadence-and-working-rules) say when each is cut: -### Status (2026-08-30) +### Status (2026-08-31) | Release | State | Next action | | --- | --- | --- | | R1 | **Shipped** — v2026.8.29 (2026-08-29) | — | -| R2a | In progress | B1 #740, #726 T0 #739, #733 #741, B2 #743, and B3 #744 merged; C1 is next ([063.008](008-b3-runner-wiring.md), [063.009](009-display-sleep-surface-spike.md)) | +| R2a | In progress | B1 #740, #726 T0 #739, #733 #741, B2 #743, and B3 #744 merged; C1 #747 is in review ([063.010](010-c1-workflow-status-center.md)) | | R2b | Planned | after R2a ships | | R3 | Planned | after R2b ships | diff --git a/docs/components/cli.md b/docs/components/cli.md index f60f73baa..e925ce148 100644 --- a/docs/components/cli.md +++ b/docs/components/cli.md @@ -4,7 +4,7 @@ > an agent) can list panes, read their screens, run commands and capture output, > send keystrokes, focus, and open/close tabs and panes programmatically. -**Keywords:** prowl cli, command line, prowl list, prowl agents, prowl agents read, prowl agents signal, prowl agents dispatch, prowl agents wait, prowl profiles list, prowl skills, skills install, agent skills, prowl read, prowl send, prowl key, prowl focus, prowl create, prowl close, prowl open, prowl handoff, pane id, agent, profile, automation, json, capture, socket +**Keywords:** prowl cli, command line, prowl list, prowl agents, prowl agents read, prowl agents signal, prowl agents dispatch, prowl agents wait, prowl profiles list, prowl skills, skills install, agent skills, prowl workflow, workflow status center, workflow run panel, workflow attention, prowl read, prowl send, prowl key, prowl focus, prowl create, prowl close, prowl open, prowl handoff, pane id, agent, profile, automation, json, capture, socket **Related:** [terminal](terminal.md) · [concepts](../concepts.md) · [active-agents](active-agents.md) · [agent-detection](agent-detection.md) · the bundled **`prowl-cli` skill** (`skills/prowl-cli/SKILL.md`) @@ -535,11 +535,21 @@ prowl workflow schema [--json] # JSON Schema (Draft 202 run, live or, after an app restart, from its `run.json` (`.data.source` is `live` or `record`; a record has no activation and no tokens). Runs an earlier app instance left unfinished are marked `interrupted` at launch; V1 does not resume them. +- While a run is active in the selected worktree, the toolbar's center status item shows its + current step (and the active-run count when several runs share the worktree). Hover previews the + run panel; click keeps it open. The panel lists all active runs in that worktree, their role panes, + repeat rounds and steps, the current instruction, run folder and log. A role chip focuses its + exact pane. When a run needs attention, the panel exposes every recovery the runner currently + permits: Focus Pane, Nudge Again, Keep Waiting, Retry, Relaunch Role, Accept as Delivered, + Accept with a declared verdict, Ask Again, Skip Step, and Cancel Run as applicable. Skip shows + whether the workflow continues or ends before confirmation. Successful completion uses the + toolbar success toast; background attention/completion also enters Notifications and focuses the + relevant pane when selected. When that worktree is already being viewed, the existing active- + surface notification preference decides whether external delivery stays quiet. - `cancel ` stops a live run: it stops advancing and injecting, abandons the pending activation, keeps every pane and output, and reports the ended run. Attention states a run reaches (an agent that went idle without delivering, a blocked or vanished pane, a - provisional delivery, a failed launch) are visible through `status` and, until the workflow - panel ships, resolvable only by `cancel`. + provisional delivery, a failed launch) are visible through `status` and the toolbar run panel. - `validate` prints every diagnostic as `path:line:column: error[code]: message` (warnings likewise) and ends with `OK ()` or `INVALID …`. Errors make the command fail with `WORKFLOW_INVALID`; in JSON the full validate payload (`path`, `valid`, `workflow`, diff --git a/docs/components/notifications.md b/docs/components/notifications.md index 0f1fa9fe0..36fc48103 100644 --- a/docs/components/notifications.md +++ b/docs/components/notifications.md @@ -72,7 +72,8 @@ top of its section. **Jump to Latest Unread** (`⌘⌥U`) takes you straight to - `muteNotificationsForActiveSurface` (default on) — skip the banner, sound, and dock bounce when the notification comes from the pane you're already looking at (selected worktree, focused pane, key + visible window). Sidebar reordering - still happens. + still happens. Workflow status-edge notices are worktree-level rather than pane- + level, so the selected worktree in a key, visible window counts as currently viewed. - `moveNotifiedWorktreeToTop` — float notified worktree to top. - `commandFinishedNotificationEnabled` + `commandFinishedNotificationThreshold` — long-command notifications and their minimum duration. diff --git a/docs/reference/settings-fields.md b/docs/reference/settings-fields.md index fdb62d181..6817da943 100644 --- a/docs/reference/settings-fields.md +++ b/docs/reference/settings-fields.md @@ -32,7 +32,7 @@ JSON is pretty-printed with sorted keys. Legacy `~/.supacode` is migrated to | `inAppNotificationsEnabled` | Bool | `true` | In-app alerts / bell indicators. | | `notificationSound` | enum (`never` / system sound raw values like `hero` / `supacodeClassic`) | `supacodeClassic` | Sound played for notifications when system banners are off; `never` disables it. Migrates the legacy `notificationSoundEnabled` Bool (`true` → `supacodeClassic`, `false` → `never`); unknown raw values fall back to the default. | | `systemNotificationsEnabled` | Bool | `false` | macOS system banners. | -| `muteNotificationsForActiveSurface` | Bool | `true` | Suppress the banner, sound, and dock bounce when the notification's pane is the one you're actively viewing (selected worktree, focused pane, key + visible window). | +| `muteNotificationsForActiveSurface` | Bool | `true` | Suppress the banner, sound, and dock bounce when the notification's pane is the one you're actively viewing (selected worktree, focused pane, key + visible window). Workflow status-edge notices use selected-worktree visibility because their status is worktree-level. | | `moveNotifiedWorktreeToTop` | Bool | `true` | Float a notified worktree to top. | | `commandFinishedNotificationEnabled` | Bool | `true` | Notify when a long command finishes. | | `commandFinishedNotificationThreshold` | Int (seconds) | `10` | Minimum duration before that notification fires. | diff --git a/supacode/App/WorkflowRuntimeComposition.swift b/supacode/App/WorkflowRuntimeComposition.swift index 62a1a4289..74eca78ea 100644 --- a/supacode/App/WorkflowRuntimeComposition.swift +++ b/supacode/App/WorkflowRuntimeComposition.swift @@ -214,13 +214,36 @@ extension SupacodeApp { return terminalManager.stateIfExists(for: worktree.id)?.closeSurface( id: surfaceID, confirmation: .skip) ?? false }, - notify: { worktree, text in - workflowLogger.notice("[\(worktree.name)] \(text)") - guard let appStore = storeBox.store, appStore.state.settings.systemNotificationsEnabled - else { return } + notify: { worktree, notification in + workflowLogger.notice("[\(worktree.name)] \(notification.title): \(notification.body)") + let state = terminalManager.stateIfExists(for: worktree.id) + let requestedSurface = notification.targetSurfaceID.flatMap { surfaceID in + terminalManager.isSurfaceLive(surfaceID) ? surfaceID : nil + } + let fallbackSurface = state?.tabManager.selectedTabId.flatMap { state?.activeSurfaceID(for: $0) } + if let surfaceID = requestedSurface ?? fallbackSurface, let state { + state.appendNotification( + title: notification.title, + body: notification.body, + surfaceId: surfaceID, + treatAsViewedWhenWorktreeIsVisible: notification.treatAsViewedWhenWorktreeIsVisible + ) + return + } + guard let appStore = storeBox.store else { return } + let settings = appStore.state.settings + let isViewed = notification.treatAsViewedWhenWorktreeIsVisible && state?.isViewingWorktree() == true + guard !(settings.muteNotificationsForActiveSurface && isViewed), settings.systemNotificationsEnabled else { + return + } @Dependency(SystemNotificationClient.self) var notifications Task { @MainActor in - await notifications.send("Workflow · \(worktree.name)", text, worktree.id, nil) + await notifications.send( + notification.title, + notification.body, + worktree.id, + notification.targetSurfaceID + ) } } ) diff --git a/supacode/Clients/Workflow/WorkflowRuntimeClient.swift b/supacode/Clients/Workflow/WorkflowRuntimeClient.swift index 058416365..5eb1b00fd 100644 --- a/supacode/Clients/Workflow/WorkflowRuntimeClient.swift +++ b/supacode/Clients/Workflow/WorkflowRuntimeClient.swift @@ -39,6 +39,25 @@ nonisolated enum WorkflowLaunchError: Error, Equatable, Sendable { case failed(String) } +nonisolated struct WorkflowRuntimeNotification: Equatable, Sendable { + let title: String + let body: String + let targetSurfaceID: UUID? + let treatAsViewedWhenWorktreeIsVisible: Bool + + init( + title: String, + body: String, + targetSurfaceID: UUID?, + treatAsViewedWhenWorktreeIsVisible: Bool = true + ) { + self.title = title + self.body = body + self.targetSurfaceID = targetSurfaceID + self.treatAsViewedWhenWorktreeIsVisible = treatAsViewedWhenWorktreeIsVisible + } +} + struct WorkflowRuntimeClient: Sendable { /// The #733 idle precondition without its five-second cap: exact `turn-ended` evidence first, /// a stabilized detector view otherwise; returns when the role can receive a line. @@ -57,7 +76,7 @@ struct WorkflowRuntimeClient: Sendable { /// author's `close` step is explicit and the run owns the pane); `false` when the pane is gone /// or another active run has bound it since. var close: @MainActor @Sendable (Worktree, UUID, UUID) -> Bool - var notify: @MainActor @Sendable (Worktree, String) -> Void + var notify: @MainActor @Sendable (Worktree, WorkflowRuntimeNotification) -> Void } extension WorkflowRuntimeClient: DependencyKey { diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 15e8b727f..661917a9b 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -117,6 +117,7 @@ struct AppFeature { @Dependency(ExternalDiffToolClient.self) var externalDiffToolClient @Dependency(OutgoingChangesClient.self) var outgoingChangesClient @Dependency(GitClientDependency.self) var gitClient + @Dependency(WorkflowRuntimeClient.self) var workflowRuntimeClient var body: some Reducer { let core = Reduce { state, action in @@ -1016,6 +1017,39 @@ struct AppFeature { case .commandPalette(let action): return reduceCommandPaletteAction(action, state: &state) + case .workflowRuns(.delegate(.notice(let notice))): + guard let worktree = state.repositories.worktree(for: notice.worktreeID) else { + return .none + } + var effects: [Effect] = [] + if notice.postsNotification { + effects.append( + .run { @MainActor _ in + workflowRuntimeClient.notify( + worktree, + WorkflowRuntimeNotification( + title: notice.title, + body: notice.body, + targetSurfaceID: notice.targetSurfaceID + ) + ) + } + ) + } + if state.repositories.selectedWorktreeID == notice.worktreeID { + switch notice.kind { + case .completed: + effects.append( + .send(.repositories(.showToast(.success("\(notice.workflowName) completed")))) + ) + case .skipped, .maxRoundsReached: + effects.append(.send(.repositories(.showToast(.warning(notice.title))))) + case .needsAttention: + break + } + } + return .merge(effects) + case .workflowRuns: return .none diff --git a/supacode/Features/Repositories/Views/ToolbarStatusView.swift b/supacode/Features/Repositories/Views/ToolbarStatusView.swift index c2e1098e9..2f1e0cbd2 100644 --- a/supacode/Features/Repositories/Views/ToolbarStatusView.swift +++ b/supacode/Features/Repositories/Views/ToolbarStatusView.swift @@ -2,13 +2,27 @@ import SwiftUI struct ToolbarStatusView: View { let toast: RepositoriesFeature.StatusToast? + let workflow: WorkflowStatusCenterPresentation let pullRequest: GithubPullRequest? let codeHost: CodeHost + let onWorkflowIntent: (WorkflowRunPanelIntent) -> Void var body: some View { - Group { - switch toast { - case .inProgress(let message): + let selection = ToolbarStatusSelection( + toast: toast, + workflow: workflow, + pullRequest: pullRequest + ) + ZStack { + // Keep this mounted across the last-run transition so its local popover state observes the + // empty run list and resets before a later run appears. + WorkflowStatusPopoverButton( + presentation: workflow, + isToolbarVisible: selection.isWorkflow, + onIntent: onWorkflowIntent + ) + switch selection { + case .toast(.inProgress(let message)): HStack(spacing: 6) { ProgressView() .controlSize(.small) @@ -17,7 +31,7 @@ struct ToolbarStatusView: View { .foregroundStyle(.secondary) } .transition(.opacity) - case .success(let message): + case .toast(.success(let message)): HStack(spacing: 6) { Image(systemName: "checkmark.circle.fill") .foregroundStyle(.green) @@ -27,7 +41,7 @@ struct ToolbarStatusView: View { .foregroundStyle(.secondary) } .transition(.opacity) - case .warning(let message): + case .toast(.warning(let message)): HStack(spacing: 6) { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(.orange) @@ -37,17 +51,47 @@ struct ToolbarStatusView: View { .foregroundStyle(.secondary) } .transition(.opacity) - case nil: - if let model = PullRequestStatusModel(pullRequest: pullRequest) { - PullRequestStatusButton(model: model, codeHost: codeHost) - .transition(.opacity) - } else { - MotivationalStatusView() - .transition(.opacity) - } + case .workflow: + EmptyView() + case .pullRequest(let model): + PullRequestStatusButton(model: model, codeHost: codeHost) + .transition(.opacity) + case .motivational: + MotivationalStatusView() + .transition(.opacity) } } .animation(.easeInOut(duration: 0.2), value: toast) + .animation(.easeInOut(duration: 0.2), value: workflow.runs.map(\.id)) + } +} + +@MainActor +enum ToolbarStatusSelection: Equatable { + case toast(RepositoriesFeature.StatusToast) + case workflow(WorkflowStatusCenterPresentation) + case pullRequest(PullRequestStatusModel) + case motivational + + var isWorkflow: Bool { + if case .workflow = self { return true } + return false + } + + init( + toast: RepositoriesFeature.StatusToast?, + workflow: WorkflowStatusCenterPresentation, + pullRequest: GithubPullRequest? + ) { + if let toast { + self = .toast(toast) + } else if !workflow.runs.isEmpty { + self = .workflow(workflow) + } else if let pullRequest = PullRequestStatusModel(pullRequest: pullRequest) { + self = .pullRequest(pullRequest) + } else { + self = .motivational + } } } diff --git a/supacode/Features/Repositories/Views/WorkflowStatusPopoverButton.swift b/supacode/Features/Repositories/Views/WorkflowStatusPopoverButton.swift new file mode 100644 index 000000000..06d7656fe --- /dev/null +++ b/supacode/Features/Repositories/Views/WorkflowStatusPopoverButton.swift @@ -0,0 +1,551 @@ +import SwiftUI + +struct WorkflowStatusPopoverButton: View { + let presentation: WorkflowStatusCenterPresentation + let isToolbarVisible: Bool + let onIntent: (WorkflowRunPanelIntent) -> Void + + @State private var isPresented = false + @State private var isPinnedOpen = false + @State private var isHoveringButton = false + @State private var isHoveringPopover = false + @State private var selectedRunID: UUID? + @State private var closeTask: Task? + + var body: some View { + Button { + togglePresentation() + } label: { + if let run = presentation.primary { + HStack(spacing: 6) { + statusIcon() + Text(run.currentStepTitle) + .lineLimit(1) + if presentation.activeRunCount > 1 { + Text(presentation.activeRunCount, format: .number) + .font(.caption2.monospacedDigit()) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(.quaternary, in: Capsule()) + .accessibilityLabel("\(presentation.activeRunCount) active workflow runs") + } + } + } + } + .buttonStyle(.plain) + .font(.caption) + .contentShape(.rect) + .help("Workflow status. Hover to preview or click to keep the run panel open.") + .accessibilityLabel(accessibilityLabel) + .onHover { hovering in + isHoveringButton = hovering + updatePresentation() + } + .opacity(isToolbarVisible ? 1 : 0) + .allowsHitTesting(isToolbarVisible) + .accessibilityHidden(!isToolbarVisible) + .popover(isPresented: $isPresented) { + WorkflowRunPanelView( + presentation: presentation, + selectedRunID: $selectedRunID, + onInteraction: pinPresentation, + onIntent: onIntent + ) + .onHover { hovering in + isHoveringPopover = hovering + updatePresentation() + } + .onDisappear { + isHoveringPopover = false + isPinnedOpen = false + selectedRunID = nil + } + } + .onChange(of: presentation.runs.map(\.id)) { _, runIDs in + guard !runIDs.isEmpty else { + closePopover() + return + } + if selectedRunID.map({ !runIDs.contains($0) }) == true { + selectedRunID = defaultRunID + } + } + .onChange(of: isToolbarVisible) { _, visible in + if !visible, !isPinnedOpen { + isHoveringButton = false + updatePresentation() + } + } + .onDisappear { + closeTask?.cancel() + } + } + + @ViewBuilder + private func statusIcon() -> some View { + if presentation.hasAttention { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + .accessibilityHidden(true) + } else { + ProgressView() + .controlSize(.small) + .accessibilityHidden(true) + } + } + + private var accessibilityLabel: String { + guard let run = presentation.attentionRun ?? presentation.primary else { return "Workflow status" } + let prefix = presentation.hasAttention ? "Workflow needs attention" : "Workflow running" + let count = presentation.activeRunCount > 1 ? ", \(presentation.activeRunCount) active runs" : "" + return "\(prefix): \(run.currentStepTitle)\(count)" + } + + private func togglePresentation() { + if isPinnedOpen { + closePopover() + return + } + closeTask?.cancel() + selectedRunID = selectedRunID ?? defaultRunID + pinPresentation() + } + + private func pinPresentation() { + closeTask?.cancel() + isPinnedOpen = true + isPresented = true + } + + private func updatePresentation() { + if isPinnedOpen || isHoveringButton || isHoveringPopover { + closeTask?.cancel() + selectedRunID = selectedRunID ?? defaultRunID + isPresented = true + return + } + closeTask?.cancel() + closeTask = Task { @MainActor in + try? await ContinuousClock().sleep(for: .milliseconds(150)) + if !Task.isCancelled { + isPresented = false + } + } + } + + private func closePopover() { + closeTask?.cancel() + isPinnedOpen = false + isPresented = false + } + + private var defaultRunID: UUID? { + presentation.attentionRun?.id ?? presentation.primary?.id + } +} + +private struct WorkflowRunPanelView: View { + let presentation: WorkflowStatusCenterPresentation + @Binding var selectedRunID: UUID? + let onInteraction: () -> Void + let onIntent: (WorkflowRunPanelIntent) -> Void + + @State private var pendingConfirmation: PendingConfirmation? + @State private var isConfirming = false + + var body: some View { + HStack(spacing: 0) { + if presentation.runs.count > 1 { + runList + Divider() + } + if let run = selectedRun { + runDetail(run) + } + } + .frame( + width: presentation.runs.count > 1 ? 760 : 580, + height: 600 + ) + .onAppear { + selectFirstRunIfNeeded() + } + .onChange(of: presentation.runs.map(\.id)) { _, _ in + selectFirstRunIfNeeded() + } + .confirmationDialog( + "Confirm Workflow Action", + isPresented: $isConfirming, + presenting: pendingConfirmation + ) { pending in + Button(pending.label, role: pending.isDestructive ? .destructive : nil) { + onIntent(pending.intent) + pendingConfirmation = nil + } + Button("Cancel", role: .cancel) { + pendingConfirmation = nil + } + } message: { pending in + Text(pending.message) + } + } + + private var selectedRun: WorkflowRunPresentation? { + presentation.runs.first { $0.id == selectedRunID } ?? presentation.primary + } + + private var runList: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 4) { + Text("Active Runs") + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal, 8) + .padding(.bottom, 2) + ForEach(presentation.runs) { run in + Button { + onInteraction() + selectedRunID = run.id + } label: { + HStack(alignment: .top, spacing: 8) { + Image(systemName: run.status.isAttention ? "exclamationmark.triangle.fill" : run.workflowIcon) + .foregroundStyle(run.status.isAttention ? .orange : .secondary) + .frame(width: 14) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 2) { + Text(run.workflowName) + .lineLimit(1) + Text(run.currentStepTitle) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + Spacer(minLength: 0) + } + .padding(8) + .contentShape(.rect) + .background( + run.id == selectedRunID ? Color.accentColor.opacity(0.12) : Color.clear, + in: RoundedRectangle(cornerRadius: 6) + ) + } + .buttonStyle(.plain) + .help("Show \(run.workflowName)") + } + } + .padding(8) + } + .frame(width: 190) + } + + private func runDetail(_ run: WorkflowRunPresentation) -> some View { + VStack(alignment: .leading, spacing: 12) { + runHeader(run) + roleRow(run) + Divider() + stepList(run) + .frame(maxHeight: .infinity) + if case .needsAttention(let message) = run.status { + attentionBlock(run, message: message) + } + Divider() + footer(run) + } + .padding(16) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private func runHeader(_ run: WorkflowRunPresentation) -> some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: run.workflowIcon) + .font(.title3) + .foregroundStyle(.secondary) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 3) { + Text(run.workflowName) + .font(.headline) + TimelineView(.periodic(from: .now, by: 30)) { context in + Text("\(run.worktreeName) · \(run.elapsedText(at: context.date)) · \(run.status.label)") + .font(.subheadline) + .foregroundStyle(run.status.isAttention ? .orange : .secondary) + } + } + Spacer(minLength: 0) + if presentation.runs.count > 1 { + Text("\(presentation.activeRunCount) active") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + .accessibilityElement(children: .combine) + } + + private func roleRow(_ run: WorkflowRunPresentation) -> some View { + ScrollView(.horizontal) { + HStack(spacing: 6) { + ForEach(run.roles) { role in + Button { + onInteraction() + guard let surfaceID = role.surfaceID else { return } + onIntent(.focusPane(worktreeID: run.worktreeID, surfaceID: surfaceID)) + } label: { + HStack(spacing: 5) { + TabIconImage( + rawName: (role.agent.flatMap(CommandIconMap.iconForFirstToken) + ?? TabIconSource(systemSymbol: "sparkles")).storageString, + pointSize: 11 + ) + Text(role.displayName) + .lineLimit(1) + if let paneHandle = role.paneHandle { + Text(paneHandle) + .monospaced() + .foregroundStyle(.secondary) + } + } + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(role.surfaceID == nil) + .help( + role.surfaceID == nil + ? "\(role.displayName) has no pane yet" + : "Focus \(role.displayName) in \(role.paneHandle ?? "its pane")" + ) + } + } + } + .scrollIndicators(.never) + } + + private func stepList(_ run: WorkflowRunPresentation) -> some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 8) { + ForEach(run.stepItems) { item in + switch item { + case .step(let step): + stepRow(step, currentInstruction: run.currentInstruction) + case .round(let round): + VStack(alignment: .leading, spacing: 6) { + Text("Round \(round.index) / \(round.maximum)") + .font(.caption) + .foregroundStyle(.secondary) + ForEach(round.steps) { step in + stepRow(step, currentInstruction: run.currentInstruction) + } + } + .padding(.top, 4) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .accessibilityLabel("Workflow steps") + } + + private func stepRow( + _ step: WorkflowStepPresentation, + currentInstruction: String? + ) -> some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: step.state.symbol) + .foregroundStyle(step.state.color) + .frame(width: 14) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 4) { + Text(step.title) + .foregroundStyle(step.state == .pending ? .secondary : .primary) + if step.state == .active, let currentInstruction { + Text(currentInstruction) + .font(.caption) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + } + Spacer(minLength: 0) + } + .font(.subheadline) + .accessibilityElement(children: .combine) + } + + private func attentionBlock( + _ run: WorkflowRunPresentation, + message: String + ) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + .accessibilityHidden(true) + Text(message) + .font(.subheadline) + .fixedSize(horizontal: false, vertical: true) + } + controlLayout(run.attentionControls.filter { $0.action != .cancel }, run: run) + } + .padding(10) + .background(.orange.opacity(0.1), in: RoundedRectangle(cornerRadius: 8)) + .accessibilityElement(children: .contain) + .accessibilityLabel("Workflow needs attention") + } + + @ViewBuilder + private func controlLayout( + _ controls: [WorkflowAttentionControl], + run: WorkflowRunPresentation + ) -> some View { + ViewThatFits(in: .horizontal) { + HStack(spacing: 6) { + ForEach(controls) { control in + controlView(control, run: run) + } + } + VStack(alignment: .leading, spacing: 6) { + ForEach(controls) { control in + controlView(control, run: run) + } + } + } + } + + @ViewBuilder + private func controlView( + _ control: WorkflowAttentionControl, + run: WorkflowRunPresentation + ) -> some View { + if control.action == .acceptWithVerdict { + Menu { + ForEach(control.verdicts, id: \.self) { verdict in + Button(verdict) { + perform(control, run: run, verdict: verdict) + } + } + } label: { + Label(control.label, systemImage: control.systemImage) + } + .onHover { hovering in + if hovering { onInteraction() } + } + .help("Accept the delivery with a declared verdict") + } else { + Button { + perform(control, run: run) + } label: { + Label(control.label, systemImage: control.systemImage) + } + .disabled(control.intent(runID: run.id, worktreeID: run.worktreeID) == nil) + .help(control.label) + } + } + + private func footer(_ run: WorkflowRunPresentation) -> some View { + HStack(spacing: 10) { + Button("Reveal Run Folder", systemImage: "folder") { + onInteraction() + onIntent(.revealRunFolder(run.runDirectory)) + } + .help("Reveal this workflow run in Finder") + Button("Open Log", systemImage: "doc.text") { + onInteraction() + onIntent(.openLog(run.logURL)) + } + .help("Open this workflow run's log") + Spacer(minLength: 0) + Button("Cancel Run", role: .destructive) { + onInteraction() + requestConfirmation( + intent: .userAction(runID: run.id, action: .cancel), + label: "Cancel Run", + message: "Cancel this workflow run? Its panes and delivered outputs will be kept.", + isDestructive: true + ) + } + .help("Cancel the workflow and keep its panes and outputs") + } + .controlSize(.small) + } + + private func perform( + _ control: WorkflowAttentionControl, + run: WorkflowRunPresentation, + verdict: String? = nil + ) { + onInteraction() + guard + let intent = control.intent( + runID: run.id, + worktreeID: run.worktreeID, + verdict: verdict + ) + else { return } + if let message = control.confirmationMessage { + requestConfirmation( + intent: intent, + label: control.label, + message: message, + isDestructive: control.isDestructive + ) + } else { + onIntent(intent) + } + } + + private func requestConfirmation( + intent: WorkflowRunPanelIntent, + label: String, + message: String, + isDestructive: Bool + ) { + pendingConfirmation = PendingConfirmation( + intent: intent, + label: label, + message: message, + isDestructive: isDestructive + ) + isConfirming = true + } + + private func selectFirstRunIfNeeded() { + let ids = presentation.runs.map(\.id) + if selectedRunID == nil || selectedRunID.map({ !ids.contains($0) }) == true { + selectedRunID = presentation.attentionRun?.id ?? ids.first + } + } + + private struct PendingConfirmation: Identifiable { + let id = UUID() + let intent: WorkflowRunPanelIntent + let label: String + let message: String + let isDestructive: Bool + } +} + +extension WorkflowRunPresentation.Status { + fileprivate var label: String { + isAttention ? "Needs Attention" : "Running" + } +} + +extension WorkflowStepPresentation.State { + fileprivate var symbol: String { + switch self { + case .pending: "circle" + case .active: "play.circle.fill" + case .completed: "checkmark.circle.fill" + case .skipped: "forward.end.circle" + case .failed: "exclamationmark.circle.fill" + } + } + + fileprivate var color: Color { + switch self { + case .pending: .secondary + case .active: .accentColor + case .completed: .green + case .skipped: .secondary + case .failed: .orange + } + } +} diff --git a/supacode/Features/Repositories/Views/WorktreeDetailToolbarViews.swift b/supacode/Features/Repositories/Views/WorktreeDetailToolbarViews.swift index 6a5fd56a3..c82eb5e53 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailToolbarViews.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailToolbarViews.swift @@ -255,6 +255,11 @@ private struct WorktreeToolbarPreview: View { ), agentsLauncherItems: [], statusToast: nil, + workflowStatus: WorkflowStatusCenterPresentation( + state: WorkflowRunsFeature.State(), + selectedWorktreeID: nil, + now: Date() + ), pullRequest: nil, codeHost: .github, notificationGroups: [], @@ -310,7 +315,8 @@ private struct WorktreeToolbarPreview: View { onActivateUpdateButton: {}, onHandOff: {}, onLaunchProfile: { _ in }, - onManageProfiles: {} + onManageProfiles: {}, + onWorkflowIntent: { _ in } ) } .environment(commandKeyObserver) diff --git a/supacode/Features/Repositories/Views/WorktreeDetailView.swift b/supacode/Features/Repositories/Views/WorktreeDetailView.swift index 483478ec0..1ac2d4db6 100644 --- a/supacode/Features/Repositories/Views/WorktreeDetailView.swift +++ b/supacode/Features/Repositories/Views/WorktreeDetailView.swift @@ -6,6 +6,7 @@ import SwiftUI struct WorktreeDetailView: View { private struct ToolbarSharedStateInput { let repositories: RepositoriesFeature.State + let workflowRuns: WorkflowRunsFeature.State let actionTargetWorktree: Worktree? let notificationGroups: [ToolbarNotificationRepositoryGroup] let unseenNotificationWorktreeCount: Int @@ -66,6 +67,7 @@ struct WorktreeDetailView: View { let sharedToolbarState = toolbarSharedState( input: ToolbarSharedStateInput( repositories: repositories, + workflowRuns: state.workflowRuns, actionTargetWorktree: actionTargetWorktree, notificationGroups: notificationGroups, unseenNotificationWorktreeCount: unseenNotificationWorktreeCount, @@ -154,7 +156,8 @@ struct WorktreeDetailView: View { onActivateUpdateButton: { store.send(.updates(.activateUpdateButton)) }, onHandOff: { store.send(.openHandoffHud) }, onLaunchProfile: { store.send(.launchAgentProfile($0)) }, - onManageProfiles: { store.send(.openAgentProfilesSettings) } + onManageProfiles: { store.send(.openAgentProfilesSettings) }, + onWorkflowIntent: handleWorkflowIntent ) } @@ -165,6 +168,11 @@ struct WorktreeDetailView: View { agentsCapsule: agentsCapsuleState(for: input.actionTargetWorktree), agentsLauncherItems: agentsLauncherItems(for: input.actionTargetWorktree), statusToast: input.repositories.statusToast, + workflowStatus: WorkflowStatusCenterPresentation( + state: input.workflowRuns, + selectedWorktreeID: input.actionTargetWorktree?.id, + now: Date() + ), pullRequest: matchedPullRequest( for: input.actionTargetWorktree, repositories: input.repositories @@ -207,8 +215,10 @@ struct WorktreeDetailView: View { ToolbarItem(placement: .principal) { ToolbarStatusView( toast: state.statusToast, + workflow: state.workflowStatus, pullRequest: state.pullRequest, - codeHost: state.codeHost + codeHost: state.codeHost, + onWorkflowIntent: handleWorkflowIntent ) .padding(.horizontal) } @@ -743,6 +753,20 @@ struct WorktreeDetailView: View { } } + private func handleWorkflowIntent(_ intent: WorkflowRunPanelIntent) { + switch intent { + case .focusPane(let worktreeID, let surfaceID): + store.send(.repositories(.selectWorktree(worktreeID))) + _ = terminalManager.stateIfExists(for: worktreeID)?.focusSurface(id: surfaceID) + case .userAction(let runID, let action): + store.send(.workflowRuns(.userAction(runID: runID, action))) + case .revealRunFolder(let url): + NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: url.path) + case .openLog(let url): + NSWorkspace.shared.open(url) + } + } + /// Hashable identity of the inputs the focused actions capture, used as the /// `FocusedAction` token. The detail body re-runs on every OSC-9 progress /// tick during agent activity; without a stable token each run would look @@ -785,6 +809,7 @@ struct WorktreeDetailView: View { let agentsCapsule: AgentsCapsuleState? let agentsLauncherItems: [AgentsLauncherItem] let statusToast: RepositoriesFeature.StatusToast? + let workflowStatus: WorkflowStatusCenterPresentation let pullRequest: GithubPullRequest? let codeHost: CodeHost let notificationGroups: [ToolbarNotificationRepositoryGroup] @@ -877,6 +902,7 @@ struct WorktreeDetailView: View { let onHandOff: () -> Void let onLaunchProfile: (AgentProfile.ID) -> Void let onManageProfiles: () -> Void + let onWorkflowIntent: (WorkflowRunPanelIntent) -> Void @Environment(\.resolvedKeybindings) private var resolvedKeybindings var body: some ToolbarContent { @@ -899,8 +925,10 @@ struct WorktreeDetailView: View { ToolbarItem(placement: .principal) { ToolbarStatusView( toast: toolbarState.shared.statusToast, + workflow: toolbarState.shared.workflowStatus, pullRequest: toolbarState.shared.pullRequest, - codeHost: toolbarState.shared.codeHost + codeHost: toolbarState.shared.codeHost, + onWorkflowIntent: onWorkflowIntent ) .padding(.horizontal) } diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+Notifications.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+Notifications.swift index a099c19ed..8f8f50f2e 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState+Notifications.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+Notifications.swift @@ -109,13 +109,23 @@ extension WorktreeTerminalState { appendNotification(title: title, body: body, surfaceId: surfaceId) } - func appendNotification(title: String, body: String, surfaceId: UUID) { + func appendNotification( + title: String, + body: String, + surfaceId: UUID, + treatAsViewedWhenWorktreeIsVisible: Bool = false + ) { let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines) guard !(trimmedTitle.isEmpty && trimmedBody.isEmpty) else { return } + let isWorktreeSelected = isSelected() + let isWorkflowViewed = treatAsViewedWhenWorktreeIsVisible && isViewingWorktree() if notificationsEnabled { let previousHasUnseen = hasUnseenNotification - let isRead = isSelected() && isFocusedSurface(surfaceId) + let isRead = + treatAsViewedWhenWorktreeIsVisible + ? isWorkflowViewed + : (isWorktreeSelected && isFocusedSurface(surfaceId)) notifications.insert( WorktreeTerminalNotification( surfaceId: surfaceId, @@ -128,7 +138,8 @@ extension WorktreeTerminalState { ) emitNotificationIndicatorIfNeeded(previousHasUnseen: previousHasUnseen) } - onNotificationReceived?(surfaceId, trimmedTitle, trimmedBody, isViewedSurface(surfaceId)) + let isViewed = treatAsViewedWhenWorktreeIsVisible ? isWorkflowViewed : isViewedSurface(surfaceId) + onNotificationReceived?(surfaceId, trimmedTitle, trimmedBody, isViewed) } static func formatDuration(_ seconds: Int) -> String { diff --git a/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift b/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift index 25b45633a..85f36f2bc 100644 --- a/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift +++ b/supacode/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift @@ -680,16 +680,20 @@ extension WorktreeTerminalState { return focusedSurfaceIdByTab[selectedTabId] == surfaceId } - /// Whether the user is actively looking at `surfaceId` right now: its worktree - /// is selected, it is the focused pane of the selected tab (`isFocusedSurface` - /// already implies both), and the app window is key and visible. Unknown window + /// Whether the user is actively looking at this worktree right now. Unknown window /// state (`nil`) is treated as not-viewed so a notification is never silently /// dropped. Canvas mode is also treated as not-viewed: the normal-mode window /// observers are torn down there, so `lastWindowIsKey`/`lastWindowIsVisible` /// freeze at their pre-canvas values and a backgrounded app would keep muting. - func isViewedSurface(_ surfaceId: UUID) -> Bool { + func isViewingWorktree() -> Bool { guard !isCanvasManaged else { return false } - return isSelected() && isFocusedSurface(surfaceId) && lastWindowIsKey == true && lastWindowIsVisible == true + return isSelected() && lastWindowIsKey == true && lastWindowIsVisible == true + } + + /// Whether the user is actively looking at `surfaceId` right now: its worktree + /// is visible and it is the focused pane of the selected tab. + func isViewedSurface(_ surfaceId: UUID) -> Bool { + isViewingWorktree() && isFocusedSurface(surfaceId) } func updateRunningState(for tabId: TerminalTabID) { diff --git a/supacode/Features/Workflow/Models/WorkflowRunNotice.swift b/supacode/Features/Workflow/Models/WorkflowRunNotice.swift new file mode 100644 index 000000000..b625506c9 --- /dev/null +++ b/supacode/Features/Workflow/Models/WorkflowRunNotice.swift @@ -0,0 +1,69 @@ +import Foundation + +nonisolated struct WorkflowRunNotice: Equatable, Sendable { + enum Kind: Equatable, Sendable { + case needsAttention + case completed + case skipped + case maxRoundsReached + } + + let kind: Kind + let runID: UUID + let worktreeID: Worktree.ID + let workflowName: String + let title: String + let body: String + let targetSurfaceID: UUID? + let postsNotification: Bool + + static func statusEdge( + from previous: WorkflowRunStatus?, + to run: WorkflowRun + ) -> WorkflowRunNotice? { + guard previous != run.status, previous?.isTerminal != true else { return nil } + let kind: Kind + let title: String + let body: String + switch run.status { + case .needsAttention(let attention): + kind = .needsAttention + title = "\(run.definition.name) needs attention" + body = attention.message + case .completed: + kind = .completed + title = "\(run.definition.name) completed" + body = "Workflow completed in \(run.context.worktree.name)." + case .skipped(let step, let dependent): + kind = .skipped + title = "\(run.definition.name) ended after a skipped step" + body = "Step '\(step)' was skipped; step '\(dependent)' depended on its output." + case .maxRoundsReached: + kind = .maxRoundsReached + title = "\(run.definition.name) reached its round limit" + body = "The workflow ended after reaching its maximum number of rounds." + case .running, .cancelled, .interrupted: + return nil + } + return WorkflowRunNotice( + kind: kind, + runID: run.id, + worktreeID: run.context.worktree.id, + workflowName: run.definition.name, + title: title, + body: body, + targetSurfaceID: targetSurfaceID(for: run), + postsNotification: true + ) + } + + static func targetSurfaceID(for run: WorkflowRun) -> UUID? { + let attentionRole = run.status.attention?.role + let currentRole = run.definition.roles.first { $0.source == .current }?.name + return [attentionRole, currentRole, run.currentInvocation?.role] + .compactMap { $0 } + .compactMap { run.bindings[$0]?.pane?.surfaceID } + .first + ?? run.definition.roles.lazy.compactMap { run.bindings[$0.name]?.pane?.surfaceID }.first + } +} diff --git a/supacode/Features/Workflow/Models/WorkflowStatusCenterPresentation.swift b/supacode/Features/Workflow/Models/WorkflowStatusCenterPresentation.swift new file mode 100644 index 000000000..5b78dc5bd --- /dev/null +++ b/supacode/Features/Workflow/Models/WorkflowStatusCenterPresentation.swift @@ -0,0 +1,451 @@ +import Foundation + +@MainActor +struct WorkflowStatusCenterPresentation: Equatable { + let runs: [WorkflowRunPresentation] + + init( + state: WorkflowRunsFeature.State, + selectedWorktreeID: Worktree.ID?, + now: Date + ) { + guard let selectedWorktreeID else { + runs = [] + return + } + runs = state.activeSessions + .map(\.run) + .filter { $0.context.worktree.id == selectedWorktreeID } + .sorted { + if $0.startedAt != $1.startedAt { return $0.startedAt > $1.startedAt } + if $0.updatedAt != $1.updatedAt { return $0.updatedAt > $1.updatedAt } + return $0.id.uuidString > $1.id.uuidString + } + .map { WorkflowRunPresentation(run: $0, now: now) } + } + + var primary: WorkflowRunPresentation? { runs.first } + var attentionRun: WorkflowRunPresentation? { runs.first { $0.status.isAttention } } + var activeRunCount: Int { runs.count } + var hasAttention: Bool { attentionRun != nil } +} + +nonisolated struct WorkflowRunPresentation: Equatable, Sendable, Identifiable { + enum Status: Equatable, Sendable { + case running + case needsAttention(String) + } + + let id: UUID + let workflowName: String + let workflowIcon: String + let worktreeID: Worktree.ID + let worktreeName: String + let startedAt: Date + let elapsedText: String + let status: Status + let currentStepTitle: String + let currentInstruction: String? + let roles: [WorkflowRolePresentation] + let stepItems: [WorkflowStepListItem] + let attentionControls: [WorkflowAttentionControl] + let runDirectory: URL + let logURL: URL + + init(run: WorkflowRun, now: Date) { + id = run.id + workflowName = run.definition.name + workflowIcon = run.definition.icon ?? "point.3.connected.trianglepath.dotted" + worktreeID = run.context.worktree.id + worktreeName = run.context.worktree.name + startedAt = run.startedAt + elapsedText = Self.elapsedText(from: run.startedAt, to: now) + status = run.status.attention.map { .needsAttention($0.message) } ?? .running + let context = Self.templateContext(for: run, iteration: run.currentIteration) + currentStepTitle = Self.title(for: run.currentStep, context: context) ?? "Finishing workflow" + currentInstruction = Self.instruction(for: run.currentStep, context: context) + roles = run.definition.roles.map { role in + WorkflowRolePresentation(role: role, binding: run.bindings[role.name]) + } + stepItems = Self.stepItems(for: run) + if let attention = run.status.attention { + let machine = WorkflowRunMachine( + run: run, + limits: WorkflowDeliveryLimits(), + now: { now }, + makeToken: { "presentation-does-not-mint-tokens" } + ) + let skipConsequence = machine.skipConsequence(forStep: attention.stepID) + attentionControls = attention.actions.map { + WorkflowAttentionControl( + action: $0, + run: run, + attention: attention, + skipConsequence: skipConsequence + ) + } + } else { + attentionControls = [] + } + runDirectory = run.runDirectory + logURL = run.runDirectory.appending(path: "log.md", directoryHint: .notDirectory) + } + + func elapsedText(at now: Date) -> String { + Self.elapsedText(from: startedAt, to: now) + } + + private static func elapsedText(from start: Date, to end: Date) -> String { + let seconds = max(0, Int(end.timeIntervalSince(start))) + if seconds < 60 { return "\(seconds)s" } + let minutes = seconds / 60 + if minutes < 60 { return "\(minutes)m" } + let hours = minutes / 60 + let remainingMinutes = minutes % 60 + if hours < 24 { + return remainingMinutes == 0 ? "\(hours)h" : "\(hours)h \(remainingMinutes)m" + } + let days = hours / 24 + let remainingHours = hours % 24 + return remainingHours == 0 ? "\(days)d" : "\(days)d \(remainingHours)h" + } + + private static func templateContext( + for run: WorkflowRun, + iteration: Int? + ) -> WorkflowTemplateContext { + WorkflowTemplateContext( + run: WorkflowTemplateContext.Run( + id: run.id.uuidString, + directory: WorkflowRunPaths.path(run.runDirectory) + ), + worktree: WorkflowTemplateContext.Worktree( + path: run.context.worktree.path, + name: run.context.worktree.name, + branch: run.context.worktree.branch + ), + roles: run.bindings.mapValues(\.templateRole), + outputs: run.outputs.mapValues { + WorkflowTemplateContext.Output(path: $0.latestPath, verdict: $0.verdict) + }, + skippedOutputs: Set(run.skippedOutputs.keys), + actions: run.actionOutputs, + inputs: run.inputs, + loop: WorkflowTemplateContext.Loop(index: iteration, count: run.loopCount) + ) + } + + private static func title( + for step: WorkflowStepDefinition?, + context: WorkflowTemplateContext + ) -> String? { + guard let step else { return nil } + guard let title = step.title else { return Self.fallbackTitle(for: step) } + return (try? WorkflowTemplate.render(title, context: context)) ?? title + } + + private static func fallbackTitle(for step: WorkflowStepDefinition) -> String { + switch step.action { + case .message(let role, _, _): "Message \(role)" + case .launch(let role, _, _, _): "Launch \(role)" + case .action(let id, _): "Run \(id)" + case .notify: "Send notification" + case .close(let role): "Close \(role)" + case .repeat: step.id + } + } + + private static func instruction( + for step: WorkflowStepDefinition?, + context: WorkflowTemplateContext + ) -> String? { + guard let step else { return nil } + let source: String? + switch step.action { + case .message(_, let content, _): + source = content.body + case .launch(_, let prompt, _, _): + source = prompt + case .action(let id, _): + source = "Run native action \(id)." + case .notify(let text): + source = text + case .close(let role): + source = "Close the pane bound to \(role)." + case .repeat: + source = nil + } + guard let source else { return nil } + return (try? WorkflowTemplate.render(source, context: context)) ?? source + } + + private static func stepItems(for run: WorkflowRun) -> [WorkflowStepListItem] { + var items: [WorkflowStepListItem] = [] + for step in run.definition.steps { + switch step.action { + case .repeat(let bound, _, let body): + let maximum = run.repeatBounds[step.id] ?? bound.literalValue ?? 1 + let recordedIterations = run.stepRecords.compactMap { record in + body.contains { $0.id == record.stepID } ? record.iteration : nil + } + var iterations = Set(recordedIterations) + if run.definition.steps[safe: run.position.index]?.id == step.id, + let current = run.position.loop?.iteration + { + iterations.insert(current) + } + if iterations.isEmpty { + items.append( + .step( + WorkflowStepPresentation( + id: "\(step.id)-pending", + stepID: step.id, + title: title(for: step, context: templateContext(for: run, iteration: nil)) ?? step.id, + state: .pending + ))) + } else { + for iteration in iterations.sorted() { + let steps = body.map { inner in + let record = run.stepRecords.last { + $0.stepID == inner.id && $0.iteration == iteration + } + let context = templateContext(for: run, iteration: iteration) + return WorkflowStepPresentation( + id: "\(step.id)-\(iteration)-\(inner.id)", + stepID: inner.id, + title: title(for: inner, context: context) ?? inner.id, + state: record.map { WorkflowStepPresentation.State($0.state) } ?? .pending + ) + } + items.append( + .round( + WorkflowRoundPresentation( + id: "\(step.id)-\(iteration)", + index: iteration, + maximum: maximum, + steps: steps + ))) + } + } + default: + let record = run.stepRecords.last { $0.stepID == step.id && $0.iteration == nil } + let context = templateContext(for: run, iteration: nil) + items.append( + .step( + WorkflowStepPresentation( + id: "\(step.id)-top", + stepID: step.id, + title: title(for: step, context: context) ?? step.id, + state: record.map { WorkflowStepPresentation.State($0.state) } ?? .pending + ))) + } + } + return items + } +} + +nonisolated extension WorkflowRunPresentation.Status { + var isAttention: Bool { + if case .needsAttention = self { return true } + return false + } +} + +nonisolated struct WorkflowRolePresentation: Equatable, Sendable, Identifiable { + let id: String + let displayName: String + let agent: String? + let paneHandle: String? + let surfaceID: UUID? + + init(role: WorkflowRoleDefinition, binding: WorkflowRoleBinding?) { + id = role.name + displayName = binding?.templateRole.name ?? role.name + agent = binding?.templateRole.agent.nilIfEmpty + paneHandle = binding?.pane?.handle + surfaceID = binding?.pane?.surfaceID + } +} + +nonisolated enum WorkflowStepListItem: Equatable, Sendable, Identifiable { + case step(WorkflowStepPresentation) + case round(WorkflowRoundPresentation) + + var id: String { + switch self { + case .step(let step): step.id + case .round(let round): round.id + } + } +} + +nonisolated struct WorkflowRoundPresentation: Equatable, Sendable, Identifiable { + let id: String + let index: Int + let maximum: Int + let steps: [WorkflowStepPresentation] +} + +nonisolated struct WorkflowStepPresentation: Equatable, Sendable, Identifiable { + enum State: Equatable, Sendable { + case pending + case active + case completed + case skipped + case failed + + init(_ state: WorkflowStepState) { + switch state { + case .active: self = .active + case .completed: self = .completed + case .skipped: self = .skipped + case .failed: self = .failed + } + } + } + + let id: String + let stepID: String + let title: String + let state: State +} + +nonisolated enum WorkflowRunPanelIntent: Equatable, Sendable { + case focusPane(worktreeID: Worktree.ID, surfaceID: UUID) + case userAction(runID: UUID, action: WorkflowUserAction) + case revealRunFolder(URL) + case openLog(URL) +} + +nonisolated struct WorkflowAttentionControl: Equatable, Sendable, Identifiable { + var id: WorkflowAttentionAction { action } + let action: WorkflowAttentionAction + let label: String + let systemImage: String + let isDestructive: Bool + let focusSurfaceID: UUID? + let verdicts: [String] + let confirmationMessage: String? + + init( + action: WorkflowAttentionAction, + run: WorkflowRun, + attention: WorkflowAttention, + skipConsequence: WorkflowSkipConsequence + ) { + self.action = action + let rolePane = attention.role.flatMap { run.bindings[$0]?.pane } + focusSurfaceID = action == .focusPane ? rolePane?.surfaceID : nil + verdicts = action == .acceptWithVerdict ? (run.activeActivation?.expect.verdict ?? []) : [] + isDestructive = action == .cancel + switch action { + case .focusPane: + label = "Focus Pane" + systemImage = "scope" + confirmationMessage = nil + case .nudge: + label = "Nudge Again" + systemImage = "bell.badge" + confirmationMessage = nil + case .keepWaiting: + label = "Keep Waiting" + systemImage = "clock" + confirmationMessage = nil + case .retry: + label = "Retry" + systemImage = "arrow.clockwise" + confirmationMessage = nil + case .relaunch: + label = "Relaunch Role" + systemImage = "arrow.trianglehead.2.clockwise.rotate.90" + confirmationMessage = nil + case .acceptDelivery: + label = "Accept as Delivered" + systemImage = "checkmark" + confirmationMessage = nil + case .acceptWithVerdict: + label = "Accept with Verdict" + systemImage = "checkmark.circle" + confirmationMessage = nil + case .askAgain: + label = "Ask Again" + systemImage = "arrowshape.turn.up.left" + confirmationMessage = nil + case .skip: + label = "Skip Step" + systemImage = "forward.end" + confirmationMessage = Self.skipConfirmation( + stepID: attention.stepID, + consequence: skipConsequence + ) + case .cancel: + label = "Cancel Run" + systemImage = "xmark" + confirmationMessage = "Cancel this workflow run? Its panes and delivered outputs will be kept." + } + } + + func intent( + runID: UUID, + worktreeID: Worktree.ID, + verdict: String? = nil + ) -> WorkflowRunPanelIntent? { + switch action { + case .focusPane: + guard let focusSurfaceID else { return nil } + return .focusPane(worktreeID: worktreeID, surfaceID: focusSurfaceID) + case .nudge: + return .userAction(runID: runID, action: .nudge) + case .keepWaiting: + return .userAction(runID: runID, action: .keepWaiting) + case .retry: + return .userAction(runID: runID, action: .retry) + case .relaunch: + return .userAction(runID: runID, action: .relaunch) + case .acceptDelivery: + return .userAction(runID: runID, action: .acceptDelivery(verdict: nil)) + case .acceptWithVerdict: + guard let verdict, verdicts.contains(verdict) else { return nil } + return .userAction(runID: runID, action: .acceptDelivery(verdict: verdict)) + case .askAgain: + return .userAction(runID: runID, action: .askAgain) + case .skip: + return .userAction(runID: runID, action: .skip) + case .cancel: + return .userAction(runID: runID, action: .cancel) + } + } + + private static func skipConfirmation( + stepID: String, + consequence: WorkflowSkipConsequence + ) -> String { + switch consequence { + case .noOutput: + "Skip step '\(stepID)'? The workflow continues without an output from this step." + case .continues(let optionalInputs): + if optionalInputs.isEmpty { + "Skip step '\(stepID)'? The workflow continues without this output." + } else { + "Skip step '\(stepID)'? The workflow continues without the optional input used by " + + optionalInputs.joined(separator: ", ") + "." + } + case .endsRun(let dependent): + "Skip step '\(stepID)'? This ends the run because step '\(dependent)' depends on its output." + } + } +} +nonisolated extension WorkflowRepeatBound { + fileprivate var literalValue: Int? { + if case .literal(let value) = self { return value } + return nil + } +} +nonisolated extension String { + fileprivate var nilIfEmpty: String? { isEmpty ? nil : self } +} +nonisolated extension Collection { + fileprivate subscript(safe index: Index) -> Element? { + indices.contains(index) ? self[index] : nil + } +} diff --git a/supacode/Features/Workflow/Reducer/WorkflowRunsFeature.swift b/supacode/Features/Workflow/Reducer/WorkflowRunsFeature.swift index 5878ad35e..3df76f0d1 100644 --- a/supacode/Features/Workflow/Reducer/WorkflowRunsFeature.swift +++ b/supacode/Features/Workflow/Reducer/WorkflowRunsFeature.swift @@ -104,6 +104,12 @@ struct WorkflowRunsFeature { case deliver(WorkflowDeliveryRequest) case userAction(runID: UUID, WorkflowUserAction) case markInterruptedRuns(worktreeRoots: [String]) + case delegate(Delegate) + } + + @CasePathable + enum Delegate: Equatable { + case notice(WorkflowRunNotice) } @Dependency(WorkflowRuntimeClient.self) var runtime @@ -134,7 +140,8 @@ struct WorkflowRunsFeature { return .merge( executor(runID: runID, batches: batches), perform(effects, runID: runID, session: session), - resolvePendingStarts(&state, runID: runID, session: session) + resolvePendingStarts(&state, runID: runID, session: session), + statusNotice(from: nil, to: session.run, effects: effects) ) case .event(let runID, let event): @@ -160,7 +167,8 @@ struct WorkflowRunsFeature { resolvePendingDeliveries(&state, runID: runID, session: session), resolvePendingStarts(&state, runID: runID, session: session), perform(effects, runID: runID, session: session), - staleEventCleanup(event, session: session) + staleEventCleanup(event, session: session), + statusNotice(from: previous.status, to: session.run, effects: effects) ) case .deliver(let request): @@ -207,7 +215,8 @@ struct WorkflowRunsFeature { return .merge( resolvePendingDeliveries(&state, runID: runID, session: session), resolvePendingStarts(&state, runID: runID, session: session), - perform(effects, runID: runID, session: session) + perform(effects, runID: runID, session: session), + statusNotice(from: previous.status, to: session.run, effects: effects) ) case .markInterruptedRuns(let roots): @@ -232,10 +241,38 @@ struct WorkflowRunsFeature { } } } + + case .delegate: + return .none } } } + private func statusNotice( + from previous: WorkflowRunStatus?, + to run: WorkflowRun, + effects: [WorkflowRunEffect] + ) -> Effect { + guard let base = WorkflowRunNotice.statusEdge(from: previous, to: run) else { + return .none + } + let hasExplicitNotification = effects.contains { + if case .notify = $0 { return true } + return false + } + let notice = WorkflowRunNotice( + kind: base.kind, + runID: base.runID, + worktreeID: base.worktreeID, + workflowName: base.workflowName, + title: base.title, + body: base.body, + targetSurfaceID: base.targetSurfaceID, + postsNotification: !(base.kind == .completed && hasExplicitNotification) + ) + return .send(.delegate(.notice(notice))) + } + // MARK: - Rendezvous private func respond(_ requestID: UUID, _ resolution: WorkflowRequestResolution) -> Effect { @@ -694,7 +731,14 @@ struct WorkflowRunsFeature { } case .notify(let text): - runtime.notify(session.worktree, text) + runtime.notify( + session.worktree, + WorkflowRuntimeNotification( + title: "Workflow · \(session.run.definition.name)", + body: text, + targetSurfaceID: WorkflowRunNotice.targetSurfaceID(for: session.run) + ) + ) case .close(let role, let surfaceID): // Revocable: a cancel that beat the close keeps the pane (cancel never closes panes); the diff --git a/supacodeTests/AppFeatureWorkflowNoticeTests.swift b/supacodeTests/AppFeatureWorkflowNoticeTests.swift new file mode 100644 index 000000000..a0708a163 --- /dev/null +++ b/supacodeTests/AppFeatureWorkflowNoticeTests.swift @@ -0,0 +1,172 @@ +import ComposableArchitecture +import Foundation +import IdentifiedCollections +import Testing + +@testable import supacode + +@MainActor +struct AppFeatureWorkflowNoticeTests { + @Test func completedRunNotifiesAndShowsToastInTheSelectedWorktree() async { + let worktree = makeWorktree(id: "selected") + var repositories = RepositoriesFeature.State( + repositories: [makeRepository(worktrees: [worktree])] + ) + repositories.snapshotPersistencePhase = .active + repositories.selection = .worktree(worktree.id) + let delivered = LockIsolated<[(Worktree.ID, WorkflowRuntimeNotification)]>([]) + let notice = makeNotice(kind: .completed, worktree: worktree) + let store = TestStore( + initialState: AppFeature.State(repositories: repositories) + ) { + AppFeature() + } withDependencies: { + $0.workflowRuntimeClient.notify = { target, notification in + delivered.withValue { $0.append((target.id, notification)) } + } + } + store.exhaustivity = .off + + await store.send(.workflowRuns(.delegate(.notice(notice)))) + await store.receive(\.repositories.showToast) + await store.finish() + + #expect(delivered.value.map(\.0) == [worktree.id]) + #expect(delivered.value.first?.1.title == "Review completed") + #expect(delivered.value.first?.1.targetSurfaceID == notice.targetSurfaceID) + #expect(store.state.repositories.statusToast == .success("Review completed")) + } + + @Test func backgroundAttentionNotifiesWithoutTakingOverTheSelectedToolbar() async { + let selected = makeWorktree(id: "selected") + let background = makeWorktree(id: "background") + var repositories = RepositoriesFeature.State( + repositories: [makeRepository(worktrees: [selected, background])] + ) + repositories.snapshotPersistencePhase = .active + repositories.selection = .worktree(selected.id) + let delivered = LockIsolated<[(Worktree.ID, WorkflowRuntimeNotification)]>([]) + let notice = makeNotice(kind: .needsAttention, worktree: background) + let store = TestStore( + initialState: AppFeature.State(repositories: repositories) + ) { + AppFeature() + } withDependencies: { + $0.workflowRuntimeClient.notify = { target, notification in + delivered.withValue { $0.append((target.id, notification)) } + } + } + store.exhaustivity = .off + + await store.send(.workflowRuns(.delegate(.notice(notice)))) + await store.finish() + + #expect(delivered.value.map(\.0) == [background.id]) + #expect(store.state.repositories.statusToast == nil) + } + + @Test func explicitCompletionNotificationStillShowsToastWithoutPostingAgain() async { + let worktree = makeWorktree(id: "selected") + var repositories = RepositoriesFeature.State( + repositories: [makeRepository(worktrees: [worktree])] + ) + repositories.snapshotPersistencePhase = .active + repositories.selection = .worktree(worktree.id) + let delivered = LockIsolated<[WorkflowRuntimeNotification]>([]) + var notice = makeNotice(kind: .completed, worktree: worktree) + notice = WorkflowRunNotice( + kind: notice.kind, + runID: notice.runID, + worktreeID: notice.worktreeID, + workflowName: notice.workflowName, + title: notice.title, + body: notice.body, + targetSurfaceID: notice.targetSurfaceID, + postsNotification: false + ) + let store = TestStore( + initialState: AppFeature.State(repositories: repositories) + ) { + AppFeature() + } withDependencies: { + $0.workflowRuntimeClient.notify = { _, notification in + delivered.withValue { $0.append(notification) } + } + } + store.exhaustivity = .off + + await store.send(.workflowRuns(.delegate(.notice(notice)))) + await store.receive(\.repositories.showToast) + await store.finish() + + #expect(delivered.value.isEmpty) + #expect(store.state.repositories.statusToast == .success("Review completed")) + } + + @Test(arguments: [WorkflowRunNotice.Kind.skipped, .maxRoundsReached]) + func selectedNonSuccessTerminalOutcomeShowsAWarning(kind: WorkflowRunNotice.Kind) async { + let worktree = makeWorktree(id: "selected") + var repositories = RepositoriesFeature.State( + repositories: [makeRepository(worktrees: [worktree])] + ) + repositories.snapshotPersistencePhase = .active + repositories.selection = .worktree(worktree.id) + let notice = makeNotice(kind: kind, worktree: worktree) + let store = TestStore( + initialState: AppFeature.State(repositories: repositories) + ) { + AppFeature() + } withDependencies: { + $0.workflowRuntimeClient.notify = { _, _ in } + } + store.exhaustivity = .off + + await store.send(.workflowRuns(.delegate(.notice(notice)))) + await store.receive(\.repositories.showToast) + await store.finish() + + #expect(store.state.repositories.statusToast == .warning(notice.title)) + } + + private func makeNotice( + kind: WorkflowRunNotice.Kind, + worktree: Worktree + ) -> WorkflowRunNotice { + let title = + switch kind { + case .needsAttention: "Review needs attention" + case .completed: "Review completed" + case .skipped: "Review ended after a skipped step" + case .maxRoundsReached: "Review reached its round limit" + } + return WorkflowRunNotice( + kind: kind, + runID: UUID(), + worktreeID: worktree.id, + workflowName: "Review", + title: title, + body: "Status changed.", + targetSurfaceID: UUID(), + postsNotification: true + ) + } + + private func makeWorktree(id: String) -> Worktree { + Worktree( + id: id, + name: id, + detail: "", + workingDirectory: URL(filePath: "/tmp/\(id)", directoryHint: .isDirectory), + repositoryRootURL: URL(filePath: "/tmp/repo", directoryHint: .isDirectory) + ) + } + + private func makeRepository(worktrees: [Worktree]) -> Repository { + Repository( + id: "/tmp/repo", + rootURL: URL(filePath: "/tmp/repo", directoryHint: .isDirectory), + name: "repo", + worktrees: IdentifiedArray(uniqueElements: worktrees) + ) + } +} diff --git a/supacodeTests/CommandFinishedNotificationTests.swift b/supacodeTests/CommandFinishedNotificationTests.swift index 668140155..a56ba53c6 100644 --- a/supacodeTests/CommandFinishedNotificationTests.swift +++ b/supacodeTests/CommandFinishedNotificationTests.swift @@ -115,6 +115,71 @@ struct CommandFinishedNotificationTests { #expect(state.notifications.count == 1) } + @Test func workflowNotificationIsReadAndMarkedViewedWhenItsWorktreeIsVisible() { + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let worktree = makeWorktree() + let state = manager.state(for: worktree) + manager.handleCommand(.setNotificationsEnabled(true)) + manager.handleCommand(.setSelectedWorktreeID(worktree.id)) + state.syncFocus(windowIsKey: true, windowIsVisible: true) + var viewed: [Bool] = [] + state.onNotificationReceived = { _, _, _, isViewed in viewed.append(isViewed) } + + state.appendNotification( + title: "Workflow needs attention", + body: "Reviewer is waiting", + surfaceId: surfaceId, + treatAsViewedWhenWorktreeIsVisible: true + ) + + #expect(state.notifications.count == 1) + #expect(state.notifications.first?.isRead == true) + #expect(viewed == [true]) + } + + @Test func selectedWorkflowNotificationRemainsUnreadWhenTheAppIsInTheBackground() { + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let worktree = makeWorktree() + let state = manager.state(for: worktree) + manager.handleCommand(.setNotificationsEnabled(true)) + manager.handleCommand(.setSelectedWorktreeID(worktree.id)) + state.syncFocus(windowIsKey: false, windowIsVisible: true) + var viewed: [Bool] = [] + state.onNotificationReceived = { _, _, _, isViewed in viewed.append(isViewed) } + + state.appendNotification( + title: "Workflow needs attention", + body: "Reviewer is waiting", + surfaceId: surfaceId, + treatAsViewedWhenWorktreeIsVisible: true + ) + + #expect(state.notifications.count == 1) + #expect(state.notifications.first?.isRead == false) + #expect(viewed == [false]) + } + + @Test func workflowNotificationRemainsUnreadAndPropagatesForABackgroundWorktree() { + let manager = WorktreeTerminalManager(runtime: GhosttyRuntime()) + let worktree = makeWorktree() + let state = manager.state(for: worktree) + manager.handleCommand(.setNotificationsEnabled(true)) + manager.handleCommand(.setSelectedWorktreeID("another-worktree")) + var received = 0 + state.onNotificationReceived = { _, _, _, _ in received += 1 } + + state.appendNotification( + title: "Workflow needs attention", + body: "Reviewer is waiting", + surfaceId: surfaceId, + treatAsViewedWhenWorktreeIsVisible: true + ) + + #expect(state.notifications.count == 1) + #expect(state.notifications.first?.isRead == false) + #expect(received == 1) + } + // MARK: - Duration Formatting @Test func formatDurationSeconds() { diff --git a/supacodeTests/WorkflowRunsFeatureTests.swift b/supacodeTests/WorkflowRunsFeatureTests.swift index 8402d51d7..2c4c912ce 100644 --- a/supacodeTests/WorkflowRunsFeatureTests.swift +++ b/supacodeTests/WorkflowRunsFeatureTests.swift @@ -81,7 +81,7 @@ struct WorkflowRunsFeatureTests { var typed: [WorkflowTypedLineRecord] = [] var launches: [WorkflowLaunchRequest] = [] var closed: [UUID] = [] - var notifications: [String] = [] + var notifications: [WorkflowRuntimeNotification] = [] var opened: [UUID] = [] var cancelled: [String] = [] var abandoned: [(dispatchID: String, reason: String)] = [] @@ -142,7 +142,7 @@ struct WorkflowRunsFeatureTests { closed.append(surfaceID) return true }, - notify: { [self] _, text in notifications.append(text) } + notify: { [self] _, notification in notifications.append(notification) } ) } @@ -1032,6 +1032,110 @@ struct WorkflowRunsFeatureTests { await store.finish(timeout: Self.timeout) } + // MARK: - Presentation notices + + @Test(.dependencies) func statusEdgesEmitOneTypedNotice() async throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let queue = RecordingQueue() + let store = makeStore(fixture, queue: queue.client) + let (session, _) = try fixture.session() + let runID = session.run.id + var expectedMachine = session.machine(now: { Self.now }, makeToken: { "unused" }) + _ = expectedMachine.apply(.injectionFailed(ordinal: 1, .surfaceMissing)) + let notice = try #require( + WorkflowRunNotice.statusEdge(from: session.run.status, to: expectedMachine.run) + ) + + await store.send(.started(session, effects: [])) + await store.send(.event(runID: runID, .injectionFailed(ordinal: 1, .surfaceMissing))) + await store.receive(\.delegate.notice, notice) + #expect(notice.kind == .needsAttention) + #expect(notice.runID == runID) + #expect(notice.worktreeID == fixture.worktree.id) + #expect(notice.targetSurfaceID == Self.authorPane.surfaceID) + #expect(notice.body.contains("pane is gone")) + + // A late event while the same attention state is active is ignored and emits no duplicate. + store.exhaustivity = .on + await store.send(.event(runID: runID, .injectionFailed(ordinal: 1, .surfaceMissing))) + _ = expectedMachine.apply(.user(.cancel)) + await store.send(.userAction(runID: runID, .cancel)) { + $0.sessions[runID]?.run = expectedMachine.run + } + await store.finish(timeout: Self.timeout) + } + + @Test func runNoticeCoversMeaningfulTerminalEdgesButNotExplicitCancellation() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + var session = try fixture.session().0 + let running = session.run.status + + session.run.status = .completed + #expect(WorkflowRunNotice.statusEdge(from: running, to: session.run)?.kind == .completed) + session.run.status = .skipped(step: "brief", dependent: "launch") + #expect(WorkflowRunNotice.statusEdge(from: running, to: session.run)?.kind == .skipped) + session.run.status = .maxRoundsReached + #expect(WorkflowRunNotice.statusEdge(from: running, to: session.run)?.kind == .maxRoundsReached) + session.run.status = .cancelled + #expect(WorkflowRunNotice.statusEdge(from: running, to: session.run) == nil) + #expect(WorkflowRunNotice.statusEdge(from: .completed, to: session.run) == nil) + } + + @Test func changedAttentionEmitsANewNoticeButIdenticalAttentionDoesNot() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + var session = try fixture.session().0 + let ordinal = try #require(session.run.currentInvocation?.ordinal) + let blocked = WorkflowAttention( + reason: .blocked, + stepID: "brief", + role: "author", + ordinal: ordinal, + actions: [.focusPane, .keepWaiting, .cancel], + message: "The role is blocked." + ) + let waiting = WorkflowAttention( + reason: .idleWithoutDelivery, + stepID: "brief", + role: "author", + ordinal: ordinal, + actions: [.focusPane, .nudge, .keepWaiting, .skip, .cancel], + message: "The role went idle without delivering." + ) + session.run.status = .needsAttention(waiting) + + #expect( + WorkflowRunNotice.statusEdge(from: .needsAttention(blocked), to: session.run)?.kind == .needsAttention + ) + #expect(WorkflowRunNotice.statusEdge(from: session.run.status, to: session.run) == nil) + } + + @Test(.dependencies) func explicitFinalNotifySuppressesTheDuplicateGenericCompletionNotification() async throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let queue = RecordingQueue() + let store = makeStore(fixture, queue: queue.client) + var session = try fixture.session().0 + session.run.status = .completed + let base = try #require(WorkflowRunNotice.statusEdge(from: nil, to: session.run)) + let expected = WorkflowRunNotice( + kind: base.kind, + runID: base.runID, + worktreeID: base.worktreeID, + workflowName: base.workflowName, + title: base.title, + body: base.body, + targetSurfaceID: base.targetSurfaceID, + postsNotification: false + ) + + await store.send(.started(session, effects: [.notify("Custom completion")])) + await store.receive(\.delegate.notice, expected) + await store.finish(timeout: Self.timeout) + } + // MARK: - Restart scan @Test(.dependencies) func interruptedRunsAreMarkedOncePerWorktreeRoot() async throws { diff --git a/supacodeTests/WorkflowStatusCenterPresentationTests.swift b/supacodeTests/WorkflowStatusCenterPresentationTests.swift new file mode 100644 index 000000000..cb4df065c --- /dev/null +++ b/supacodeTests/WorkflowStatusCenterPresentationTests.swift @@ -0,0 +1,483 @@ +import Foundation +import Testing + +@testable import supacode + +@MainActor +struct WorkflowStatusCenterPresentationTests { + nonisolated private static let now = Date(timeIntervalSince1970: 1_800_000_000) + + @Test func selectsActiveRunsForTheWorktreeAndOrdersMostRecentlyStartedFirst() throws { + let older = try makeSession( + id: UUID(1), + worktreeID: "selected", + startedAt: Self.now.addingTimeInterval(-120), + updatedAt: Self.now + ) + var attention = try makeSession( + id: UUID(2), + worktreeID: "selected", + startedAt: Self.now.addingTimeInterval(-70), + updatedAt: Self.now.addingTimeInterval(-10) + ) + attention.run.status = .needsAttention( + WorkflowAttention( + reason: .blocked, + stepID: "brief", + role: "author", + ordinal: attention.run.currentInvocation?.ordinal, + actions: [.focusPane, .cancel], + message: "author is blocked" + )) + let otherWorktree = try makeSession(id: UUID(3), worktreeID: "other", updatedAt: Self.now) + var completed = try makeSession(id: UUID(4), worktreeID: "selected", updatedAt: Self.now) + completed.run.status = .completed + + var state = WorkflowRunsFeature.State() + state.sessions = [ + older.run.id: older, + attention.run.id: attention, + otherWorktree.run.id: otherWorktree, + completed.run.id: completed, + ] + + let presentation = WorkflowStatusCenterPresentation( + state: state, + selectedWorktreeID: "selected", + now: Self.now + ) + + #expect(presentation.runs.map(\.id) == [attention.run.id, older.run.id]) + #expect(presentation.primary?.id == attention.run.id) + #expect(presentation.activeRunCount == 2) + #expect(presentation.primary?.status == .needsAttention("author is blocked")) + #expect(presentation.primary?.currentStepTitle == "Write the brief") + #expect(presentation.primary?.elapsedText == "1m") + } + + @Test func runOrderingUsesUpdateAndIdentityOnlyAsStableTieBreakers() throws { + let lowerID = try makeSession( + id: UUID(10), + worktreeID: "selected", + startedAt: Self.now.addingTimeInterval(-60), + updatedAt: Self.now.addingTimeInterval(-20) + ) + let newerUpdate = try makeSession( + id: UUID(11), + worktreeID: "selected", + startedAt: lowerID.run.startedAt, + updatedAt: Self.now.addingTimeInterval(-10) + ) + let higherID = try makeSession( + id: UUID(12), + worktreeID: "selected", + startedAt: lowerID.run.startedAt, + updatedAt: lowerID.run.updatedAt + ) + var state = WorkflowRunsFeature.State() + state.sessions = [ + lowerID.run.id: lowerID, + newerUpdate.run.id: newerUpdate, + higherID.run.id: higherID, + ] + + let presentation = WorkflowStatusCenterPresentation( + state: state, + selectedWorktreeID: "selected", + now: Self.now + ) + + #expect(presentation.runs.map(\.id) == [newerUpdate.run.id, higherID.run.id, lowerID.run.id]) + } + + @Test func anyRunNeedingAttentionIsVisibleWithoutChangingThePrimaryRun() throws { + var olderAttention = try makeSession( + id: UUID(14), + worktreeID: "selected", + startedAt: Self.now.addingTimeInterval(-120), + updatedAt: Self.now + ) + olderAttention.run.status = .needsAttention( + WorkflowAttention( + reason: .blocked, + stepID: "brief", + role: "author", + ordinal: olderAttention.run.currentInvocation?.ordinal, + actions: [.focusPane, .keepWaiting, .cancel], + message: "The older run needs attention." + )) + let newerRunning = try makeSession( + id: UUID(15), + worktreeID: "selected", + startedAt: Self.now.addingTimeInterval(-30), + updatedAt: Self.now + ) + var state = WorkflowRunsFeature.State() + state.sessions = [ + olderAttention.run.id: olderAttention, + newerRunning.run.id: newerRunning, + ] + + let presentation = WorkflowStatusCenterPresentation( + state: state, + selectedWorktreeID: "selected", + now: Self.now + ) + + #expect(presentation.primary?.id == newerRunning.run.id) + #expect(presentation.attentionRun?.id == olderAttention.run.id) + #expect(presentation.hasAttention) + } + + @Test func attentionControlsExhaustivelyMapEveryMachineAction() throws { + var session = try makeSession(id: UUID(5), worktreeID: "selected", updatedAt: Self.now) + let ordinal = try #require(session.run.currentInvocation?.ordinal) + let expectation = WorkflowExpectation(output: "brief", verdict: ["clean", "issues"]) + session.run.invocations[0].activation = WorkflowActivation( + ordinal: ordinal, + stepID: "brief", + role: "author", + token: "token", + expect: expectation, + outputName: "brief", + dispatchID: "dispatch", + state: .provisional, + pendingDelivery: WorkflowValidatedDelivery( + body: "body", + verdict: nil, + issues: [.verdictMissing(allowed: ["clean", "issues"])] + ) + ) + session.run.phase = .waitingForDelivery(ordinal: ordinal) + session.run.status = .needsAttention( + WorkflowAttention( + reason: .deliveryIssues([.verdictMissing(allowed: ["clean", "issues"])]), + stepID: "brief", + role: "author", + ordinal: ordinal, + actions: WorkflowAttentionAction.allCases, + message: "Choose how to continue." + )) + + let run = WorkflowRunPresentation(run: session.run, now: Self.now) + + #expect(run.attentionControls.map(\.action) == WorkflowAttentionAction.allCases) + #expect(run.attentionControls.first { $0.action == .acceptWithVerdict }?.verdicts == ["clean", "issues"]) + for control in run.attentionControls { + if control.action == .acceptWithVerdict { + #expect(control.intent(runID: run.id, worktreeID: run.worktreeID, verdict: "clean") != nil) + } else { + #expect(control.intent(runID: run.id, worktreeID: run.worktreeID) != nil) + } + } + + #expect( + run.attentionControls.first { $0.action == .focusPane }?.intent( + runID: run.id, + worktreeID: run.worktreeID + ) == .focusPane(worktreeID: "selected", surfaceID: Self.authorPane.surfaceID) + ) + #expect( + run.attentionControls.first { $0.action == .acceptWithVerdict }?.intent( + runID: run.id, + worktreeID: run.worktreeID, + verdict: "issues" + ) == .userAction(runID: run.id, action: .acceptDelivery(verdict: "issues")) + ) + } + + @Test func stepListPreservesDocumentOrderAndGroupsRepeatIterations() throws { + var session = try makeSession(id: UUID(6), worktreeID: "selected", updatedAt: Self.now) + session.run.position = WorkflowRunPosition( + index: 1, + loop: WorkflowRunPosition.Loop(iteration: 2, bodyIndex: 0, max: 3) + ) + session.run.stepRecords = [ + WorkflowStepRecord(stepID: "brief", iteration: nil, state: .completed, ordinal: 1), + WorkflowStepRecord(stepID: "fix", iteration: 1, state: .completed, ordinal: 2), + WorkflowStepRecord(stepID: "rereview", iteration: 1, state: .completed, ordinal: 3), + WorkflowStepRecord(stepID: "fix", iteration: 2, state: .active, ordinal: 4), + ] + let briefPath = "/tmp/selected/.prowl/workflow-runs/\(session.run.id.uuidString)/outputs/brief.md" + session.run.outputs["brief"] = WorkflowOutputRecord( + name: "brief", + ordinal: 1, + path: briefPath, + latestPath: briefPath, + verdict: nil, + deliveredAt: Self.now + ) + session.run.invocations = [ + WorkflowInvocation( + ordinal: 4, + stepID: "fix", + iteration: 2, + role: "author", + kind: .message, + startedAt: Self.now, + instructionPath: nil, + activation: nil, + endedAt: nil + ) + ] + session.run.phase = .waitingForRole(role: "author", ordinal: 4) + + let run = WorkflowRunPresentation(run: session.run, now: Self.now) + let expectedInstruction = + "Read /tmp/selected/.prowl/workflow-runs/\(run.id.uuidString)/outputs/brief.md " + + "and address the findings in round 2." + + #expect(run.currentStepTitle == "Round 2: address findings") + #expect(run.currentInstruction == expectedInstruction) + #expect(run.stepItems.count == 4) + guard case .step(let brief) = run.stepItems[0] else { + Issue.record("Expected the top-level brief step first") + return + } + #expect(brief.stepID == "brief") + #expect(brief.state == .completed) + guard case .round(let firstRound) = run.stepItems[1] else { + Issue.record("Expected round 1 after the brief") + return + } + #expect(firstRound.index == 1) + #expect(firstRound.maximum == 3) + #expect(firstRound.steps.map(\.stepID) == ["fix", "rereview"]) + guard case .round(let secondRound) = run.stepItems[2] else { + Issue.record("Expected round 2 after round 1") + return + } + #expect(secondRound.steps.map(\.stepID) == ["fix", "rereview"]) + #expect(secondRound.steps.map(\.state) == [.active, .pending]) + guard case .step(let finish) = run.stepItems[3] else { + Issue.record("Expected the final top-level step last") + return + } + #expect(finish.stepID == "finish") + #expect(finish.state == .pending) + } + + @Test func rolesAndElapsedTimeStayHonestAtPresentationEdges() throws { + var session = try makeSession( + id: UUID(13), + worktreeID: "selected", + startedAt: Self.now.addingTimeInterval(-90_061), + updatedAt: Self.now, + yaml: Self.twoRoleWorkflow, + bindings: [ + "author": .current(Self.authorPane), + "reviewer": .launch(Self.reviewerProfile, pane: nil), + ] + ) + session.run.position = WorkflowRunPosition(index: session.run.definition.steps.count, loop: nil) + + let run = WorkflowRunPresentation(run: session.run, now: Self.now) + + #expect(run.roles.map(\.displayName) == ["Author", "Pi Reviewer"]) + #expect(run.roles.map(\.surfaceID) == [Self.authorPane.surfaceID, nil]) + #expect(run.currentStepTitle == "Finishing workflow") + #expect(run.currentInstruction == nil) + #expect(run.elapsedText == "1d 1h") + #expect(run.elapsedText(at: session.run.startedAt.addingTimeInterval(-1)) == "0s") + } + + @Test func skipControlExplainsWhetherTheRunContinuesOrEnds() throws { + var ending = try makeSession(id: UUID(7), worktreeID: "selected", updatedAt: Self.now) + let ordinal = try #require(ending.run.currentInvocation?.ordinal) + ending.run.status = .needsAttention( + WorkflowAttention( + reason: .idleWithoutDelivery, + stepID: "brief", + role: "author", + ordinal: ordinal, + actions: [.skip, .cancel], + message: "No delivery." + )) + + let endingRun = WorkflowRunPresentation(run: ending.run, now: Self.now) + let endingSkip = try #require(endingRun.attentionControls.first { $0.action == .skip }) + #expect(endingSkip.confirmationMessage?.contains("ends the run") == true) + #expect(endingSkip.confirmationMessage?.contains("fix") == true) + + var continuing = try makeSession( + id: UUID(8), + worktreeID: "selected", + updatedAt: Self.now, + yaml: Self.independentOutputWorkflow + ) + let continuingOrdinal = try #require(continuing.run.currentInvocation?.ordinal) + continuing.run.status = .needsAttention( + WorkflowAttention( + reason: .idleWithoutDelivery, + stepID: "note", + role: "author", + ordinal: continuingOrdinal, + actions: [.skip, .cancel], + message: "No delivery." + )) + let continuingRun = WorkflowRunPresentation(run: continuing.run, now: Self.now) + let continuingSkip = try #require(continuingRun.attentionControls.first { $0.action == .skip }) + #expect(continuingSkip.confirmationMessage?.contains("continues") == true) + } + + @Test func toolbarSelectionKeepsToastAboveWorkflowAndWorkflowAboveFallbacks() throws { + let session = try makeSession(id: UUID(9), worktreeID: "selected", updatedAt: Self.now) + var state = WorkflowRunsFeature.State() + state.sessions[session.run.id] = session + let workflow = WorkflowStatusCenterPresentation( + state: state, + selectedWorktreeID: "selected", + now: Self.now + ) + + #expect( + ToolbarStatusSelection( + toast: .success("Saved"), + workflow: workflow, + pullRequest: nil + ) == .toast(.success("Saved")) + ) + #expect( + ToolbarStatusSelection( + toast: nil, + workflow: workflow, + pullRequest: nil + ) == .workflow(workflow) + ) + #expect( + ToolbarStatusSelection( + toast: nil, + workflow: WorkflowStatusCenterPresentation( + state: WorkflowRunsFeature.State(), + selectedWorktreeID: "selected", + now: Self.now + ), + pullRequest: nil + ) == .motivational + ) + } + + nonisolated private static let authorPane = WorkflowPaneIdentity( + surfaceID: UUID(101), + tabID: UUID(102), + handle: "p1", + displayName: "Author", + agent: "codex" + ) + + nonisolated private static let reviewerProfile = WorkflowProfileBinding( + id: UUID(100), + name: "Pi Reviewer", + agent: "pi" + ) + + nonisolated private static let workflow = """ + schema: prowl.workflow/v1 + id: test.status-center + name: Status Center Test + roles: + author: + source: current + steps: + - id: brief + title: "Write the brief" + message: author + instruction: "Write a brief." + expect: { output: brief } + - id: rounds + repeat: + max: 3 + steps: + - id: fix + title: "Round {{ loop.index }}: address findings" + message: author + instruction: "Read {{ outputs.brief.path }} and address the findings in round {{ loop.index }}." + expect: { output: disposition } + - id: rereview + title: "Round {{ loop.index }}: re-review" + message: author + text: "Review again." + expect: { output: findings, verdict: [clean, issues] } + - id: finish + notify: "Done" + """ + + nonisolated private static let independentOutputWorkflow = """ + schema: prowl.workflow/v1 + id: test.independent-output + name: Independent Output + roles: + author: + source: current + steps: + - id: note + message: author + text: "Write an optional note." + expect: { output: note } + - id: finish + notify: "Done" + """ + + nonisolated private static let twoRoleWorkflow = """ + schema: prowl.workflow/v1 + id: test.two-role + name: Two Role Test + roles: + author: + source: current + reviewer: + source: launch + agents: [claude] + steps: + - id: brief + message: author + text: "Write a brief." + """ + + private func makeSession( + id: UUID, + worktreeID: String, + startedAt: Date = Self.now.addingTimeInterval(-70), + updatedAt: Date, + yaml: String = Self.workflow, + bindings: [String: WorkflowRoleBinding]? = nil + ) throws -> WorkflowRunSession { + let definition = try #require(WorkflowDocumentParser.parse(yaml).definition) + let started = try WorkflowRunMachine.start( + WorkflowRunStartRequest( + definition: definition, + runID: id, + context: WorkflowRunContext( + scope: .user, + definitionPath: nil, + worktree: WorkflowRunWorktree( + id: worktreeID, + name: worktreeID, + branch: "feat/status", + path: "/tmp/\(worktreeID)" + ) + ), + bindings: bindings ?? ["author": .current(Self.authorPane)], + selfInitiated: false + ), + now: { startedAt }, + makeToken: { "token" } + ) + var run = started.machine.run + run.updatedAt = updatedAt + let worktree = Worktree( + id: worktreeID, + name: worktreeID, + detail: "", + workingDirectory: URL(filePath: "/tmp/\(worktreeID)", directoryHint: .isDirectory), + repositoryRootURL: URL(filePath: "/tmp/\(worktreeID)", directoryHint: .isDirectory) + ) + return WorkflowRunSession(run: run, worktree: worktree, launchPlans: [:]) + } +} + +extension UUID { + fileprivate nonisolated init(_ value: UInt8) { + self.init(uuid: (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, value)) + } +}