Merge PR #156: pending-verification — applied_pending non-terminal outcome
All checks were successful
CI / frontend (push) Successful in 5m6s
Mirror to GitHub / mirror (push) Successful in 6s
CI / backend (push) Successful in 10m6s
CI / e2e (push) Successful in 10m33s

Adds applied_pending non-terminal status, pending_reason column, PendingBanner UI, and review fixes for page-level Resolve/Escalate intercepts.

QA: 5/7 scripted checks PASS with concrete evidence. 2 entry-path checks deferred — same handlers verified via tested transitions.
This commit was merged in pull request #156.
This commit is contained in:
2026-05-01 03:42:10 +00:00
17 changed files with 422 additions and 119 deletions

View File

@@ -1,83 +1,50 @@
# CURRENT_TASK.md # CURRENT_TASK.md
**Task:** Build **Escalation Mode** — the wedge for ResolutionFlow's GTM (first paying-customer push). When a junior tech escalates a FlowPilot session, the senior tech sees structured handoff context in seconds instead of running a 5-minute verbal "tell me what you tried" call. **Task:** Add a fourth, non-terminal outcome to the suggested-fix banner — **Awaiting verification** (`applied_pending`). Today the verifying banner forces a synchronous verdict (worked / didn't / partial), but a lot of real fixes are async — engineer ran the script but is waiting on the client to power-cycle, AD replication, an O365 license sync. Without a fourth state the banner sits stale or the engineer guesses wrong.
**Status:****Engineering complete.** Browser QA passed (2026-04-30). Branch `feat/escalation-metric-endpoint`; PR #155 ready to mark ready-for-review. **Status:****Engineering complete; PR #156 open.** Backend tests, prompt guardrail, frontend tsc/build clean; Alembic migration applies. Pending browser QA.
**Plan:** [`docs/plans/2026-04-27-escalation-mode-wedge-design.md`](../docs/plans/2026-04-27-escalation-mode-wedge-design.md). Reviewed by `/office-hours`, `/plan-eng-review`, `/plan-design-review`, `/codex review`. Eng + Design CLEARED. **Branch:** `feat/fix-pending-verification` (off `main` after the Escalation Mode merge).
**Test plan artifact:** [`docs/plans/2026-04-27-escalation-mode-wedge-test-plan.md`](../docs/plans/2026-04-27-escalation-mode-wedge-test-plan.md). **PR:** https://gitea.resolutionflow.com/chihlasm/resolutionflow/pulls/156
## What's done (all sessions combined) ## What ships
All plan items complete. Key commits on `feat/escalation-metric-endpoint`: | Layer | Change |
| Commit | What it ships |
|---|---| |---|---|
| `d51e95c` | Plan + test-plan artifacts | | Schema | New `FixStatus="applied_pending"` + new `pending_reason` Text column on `session_suggested_fixes`. |
| `52f6d03` | `GET /analytics/flowpilot/escalations` — time-to-first-action metric | | Migration | `c0f3a4b7e91d` — adds `pending_reason`, extends status CHECK constraint. |
| `7a5b853` | Role-gate claim to engineer-or-admin | | API | `PATCH /suggested-fixes/{id}/outcome` accepts `applied_pending`, requires `notes`, stamps `applied_at` only (NOT `verified_at`). Pending in/out transitions allowed (parked, like partial). |
| `07d0db9` | Email notifications on escalation | | Generators | `resolution_note_generator` and `escalation_package_generator` system prompts handle the new status without real-looking examples; resolution note frames the fix as provisional; escalation package surfaces pending verification as the leading hypothesis with reference to what's being waited on. |
| `9f0bfd4` | `EscalationMetricCard` on `/escalations` | | Frontend | New `BannerMode='pending'` + `PendingBanner` component (info-tone, mirrors `PartialBanner`) with worked / didn't / update reason / dismiss actions; "Waiting to verify…" overflow option in `VerifyingBanner`; nudge "Still checking" now records pending with a reason instead of just silencing; `AssistantChatPage` banner-mode derivation maps `applied_pending → 'pending'`; page-level Resolve/Escalate now handle pending honestly. |
| `b8627f4` | SSE live-arrival animations in `EscalationQueue` | | Tests | 4 new integration tests in `test_fix_outcome_endpoint.py`: pending-requires-notes, pending stores reason + applied_at-not-verified_at, pending→success transition, pending_reason update on re-PATCH. 21/21 pass. Prompt anti-parrot guardrail passes. Frontend `tsc -b` and Vite build pass via Docker. |
| `8e9d22e` | Magic-moment handoff-context screen |
| `641853a` | Bell-icon opens pickup flow |
| `029680a` | Unify `/escalate` through `HandoffManager` |
| `0f00ee5` | Plan-locked polish: chips, unread dot, race toast, AI refresh |
| `665530f` | Structural task-lane race fix |
| `db717b0` | 3-option CTA, copy button fix, post-escalation redirect, claim 500 fix |
| `dc69c9d` | Allow `escalated_to_id` to send chat (GET AI analysis fix) |
**Browser QA results (2026-04-30):** ## Out of scope (intentionally)
- ✅ Post-escalation redirect (dashboard + toast) - Cross-session "Follow-ups" dashboard rollup. The chat-anchored `PendingBanner` is the per-session reminder. Add the dashboard surface only if engineers report losing track across multiple pending sessions.
- ✅ Magic-moment screen: header, AI assessment, 2-option CTA - Optional follow-up timer ("remind me in 30m"). Nice but not the wedge.
- ✅ "I'll take it from here": claim → dismiss → composer focused
- ✅ "Get AI analysis": claim → briefing → AI responds → task lane populates
- ✅ Task lane copy button: toast + checkmark
- ✅ Chip expansion: inline detail + "Open in Tasks panel"
- ✅ Post-claim overlay: dismissible mode, Close only
## Done on `feat/escalation-metric-endpoint` (branched from `main` @ `c0ed6d9`) ## Resume point — DO THIS NEXT
| Commit | What it ships | **Browser QA:** verify the new flow end-to-end in dev:
|---|---| 1. Trigger a suggested fix, click Apply, then open the verifying banner overflow → "Waiting to verify…" → enter a reason → confirm `PendingBanner` renders with the reason.
| `d51e95c` | Plan + test-plan artifacts | 2. From `PendingBanner`, click "It worked" → confirm transition to terminal success and banner dismissal.
| `52f6d03` | `GET /analytics/flowpilot/escalations` — in-product time-to-first-action | 3. From `PendingBanner`, click "Update reason" → confirm reason updates server-side.
| `7a5b853` | Role-gate POST `/handoffs/{id}/claim` to engineer-or-admin | 4. From `PendingBanner`, click "Dismiss" → confirm banner dismissal and no terminal timestamps.
| `07d0db9` | `HandoffManager.dispatch_escalation_notifications` — emails engineer/admin teammates | 5. Trigger the nudge state (3+ post-apply messages) → click "Still checking" → enter a reason → confirm pending state takes over.
| `9f0bfd4` | `EscalationMetricCard` mounted above the queue list | 6. From a pending fix, use page-level Resolve → confirm the fix is patched to `applied_success` before the resolution note.
| `bc15952` | Codex: stabilize SSE backend tests | 7. From a pending fix, use page-level Escalate → confirm the outcome intercept appears before the conclude modal.
| `9bdd995` | Bound escalation assessment latency (ORIGINAL: 5s) |
| `b8627f4` | Frontend SSE subscription in `EscalationQueue.tsx` — live-arrival animations |
| `8e9d22e` | Magic-moment handoff-context screen on pickup |
| `641853a` | Bell-icon notification opens the pickup flow |
| `029680a` | Unify `/escalate` through `HandoffManager` |
| `8914391` | First task-lane race fix (insufficient — see `665530f`) |
| `0f00ee5` | Four plan-locked items: live AI refresh, suggested-step chips, unread dot, race-condition toast |
| `665530f` | Structural task-lane fix — `taskLaneOwnerChatId` tagging |
| `b7d7ff0` | docs(ai): refresh handoff for compute swap |
| `0d1b305` | **Live-test fixes**: selectChat-gating bug (loadedChatIdsRef), 45s timeout bump, Enter-to-submit on escalate forms, dashboard expand-to-preview |
## Live-test results (2026-04-29 morning) After QA passes, commit/push the local review fixes and merge PR #156.
After the structural task-lane fix and the four polish items, end-to-end test confirmed: ## Just-shipped (2026-04-30)
- ✅ Junior escalates → senior gets bell-icon notification. **PR #155 — Escalation Mode wedge** merged into main as `ac42f97`. The wedge for ResolutionFlow's GTM (first paying-customer push). Senior tech sees structured handoff context in seconds via the magic-moment screen. Plan: [`docs/plans/2026-04-27-escalation-mode-wedge-design.md`](../docs/plans/2026-04-27-escalation-mode-wedge-design.md).
- ✅ Magic-moment screen renders with handoff data on Pick Up.
- ✅ Senior's chat surface loads with conversation history (after `0d1b305`'s selectChat fix — was completely broken before).
- ✅ Sidebar shows the picked-up session with the "Escalated" pill (after `0d1b305`'s `loadChats()` call).
- ✅ Suggested-step chips render below the composer.
- ✅ Unread 6px dot on queue cards.
- ✅ Task-lane regression is gone — no stale flash on new sessions.
-**AI assessment placeholder never clears.** Drives the consolidation work above.
Untested live (low priority, can verify post-consolidation): race-condition toast (needs second user in same account). ## Two-metric framing (Escalation Mode — read before quoting numbers)
## Two-metric framing — read this before quoting numbers to anyone The in-product `GET /analytics/flowpilot/escalations` endpoint measures *post-claim time-to-first-action*. The "minutes recovered" sales claim is `manual_baseline in_product_metric`. Manual baseline comes from the founder's stopwatch on the next 5 escalations. Don't roll the in-product number alone into "minutes recovered" — that's the apples-to-oranges miscount Codex caught.
The in-product endpoint measures *post-claim time-to-first-action*. The "minutes recovered" sales claim is `manual_baseline in_product_metric`. Manual baseline comes from the founder's stopwatch on the next 5 escalations. Don't roll the in-product number alone into "minutes recovered" — that's the apples-to-oranges miscount Codex caught. ## Kill-switch (Escalation Mode)
## Kill-switch
Week 8: if 0 of 3 pilots produce a verifiable hours-saved-per-week number above 1.0, revisit the wedge. Week 8: if 0 of 3 pilots produce a verifiable hours-saved-per-week number above 1.0, revisit the wedge.

View File

@@ -13,6 +13,28 @@
--- ---
## 2026-04-30 — Add `applied_pending` non-terminal status to suggested fixes
**Context:** The verifying banner forces a synchronous verdict — worked / didn't / partial — but a lot of real MSP fixes are async. Engineer ran the script but is waiting on the client to power-cycle, AD replication, an O365 license sync. With only the existing outcomes, the engineer either leaves the banner stale (eroding the verifying signal) or guesses wrong (corrupting outcome data). User flagged the gap directly. Today's `NudgeBanner` "Still checking" button just silences the nudge — it doesn't tell the system anything.
**Decision:** Add a fourth, non-terminal outcome `applied_pending`, parallel to `applied_partial`. Required `pending_reason` Text column stores the "what are you waiting on?" reason. Outcome endpoint allows pending → {success, failed, partial, dismissed} transitions; pending stamps `applied_at` but NOT `verified_at` (it's parked, not verified). Resolution-note generator frames the fix as provisional (no closure language); escalation-package generator surfaces pending verification as the leading hypothesis with a reference to what's being waited on. Frontend exposes the state via a new `PendingBanner` component (info-tone, mirrors `PartialBanner`) plus a "Waiting to verify…" overflow option in the verifying banner. `NudgeBanner` "Still checking" now records pending with a reason instead of just silencing.
**Rejected:**
- **Reuse `applied_partial`.** Semantically wrong — partial means "I did some of it." Pending means "I did all of it, just can't tell if it worked." Generators write different prose for each, and conflating them would lose the distinction in the customer-facing resolution note and the next-engineer escalation handoff.
- **Add a `pending_reason` column without a new status.** The status field is what the dashboard, banner, and generators all branch on. Hiding pending state in a separate column would proliferate `IF pending_reason IS NOT NULL` checks across every consumer.
- **Cross-session "Follow-ups" dashboard rollup in v1.** Per-session `PendingBanner` is the chat-anchored reminder. Add the dashboard surface only if engineers report losing track across multiple pending sessions in pilot use.
- **Optional follow-up timer ("remind me in 30m").** Out of scope; nice-to-have but not the wedge.
**Consequences:**
- Engineers can park a fix honestly without losing the verifying signal. The state survives across sessions because it's persisted server-side.
- `pending_reason` is preserved as audit trail when the engineer advances pending → success/failed/dismissed; it is not auto-cleared. Intentional — it tells the next reader "we waited for X, then it worked."
- New consumers of `FixStatus` must handle the `applied_pending` case. Currently three: the banner derivation in `AssistantChatPage`, the resolution-note generator, and the escalation-package generator. All three updated in this change.
- Migration `c0f3a4b7e91d` is reversible — downgrade rewrites pending rows back to `applied_partial` and copies `pending_reason` into `partial_notes` if the partial slot was empty, then drops the column.
---
## 2026-04-30 — Allow `escalated_to_id` to send chat messages in claimed sessions ## 2026-04-30 — Allow `escalated_to_id` to send chat messages in claimed sessions
**Context:** During browser QA, clicking "Get AI analysis" on the magic-moment screen returned `POST /ai-sessions/{id}/chat → 400`. The senior tech who claimed the session is stored as `escalated_to_id` on `AISession`, not `user_id` (which remains the junior who created the session). `unified_chat_service.send_chat_message` queried `WHERE ai_sessions.user_id = :user_id`, so the senior's ID never matched and the endpoint rejected the request. **Context:** During browser QA, clicking "Get AI analysis" on the magic-moment screen returned `POST /ai-sessions/{id}/chat → 400`. The senior tech who claimed the session is stored as `escalated_to_id` on `AISession`, not `user_id` (which remains the junior who created the session). `unified_chat_service.send_chat_message` queried `WHERE ai_sessions.user_id = :user_id`, so the senior's ID never matched and the endpoint rejected the request.

View File

@@ -2,55 +2,51 @@
# HANDOFF.md # HANDOFF.md
**Last updated:** 2026-04-30 (Codex review-fix pass) **Last updated:** 2026-05-01 (session 5 — PR #156 review fixes applied)
**Active task:** **Escalation Mode** wedge — BROWSER QA COMPLETE + review fixes applied. Branch: `feat/escalation-metric-endpoint`. PR #155 ready to mark ready-for-review after committing this fix pass. **Active task:** Suggested-fix `applied_pending` outcome. Branch: `feat/fix-pending-verification`. PR #156 open; review fixes applied locally and ready for browser QA, then commit/merge.
**Just-merged:** PR #155 (Escalation Mode wedge) merged into main as `ac42f97`.
## Where this session ended ## Where this session ended
Code-review fixes were applied after browser QA: PR #156 was reviewed for missed bugs and three fixes were applied:
- `claim_session` now uses atomic conditional `UPDATE ... WHERE claimed_by IS NULL` instead of read-then-write, so simultaneous senior pickup cannot silently overwrite `claimed_by`. 1. Page-level **Resolve** now treats `applied_pending` as a verifying state and patches the fix to `applied_success` before opening the resolution flow, avoiding provisional notes on a resolved session.
- Original escalators cannot claim their own handoff. The escalation queue also excludes the current user's own escalated sessions, preventing the post-escalation dashboard from showing the junior their own handoff. 2. Page-level **Escalate** intercept now catches `applied_pending` as well as verifying/partial, so a pending fix cannot bypass outcome capture before handoff. Intercept copy was generalized from "Verifying state" to "still needs an outcome."
- `session.escalation_package["handoff_id"]` is now populated from a preassigned UUID instead of `None` before flush. 3. `PendingBanner` now includes a **Dismiss** action, matching the PR body and backend's allowed pending → dismissed transition.
- Frontend build blockers removed: deleted unused legacy `claiming` / `handleStartHere` path in `AssistantChatPage` and unused `onStartHere` destructuring in `HandoffContextScreen`. 4. Real-looking pending examples were removed from `resolution_note_generator` and `escalation_package_generator` system prompts to stay aligned with the prompt anti-parrot guardrail.
**Validation:** **Validation on PR #156:**
- `git diff --check` - `docker exec resolutionflow_backend pytest --override-ini="addopts=" tests/test_prompt_anti_parrot.py -q` ✅ 2/2 pass.
- `cd backend && pytest --override-ini='addopts=' tests/test_handoff_manager.py tests/test_session_handoffs_api.py tests/test_escalation_bus.py``28 passed in 42.23s` - `docker exec resolutionflow_backend pytest --override-ini="addopts=" tests/test_fix_outcome_endpoint.py -q` ✅ 21/21 pass.
- `cd frontend && /config/.bun/bin/bunx tsc -p tsconfig.app.json --noEmit --pretty false && /config/.bun/bin/bunx tsc -p tsconfig.node.json --noEmit --pretty false` - `docker exec -w /app resolutionflow_frontend npx tsc -b` ✅ exit 0.
- Full frontend build could not complete because generated dirs are root-owned in this workspace: `frontend/node_modules/.tmp`, `frontend/node_modules/.vite-temp`, and likely `frontend/dist` produce EACCES. Type errors from review are fixed. - `docker exec -w /app resolutionflow_frontend npm run build` ✅ exit 0; only existing Vite large-chunk warning.
- `git diff --check` ✅ clean.
**Not testable in dev (known limitations):** - Previous session also verified `alembic upgrade heads` applied migration `c0f3a4b7e91d` cleanly.
- "Continue where X left off": requires senior to have existing task lane for session (won't occur on first pickup)
- Browser-level 409 race toast still requires two distinct senior accounts. Backend claim write is now atomic and covered by service/API tests for conflict, self-claim, and idempotent same-user retry.
## Resume point — DO THIS NEXT ## Resume point — DO THIS NEXT
**Ship:** Commit this review-fix pass, then mark PR #155 ready-for-review and demo to stakeholder. **Browser QA on PR #156** (see CURRENT_TASK.md "Resume point" for the checklist). Include the new review-fix paths: PendingBanner Dismiss, page-level Resolve from pending, and Escalate from pending. Then commit local fixes, push, and merge when QA is green.
Optional before shipping: ## Key files for PR #156
- Record Loom demo walking through the escalation flow end-to-end
## Key files changed this session - `backend/app/models/session_suggested_fix.py` — CHECK constraint extended; `pending_reason` Text column.
- `backend/app/schemas/session_suggested_fix.py``applied_pending` added to both `FixStatus` and `FixOutcome` literals; `pending_reason` on response model; updated docstring on `SessionSuggestedFixOutcomeRequest`.
- `backend/app/services/handoff_manager.py``_generate_handoff_summary` replaces old assessment pair; `enrich_escalation_async` unified; `claim_session` eager-loads `handed_off_by_user` - `backend/alembic/versions/71efd2102f49_add_pending_status_to_suggested_fixes.py` — new migration (rev `c0f3a4b7e91d`, down `71efd2102f49`).
- `backend/app/api/endpoints/ai_sessions.py` — escalation queue excludes the current user's own escalations - `backend/app/api/endpoints/session_suggested_fixes.py``patch_outcome` accepts pending, requires notes, stamps applied_at only.
- `backend/app/api/endpoints/session_handoffs.py` — self-claim returns 403 - `backend/app/services/{resolution_note,escalation_package}_generator.py` — system-prompt handling for the new status; `pending_reason` line in input bundle; real-looking examples removed.
- `backend/app/services/flowpilot_engine.py``generate_status_update` early-returns saved prose for `context='escalation'` - `backend/tests/test_fix_outcome_endpoint.py`4 new tests.
- `backend/app/schemas/session_handoff.py``handed_off_by_name: str | None = None` added - `frontend/src/api/sessionSuggestedFixes.ts` — types updated; `pending_reason` on `SessionSuggestedFix`.
- `backend/app/api/endpoints/session_handoffs.py` — both create + claim endpoints pass `handed_off_by_name` - `frontend/src/components/pilot/ProposalBanner.tsx``'pending'` `BannerMode`; `PendingBanner` component + Dismiss; "Waiting to verify…" overflow option; nudge "Still checking" wired to record pending.
- `frontend/src/types/branching.ts``HandoffResponse` updated with `summary_prose`, `what_we_know`, `confidence: string`, `handed_off_by_name` - `frontend/src/components/pilot/EscalateInterceptDialog.tsx`copy generalized for pending/partial/verifying outcome capture.
- `frontend/src/components/flowpilot/HandoffContextScreen.tsx` — 3-option CTA; `hasTaskLane`, `activeOptionKey`, `onContinue/onAIAnalysis/onOwnThing` props - `frontend/src/pages/AssistantChatPage.tsx` — banner-mode derivation maps `applied_pending → 'pending'`; Resolve/Escalate paths now handle pending.
- `frontend/src/components/assistant/TaskLane.tsx``id="task-lane-card-{idx}"` on all card variants
- `frontend/src/pages/AssistantChatPage.tsx``handleContinue`, `handleAIAnalysis`, `handleOwnThing` handlers; chip → card navigation; `activeOptionKey` state
- `backend/tests/test_handoff_manager.py`, `backend/tests/test_session_handoffs_api.py` — regression coverage for atomic/idempotent claim, self-claim rejection, queue self-exclusion, and pre-flush handoff ID
## Watch-outs ## Watch-outs
- Dev stack: backend `:8000`, frontend `:5173`, postgres `:5433` (docker-compose). HMR works. - Dev stack: backend `:8000`, frontend `:5173`, postgres `:5433` (docker-compose). HMR works. On this host use `docker exec` for Python/npm commands; see `.ai/PROJECT_CONTEXT.md`.
- Test users (Acme MSP, password `TestPass123!`): `engineer@resolutionflow.example.com` (junior), `teamadmin@resolutionflow.example.com` (senior). - Test users (Acme MSP, password `TestPass123!`): `engineer@resolutionflow.example.com`, `teamadmin@resolutionflow.example.com`.
- `handleAIAnalysis` pre-adds `urlSessionId` to `loadedChatIdsRef` before dismissing so the normal selectChat effect doesn't double-fire. It then calls `selectChat` manually before sending the briefing. - Multi-head alembic state on main is pre-existing (heads `070`, `c0f3a4b7e91d`, `024`); not introduced by this work but worth knowing if `alembic upgrade head` complains — use `upgrade heads` (plural).
- Legacy `claiming` / `handleStartHere` on `AssistantChatPage` was removed; `activeOptionKey !== null` is the active pre-claim processing signal. - `pending_reason` is preserved as audit trail when an engineer advances pending → success/failed/dismissed; it is not auto-cleared. Intentional.
- The bus is acceptable for v1 pilot scale only (Railway single-replica). Redis pub/sub is the swap when horizontal scaling appears. - Working tree also has documentation edits in `.ai/PROJECT_CONTEXT.md` and `AGENTS.md` describing Docker exec commands. Those were not part of the feature fix but should be preserved.

View File

@@ -89,6 +89,15 @@ python -m scripts.seed_trees # seed (from
**Never pass `--rev-id`** to alembic — let it generate the hex hash. **Never pass `--rev-id`** to alembic — let it generate the hex hash.
**On hosts without native `python`/`node`/`npm`** (e.g. the code-server LXC), run commands inside the already-running containers instead:
```bash
docker exec resolutionflow_backend pytest --override-ini="addopts="
docker exec resolutionflow_backend alembic upgrade head
docker exec -w /app resolutionflow_frontend npm run build
docker exec -w /app resolutionflow_frontend npx tsc -b
```
--- ---
## URLs & test users ## URLs & test users

View File

@@ -12,6 +12,31 @@
--- ---
## 2026-05-01 02:24 UTC — Codex — Review-fix PR #156 pending-verification flow
- Reviewed PR #156 for bugs and found three actionable gaps: pending fixes could be resolved from the page-level Resolve path without updating the fix outcome, the PendingBanner lacked the dismiss action described in the PR body, and new system-prompt examples used real-looking pending reasons contrary to the prompt anti-parrot lesson.
- Applied fixes locally on `feat/fix-pending-verification`: page-level Resolve now patches `applied_pending` to `applied_success`; page-level Escalate now intercepts `applied_pending` before handoff; PendingBanner now has Dismiss; escalation intercept copy no longer says only "Verifying state"; generator prompts no longer include real-looking pending examples.
- Verified via running containers: prompt anti-parrot guardrail `2 passed`, suggested-fix outcome suite `21 passed`, frontend `npx tsc -b` clean, frontend `npm run build` clean except the existing Vite large-chunk warning, and `git diff --check` clean.
- Left for next session: browser QA PR #156 using CURRENT_TASK.md checklist, then commit/push local review fixes and merge.
- Files touched: `backend/app/services/resolution_note_generator.py`, `backend/app/services/escalation_package_generator.py`, `frontend/src/components/pilot/ProposalBanner.tsx`, `frontend/src/components/pilot/EscalateInterceptDialog.tsx`, `frontend/src/pages/AssistantChatPage.tsx`, `.ai/HANDOFF.md`, `.ai/CURRENT_TASK.md`, `.ai/SESSION_LOG.md`.
## 2026-04-30 — Claude Code — Land PR #155, ship pending-verification feature on PR #156
- Committed Codex's review-pass changes (atomic conditional `UPDATE` for `claim_session`, self-claim 403, queue self-exclusion, pre-flush handoff UUID, frontend dead-code removal) as `f10649a` on `feat/escalation-metric-endpoint`.
- Pushed `feat/escalation-metric-endpoint`, un-drafted PR #155, retitled it (stripped "WIP:"), and merged via Gitea API as a merge commit (`ac42f97`). 4/4 CI checks green at merge.
- Picked up follow-up work surfaced by the user: the suggested-fix verifying banner forces a synchronous verdict, but real fixes are often async (waiting on client power-cycle, AD replication, license sync). Added a fourth, non-terminal outcome.
- Designed the model: new `FixStatus="applied_pending"` parallel to `applied_partial`. Distinct semantics — partial = "did some of it"; pending = "did all of it, can't verify yet." Distinct prose in the resolution-note + escalation-package generators.
- Implemented on a fresh branch `feat/fix-pending-verification` off main:
- Backend: extended `FixStatus`/`FixOutcome` literals, added `pending_reason` Text column and CHECK constraint update via Alembic migration `c0f3a4b7e91d`. `patch_outcome` accepts pending, requires notes, stamps `applied_at` only (NOT `verified_at`); pending in/out transitions allowed.
- Frontend: new `BannerMode='pending'` + `PendingBanner` component (info-tone, mirrors `PartialBanner`). "Waiting to verify…" added to `VerifyingBanner` overflow menu. `NudgeBanner` "Still checking" button now records `applied_pending` with a reason instead of just silencing for the session — closes the loop semantically. `AssistantChatPage` banner-mode derivation maps the new status.
- Tests: 4 new integration tests in `test_fix_outcome_endpoint.py` covering notes-required, reason-storage with applied_at-not-verified_at semantics, pending→success transition, and pending_reason update on re-PATCH. 21/21 pass.
- Validation: `tsc --noEmit -p tsconfig.app.json` exit 0; `alembic upgrade heads` applied cleanly.
- Single-commit PR #156 opened: https://gitea.resolutionflow.com/chihlasm/resolutionflow/pulls/156. Branch rebased onto post-merge main.
- Cleanup: removed 10 stray `core.*` dumps from the worktree; deleted merged `feat/escalation-metric-endpoint` locally and on the remote.
- Files touched: `backend/app/models/session_suggested_fix.py`, `backend/app/schemas/session_suggested_fix.py`, `backend/app/api/endpoints/session_suggested_fixes.py`, `backend/app/services/resolution_note_generator.py`, `backend/app/services/escalation_package_generator.py`, `backend/tests/test_fix_outcome_endpoint.py`, `backend/alembic/versions/71efd2102f49_add_pending_status_to_suggested_fixes.py`, `frontend/src/api/sessionSuggestedFixes.ts`, `frontend/src/components/pilot/ProposalBanner.tsx`, `frontend/src/pages/AssistantChatPage.tsx`, `.ai/CURRENT_TASK.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`, `.ai/DECISIONS.md`.
---
## 2026-04-30 06:25 UTC — Codex — Apply Escalation Mode review fixes ## 2026-04-30 06:25 UTC — Codex — Apply Escalation Mode review fixes
- Reviewed the recent Escalation Mode wedge work and fixed the actionable findings before PR #155 is marked ready. - Reviewed the recent Escalation Mode wedge work and fixed the actionable findings before PR #155 is marked ready.

View File

@@ -40,7 +40,7 @@ Prefer correct architecture over minimal diff. Flag "simpler approach" tradeoffs
### Tooling you do NOT have ### Tooling you do NOT have
- **No GitNexus tools.** Use `grep -r`, `rg`, `git grep`, or `find` for code search. For blast-radius reasoning, grep call sites manually and read the files. - **No GitNexus tools.** Use `grep -r`, `rg`, `git grep`, or `find` for code search. For blast-radius reasoning, grep call sites manually and read the files.
- **No gstack slash commands** (`/review`, `/ship`, `/qa`, `/browse`, `/investigate`, `/design-review`, `/plan-*`). Run the equivalent work directly: `pytest` for tests, `npm run build` for frontend validation, manual PR description for review flow. - **No gstack slash commands** (`/review`, `/ship`, `/qa`, `/browse`, `/investigate`, `/design-review`, `/plan-*`). Run the equivalent work directly: `pytest` for tests, `npm run build` for frontend validation, manual PR description for review flow. If `python`/`npm` aren't on PATH, the host runs services in Docker — use the `docker exec resolutionflow_{backend,frontend} …` form documented in `.ai/PROJECT_CONTEXT.md` rather than installing toolchains.
- **No `/codex` second-opinion command.** You are Codex. - **No `/codex` second-opinion command.** You are Codex.
### Git trailer ### Git trailer

View File

@@ -0,0 +1,60 @@
"""add applied_pending status + pending_reason to session_suggested_fixes
Adds the `applied_pending` non-terminal status (engineer ran the fix but
verification is deferred — waiting on client, async sync, etc) alongside
the existing `applied_partial` status. Mirrors partial_notes with a new
pending_reason column for the "what are you waiting on?" prose.
Revision ID: c0f3a4b7e91d
Revises: 71efd2102f49
Create Date: 2026-04-30
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "c0f3a4b7e91d"
down_revision: Union[str, None] = "71efd2102f49"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"session_suggested_fixes",
sa.Column("pending_reason", sa.Text(), nullable=True),
)
op.drop_constraint(
"ck_session_suggested_fixes_status",
"session_suggested_fixes",
type_="check",
)
op.create_check_constraint(
"ck_session_suggested_fixes_status",
"session_suggested_fixes",
"status IN ('proposed', 'applied_success', 'applied_failed', "
"'applied_partial', 'applied_pending', 'dismissed')",
)
def downgrade() -> None:
op.execute(
"UPDATE session_suggested_fixes "
"SET status = 'applied_partial', "
" partial_notes = COALESCE(partial_notes, pending_reason) "
"WHERE status = 'applied_pending'"
)
op.drop_constraint(
"ck_session_suggested_fixes_status",
"session_suggested_fixes",
type_="check",
)
op.create_check_constraint(
"ck_session_suggested_fixes_status",
"session_suggested_fixes",
"status IN ('proposed', 'applied_success', 'applied_failed', "
"'applied_partial', 'dismissed')",
)
op.drop_column("session_suggested_fixes", "pending_reason")

View File

@@ -318,6 +318,11 @@ async def patch_suggested_fix_outcome(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="notes are required when outcome is applied_partial", detail="notes are required when outcome is applied_partial",
) )
if body.outcome == "applied_pending" and not (body.notes and body.notes.strip()):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="notes are required when outcome is applied_pending",
)
TERMINAL = {"applied_success", "applied_failed", "dismissed"} TERMINAL = {"applied_success", "applied_failed", "dismissed"}
if fix.status in TERMINAL: if fix.status in TERMINAL:
@@ -329,6 +334,10 @@ async def patch_suggested_fix_outcome(
fix.status = body.outcome fix.status = body.outcome
if body.outcome == "applied_partial": if body.outcome == "applied_partial":
fix.partial_notes = (body.notes or "").strip() or None fix.partial_notes = (body.notes or "").strip() or None
elif body.outcome == "applied_pending":
# Pending is parked, not terminal — keep applied_at, do NOT stamp
# verified_at. Reason explains what the engineer is waiting on.
fix.pending_reason = (body.notes or "").strip() or None
elif body.outcome == "applied_failed": elif body.outcome == "applied_failed":
fix.failure_reason = (body.notes or "").strip() or None fix.failure_reason = (body.notes or "").strip() or None
fix.verified_at = now fix.verified_at = now

View File

@@ -37,7 +37,7 @@ class SessionSuggestedFix(Base):
), ),
CheckConstraint( CheckConstraint(
"status IN ('proposed', 'applied_success', 'applied_failed', " "status IN ('proposed', 'applied_success', 'applied_failed', "
"'applied_partial', 'dismissed')", "'applied_partial', 'applied_pending', 'dismissed')",
name="ck_session_suggested_fixes_status", name="ck_session_suggested_fixes_status",
), ),
) )
@@ -81,6 +81,7 @@ class SessionSuggestedFix(Base):
DateTime(timezone=True), nullable=True DateTime(timezone=True), nullable=True
) )
partial_notes: Mapped[str | None] = mapped_column(Text, nullable=True) partial_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
pending_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
failure_reason: Mapped[str | None] = mapped_column(Text, nullable=True) failure_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
ai_outcome_proposal: Mapped[dict[str, Any] | None] = mapped_column( ai_outcome_proposal: Mapped[dict[str, Any] | None] = mapped_column(
JSONB, nullable=True JSONB, nullable=True

View File

@@ -20,6 +20,7 @@ FixStatus = Literal[
"applied_success", "applied_success",
"applied_failed", "applied_failed",
"applied_partial", "applied_partial",
"applied_pending",
"dismissed", "dismissed",
] ]
@@ -40,6 +41,7 @@ class SessionSuggestedFixResponse(BaseModel):
applied_at: datetime | None applied_at: datetime | None
verified_at: datetime | None verified_at: datetime | None
partial_notes: str | None partial_notes: str | None
pending_reason: str | None
failure_reason: str | None failure_reason: str | None
ai_outcome_proposal: dict[str, Any] | None ai_outcome_proposal: dict[str, Any] | None
@@ -91,7 +93,11 @@ class SessionSuggestedFixDecisionResponse(BaseModel):
# Subset of FixStatus that the engineer can set via the outcome endpoint — # Subset of FixStatus that the engineer can set via the outcome endpoint —
# `proposed` is excluded because you can't un-decide a fix back to "proposed". # `proposed` is excluded because you can't un-decide a fix back to "proposed".
FixOutcome = Literal[ FixOutcome = Literal[
"applied_success", "applied_failed", "applied_partial", "dismissed" "applied_success",
"applied_failed",
"applied_partial",
"applied_pending",
"dismissed",
] ]
@@ -103,14 +109,18 @@ class SessionSuggestedFixOutcomeRequest(BaseModel):
engineer took); outcome captures whether the fix actually worked. engineer took); outcome captures whether the fix actually worked.
Allowed transitions: Allowed transitions:
- from `proposed` or `applied_partial`: any outcome is valid - from `proposed`, `applied_partial`, or `applied_pending`: any outcome
(partial is parked, not terminal — the engineer may update notes, is valid. Partial means "did some of it"; pending means "did all of
abandon via dismiss, or advance to success/failed) it but verification is deferred (waiting on client, async sync, etc)".
Both are parked, not terminal — the engineer may advance them to
success/failed/dismiss.
- from any terminal outcome (`applied_success`, `applied_failed`, - from any terminal outcome (`applied_success`, `applied_failed`,
`dismissed`): server returns 409 `dismissed`): server returns 409
""" """
outcome: FixOutcome outcome: FixOutcome
# Required for applied_partial, optional for applied_failed, ignored otherwise. # Required for applied_partial AND applied_pending; optional for
# applied_failed; ignored otherwise. For pending, this is the
# "what are you waiting on?" reason (e.g. "client power-cycling router").
notes: str | None = Field(None, max_length=500) notes: str | None = Field(None, max_length=500)

View File

@@ -63,6 +63,9 @@ the active suggested fix, as given in the input bundle under "Outcome status":>
provided. State that it did not resolve the issue. provided. State that it did not resolve the issue.
- applied_partial: Include the fix as a partially tried path. Include partial \ - applied_partial: Include the fix as a partially tried path. Include partial \
notes if provided. Indicate it was not fully completed or not verified. notes if provided. Indicate it was not fully completed or not verified.
- applied_pending: List the fix as applied but awaiting verification. Include \
the pending reason if provided. Make it clear the next engineer should follow \
up to confirm it worked.
- applied_success: Note that the fix was applied and verified but escalation \ - applied_success: Note that the fix was applied and verified but escalation \
is still needed for another reason (unusual — reflect this accurately). is still needed for another reason (unusual — reflect this accurately).
- dismissed: Do not mention the fix as a tried path; it was only considered. - dismissed: Do not mention the fix as a tried path; it was only considered.
@@ -80,6 +83,8 @@ symptoms are still being narrowed."
- applied_failed or dismissed: Say the proposed fix did not hold or was set \ - applied_failed or dismissed: Say the proposed fix did not hold or was set \
aside. State any remaining uncertainty. aside. State any remaining uncertainty.
- applied_partial: Note the partial application and what remains open. - applied_partial: Note the partial application and what remains open.
- applied_pending: Note that the fix is in place but unverified. Reference the \
pending reason. Frame this as the leading hypothesis pending confirmation.
- applied_success: Unusual in an escalate path — state the fix resolved the \ - applied_success: Unusual in an escalate path — state the fix resolved the \
original symptom but a new or related issue requires escalation. original symptom but a new or related issue requires escalation.
@@ -92,6 +97,8 @@ accordingly — e.g. suggest alternatives or deeper investigation paths, \
drawing on the failure reason if provided. \ drawing on the failure reason if provided. \
If the fix is partially applied (applied_partial), the first step is typically \ If the fix is partially applied (applied_partial), the first step is typically \
to complete or verify it. \ to complete or verify it. \
If the fix is pending verification (applied_pending), the first step is \
typically to confirm whether the fix held — reference what was being waited on. \
If the fix is still proposed (no outcome), the first step is to try it if \ If the fix is still proposed (no outcome), the first step is to try it if \
confidence is high (>80%).> confidence is high (>80%).>
@@ -299,6 +306,8 @@ class EscalationPackageGeneratorService:
lines.append(f"Verified at: {active_fix.verified_at.isoformat()}") lines.append(f"Verified at: {active_fix.verified_at.isoformat()}")
if active_fix.partial_notes: if active_fix.partial_notes:
lines.append(f"Partial notes: {active_fix.partial_notes}") lines.append(f"Partial notes: {active_fix.partial_notes}")
if active_fix.pending_reason:
lines.append(f"Pending reason: {active_fix.pending_reason}")
if active_fix.failure_reason: if active_fix.failure_reason:
lines.append(f"Failure reason: {active_fix.failure_reason}") lines.append(f"Failure reason: {active_fix.failure_reason}")

View File

@@ -83,6 +83,10 @@ state means the engineer resolved the issue another way; the note should cover \
that actual resolution, not just the failed attempt. that actual resolution, not just the failed attempt.
- applied_partial: Note that the fix was partially applied. If partial_notes \ - applied_partial: Note that the fix was partially applied. If partial_notes \
are provided, include them. Then describe the final resolution path taken. are provided, include them. Then describe the final resolution path taken.
- applied_pending: Note that the fix was applied and verification is pending. \
If pending_reason is provided, include it as the provided waiting reason. \
Frame the resolution as provisional — the fix is in place but not yet \
confirmed. Do not write closure language.
- dismissed: Treat the fix as considered and set aside. Do not center the note \ - dismissed: Treat the fix as considered and set aside. Do not center the note \
on it. Describe the resolution based on what was actually confirmed and done. on it. Describe the resolution based on what was actually confirmed and done.
- proposed (no outcome yet): Write "Resolution not yet applied — fix proposed: \ - proposed (no outcome yet): Write "Resolution not yet applied — fix proposed: \
@@ -322,6 +326,8 @@ class ResolutionNoteGeneratorService:
lines.append(f"Verified at: {active_fix.verified_at.isoformat()}") lines.append(f"Verified at: {active_fix.verified_at.isoformat()}")
if active_fix.partial_notes: if active_fix.partial_notes:
lines.append(f"Partial notes: {active_fix.partial_notes}") lines.append(f"Partial notes: {active_fix.partial_notes}")
if active_fix.pending_reason:
lines.append(f"Pending reason: {active_fix.pending_reason}")
if active_fix.failure_reason: if active_fix.failure_reason:
lines.append(f"Failure reason: {active_fix.failure_reason}") lines.append(f"Failure reason: {active_fix.failure_reason}")

View File

@@ -193,6 +193,95 @@ async def test_applied_at_auto_stamped_on_first_outcome(
assert body["verified_at"] is not None assert body["verified_at"] is not None
@pytest.mark.asyncio
async def test_pending_requires_notes(
client: AsyncClient, test_user, auth_headers, test_db
):
"""applied_pending requires notes (the "what are you waiting on?" reason)."""
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
r = await client.patch(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
headers=auth_headers,
json={"outcome": "applied_pending"},
)
assert r.status_code == 400
assert "notes" in r.text.lower()
@pytest.mark.asyncio
async def test_pending_stores_reason_and_stamps_applied_at(
client: AsyncClient, test_user, auth_headers, test_db
):
"""applied_pending stores notes under pending_reason and stamps applied_at
but NOT verified_at — the fix is parked, not verified."""
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
r = await client.patch(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
headers=auth_headers,
json={"outcome": "applied_pending", "notes": "client power-cycling router"},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["status"] == "applied_pending"
assert body["pending_reason"] == "client power-cycling router"
assert body["applied_at"] is not None
assert body["verified_at"] is None
assert body["partial_notes"] is None
assert body["failure_reason"] is None
@pytest.mark.asyncio
async def test_pending_to_success_allowed(
client: AsyncClient, test_user, auth_headers, test_db
):
"""pending is non-terminal — engineer can advance to success once verified."""
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
r1 = await client.patch(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
headers=auth_headers,
json={"outcome": "applied_pending", "notes": "waiting on AD replication"},
)
assert r1.status_code == 200
r2 = await client.patch(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
headers=auth_headers,
json={"outcome": "applied_success"},
)
assert r2.status_code == 200
body = r2.json()
assert body["status"] == "applied_success"
assert body["verified_at"] is not None
# pending_reason is preserved as audit trail
assert body["pending_reason"] == "waiting on AD replication"
@pytest.mark.asyncio
async def test_pending_reason_can_be_updated(
client: AsyncClient, test_user, auth_headers, test_db
):
"""pending→pending with new notes updates the stored pending_reason."""
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
r1 = await client.patch(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
json={"outcome": "applied_pending", "notes": "waiting on AD replication"},
headers=auth_headers,
)
assert r1.status_code == 200
assert r1.json()["pending_reason"] == "waiting on AD replication"
r2 = await client.patch(
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
json={"outcome": "applied_pending", "notes": "now waiting on client to confirm login"},
headers=auth_headers,
)
assert r2.status_code == 200
assert r2.json()["pending_reason"] == "now waiting on client to confirm login"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_failed_outcome_stores_notes_as_failure_reason( async def test_failed_outcome_stores_notes_as_failure_reason(
client: AsyncClient, test_user, auth_headers, test_db client: AsyncClient, test_user, auth_headers, test_db

View File

@@ -13,12 +13,14 @@ export type FixStatus =
| 'applied_success' | 'applied_success'
| 'applied_failed' | 'applied_failed'
| 'applied_partial' | 'applied_partial'
| 'applied_pending'
| 'dismissed' | 'dismissed'
export type FixOutcome = export type FixOutcome =
| 'applied_success' | 'applied_success'
| 'applied_failed' | 'applied_failed'
| 'applied_partial' | 'applied_partial'
| 'applied_pending'
| 'dismissed' | 'dismissed'
export interface AIOutcomeProposal { export interface AIOutcomeProposal {
@@ -41,6 +43,7 @@ export interface SessionSuggestedFix {
applied_at: string | null applied_at: string | null
verified_at: string | null verified_at: string | null
partial_notes: string | null partial_notes: string | null
pending_reason: string | null
failure_reason: string | null failure_reason: string | null
ai_outcome_proposal: AIOutcomeProposal | null ai_outcome_proposal: AIOutcomeProposal | null
superseded_at: string | null superseded_at: string | null
@@ -126,11 +129,12 @@ export const sessionSuggestedFixesApi = {
/** /**
* Record the outcome of applying a suggested fix. Transition rules: * Record the outcome of applying a suggested fix. Transition rules:
* - from `proposed` or `applied_partial`: any outcome is valid (partial is * - from `proposed`, `applied_partial`, or `applied_pending`: any outcome
* parked, not terminal — engineer may update notes, abandon via dismiss, * is valid. Partial = "did some of it"; pending = "did all of it but
* or advance to success/failed). * verification is deferred". Both are parked, not terminal.
* - from a terminal status (`applied_success`, `applied_failed`, `dismissed`): * - from a terminal status (`applied_success`, `applied_failed`, `dismissed`):
* server returns 409. * server returns 409.
* - `applied_pending` requires `notes` (the "what are you waiting on?" reason).
*/ */
async patchOutcome( async patchOutcome(
sessionId: string, sessionId: string,

View File

@@ -49,8 +49,8 @@ export function EscalateInterceptDialog({
Before escalating what happened with the fix? Before escalating what happened with the fix?
</div> </div>
<div className="text-[12px] text-muted-foreground leading-[1.5] mb-3"> <div className="text-[12px] text-muted-foreground leading-[1.5] mb-3">
&ldquo;{fixTitle}&rdquo; is still in the Verifying state. Tag its outcome so &ldquo;{fixTitle}&rdquo; still needs an outcome. Tag it so the senior
the senior picking this up knows what&apos;s been tried. picking this up knows what&apos;s been tried.
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<button <button

View File

@@ -10,7 +10,7 @@
* + 07-verify-states.html. * + 07-verify-states.html.
*/ */
import { useState } from 'react' import { useState } from 'react'
import { Sparkles, Check, ChevronDown, X, MoreHorizontal, Info } from 'lucide-react' import { Sparkles, Check, ChevronDown, X, MoreHorizontal, Info, Clock3 } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { import type {
SessionSuggestedFix, SessionSuggestedFix,
@@ -21,6 +21,7 @@ export type BannerMode =
| 'proposed' // AI just proposed; engineer hasn't applied yet | 'proposed' // AI just proposed; engineer hasn't applied yet
| 'verifying' // Engineer clicked Apply; awaiting outcome | 'verifying' // Engineer clicked Apply; awaiting outcome
| 'partial' // Applied partially; awaiting finish or terminal outcome | 'partial' // Applied partially; awaiting finish or terminal outcome
| 'pending' // Applied fully; verification deferred (waiting on client, etc)
| 'ai_confirming' // AI emitted [FIX_OUTCOME]; engineer confirms | 'ai_confirming' // AI emitted [FIX_OUTCOME]; engineer confirms
| 'nudge' // Compact nudge shown after N post-apply messages | 'nudge' // Compact nudge shown after N post-apply messages
@@ -45,6 +46,7 @@ export function ProposalBanner(props: ProposalBannerProps) {
case 'proposed': return <ProposedBanner {...props} /> case 'proposed': return <ProposedBanner {...props} />
case 'verifying': return <VerifyingBanner {...props} /> case 'verifying': return <VerifyingBanner {...props} />
case 'partial': return <PartialBanner {...props} /> case 'partial': return <PartialBanner {...props} />
case 'pending': return <PendingBanner {...props} />
case 'ai_confirming': return <AIConfirmingBanner {...props} /> case 'ai_confirming': return <AIConfirmingBanner {...props} />
case 'nudge': return <NudgeBanner {...props} /> case 'nudge': return <NudgeBanner {...props} />
} }
@@ -148,7 +150,7 @@ function VerifyingBanner({ fix, onOutcome }: ProposalBannerProps) {
</button> </button>
{showOverflow && ( {showOverflow && (
<div className={cn( <div className={cn(
'absolute top-full right-0 mt-1 w-48 rounded-lg', 'absolute top-full right-0 mt-1 w-56 rounded-lg',
'border border-white/10 bg-card shadow-xl py-1 z-10', 'border border-white/10 bg-card shadow-xl py-1 z-10',
)}> )}>
<button <button
@@ -161,6 +163,17 @@ function VerifyingBanner({ fix, onOutcome }: ProposalBannerProps) {
> >
Mark partial Mark partial
</button> </button>
<button
onClick={() => {
setShowOverflow(false)
const reason = window.prompt('What are you waiting on? (e.g. "client power-cycling router")')
if (reason && reason.trim()) onOutcome('applied_pending', reason.trim())
}}
className="w-full text-left px-3 py-2 text-[12.5px] hover:bg-elevated text-primary inline-flex items-center gap-2"
>
<Clock3 size={12} className="text-info" />
Waiting to verify
</button>
</div> </div>
)} )}
<button <button
@@ -247,6 +260,72 @@ function PartialBanner({ fix, onOutcome, onApply }: ProposalBannerProps) {
) )
} }
function PendingBanner({ fix, onOutcome, onDismiss }: ProposalBannerProps) {
return (
<div className="relative border-t border-info/30 bg-gradient-to-b from-info-dim/40 to-info-dim/20 px-5 py-3 animate-slide-up">
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-info" />
<div className="flex items-start gap-3">
<div className="shrink-0 mt-0.5 w-7 h-7 rounded-md border border-info/30 bg-info-dim flex items-center justify-center text-info">
<Clock3 size={15} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 font-heading text-[10px] font-semibold uppercase tracking-[1.2px] text-info">
<span>Awaiting verification</span>
<span className="px-2 py-[2px] rounded-full bg-info/20 text-info text-[10.5px] font-bold normal-case tracking-normal">
Parked
</span>
</div>
<div className="mt-0.5 text-[14px] font-semibold text-heading leading-snug">
{fix.title}
</div>
{fix.pending_reason && (
<div className="mt-1.5 flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-info/[0.08] border border-info/30 text-[12px] italic text-primary">
<span className="not-italic font-bold text-info text-[10.5px] uppercase tracking-[0.6px]">Waiting on</span>
<span>{fix.pending_reason}</span>
</div>
)}
</div>
<div className="flex items-center gap-2 shrink-0 pt-0.5">
<button
onClick={onDismiss}
className="px-3 py-[9px] rounded-lg text-muted-foreground text-[12.5px] hover:bg-white/[0.08] hover:text-primary"
>
Dismiss
</button>
<button
onClick={() => {
const reason = window.prompt(
'Update what you\'re waiting on:',
fix.pending_reason ?? '',
)
if (reason && reason.trim()) onOutcome('applied_pending', reason.trim())
}}
className="px-3 py-[9px] rounded-lg text-muted-foreground text-[12.5px] hover:bg-white/[0.08] hover:text-primary"
>
Update reason
</button>
<button
onClick={() => {
const reason = window.prompt("Why didn't it work? (optional)")
onOutcome('applied_failed', reason?.trim() || undefined)
}}
className="px-3 py-[9px] rounded-lg border border-danger/30 text-danger text-[12.5px] font-medium hover:bg-danger-dim hover:border-danger"
>
Didn't work
</button>
<button
onClick={() => onOutcome('applied_success')}
className="px-3 py-[9px] rounded-lg bg-success text-[#0a1a12] font-semibold text-[12.5px] hover:brightness-110 inline-flex items-center gap-1.5"
>
<Check size={12} strokeWidth={2.5} />
It worked
</button>
</div>
</div>
</div>
)
}
function AIConfirmingBanner({ fix, onAcceptAIProposal, onRejectAIProposal }: ProposalBannerProps) { function AIConfirmingBanner({ fix, onAcceptAIProposal, onRejectAIProposal }: ProposalBannerProps) {
const proposal = fix.ai_outcome_proposal const proposal = fix.ai_outcome_proposal
if (!proposal) return null if (!proposal) return null
@@ -318,9 +397,19 @@ function NudgeBanner({ fix, onOutcome, onSilenceNudge }: ProposalBannerProps) {
Did <strong className="text-heading">"{fix.title}"</strong> work? Did <strong className="text-heading">"{fix.title}"</strong> work?
</span> </span>
<button <button
onClick={onSilenceNudge} onClick={() => {
className="px-2.5 py-1 rounded text-[12px] text-muted-foreground hover:bg-white/[0.08] hover:text-primary" const reason = window.prompt(
'What are you waiting on? (e.g. "client power-cycling router")',
)
if (reason && reason.trim()) {
onOutcome('applied_pending', reason.trim())
} else {
onSilenceNudge()
}
}}
className="px-2.5 py-1 rounded text-[12px] text-muted-foreground hover:bg-white/[0.08] hover:text-primary inline-flex items-center gap-1"
> >
<Clock3 size={11} />
Still checking Still checking
</button> </button>
<button <button

View File

@@ -221,6 +221,7 @@ export default function AssistantChatPage() {
if (activeFix.status === 'dismissed') return null if (activeFix.status === 'dismissed') return null
if (activeFix.ai_outcome_proposal) return 'ai_confirming' if (activeFix.ai_outcome_proposal) return 'ai_confirming'
if (activeFix.status === 'applied_partial') return 'partial' if (activeFix.status === 'applied_partial') return 'partial'
if (activeFix.status === 'applied_pending') return 'pending'
if (activeFix.status === 'applied_success' || activeFix.status === 'applied_failed') return null if (activeFix.status === 'applied_success' || activeFix.status === 'applied_failed') return null
if (activeFix.applied_at) { if (activeFix.applied_at) {
if (postApplyMsgCount >= 3 && !nudgeSilenced) return 'nudge' if (postApplyMsgCount >= 3 && !nudgeSilenced) return 'nudge'
@@ -965,7 +966,8 @@ export default function AssistantChatPage() {
const inVerifyState = const inVerifyState =
activeFix && ( activeFix && (
(!!activeFix.applied_at && activeFix.status === 'proposed') || (!!activeFix.applied_at && activeFix.status === 'proposed') ||
activeFix.status === 'applied_partial' activeFix.status === 'applied_partial' ||
activeFix.status === 'applied_pending'
) )
if (inVerifyState && activeFix) { if (inVerifyState && activeFix) {
setEscalateIntercept({ fixId: activeFix.id, fixTitle: activeFix.title }) setEscalateIntercept({ fixId: activeFix.id, fixTitle: activeFix.title })
@@ -1003,10 +1005,15 @@ export default function AssistantChatPage() {
} }
}, [activeChatId, escalateIntercept]) }, [activeChatId, escalateIntercept])
// Phase 8: Resolve click — auto-mark applied_success if in verifying state // Phase 8: Resolve click — auto-mark applied_success if in verifying/pending state
// before opening the resolution note preview. // before opening the resolution note preview.
const handleResolveClick = useCallback(async () => { const handleResolveClick = useCallback(async () => {
if (activeFix && activeFix.applied_at && activeFix.status === 'proposed' && activeChatId) { const shouldMarkFixSuccessful =
activeFix
&& activeFix.applied_at
&& (activeFix.status === 'proposed' || activeFix.status === 'applied_pending')
&& activeChatId
if (shouldMarkFixSuccessful) {
try { try {
const updated = await sessionSuggestedFixesApi.patchOutcome(activeChatId, activeFix.id, 'applied_success') const updated = await sessionSuggestedFixesApi.patchOutcome(activeChatId, activeFix.id, 'applied_success')
setActiveFix(updated) setActiveFix(updated)