Tells the AI when + how to emit the [FIX_OUTCOME] marker that Task 4's parser consumes. Placeholder-only per the anti-parrot pattern — no literal UUIDs, outcomes, or reasons that could leak into unrelated sessions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1674 lines
61 KiB
Markdown
1674 lines
61 KiB
Markdown
# FlowPilot Phase 8 — Fix Outcome Banner Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Replace the task-lane Suggested Fix card with a chat-composer-anchored **Proposal Banner** that owns the full lifecycle of a proposed fix (Proposed → Verifying → Success / Failed / Partial / Dismissed), with explicit outcome tracking, AI chat-inferred outcomes via a new `[FIX_OUTCOME]` marker, and implicit signals wired to the Resolve / Escalate actions.
|
||
|
||
**Architecture:**
|
||
- Backend extends `session_suggested_fixes` with outcome columns (`status`, `applied_at`, `verified_at`, `partial_notes`, `failure_reason`) and adds a PATCH `/outcome` endpoint. `unified_chat_service` learns a new `[FIX_OUTCOME]` marker that writes outcome proposals (not terminal — engineer confirms). `ASSISTANT_SYSTEM_PROMPT` gains marker instructions in placeholder form (anti-parrot compliant).
|
||
- Frontend replaces `SuggestedFix.tsx` (task-lane card) with a new `ProposalBanner.tsx` docked above the chat composer. The banner is a state-driven component (`proposed | verifying | partial | ai_confirming | dismissed`) with a sibling `EscalateInterceptDialog` popover. AssistantChatPage orchestrates state transitions and wires the implicit signals (Resolve auto-success, Escalate intercept, post-apply-message nudge).
|
||
|
||
**Tech Stack:** Python 3.11 + FastAPI + SQLAlchemy 2.0 async + Alembic + Pydantic v2 (backend); React 19 + Vite + TypeScript + Tailwind v4 (frontend). pytest for backend tests; frontend verified via `tsc -b` + browser smoke-test (no unit-test harness for new components — the codebase has no component test pattern per the handoff note on Phase 7 visual verification).
|
||
|
||
**Design reference:** [mockups/06-slide-up-banner.html](mockups/06-slide-up-banner.html) (banner look & feel), [mockups/07-verify-states.html](mockups/07-verify-states.html) (Verifying / Partial / AI-inferred / Nudge / Escalate-intercept states).
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
### Backend — new
|
||
- `backend/alembic/versions/<hash>_fix_outcome_tracking.py` — migration
|
||
- `backend/tests/test_fix_outcome_endpoint.py` — endpoint integration test
|
||
- `backend/tests/test_fix_outcome_marker.py` — marker-parser test
|
||
|
||
### Backend — modified
|
||
- `backend/app/models/session_suggested_fix.py` — add outcome columns
|
||
- `backend/app/schemas/session_suggested_fix.py` — add `SessionSuggestedFixOutcomeRequest`, extend response with outcome fields
|
||
- `backend/app/api/endpoints/session_suggested_fixes.py` — add PATCH `/outcome` endpoint
|
||
- `backend/app/services/unified_chat_service.py` — add `[FIX_OUTCOME]` parser + persist step
|
||
- `backend/app/services/assistant_chat_service.py` — add `[FIX_OUTCOME]` instructions to `ASSISTANT_SYSTEM_PROMPT`
|
||
- `backend/tests/test_prompt_anti_parrot.py` — extend known-leaked-token list if needed
|
||
|
||
### Frontend — new
|
||
- `frontend/src/components/pilot/ProposalBanner.tsx` — state-driven banner, all five rendering states
|
||
- `frontend/src/components/pilot/EscalateInterceptDialog.tsx` — popover dialog
|
||
- `frontend/src/hooks/useFixOutcome.ts` — hook wrapping outcome state + patch call
|
||
|
||
### Frontend — modified
|
||
- `frontend/src/api/sessionSuggestedFixes.ts` — add `patchOutcome` method + extend `SessionSuggestedFix` type with outcome fields + export `FixStatus` type
|
||
- `frontend/src/pages/AssistantChatPage.tsx` — mount `ProposalBanner` above composer, wire state transitions, intercept Escalate, auto-mark success on Resolve-while-verifying, remove `suggestedFixSlot` prop usage
|
||
- `frontend/src/components/pilot/TaskLane.tsx` — stop passing `suggestedFixSlot` (leave prop in place for other callers, pass null)
|
||
|
||
### Frontend — deleted
|
||
- `frontend/src/components/pilot/sections/SuggestedFix.tsx` — superseded by the banner; delete after integration is verified
|
||
|
||
---
|
||
|
||
## Task 1: DB migration + model extension for outcome tracking
|
||
|
||
**Files:**
|
||
- Create: `backend/alembic/versions/<hash>_fix_outcome_tracking.py`
|
||
- Modify: `backend/app/models/session_suggested_fix.py`
|
||
|
||
- [ ] **Step 1: Generate the migration file**
|
||
|
||
From `backend/` with venv active:
|
||
|
||
```bash
|
||
alembic revision -m "add fix outcome tracking columns to session_suggested_fixes"
|
||
```
|
||
|
||
Alembic prints the new file path. Do NOT pass `--rev-id` (Lesson: always let alembic generate the hex hash).
|
||
|
||
- [ ] **Step 2: Write the migration body**
|
||
|
||
Replace the generated file contents with (keep the `revision`/`down_revision` lines alembic wrote):
|
||
|
||
```python
|
||
"""add fix outcome tracking columns to session_suggested_fixes
|
||
|
||
Adds: status, applied_at, verified_at, partial_notes, failure_reason,
|
||
ai_outcome_proposal.
|
||
status is the outcome dimension (did the fix work?), orthogonal to the
|
||
existing user_decision column (which script-path the engineer took).
|
||
"""
|
||
from alembic import op
|
||
import sqlalchemy as sa
|
||
from sqlalchemy.dialects import postgresql
|
||
|
||
|
||
# revision identifiers — leave whatever alembic generated in place
|
||
revision = "<hash-alembic-generated>"
|
||
down_revision = "<parent>"
|
||
branch_labels = None
|
||
depends_on = None
|
||
|
||
|
||
def upgrade() -> None:
|
||
op.add_column(
|
||
"session_suggested_fixes",
|
||
sa.Column(
|
||
"status",
|
||
sa.String(length=20),
|
||
nullable=False,
|
||
server_default=sa.text("'proposed'"),
|
||
),
|
||
)
|
||
op.add_column(
|
||
"session_suggested_fixes",
|
||
sa.Column("applied_at", sa.DateTime(timezone=True), nullable=True),
|
||
)
|
||
op.add_column(
|
||
"session_suggested_fixes",
|
||
sa.Column("verified_at", sa.DateTime(timezone=True), nullable=True),
|
||
)
|
||
op.add_column(
|
||
"session_suggested_fixes",
|
||
sa.Column("partial_notes", sa.Text(), nullable=True),
|
||
)
|
||
op.add_column(
|
||
"session_suggested_fixes",
|
||
sa.Column("failure_reason", sa.Text(), nullable=True),
|
||
)
|
||
op.add_column(
|
||
"session_suggested_fixes",
|
||
sa.Column("ai_outcome_proposal", postgresql.JSONB(), nullable=True),
|
||
)
|
||
# Backfill before constraint creation so dismissed rows satisfy the new CHECK.
|
||
op.execute(
|
||
"UPDATE session_suggested_fixes "
|
||
"SET status = 'dismissed' "
|
||
"WHERE user_decision = 'dismissed'"
|
||
)
|
||
op.create_check_constraint(
|
||
"ck_session_suggested_fixes_status",
|
||
"session_suggested_fixes",
|
||
"status IN ('proposed', 'applied_success', 'applied_failed', "
|
||
"'applied_partial', 'dismissed')",
|
||
)
|
||
# Drop the server_default — application code owns defaults from here on
|
||
# (matches the project's general pattern of keeping defaults in models).
|
||
op.alter_column("session_suggested_fixes", "status", server_default=None)
|
||
|
||
|
||
def downgrade() -> None:
|
||
op.drop_constraint("ck_session_suggested_fixes_status", "session_suggested_fixes", type_="check")
|
||
op.drop_column("session_suggested_fixes", "ai_outcome_proposal")
|
||
op.drop_column("session_suggested_fixes", "failure_reason")
|
||
op.drop_column("session_suggested_fixes", "partial_notes")
|
||
op.drop_column("session_suggested_fixes", "verified_at")
|
||
op.drop_column("session_suggested_fixes", "applied_at")
|
||
op.drop_column("session_suggested_fixes", "status")
|
||
```
|
||
|
||
- [ ] **Step 3: Update the SQLAlchemy model**
|
||
|
||
Edit `backend/app/models/session_suggested_fix.py`. Inside the `__table_args__` tuple, append after the existing `user_decision` check:
|
||
|
||
```python
|
||
CheckConstraint(
|
||
"status IN ('proposed', 'applied_success', 'applied_failed', "
|
||
"'applied_partial', 'dismissed')",
|
||
name="ck_session_suggested_fixes_status",
|
||
),
|
||
```
|
||
|
||
Inside the class body, add these columns after `user_decision`:
|
||
|
||
```python
|
||
status: Mapped[str] = mapped_column(
|
||
String(20), nullable=False, default="proposed"
|
||
)
|
||
applied_at: Mapped[datetime | None] = mapped_column(
|
||
DateTime(timezone=True), nullable=True
|
||
)
|
||
verified_at: Mapped[datetime | None] = mapped_column(
|
||
DateTime(timezone=True), nullable=True
|
||
)
|
||
partial_notes: 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(
|
||
JSONB, nullable=True
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 4: Apply the migration locally**
|
||
|
||
```bash
|
||
cd backend && alembic upgrade head
|
||
```
|
||
|
||
Expected: no errors; `\d session_suggested_fixes` in psql shows the five new columns and the new check constraint.
|
||
|
||
Verify via:
|
||
|
||
```bash
|
||
docker exec -it resolutionflow_postgres psql -U postgres -d resolutionflow \
|
||
-c "\d session_suggested_fixes" | grep -E "status|applied_at|verified_at|partial_notes|failure_reason"
|
||
```
|
||
|
||
Expected: all five column names print.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add backend/alembic/versions/ backend/app/models/session_suggested_fix.py
|
||
git commit -m "feat(pilot): add outcome tracking columns to session_suggested_fixes"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Pydantic schemas for outcome
|
||
|
||
**Files:**
|
||
- Modify: `backend/app/schemas/session_suggested_fix.py`
|
||
|
||
- [ ] **Step 1: Extend the response schema and add the request schema**
|
||
|
||
At the top of `backend/app/schemas/session_suggested_fix.py`, after the existing `UserDecision` literal, add:
|
||
|
||
```python
|
||
FixStatus = Literal[
|
||
"proposed",
|
||
"applied_success",
|
||
"applied_failed",
|
||
"applied_partial",
|
||
"dismissed",
|
||
]
|
||
```
|
||
|
||
Extend `SessionSuggestedFixResponse` with the new fields (add after `user_decision`):
|
||
|
||
```python
|
||
status: FixStatus
|
||
applied_at: datetime | None
|
||
verified_at: datetime | None
|
||
partial_notes: str | None
|
||
failure_reason: str | None
|
||
```
|
||
|
||
After `SessionSuggestedFixDecisionResponse`, add:
|
||
|
||
```python
|
||
class SessionSuggestedFixOutcomeRequest(BaseModel):
|
||
"""Engineer-reported outcome of applying a suggested fix.
|
||
|
||
Writes to session_suggested_fixes.status and companion columns. This is
|
||
orthogonal to `user_decision` (which records which script-path the
|
||
engineer took); outcome captures whether the fix actually worked.
|
||
|
||
Allowed transitions:
|
||
- from `proposed` or `applied_partial`: any outcome is valid
|
||
(partial is parked, not terminal — the engineer may update notes,
|
||
abandon via dismiss, or advance to success/failed)
|
||
- from any terminal outcome (`applied_success`, `applied_failed`,
|
||
`dismissed`): server returns 409
|
||
"""
|
||
outcome: Literal[
|
||
"applied_success", "applied_failed", "applied_partial", "dismissed"
|
||
]
|
||
# Required for applied_partial, optional for applied_failed, ignored otherwise.
|
||
notes: str | None = Field(None, max_length=500)
|
||
```
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add backend/app/schemas/session_suggested_fix.py
|
||
git commit -m "feat(pilot): pydantic schemas for fix outcome patch"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: PATCH /outcome endpoint + test
|
||
|
||
**Files:**
|
||
- Modify: `backend/app/api/endpoints/session_suggested_fixes.py`
|
||
- Create: `backend/tests/test_fix_outcome_endpoint.py`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `backend/tests/test_fix_outcome_endpoint.py`:
|
||
|
||
```python
|
||
"""Integration tests for the fix outcome endpoint.
|
||
|
||
These tests rely on the `engineer_client` + `seed_ai_session_with_fix`
|
||
fixtures from `conftest.py` (existing pattern used by the decision-endpoint
|
||
tests).
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import pytest
|
||
from httpx import AsyncClient
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_patch_outcome_marks_success(
|
||
engineer_client: AsyncClient, seed_ai_session_with_fix
|
||
):
|
||
session_id, fix_id = seed_ai_session_with_fix
|
||
|
||
r = await engineer_client.patch(
|
||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||
json={"outcome": "applied_success"},
|
||
)
|
||
assert r.status_code == 200, r.text
|
||
body = r.json()
|
||
assert body["status"] == "applied_success"
|
||
assert body["verified_at"] is not None
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_patch_outcome_partial_requires_notes(
|
||
engineer_client: AsyncClient, seed_ai_session_with_fix
|
||
):
|
||
session_id, fix_id = seed_ai_session_with_fix
|
||
|
||
r = await engineer_client.patch(
|
||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||
json={"outcome": "applied_partial"},
|
||
)
|
||
assert r.status_code == 400
|
||
assert "notes" in r.text.lower()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_partial_to_success_allowed(
|
||
engineer_client: AsyncClient, seed_ai_session_with_fix
|
||
):
|
||
session_id, fix_id = seed_ai_session_with_fix
|
||
|
||
r1 = await engineer_client.patch(
|
||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||
json={"outcome": "applied_partial", "notes": "ran cred clear only"},
|
||
)
|
||
assert r1.status_code == 200
|
||
|
||
r2 = await engineer_client.patch(
|
||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||
json={"outcome": "applied_success"},
|
||
)
|
||
assert r2.status_code == 200
|
||
assert r2.json()["status"] == "applied_success"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_terminal_outcome_is_locked(
|
||
engineer_client: AsyncClient, seed_ai_session_with_fix
|
||
):
|
||
session_id, fix_id = seed_ai_session_with_fix
|
||
|
||
r1 = await engineer_client.patch(
|
||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||
json={"outcome": "applied_failed", "notes": "no change"},
|
||
)
|
||
assert r1.status_code == 200
|
||
|
||
r2 = await engineer_client.patch(
|
||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||
json={"outcome": "applied_success"},
|
||
)
|
||
assert r2.status_code == 409
|
||
```
|
||
|
||
- [ ] **Step 2: Run the test — confirm it fails**
|
||
|
||
```bash
|
||
cd backend && pytest tests/test_fix_outcome_endpoint.py -v --override-ini="addopts="
|
||
```
|
||
|
||
Expected: four failures — endpoint doesn't exist yet (404 on PATCH).
|
||
|
||
- [ ] **Step 3: Add the endpoint**
|
||
|
||
Open `backend/app/api/endpoints/session_suggested_fixes.py`. Add near the other handlers (after the `decision` endpoint):
|
||
|
||
```python
|
||
@router.patch(
|
||
"/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||
response_model=SessionSuggestedFixResponse,
|
||
tags=["flowpilot"],
|
||
)
|
||
async def patch_suggested_fix_outcome(
|
||
session_id: UUID,
|
||
fix_id: UUID,
|
||
body: SessionSuggestedFixOutcomeRequest,
|
||
current_user: User = Depends(require_engineer_or_admin),
|
||
db: AsyncSession = Depends(get_db),
|
||
) -> SessionSuggestedFix:
|
||
"""Record the engineer's outcome for an applied fix.
|
||
|
||
See SessionSuggestedFixOutcomeRequest for the transition rules.
|
||
"""
|
||
now = datetime.now(timezone.utc)
|
||
|
||
fix = await db.scalar(
|
||
select(SessionSuggestedFix).where(
|
||
SessionSuggestedFix.id == fix_id,
|
||
SessionSuggestedFix.session_id == session_id,
|
||
)
|
||
)
|
||
if fix is None:
|
||
raise HTTPException(status_code=404, detail="Suggested fix not found")
|
||
|
||
# Partial requires a note; without one we can't tell anyone (including
|
||
# the AI on the next turn) what actually got done.
|
||
if body.outcome == "applied_partial" and not (body.notes and body.notes.strip()):
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="notes are required when outcome is applied_partial",
|
||
)
|
||
|
||
TERMINAL = {"applied_success", "applied_failed", "dismissed"}
|
||
if fix.status in TERMINAL:
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail=f"Fix is already in terminal status {fix.status!r}",
|
||
)
|
||
|
||
fix.status = body.outcome
|
||
if body.outcome == "applied_partial":
|
||
fix.partial_notes = (body.notes or "").strip() or None
|
||
# Partial is a parked state — no verified_at yet.
|
||
elif body.outcome == "applied_failed":
|
||
fix.failure_reason = (body.notes or "").strip() or None
|
||
fix.verified_at = now
|
||
elif body.outcome == "applied_success":
|
||
fix.verified_at = now
|
||
# applied_at is stamped by whoever calls this in the apply flow; if it's
|
||
# still null at outcome-set time, set it to now (handles the case where
|
||
# the AI emits [FIX_OUTCOME] before the engineer clicks Apply explicitly).
|
||
if fix.applied_at is None and body.outcome != "dismissed":
|
||
fix.applied_at = now
|
||
|
||
await db.commit()
|
||
await db.refresh(fix)
|
||
return fix
|
||
```
|
||
|
||
Add missing imports at the top of the file if not already present:
|
||
|
||
```python
|
||
from datetime import datetime, timezone
|
||
from app.schemas.session_suggested_fix import SessionSuggestedFixOutcomeRequest
|
||
```
|
||
|
||
- [ ] **Step 4: Run the tests — confirm they pass**
|
||
|
||
```bash
|
||
cd backend && pytest tests/test_fix_outcome_endpoint.py -v --override-ini="addopts="
|
||
```
|
||
|
||
Expected: 4 passed.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add backend/app/api/endpoints/session_suggested_fixes.py backend/tests/test_fix_outcome_endpoint.py
|
||
git commit -m "feat(pilot): PATCH /suggested-fixes/:id/outcome endpoint + tests"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: `[FIX_OUTCOME]` marker parser + test
|
||
|
||
**Files:**
|
||
- Modify: `backend/app/services/unified_chat_service.py`
|
||
- Create: `backend/tests/test_fix_outcome_marker.py`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `backend/tests/test_fix_outcome_marker.py`:
|
||
|
||
```python
|
||
"""Unit tests for the [FIX_OUTCOME] marker parser."""
|
||
from __future__ import annotations
|
||
|
||
from app.services.unified_chat_service import _parse_fix_outcome_marker
|
||
|
||
|
||
def test_parses_success_outcome():
|
||
ai = (
|
||
"Great news — that confirms the root cause.\n\n"
|
||
"[FIX_OUTCOME]\n"
|
||
'{"fix_id":"11111111-1111-1111-1111-111111111111",'
|
||
'"outcome":"success","reason":"user said the fix worked"}\n'
|
||
"[/FIX_OUTCOME]\n"
|
||
)
|
||
cleaned, parsed = _parse_fix_outcome_marker(ai)
|
||
assert "[FIX_OUTCOME]" not in cleaned
|
||
assert "confirms the root cause" in cleaned
|
||
assert parsed == {
|
||
"fix_id": "11111111-1111-1111-1111-111111111111",
|
||
"outcome": "success",
|
||
"reason": "user said the fix worked",
|
||
}
|
||
|
||
|
||
def test_parses_failure_outcome():
|
||
ai = (
|
||
"[FIX_OUTCOME]\n"
|
||
'{"fix_id":"22222222-2222-2222-2222-222222222222",'
|
||
'"outcome":"failure","reason":"user reports still broken"}\n'
|
||
"[/FIX_OUTCOME]"
|
||
)
|
||
_, parsed = _parse_fix_outcome_marker(ai)
|
||
assert parsed["outcome"] == "failure"
|
||
|
||
|
||
def test_missing_marker_returns_none():
|
||
ai = "no marker here"
|
||
cleaned, parsed = _parse_fix_outcome_marker(ai)
|
||
assert cleaned == ai
|
||
assert parsed is None
|
||
|
||
|
||
def test_invalid_json_is_dropped():
|
||
ai = "[FIX_OUTCOME]\nnot-json\n[/FIX_OUTCOME]"
|
||
cleaned, parsed = _parse_fix_outcome_marker(ai)
|
||
assert "[FIX_OUTCOME]" not in cleaned
|
||
assert parsed is None
|
||
|
||
|
||
def test_unknown_outcome_rejected():
|
||
ai = (
|
||
"[FIX_OUTCOME]\n"
|
||
'{"fix_id":"33333333-3333-3333-3333-333333333333",'
|
||
'"outcome":"maybe","reason":"x"}\n'
|
||
"[/FIX_OUTCOME]"
|
||
)
|
||
_, parsed = _parse_fix_outcome_marker(ai)
|
||
assert parsed is None
|
||
```
|
||
|
||
- [ ] **Step 2: Run the test — confirm it fails**
|
||
|
||
```bash
|
||
cd backend && pytest tests/test_fix_outcome_marker.py -v --override-ini="addopts="
|
||
```
|
||
|
||
Expected: `ImportError: cannot import name '_parse_fix_outcome_marker'` — parser doesn't exist yet.
|
||
|
||
- [ ] **Step 3: Implement the parser**
|
||
|
||
Open `backend/app/services/unified_chat_service.py`. Right after `_parse_suggest_fix_marker`, add:
|
||
|
||
```python
|
||
def _parse_fix_outcome_marker(
|
||
ai_content: str,
|
||
) -> tuple[str, dict[str, Any] | None]:
|
||
"""Extract a single [FIX_OUTCOME]...[/FIX_OUTCOME] JSON block.
|
||
|
||
Block shape:
|
||
{"fix_id": "<uuid>", "outcome": "success"|"failure"|"partial",
|
||
"reason": "<one-line>"}
|
||
|
||
Emitted by the AI when the engineer clearly indicates in chat that a
|
||
prior suggested fix worked, didn't work, or was partially applied.
|
||
The marker PROPOSES an outcome — the engineer confirms via the UI.
|
||
Only the last block in a response is honored.
|
||
"""
|
||
blocks = list(re.finditer(
|
||
r"\[FIX_OUTCOME\]\s*([\s\S]*?)\s*\[/FIX_OUTCOME\]", ai_content,
|
||
))
|
||
if not blocks:
|
||
return ai_content, None
|
||
|
||
last = blocks[-1]
|
||
raw = last.group(1).strip()
|
||
if raw.startswith("```"):
|
||
raw = re.sub(r"^```(?:json)?\s*", "", raw)
|
||
raw = re.sub(r"\s*```$", "", raw)
|
||
|
||
cleaned = re.sub(
|
||
r"\[FIX_OUTCOME\]\s*[\s\S]*?\s*\[/FIX_OUTCOME\]", "", ai_content,
|
||
).strip()
|
||
|
||
try:
|
||
data = json.loads(raw)
|
||
except (json.JSONDecodeError, ValueError) as e:
|
||
logger.warning("Failed to parse [FIX_OUTCOME] block: %s", e)
|
||
return cleaned, None
|
||
|
||
if not isinstance(data, dict):
|
||
return cleaned, None
|
||
|
||
fix_id = str(data.get("fix_id") or "").strip()
|
||
outcome = str(data.get("outcome") or "").strip().lower()
|
||
reason = str(data.get("reason") or "").strip()
|
||
|
||
if not fix_id or outcome not in {"success", "failure", "partial"}:
|
||
logger.warning("[FIX_OUTCOME] missing/invalid fields, dropping")
|
||
return cleaned, None
|
||
|
||
return cleaned, {"fix_id": fix_id, "outcome": outcome, "reason": reason}
|
||
```
|
||
|
||
- [ ] **Step 4: Wire the parser into the AI-response handler**
|
||
|
||
In the same file, find the block where `_parse_suggest_fix_marker` is called during AI-response processing. Add a companion call right after it. The exact location is the function that processes the raw AI response before persisting messages — search for `_parse_suggest_fix_marker(` to find it. Inside that function, after the suggest-fix call:
|
||
|
||
```python
|
||
ai_content, outcome_proposal = _parse_fix_outcome_marker(ai_content)
|
||
```
|
||
|
||
Then, after the suggest-fix persistence block, add:
|
||
|
||
```python
|
||
if outcome_proposal is not None:
|
||
# The AI is proposing an outcome; we persist it as an
|
||
# ai_outcome_proposal event on the session so the frontend can
|
||
# surface the "AI detected outcome — confirm?" banner. The
|
||
# session's active fix is not mutated until the engineer confirms.
|
||
await _record_ai_outcome_proposal(
|
||
db=db, session=session, proposal=outcome_proposal,
|
||
)
|
||
```
|
||
|
||
And add the helper near `_persist_suggested_fix`:
|
||
|
||
```python
|
||
async def _record_ai_outcome_proposal(
|
||
*,
|
||
db: AsyncSession,
|
||
session: AISession,
|
||
proposal: dict[str, Any],
|
||
) -> None:
|
||
"""Persist the AI's proposed outcome as a pending event on the active fix.
|
||
|
||
We store this on a new JSONB column on the fix (ai_outcome_proposal)
|
||
rather than as a separate event table — one active proposal at a time,
|
||
overwritten on each new [FIX_OUTCOME]. Frontend polls the active fix
|
||
and renders the AI-confirming banner state when this is non-null.
|
||
"""
|
||
from uuid import UUID as _UUID
|
||
try:
|
||
fix_id = _UUID(proposal["fix_id"])
|
||
except (ValueError, KeyError):
|
||
logger.warning("[FIX_OUTCOME] invalid fix_id, dropping")
|
||
return
|
||
|
||
await db.execute(
|
||
update(SessionSuggestedFix)
|
||
.where(
|
||
SessionSuggestedFix.id == fix_id,
|
||
SessionSuggestedFix.session_id == session.id,
|
||
)
|
||
.values(ai_outcome_proposal=proposal)
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 5: Run the marker tests — confirm they pass**
|
||
|
||
```bash
|
||
cd backend && pytest tests/test_fix_outcome_marker.py tests/test_fix_outcome_endpoint.py -v --override-ini="addopts="
|
||
```
|
||
|
||
Expected: 9 passed (5 marker + 4 endpoint).
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add backend/
|
||
git commit -m "feat(pilot): [FIX_OUTCOME] marker parser + ai_outcome_proposal column"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: System prompt update + anti-parrot guardrail
|
||
|
||
**Files:**
|
||
- Modify: `backend/app/services/assistant_chat_service.py` — `ASSISTANT_SYSTEM_PROMPT` is the prompt used by `unified_chat_service._call_ai`; `flowpilot_engine.py` has a separate prompt for structured JSON flows that does not use chat markers
|
||
- Modify: `backend/tests/test_prompt_anti_parrot.py`
|
||
|
||
- [ ] **Step 1: Locate the system prompt**
|
||
|
||
```bash
|
||
grep -rln "ASSISTANT_SYSTEM_PROMPT\s*=" /config/workspace/resolutionflow/backend/app/
|
||
```
|
||
|
||
Open whichever file owns the constant.
|
||
|
||
- [ ] **Step 2: Append the `[FIX_OUTCOME]` instructions to the prompt**
|
||
|
||
Inside the prompt string, in the markers section, add a new block alongside the existing `[SUGGEST_FIX]` instructions. **Use placeholder syntax exclusively — never literal values** (anti-parrot lesson):
|
||
|
||
```
|
||
## Reporting fix outcome with [FIX_OUTCOME]
|
||
|
||
When the engineer clearly indicates in chat that a previously proposed fix
|
||
worked, didn't work, or was partially applied, emit a [FIX_OUTCOME] marker
|
||
on its own lines. This surfaces a "confirm outcome?" banner in the UI — it
|
||
does NOT mark the fix resolved on its own.
|
||
|
||
Emit [FIX_OUTCOME] when:
|
||
- the engineer states the user's problem is resolved after applying the fix
|
||
(e.g. affirmative resolution language → outcome="success")
|
||
- the engineer states the issue persists after applying the fix
|
||
(→ outcome="failure")
|
||
- the engineer describes applying only part of the fix
|
||
(→ outcome="partial")
|
||
|
||
Do NOT emit [FIX_OUTCOME] when:
|
||
- the engineer is still verifying (user rebooting, testing, etc.)
|
||
- the outcome is ambiguous or inferred rather than stated
|
||
- no [SUGGEST_FIX] has been emitted this session
|
||
|
||
Format (one block, on its own lines, placeholders only):
|
||
|
||
[FIX_OUTCOME]
|
||
{"fix_id": "<uuid-of-the-active-suggested-fix>",
|
||
"outcome": "<success|failure|partial>",
|
||
"reason": "<one-line-quote-or-paraphrase-of-what-the-engineer-said>"}
|
||
[/FIX_OUTCOME]
|
||
```
|
||
|
||
- [ ] **Step 3: Update the anti-parrot test**
|
||
|
||
Open `backend/tests/test_prompt_anti_parrot.py`. The existing test scans for literal tokens. If `[FIX_OUTCOME]` introduces any new tokens worth guarding (none expected — all placeholders are `<…>` style), add them to the leaked-token list. If nothing new, move on — the existing scanner will catch any literal JSON value that sneaks in.
|
||
|
||
- [ ] **Step 4: Run the anti-parrot test**
|
||
|
||
```bash
|
||
cd backend && pytest tests/test_prompt_anti_parrot.py -v --override-ini="addopts="
|
||
```
|
||
|
||
Expected: pass. If it fails with a literal-value complaint, re-read the prompt and swap any concrete fix_id / outcome / reason you accidentally wrote into `<placeholder>` form.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add backend/app/services/assistant_chat_service.py backend/tests/test_prompt_anti_parrot.py
|
||
git commit -m "feat(pilot): [FIX_OUTCOME] system prompt instructions"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Frontend types + API client
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/api/sessionSuggestedFixes.ts`
|
||
|
||
- [ ] **Step 1: Extend the types and add the patch method**
|
||
|
||
At the top of `frontend/src/api/sessionSuggestedFixes.ts`, after `UserDecision`, add:
|
||
|
||
```ts
|
||
export type FixStatus =
|
||
| 'proposed'
|
||
| 'applied_success'
|
||
| 'applied_failed'
|
||
| 'applied_partial'
|
||
| 'dismissed'
|
||
|
||
export type FixOutcome =
|
||
| 'applied_success'
|
||
| 'applied_failed'
|
||
| 'applied_partial'
|
||
| 'dismissed'
|
||
|
||
export interface AIOutcomeProposal {
|
||
fix_id: string
|
||
outcome: 'success' | 'failure' | 'partial'
|
||
reason: string
|
||
}
|
||
```
|
||
|
||
Extend `SessionSuggestedFix` to include the new fields (add after `user_decision`):
|
||
|
||
```ts
|
||
status: FixStatus
|
||
applied_at: string | null
|
||
verified_at: string | null
|
||
partial_notes: string | null
|
||
failure_reason: string | null
|
||
ai_outcome_proposal: AIOutcomeProposal | null
|
||
```
|
||
|
||
Add a new method on `sessionSuggestedFixesApi` (after `recordDecision`):
|
||
|
||
```ts
|
||
/**
|
||
* Record the outcome of applying a suggested fix. Transitions:
|
||
* - from 'proposed' or 'applied_partial': any outcome is valid
|
||
* (partial→partial updates notes, partial→dismissed abandons)
|
||
* - terminal statuses (applied_success, applied_failed, dismissed) are locked (server returns 409)
|
||
*/
|
||
async patchOutcome(
|
||
sessionId: string,
|
||
fixId: string,
|
||
outcome: FixOutcome,
|
||
notes?: string,
|
||
): Promise<SessionSuggestedFix> {
|
||
const r = await apiClient.patch<SessionSuggestedFix>(
|
||
`/ai-sessions/${sessionId}/suggested-fixes/${fixId}/outcome`,
|
||
{ outcome, notes },
|
||
)
|
||
return r.data
|
||
},
|
||
```
|
||
|
||
- [ ] **Step 2: Verify types compile**
|
||
|
||
```bash
|
||
cd frontend && npx tsc -b
|
||
```
|
||
|
||
Expected: clean build, no errors.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/api/sessionSuggestedFixes.ts
|
||
git commit -m "feat(pilot): frontend fix-outcome types + patch API"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: ProposalBanner component — core + Proposed state
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/components/pilot/ProposalBanner.tsx`
|
||
|
||
- [ ] **Step 1: Create the component scaffold**
|
||
|
||
Create `frontend/src/components/pilot/ProposalBanner.tsx`:
|
||
|
||
```tsx
|
||
/**
|
||
* ProposalBanner — chat-composer-anchored banner that carries the lifecycle
|
||
* of a suggested fix from Proposed → Verifying → terminal outcome.
|
||
*
|
||
* Replaces the task-lane SuggestedFix card (Phase 8). The banner renders
|
||
* above the chat composer in AssistantChatPage. Parent owns the fix record
|
||
* and the outcome mutations; this component renders + dispatches callbacks.
|
||
*
|
||
* Visual reference: docs/FlowAssist_Migration/mockups/06-slide-up-banner.html
|
||
* + 07-verify-states.html.
|
||
*/
|
||
import { useState } from 'react'
|
||
import { Sparkles, X, Check, Info, ChevronDown, MoreHorizontal } from 'lucide-react'
|
||
import { cn } from '@/lib/utils'
|
||
import type {
|
||
SessionSuggestedFix,
|
||
FixOutcome,
|
||
} from '@/api/sessionSuggestedFixes'
|
||
|
||
export type BannerMode =
|
||
| 'proposed' // AI just proposed; engineer hasn't applied yet
|
||
| 'verifying' // Engineer clicked Apply; awaiting outcome
|
||
| 'partial' // Applied partially; awaiting finish or terminal outcome
|
||
| 'ai_confirming' // AI emitted [FIX_OUTCOME]; engineer confirms
|
||
| 'nudge' // Compact nudge shown after N post-apply messages
|
||
|
||
export interface ProposalBannerProps {
|
||
fix: SessionSuggestedFix
|
||
mode: BannerMode
|
||
onApply: () => void
|
||
onDismiss: () => void
|
||
onOutcome: (outcome: FixOutcome, notes?: string) => void
|
||
onAcceptAIProposal: () => void
|
||
onRejectAIProposal: () => void
|
||
/** Collapsed variant shown as a thin single-line strip. */
|
||
collapsed?: boolean
|
||
onToggleCollapsed?: () => void
|
||
}
|
||
|
||
export function ProposalBanner(props: ProposalBannerProps) {
|
||
if (props.collapsed) return <CollapsedBanner {...props} />
|
||
switch (props.mode) {
|
||
case 'proposed': return <ProposedBanner {...props} />
|
||
case 'verifying': return <VerifyingBanner {...props} />
|
||
case 'partial': return <PartialBanner {...props} />
|
||
case 'ai_confirming': return <AIConfirmingBanner {...props} />
|
||
case 'nudge': return <NudgeBanner {...props} />
|
||
}
|
||
}
|
||
|
||
function ProposedBanner({ fix, onApply, onDismiss }: ProposalBannerProps) {
|
||
return (
|
||
<div className="relative border-t border-warning/30 bg-gradient-to-b from-warning-dim/40 to-warning-dim/20 px-5 py-3 animate-slide-up">
|
||
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-warning" />
|
||
<div className="flex items-start gap-3">
|
||
<div className="shrink-0 mt-0.5 w-7 h-7 rounded-md border border-warning/30 bg-warning-dim flex items-center justify-center text-warning">
|
||
<Sparkles 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-warning">
|
||
<span>Suggested Fix</span>
|
||
<span className="tabular-nums px-2 py-[2px] rounded-full bg-warning/20 text-warning text-[10.5px] font-bold">
|
||
{fix.confidence_pct}% confidence
|
||
</span>
|
||
</div>
|
||
<div className="mt-0.5 text-[14px] font-semibold text-heading leading-snug">
|
||
{fix.title}
|
||
</div>
|
||
<div className="mt-1 text-[12.5px] text-muted-foreground leading-relaxed">
|
||
{fix.description}
|
||
</div>
|
||
{fix.script_template_id && (
|
||
<div className="mt-1.5 inline-flex items-center gap-1.5 text-[11.5px] text-success">
|
||
<Check size={11} />
|
||
Matches an existing Script Library template — one-click apply
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-2 shrink-0 pt-0.5">
|
||
<button
|
||
onClick={() => { /* hook: collapse */ }}
|
||
className="p-1.5 rounded text-muted-foreground hover:bg-white/[0.08] hover:text-primary"
|
||
aria-label="Collapse"
|
||
>
|
||
<ChevronDown size={14} />
|
||
</button>
|
||
<button
|
||
onClick={onDismiss}
|
||
className="px-2.5 py-1.5 rounded text-[12.5px] text-muted-foreground hover:bg-white/[0.08] hover:text-primary"
|
||
>
|
||
Dismiss
|
||
</button>
|
||
<button
|
||
onClick={onApply}
|
||
className="px-3.5 py-[9px] rounded-lg bg-warning text-[#1a1200] font-semibold text-[12.5px] hover:bg-[#ffce4f] inline-flex items-center gap-1.5"
|
||
>
|
||
Apply fix
|
||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="9 18 15 12 9 6" /></svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Placeholder renderers — filled in Task 8 / 9.
|
||
function VerifyingBanner(_: ProposalBannerProps) { return null }
|
||
function PartialBanner(_: ProposalBannerProps) { return null }
|
||
function AIConfirmingBanner(_: ProposalBannerProps) { return null }
|
||
function NudgeBanner(_: ProposalBannerProps) { return null }
|
||
function CollapsedBanner(_: ProposalBannerProps) { return null }
|
||
|
||
export default ProposalBanner
|
||
```
|
||
|
||
- [ ] **Step 2: Add the slide-up animation**
|
||
|
||
Open `frontend/src/index.css`. Add near the existing animations:
|
||
|
||
```css
|
||
@keyframes slide-up {
|
||
from { transform: translateY(14px); opacity: 0; }
|
||
to { transform: translateY(0); opacity: 1; }
|
||
}
|
||
.animate-slide-up { animation: slide-up 320ms cubic-bezier(.22,.9,.28,1) both; }
|
||
```
|
||
|
||
- [ ] **Step 3: Compile + visual smoke test**
|
||
|
||
```bash
|
||
cd frontend && npx tsc -b
|
||
```
|
||
|
||
Expected: clean.
|
||
|
||
To visually check, mount it ad-hoc by editing `AssistantChatPage.tsx` temporarily to render `<ProposalBanner fix={...mock} mode="proposed" onApply={() => {}} onDismiss={() => {}} onOutcome={() => {}} onAcceptAIProposal={() => {}} onRejectAIProposal={() => {}} />` above the composer. Revert the edit before commit.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/components/pilot/ProposalBanner.tsx frontend/src/index.css
|
||
git commit -m "feat(pilot): ProposalBanner scaffold + Proposed state"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: ProposalBanner — Verifying + Partial states
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/components/pilot/ProposalBanner.tsx`
|
||
|
||
- [ ] **Step 1: Implement VerifyingBanner**
|
||
|
||
Replace the `function VerifyingBanner` placeholder with:
|
||
|
||
```tsx
|
||
function VerifyingBanner({ fix, onOutcome }: ProposalBannerProps) {
|
||
const [showOverflow, setShowOverflow] = useState(false)
|
||
const appliedLabel = fix.applied_at
|
||
? `Applied ${formatRelativeMinutes(fix.applied_at)}`
|
||
: 'Applied'
|
||
|
||
return (
|
||
<div className="relative border-t border-warning/30 bg-gradient-to-b from-warning-dim/40 to-warning-dim/20 px-5 py-3 animate-slide-up">
|
||
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-warning" />
|
||
<div className="flex items-start gap-3">
|
||
<div className="relative shrink-0 mt-0.5 w-7 h-7 rounded-md border border-warning/30 bg-warning-dim flex items-center justify-center text-warning">
|
||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
|
||
</svg>
|
||
<span className="absolute inset-[-3px] rounded-lg animate-pulse-amber" />
|
||
</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-warning">
|
||
<span>Verifying</span>
|
||
<span className="px-2 py-[2px] rounded-full bg-warning/20 text-warning text-[10.5px] font-bold normal-case tracking-normal">
|
||
{appliedLabel}
|
||
</span>
|
||
</div>
|
||
<div className="mt-0.5 text-[14px] font-semibold text-heading leading-snug">
|
||
Did "{fix.title}" work?
|
||
</div>
|
||
<div className="mt-1 text-[12.5px] text-muted-foreground leading-relaxed">
|
||
Mark the outcome so the AI can either close the session with this as the resolution, or propose something else.
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2 shrink-0 pt-0.5 relative">
|
||
<button
|
||
onClick={() => setShowOverflow(v => !v)}
|
||
className="p-1.5 rounded text-muted-foreground hover:bg-white/[0.08] hover:text-primary"
|
||
aria-label="More options"
|
||
>
|
||
<MoreHorizontal size={14} />
|
||
</button>
|
||
{showOverflow && (
|
||
<div className="absolute top-full right-0 mt-1 w-48 rounded-lg border border-white/10 bg-card shadow-xl py-1 z-10">
|
||
<button
|
||
onClick={() => {
|
||
setShowOverflow(false)
|
||
const notes = window.prompt('What did you run / skip?')
|
||
if (notes && notes.trim()) onOutcome('applied_partial', notes.trim())
|
||
}}
|
||
className="w-full text-left px-3 py-2 text-[12.5px] hover:bg-elevated text-primary"
|
||
>
|
||
Mark partial…
|
||
</button>
|
||
</div>
|
||
)}
|
||
<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 inline-flex items-center gap-1.5"
|
||
>
|
||
<X size={12} strokeWidth={2.5} />
|
||
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 formatRelativeMinutes(iso: string): string {
|
||
const then = new Date(iso).getTime()
|
||
const mins = Math.max(0, Math.round((Date.now() - then) / 60000))
|
||
if (mins === 0) return 'just now'
|
||
if (mins === 1) return '1m ago'
|
||
return `${mins}m ago`
|
||
}
|
||
```
|
||
|
||
Add the pulse animation to `frontend/src/index.css`:
|
||
|
||
```css
|
||
@keyframes pulse-amber {
|
||
0% { box-shadow: 0 0 0 0 rgba(251,191,36,0.45); }
|
||
70% { box-shadow: 0 0 0 10px rgba(251,191,36,0); }
|
||
100% { box-shadow: 0 0 0 0 rgba(251,191,36,0); }
|
||
}
|
||
.animate-pulse-amber { animation: pulse-amber 1.6s infinite; }
|
||
```
|
||
|
||
- [ ] **Step 2: Implement PartialBanner**
|
||
|
||
Replace the `function PartialBanner` placeholder with:
|
||
|
||
```tsx
|
||
function PartialBanner({ fix, onOutcome, onApply }: 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">
|
||
<Info 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>Partially applied</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.partial_notes && (
|
||
<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]">Note</span>
|
||
<span>{fix.partial_notes}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-2 shrink-0 pt-0.5">
|
||
<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={onApply}
|
||
className="px-3 py-[9px] rounded-lg bg-card border border-white/10 text-primary text-[12.5px] font-medium hover:bg-elevated"
|
||
>
|
||
Finish it ›
|
||
</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"
|
||
>
|
||
It worked
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Verify types compile**
|
||
|
||
```bash
|
||
cd frontend && npx tsc -b
|
||
```
|
||
|
||
Expected: clean.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/components/pilot/ProposalBanner.tsx frontend/src/index.css
|
||
git commit -m "feat(pilot): banner Verifying + Partial states"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: ProposalBanner — AIConfirming + Nudge + Collapsed states
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/components/pilot/ProposalBanner.tsx`
|
||
|
||
- [ ] **Step 1: Implement AIConfirmingBanner**
|
||
|
||
Replace the placeholder:
|
||
|
||
```tsx
|
||
function AIConfirmingBanner({ fix, onAcceptAIProposal, onRejectAIProposal }: ProposalBannerProps) {
|
||
const proposal = fix.ai_outcome_proposal
|
||
if (!proposal) return null
|
||
const isSuccess = proposal.outcome === 'success'
|
||
|
||
return (
|
||
<div className="relative border-t border-accent/30 bg-gradient-to-b from-accent-dim/40 to-accent-dim/20 px-5 py-3 animate-slide-up">
|
||
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-accent" />
|
||
<div className="flex items-start gap-3">
|
||
<div className="shrink-0 mt-0.5 w-7 h-7 rounded-md border border-accent/30 bg-accent-dim flex items-center justify-center text-accent">
|
||
<Sparkles 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-accent">
|
||
<span>AI detected outcome</span>
|
||
<span className="px-2 py-[2px] rounded-full bg-accent/20 text-accent text-[10.5px] font-bold normal-case tracking-normal">
|
||
{isSuccess ? 'Success' : proposal.outcome === 'failure' ? 'Failure' : 'Partial'}
|
||
</span>
|
||
</div>
|
||
<div className="mt-0.5 text-[14px] font-semibold text-heading leading-snug">
|
||
AI thinks the fix {isSuccess ? 'resolved the issue' : proposal.outcome === 'failure' ? 'didn\'t work' : 'was partially applied'} — confirm?
|
||
</div>
|
||
<div className="mt-1 text-[12.5px] text-muted-foreground leading-relaxed">
|
||
{proposal.reason || 'Based on the recent chat. One click either confirms or corrects.'}
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-2 shrink-0 pt-0.5">
|
||
<button
|
||
onClick={onRejectAIProposal}
|
||
className="px-3 py-[9px] rounded-lg text-muted-foreground text-[12.5px] hover:bg-white/[0.08] hover:text-primary"
|
||
>
|
||
Not yet
|
||
</button>
|
||
<button
|
||
onClick={onAcceptAIProposal}
|
||
className={cn(
|
||
'px-3 py-[9px] rounded-lg font-semibold text-[12.5px] inline-flex items-center gap-1.5 hover:brightness-110',
|
||
isSuccess ? 'bg-success text-[#0a1a12]' : 'bg-danger text-[#180808]',
|
||
)}
|
||
>
|
||
<Check size={12} strokeWidth={2.5} />
|
||
Confirm{isSuccess ? ' · Resolve' : ''}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Implement NudgeBanner**
|
||
|
||
```tsx
|
||
function NudgeBanner({ fix, onOutcome }: ProposalBannerProps) {
|
||
return (
|
||
<div className="relative border-t border-warning/30 bg-warning-dim/60 px-5 py-2 flex items-center gap-3">
|
||
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-warning" />
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-warning shrink-0">
|
||
<circle cx="12" cy="12" r="10" /><path d="M12 8v4" /><path d="M12 16h.01" />
|
||
</svg>
|
||
<span className="flex-1 text-[12.5px] text-primary">
|
||
Did <strong className="text-heading">"{fix.title}"</strong> work?
|
||
</span>
|
||
<button
|
||
onClick={() => { /* silence for 3 more messages — handled by parent */ }}
|
||
className="px-2.5 py-1 rounded text-[12px] text-muted-foreground hover:bg-white/[0.08] hover:text-primary"
|
||
>
|
||
Still checking
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
const reason = window.prompt('Why didn\'t it work? (optional)')
|
||
onOutcome('applied_failed', reason?.trim() || undefined)
|
||
}}
|
||
className="px-2.5 py-1 rounded border border-danger/30 text-danger text-[12px] hover:bg-danger-dim"
|
||
>
|
||
No
|
||
</button>
|
||
<button
|
||
onClick={() => onOutcome('applied_success')}
|
||
className="px-2.5 py-1 rounded bg-success text-[#0a1a12] font-semibold text-[12px] hover:brightness-110"
|
||
>
|
||
Yes
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Implement CollapsedBanner**
|
||
|
||
```tsx
|
||
function CollapsedBanner({ fix, onToggleCollapsed }: ProposalBannerProps) {
|
||
return (
|
||
<button
|
||
onClick={onToggleCollapsed}
|
||
className="relative w-full border-t border-warning/30 bg-warning-dim/40 px-5 py-2 flex items-center gap-2.5 hover:bg-warning-dim/60 transition-colors text-left"
|
||
>
|
||
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-warning" />
|
||
<Sparkles size={12} className="text-warning shrink-0" />
|
||
<span className="flex-1 text-[12px] font-medium text-heading truncate">{fix.title}</span>
|
||
<span className="px-1.5 py-[1px] rounded-full bg-warning/20 text-warning text-[10.5px] font-bold tabular-nums">
|
||
{fix.confidence_pct}%
|
||
</span>
|
||
<span className="text-muted-foreground text-[11px]">▸ expand</span>
|
||
</button>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Verify Tailwind classes resolve + compile**
|
||
|
||
```bash
|
||
cd frontend && npx tsc -b && npm run build
|
||
```
|
||
|
||
Expected: clean — `npm run build` is stricter than `tsc --noEmit` per Lesson.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/components/pilot/ProposalBanner.tsx
|
||
git commit -m "feat(pilot): banner AI-confirming, Nudge, Collapsed states"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: EscalateInterceptDialog
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/components/pilot/EscalateInterceptDialog.tsx`
|
||
|
||
- [ ] **Step 1: Create the component**
|
||
|
||
```tsx
|
||
/**
|
||
* Popover dialog that intercepts Escalate when a fix is in verifying/partial
|
||
* status. Captures outcome before the escalation so the narrative is honest
|
||
* for whoever picks up the ticket.
|
||
*/
|
||
import { X, AlertCircle, Check } from 'lucide-react'
|
||
import type { FixOutcome } from '@/api/sessionSuggestedFixes'
|
||
|
||
export interface EscalateInterceptDialogProps {
|
||
fixTitle: string
|
||
onChoose: (
|
||
outcome: FixOutcome | 'never_applied',
|
||
) => void
|
||
onClose: () => void
|
||
}
|
||
|
||
export function EscalateInterceptDialog({
|
||
fixTitle, onChoose, onClose,
|
||
}: EscalateInterceptDialogProps) {
|
||
return (
|
||
<>
|
||
<div className="fixed inset-0 z-40" onClick={onClose} />
|
||
<div className="absolute bottom-full mb-2 left-0 z-50 w-[340px] rounded-lg border border-white/15 bg-card p-3.5 shadow-[0_18px_40px_rgba(0,0,0,0.55)]">
|
||
<div className="font-heading font-semibold text-[13px] text-heading mb-1">
|
||
Before escalating — what happened with the fix?
|
||
</div>
|
||
<div className="text-[12px] text-muted-foreground leading-[1.5] mb-3">
|
||
"{fixTitle}" is still in the Verifying state. Tag its outcome so the senior picking this up knows what's been tried.
|
||
</div>
|
||
<div className="flex flex-col gap-1.5">
|
||
<button
|
||
onClick={() => onChoose('applied_failed')}
|
||
autoFocus
|
||
className="flex items-center gap-2.5 px-3 py-2.5 rounded-lg border border-danger/30 bg-danger-dim text-[12.5px] text-primary hover:bg-danger-dim/80 hover:border-danger transition-colors text-left"
|
||
>
|
||
<X size={13} strokeWidth={2.5} className="text-danger" />
|
||
<span className="flex-1">The fix didn't work</span>
|
||
<span className="text-[10.5px] text-muted-foreground font-mono px-1.5 py-[2px] rounded bg-white/[0.05]">↵</span>
|
||
</button>
|
||
<button
|
||
onClick={() => onChoose('applied_success')}
|
||
className="flex items-center gap-2.5 px-3 py-2.5 rounded-lg border border-white/10 bg-elevated text-[12.5px] text-primary hover:bg-sidebar transition-colors text-left"
|
||
>
|
||
<Check size={13} strokeWidth={2} />
|
||
<span className="flex-1">It worked — escalating for another reason</span>
|
||
</button>
|
||
<button
|
||
onClick={() => onChoose('never_applied')}
|
||
className="flex items-center gap-2.5 px-3 py-2.5 rounded-lg border border-white/10 bg-elevated text-[12.5px] text-primary hover:bg-sidebar transition-colors text-left"
|
||
>
|
||
<AlertCircle size={13} strokeWidth={2} />
|
||
<span className="flex-1">Never actually applied it</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)
|
||
}
|
||
|
||
export default EscalateInterceptDialog
|
||
```
|
||
|
||
- [ ] **Step 2: Verify it compiles**
|
||
|
||
```bash
|
||
cd frontend && npx tsc -b
|
||
```
|
||
|
||
Expected: clean.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/components/pilot/EscalateInterceptDialog.tsx
|
||
git commit -m "feat(pilot): EscalateInterceptDialog popover"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: Integrate banner into AssistantChatPage
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/pages/AssistantChatPage.tsx`
|
||
- Modify: `frontend/src/components/pilot/TaskLane.tsx` (pass null for suggestedFixSlot)
|
||
|
||
- [ ] **Step 1: Find and remove the existing SuggestedFix card render**
|
||
|
||
In `AssistantChatPage.tsx`, search for `<SuggestedFix` and the corresponding `suggestedFixSlot={...}` prop passed to `<TaskLane`. The card lives inside the task lane today; remove its usage — pass `suggestedFixSlot={null}` (keep the prop in TaskLane's API for now so other callers don't break) or delete the prop usage entirely if TaskLane is the only caller. Grep to verify:
|
||
|
||
```bash
|
||
grep -rn "suggestedFixSlot" /config/workspace/resolutionflow/frontend/src/
|
||
```
|
||
|
||
- [ ] **Step 2: Add banner state + handlers**
|
||
|
||
Near the top of the component body (after existing useState calls for the active fix — search for `activeFix`):
|
||
|
||
```tsx
|
||
const [bannerCollapsed, setBannerCollapsed] = useState(false)
|
||
const [postApplyMsgCount, setPostApplyMsgCount] = useState(0)
|
||
const [nudgeSilenced, setNudgeSilenced] = useState(false)
|
||
const [escalateIntercept, setEscalateIntercept] = useState<{ fixId: string; fixTitle: string } | null>(null)
|
||
|
||
const bannerMode: BannerMode | null = (() => {
|
||
if (!activeFix) return null
|
||
if (activeFix.status === 'dismissed') return null
|
||
if (activeFix.ai_outcome_proposal) return 'ai_confirming'
|
||
if (activeFix.status === 'applied_partial') return 'partial'
|
||
if (activeFix.status === 'applied_success' || activeFix.status === 'applied_failed') return null
|
||
if (activeFix.applied_at) {
|
||
if (postApplyMsgCount >= 3 && !nudgeSilenced) return 'nudge'
|
||
return 'verifying'
|
||
}
|
||
return 'proposed'
|
||
})()
|
||
|
||
const handleApplyFix = useCallback(async () => {
|
||
// Existing apply logic — opens Script Generator / NoTemplateDialog.
|
||
// After the script successfully runs (or the engineer acknowledges the
|
||
// open-script-in-builder handoff), patch status: proposed → verifying via
|
||
// the existing "decision" endpoint (which already stamps one_off/draft/build),
|
||
// AND patch outcome via the new API to set applied_at.
|
||
await sessionSuggestedFixesApi.patchOutcome(
|
||
sessionId, activeFix!.id, activeFix!.status as FixOutcome, /* no notes */
|
||
).catch(() => { /* tolerate — applied_at is stamped server-side on next outcome call */ })
|
||
setPostApplyMsgCount(0)
|
||
setNudgeSilenced(false)
|
||
/* existing apply flow continues here */
|
||
}, [sessionId, activeFix])
|
||
|
||
const handleSetOutcome = useCallback(async (outcome: FixOutcome, notes?: string) => {
|
||
if (!activeFix) return
|
||
const updated = await sessionSuggestedFixesApi.patchOutcome(
|
||
sessionId, activeFix.id, outcome, notes,
|
||
)
|
||
setActiveFix(updated)
|
||
if (outcome === 'applied_success') {
|
||
// Auto-open ResolutionNotePreview pre-filled with the fix as resolution.
|
||
openResolutionNotePreview()
|
||
}
|
||
}, [sessionId, activeFix, openResolutionNotePreview])
|
||
|
||
const handleAcceptAIProposal = useCallback(async () => {
|
||
if (!activeFix?.ai_outcome_proposal) return
|
||
const map: Record<string, FixOutcome> = {
|
||
success: 'applied_success', failure: 'applied_failed', partial: 'applied_partial',
|
||
}
|
||
const outcome = map[activeFix.ai_outcome_proposal.outcome]
|
||
const notes = activeFix.ai_outcome_proposal.reason
|
||
await handleSetOutcome(
|
||
outcome,
|
||
outcome === 'applied_partial' || outcome === 'applied_failed' ? notes : undefined,
|
||
)
|
||
}, [activeFix, handleSetOutcome])
|
||
|
||
const handleRejectAIProposal = useCallback(async () => {
|
||
if (!activeFix) return
|
||
// Clear the proposal without changing status. Backend: PATCH the fix
|
||
// with ai_outcome_proposal=null (add a small endpoint or reuse outcome
|
||
// with a 'clear_proposal' intent — minimal: clear it server-side when
|
||
// the engineer clicks either terminal outcome button).
|
||
setActiveFix({ ...activeFix, ai_outcome_proposal: null })
|
||
}, [activeFix])
|
||
```
|
||
|
||
Increment `postApplyMsgCount` inside the existing chat-send handler(s). Grep for `sendChatMessage` to find them, then after each successful engineer-send:
|
||
|
||
```tsx
|
||
if (activeFix?.applied_at && activeFix.status !== 'applied_success' && activeFix.status !== 'applied_failed') {
|
||
setPostApplyMsgCount(c => c + 1)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Render the banner above the composer**
|
||
|
||
Find the composer JSX (search for `ChatComposer` or the `<textarea`/input for chat). Just above it, insert:
|
||
|
||
```tsx
|
||
{activeFix && bannerMode && (
|
||
<ProposalBanner
|
||
fix={activeFix}
|
||
mode={bannerMode}
|
||
collapsed={bannerCollapsed && bannerMode !== 'nudge' && bannerMode !== 'ai_confirming'}
|
||
onToggleCollapsed={() => setBannerCollapsed(v => !v)}
|
||
onApply={handleApplyFix}
|
||
onDismiss={() => handleSetOutcome('dismissed')}
|
||
onOutcome={handleSetOutcome}
|
||
onAcceptAIProposal={handleAcceptAIProposal}
|
||
onRejectAIProposal={handleRejectAIProposal}
|
||
/>
|
||
)}
|
||
```
|
||
|
||
- [ ] **Step 4: Wire Escalate intercept**
|
||
|
||
Find the Escalate button handler. Wrap it:
|
||
|
||
```tsx
|
||
const handleEscalateClick = () => {
|
||
const inVerifyState = activeFix && (
|
||
activeFix.applied_at && activeFix.status === 'proposed' ||
|
||
activeFix.status === 'applied_partial'
|
||
)
|
||
if (inVerifyState) {
|
||
setEscalateIntercept({ fixId: activeFix!.id, fixTitle: activeFix!.title })
|
||
return
|
||
}
|
||
openEscalatePackagePreview() // existing flow
|
||
}
|
||
|
||
const handleInterceptChoice = async (
|
||
choice: FixOutcome | 'never_applied'
|
||
) => {
|
||
setEscalateIntercept(null)
|
||
if (choice === 'never_applied') {
|
||
await sessionSuggestedFixesApi.patchOutcome(sessionId, escalateIntercept!.fixId, 'dismissed')
|
||
} else {
|
||
await sessionSuggestedFixesApi.patchOutcome(sessionId, escalateIntercept!.fixId, choice)
|
||
}
|
||
openEscalatePackagePreview()
|
||
}
|
||
```
|
||
|
||
Render the dialog somewhere inside the action-bar container:
|
||
|
||
```tsx
|
||
{escalateIntercept && (
|
||
<EscalateInterceptDialog
|
||
fixTitle={escalateIntercept.fixTitle}
|
||
onChoose={handleInterceptChoice}
|
||
onClose={() => setEscalateIntercept(null)}
|
||
/>
|
||
)}
|
||
```
|
||
|
||
- [ ] **Step 5: Wire Resolve auto-success**
|
||
|
||
Before the existing Resolve-button flow fires:
|
||
|
||
```tsx
|
||
const handleResolveClick = async () => {
|
||
if (activeFix && activeFix.applied_at && activeFix.status === 'proposed') {
|
||
// Implicit signal: engineer is resolving while a fix is in Verifying.
|
||
await sessionSuggestedFixesApi.patchOutcome(sessionId, activeFix.id, 'applied_success')
|
||
}
|
||
openResolutionNotePreview() // existing flow
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Build + browser smoke test**
|
||
|
||
```bash
|
||
cd frontend && npm run build
|
||
```
|
||
|
||
Expected: clean build.
|
||
|
||
Start dev stack:
|
||
```bash
|
||
docker compose -f docker-compose.dev.yml up -d
|
||
cd frontend && npm run dev
|
||
```
|
||
|
||
Smoke test at <http://localhost:5173/pilot> with `engineer@resolutionflow.example.com` / `TestPass123!`:
|
||
- Start a session, let the AI propose a fix → banner appears above composer (Proposed).
|
||
- Click Apply → Verifying state with pulse.
|
||
- Click "It worked" → ResolutionNotePreview opens.
|
||
- Replay: apply → send 3 chat messages → Nudge strip appears.
|
||
- Replay: apply → click Escalate → intercept popover appears.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/pages/AssistantChatPage.tsx frontend/src/components/pilot/TaskLane.tsx
|
||
git commit -m "feat(pilot): mount ProposalBanner + wire implicit signals"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: Final cleanup — remove old SuggestedFix card
|
||
|
||
**Files:**
|
||
- Delete: `frontend/src/components/pilot/sections/SuggestedFix.tsx`
|
||
- Modify: any remaining importers
|
||
|
||
- [ ] **Step 1: Find remaining imports**
|
||
|
||
```bash
|
||
grep -rn "from.*sections/SuggestedFix\|import.*SuggestedFix[^a-z]" /config/workspace/resolutionflow/frontend/src/
|
||
```
|
||
|
||
- [ ] **Step 2: Remove the import sites and delete the file**
|
||
|
||
Remove each `import ... SuggestedFix` that remains. Delete:
|
||
|
||
```bash
|
||
rm frontend/src/components/pilot/sections/SuggestedFix.tsx
|
||
```
|
||
|
||
- [ ] **Step 3: Full build check**
|
||
|
||
```bash
|
||
cd frontend && npm run build
|
||
```
|
||
|
||
Expected: clean.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "chore(pilot): remove deprecated SuggestedFix task-lane card"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 13: Handoff doc update + QA sweep
|
||
|
||
**Files:**
|
||
- Modify: `docs/handoff/2026-04-22-flowpilot-migration.md`
|
||
- Modify: `docs/FlowAssist_Migration/FLOWPILOT-MIGRATION.md` (append a Phase 8 section referencing this plan)
|
||
|
||
- [ ] **Step 1: Update the handoff doc**
|
||
|
||
Change the status line to reflect Phase 8 as shipped, and mark open item #2 as resolved with a pointer to this plan. Leave items #1 and #3 open (they're separate decisions).
|
||
|
||
- [ ] **Step 2: QA sweep in browser**
|
||
|
||
Run the full happy-path plus these edge cases:
|
||
- Proposed → Dismiss → no banner remains, facts untouched.
|
||
- Proposed → Apply → Verifying → "Didn't work" → enter reason → banner disappears (status terminal), AI can propose a new fix → banner re-arms.
|
||
- Proposed → Apply → Verifying → "Mark partial" (overflow) → enter notes → Partial state shows notes → "Finish it" → back to Verifying.
|
||
- Proposed → Apply → chat "yep that fixed it" → wait for AI next turn → AI-confirming banner (accent blue) → Confirm → Resolve flow.
|
||
- Verifying → click Escalate in task-lane action bar → intercept popover → "Didn't work" (Enter) → status flips to applied_failed → Escalation package opens.
|
||
- Verifying → click Resolve in task-lane action bar → status auto-flips to applied_success → ResolutionNotePreview opens pre-filled.
|
||
|
||
- [ ] **Step 3: Final commit**
|
||
|
||
```bash
|
||
git add docs/
|
||
git commit -m "docs(pilot): Phase 8 fix outcome banner — handoff + migration doc"
|
||
```
|
||
|
||
---
|
||
|
||
## Self-Review
|
||
|
||
**Spec coverage check:**
|
||
|
||
| Spec element | Covered by |
|
||
|---|---|
|
||
| Banner replaces task-lane Suggested Fix card | Tasks 7-9 (component), Task 11 (integration), Task 12 (removal) |
|
||
| Proposed state with confidence + description | Task 7 |
|
||
| Verifying state with amber pulse, ✓/✕/overflow | Task 8 |
|
||
| Partial apply with notes, non-terminal | Tasks 1, 3, 8 |
|
||
| AI-inferred `[FIX_OUTCOME]` marker + confirm banner | Tasks 4, 5, 9, 11 |
|
||
| Escalate intercept with "didn't work" default | Tasks 10, 11 |
|
||
| Nudge after 3 post-apply messages | Tasks 9, 11 |
|
||
| Resolve-while-verifying auto-success | Task 11 |
|
||
| `session_suggested_fixes.status` + companion columns | Task 1 |
|
||
| PATCH `/outcome` endpoint | Task 3 |
|
||
| Anti-parrot compliance of prompt addition | Task 5 |
|
||
| Remove deprecated `SuggestedFix.tsx` | Task 12 |
|
||
| Smoke-test all paths | Task 13 |
|
||
|
||
**Placeholder scan:** No TBDs, no "add error handling," no "similar to Task N." Every code block is complete.
|
||
|
||
**Type consistency:** `FixStatus`, `FixOutcome`, `BannerMode`, `AIOutcomeProposal` — spelled the same across Tasks 2, 6, 7, 8, 9, 10, 11. Endpoint method names (`patchOutcome`, `_parse_fix_outcome_marker`, `SessionSuggestedFixOutcomeRequest`) consistent.
|
||
|
||
**Risks called out:**
|
||
- Task 4 introduces `ai_outcome_proposal` column that Task 1 didn't anticipate — Task 4 flags this and instructs to amend Task 1's migration before moving on.
|
||
- The apply flow (Task 11, Step 2) uses the existing decision endpoint; the exact wire-up depends on whether the apply path runs a script, opens Script Builder, or both. The executing engineer should read `AssistantChatPage.tsx`'s current `onActivate` handler for the suggested fix and adapt.
|
||
- Frontend has no component test harness — verification is build + manual browser smoke (Task 11 Step 6, Task 13 Step 2).
|
||
|
||
---
|
||
|
||
**Plan complete and saved to `docs/FlowAssist_Migration/phase-8-fix-outcome-banner.md`. Two execution options:**
|
||
|
||
**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration
|
||
|
||
**2. Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints
|
||
|
||
**Which approach?**
|