14-task plan covering: - DB migration for origin + partial unique index on script_builder_sessions - Pydantic schemas for inline origin + PATCH /script - POST /script-builder/sessions idempotent for pilot_inline + auth - list_sessions + count_user_sessions filtered to standalone - PATCH /suggested-fixes/:id/script (bumps state_version, no applied_at) - Frontend API client additions - ChatTabStrip, ScriptBuilderTab (controller), InlineNoTemplateDialog - TemplateMatchPanel 'I ran this' action - EscalateInterceptDialog fourth 'partial' choice - AssistantChatPage integration + applyFix call-site relocation - Docs + handoff updates Paired with the spec at phase-9-script-builder-tab.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1898 lines
68 KiB
Markdown
1898 lines
68 KiB
Markdown
# FlowPilot Phase 9 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:** Ship the remaining two FlowPilot migration items (tabbed Script Builder inside the chat; NoTemplateDialog relocation) plus two Phase 8 cleanups (EscalateInterceptDialog partial choice; applied_at stamp semantics correction).
|
||
|
||
**Architecture:** Adds `origin` to `script_builder_sessions` (plus a partial unique index for idempotent inline create), a new `PATCH /suggested-fixes/:id/script` endpoint, and a new `ScriptBuilderTab` controller in the chat region. `ScriptBuilderChat` stays presentational — the new controller owns session lifecycle + submit. `applyFix` call site moves from banner click to three explicit run-declaring surfaces.
|
||
|
||
**Tech Stack:** Python 3.11 + FastAPI + SQLAlchemy 2.0 async + Alembic + Pydantic v2 (backend); React 19 + Vite + TypeScript + Tailwind v4 + Monaco (frontend).
|
||
|
||
**Spec reference:** [docs/FlowAssist_Migration/phase-9-script-builder-tab.md](phase-9-script-builder-tab.md)
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
### Backend — new
|
||
- `backend/alembic/versions/<hash>_script_builder_origin.py` — migration
|
||
- `backend/tests/test_fix_script_endpoint.py` — PATCH /script tests
|
||
- `backend/tests/test_script_builder_inline.py` — inline session tests (idempotency, auth, list/count filter)
|
||
|
||
### Backend — modified
|
||
- `backend/app/models/script_builder_session.py` — add `origin` column
|
||
- `backend/app/schemas/script_builder.py` — extend `ScriptBuilderCreateRequest` with `origin` + `ai_session_id`
|
||
- `backend/app/schemas/session_suggested_fix.py` — add `SessionSuggestedFixScriptRequest`
|
||
- `backend/app/api/endpoints/script_builder.py` — validate + resolve inline origin in `create_session`
|
||
- `backend/app/api/endpoints/session_suggested_fixes.py` — add PATCH /script endpoint
|
||
- `backend/app/services/script_builder_service.py` — persist `origin`; idempotent inline lookup; filter `list_sessions` / `count_user_sessions` to standalone
|
||
|
||
### Frontend — new
|
||
- `frontend/src/components/pilot/ChatTabStrip.tsx` — `[Chat] [Script Builder ●]` tab strip
|
||
- `frontend/src/components/pilot/ScriptBuilderTab.tsx` — controller (session lifecycle + mode toggle + submit)
|
||
- `frontend/src/components/pilot/InlineNoTemplateDialog.tsx` — chat-region render wrapper for the existing `NoTemplateDialog`
|
||
|
||
### Frontend — modified
|
||
- `frontend/src/api/sessionSuggestedFixes.ts` — `patchScript` method
|
||
- `frontend/src/api/scriptBuilder.ts` — `createSession(language, options?)` takes optional `origin` + `aiSessionId`
|
||
- `frontend/src/components/pilot/script/TemplateMatchPanel.tsx` — new "✓ I ran this" button; calls parent callback
|
||
- `frontend/src/components/pilot/EscalateInterceptDialog.tsx` — fourth "I applied some of it — partial" choice
|
||
- `frontend/src/pages/AssistantChatPage.tsx` — tab strip + `ScriptBuilderTab` mount + banner routing + applyFix call-site moves + inline `NoTemplateDialog` render
|
||
- `frontend/src/components/pilot/TaskLane.tsx` — remove `NoTemplateDialog` from `bottomSlot`
|
||
|
||
### Frontend — unchanged (intentionally)
|
||
- `frontend/src/components/script-builder/ScriptBuilderChat.tsx` — presentational, stays pure
|
||
- `frontend/src/pages/ScriptBuilderPage.tsx` — standalone path preserved (defaults)
|
||
- `frontend/src/components/pilot/script/NoTemplateDialog.tsx` — existing three-card UX unchanged (new wrapper adjusts placement only)
|
||
|
||
---
|
||
|
||
## Task 1: DB migration — add `origin` column + constraints + partial unique index
|
||
|
||
**Files:**
|
||
- Create: `backend/alembic/versions/<hash>_script_builder_origin.py` (hash chosen by alembic)
|
||
- No tests in this task — exercised by Task 4 + Task 5 integration tests.
|
||
|
||
- [ ] **Step 1: Generate migration**
|
||
|
||
```bash
|
||
cd /config/workspace/resolutionflow/backend && source venv/bin/activate \
|
||
&& alembic revision -m "add origin discriminator + inline idempotency to script_builder_sessions"
|
||
```
|
||
|
||
(Do NOT pass `--rev-id` — alembic generates the hash.)
|
||
|
||
- [ ] **Step 2: Write migration body**
|
||
|
||
Open the newly-generated file. Replace the body (keep alembic's generated `revision` / `down_revision`):
|
||
|
||
```python
|
||
"""add origin discriminator + inline idempotency to script_builder_sessions
|
||
|
||
Adds:
|
||
- origin VARCHAR(20) NOT NULL DEFAULT 'standalone' with CHECK enum
|
||
- invariant: pilot_inline rows must have ai_session_id
|
||
- partial unique index: one pilot_inline session per (user, pilot session)
|
||
"""
|
||
from alembic import op
|
||
import sqlalchemy as sa
|
||
|
||
|
||
revision = "<hash-alembic-generated>"
|
||
down_revision = "<parent>"
|
||
branch_labels = None
|
||
depends_on = None
|
||
|
||
|
||
def upgrade() -> None:
|
||
op.add_column(
|
||
"script_builder_sessions",
|
||
sa.Column(
|
||
"origin",
|
||
sa.String(length=20),
|
||
nullable=False,
|
||
server_default=sa.text("'standalone'"),
|
||
),
|
||
)
|
||
op.create_check_constraint(
|
||
"ck_script_builder_sessions_origin",
|
||
"script_builder_sessions",
|
||
"origin IN ('standalone', 'pilot_inline')",
|
||
)
|
||
op.create_check_constraint(
|
||
"ck_script_builder_sessions_origin_ai_session",
|
||
"script_builder_sessions",
|
||
"origin <> 'pilot_inline' OR ai_session_id IS NOT NULL",
|
||
)
|
||
op.create_index(
|
||
"ux_script_builder_sessions_pilot_inline",
|
||
"script_builder_sessions",
|
||
["user_id", "ai_session_id"],
|
||
unique=True,
|
||
postgresql_where=sa.text("origin = 'pilot_inline'"),
|
||
)
|
||
# Drop the server_default — app code owns the default via model default.
|
||
op.alter_column("script_builder_sessions", "origin", server_default=None)
|
||
|
||
|
||
def downgrade() -> None:
|
||
op.drop_index(
|
||
"ux_script_builder_sessions_pilot_inline",
|
||
table_name="script_builder_sessions",
|
||
)
|
||
op.drop_constraint(
|
||
"ck_script_builder_sessions_origin_ai_session",
|
||
"script_builder_sessions",
|
||
type_="check",
|
||
)
|
||
op.drop_constraint(
|
||
"ck_script_builder_sessions_origin",
|
||
"script_builder_sessions",
|
||
type_="check",
|
||
)
|
||
op.drop_column("script_builder_sessions", "origin")
|
||
```
|
||
|
||
- [ ] **Step 3: Apply + verify**
|
||
|
||
```bash
|
||
cd /config/workspace/resolutionflow/backend && alembic upgrade head
|
||
```
|
||
|
||
Verify:
|
||
|
||
```bash
|
||
docker exec -i resolutionflow_postgres psql -U postgres -d resolutionflow -tAc \
|
||
"SELECT column_name FROM information_schema.columns WHERE table_name='script_builder_sessions' AND column_name='origin';"
|
||
# expected: origin
|
||
```
|
||
|
||
```bash
|
||
docker exec -i resolutionflow_postgres psql -U postgres -d resolutionflow -tAc \
|
||
"SELECT indexname FROM pg_indexes WHERE indexname='ux_script_builder_sessions_pilot_inline';"
|
||
# expected: ux_script_builder_sessions_pilot_inline
|
||
```
|
||
|
||
- [ ] **Step 4: Round-trip downgrade/upgrade to confirm reversibility**
|
||
|
||
```bash
|
||
cd /config/workspace/resolutionflow/backend && alembic downgrade -1 && alembic upgrade head
|
||
```
|
||
|
||
Expected: both clean, no errors.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /config/workspace/resolutionflow && git add backend/alembic/versions/ \
|
||
&& git commit -m "$(cat <<'EOF'
|
||
feat(pilot): add origin + inline idempotency to script_builder_sessions
|
||
|
||
Phase 9 prep. Adds:
|
||
- origin VARCHAR(20) NOT NULL with CHECK ('standalone' | 'pilot_inline')
|
||
- invariant: pilot_inline rows must have ai_session_id
|
||
- partial unique index on (user_id, ai_session_id) WHERE origin='pilot_inline'
|
||
— backs get-or-create idempotency for the inline Script Builder tab.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: SQLAlchemy model — add `origin` column
|
||
|
||
**Files:**
|
||
- Modify: `backend/app/models/script_builder_session.py`
|
||
|
||
- [ ] **Step 1: Add the column to the model**
|
||
|
||
Open the file. After the existing `ai_session_id` column (around line 59) and before `created_at`, add:
|
||
|
||
```python
|
||
origin: Mapped[str] = mapped_column(
|
||
String(20),
|
||
nullable=False,
|
||
default="standalone",
|
||
comment=(
|
||
"Session origin — 'standalone' (from /script-builder) or "
|
||
"'pilot_inline' (from FlowPilot Script Builder tab). "
|
||
"Invariant: pilot_inline rows must have ai_session_id set."
|
||
),
|
||
)
|
||
```
|
||
|
||
- [ ] **Step 2: Sanity-check imports + run a quick parse**
|
||
|
||
```bash
|
||
docker exec resolutionflow_backend sh -c "cd /app && python -c 'from app.models.script_builder_session import ScriptBuilderSession; print(ScriptBuilderSession.__table__.columns.keys())'"
|
||
```
|
||
|
||
Expected: output includes `origin` alongside existing columns.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
cd /config/workspace/resolutionflow && git add backend/app/models/script_builder_session.py \
|
||
&& git commit -m "$(cat <<'EOF'
|
||
feat(pilot): script_builder_sessions.origin on SQLAlchemy model
|
||
|
||
Mirrors the DB column added in the prior migration. App-level default
|
||
is 'standalone' so existing callers of ScriptBuilderSession(...) work
|
||
without code changes.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Backend schemas — extend create request + add PATCH /script request
|
||
|
||
**Files:**
|
||
- Modify: `backend/app/schemas/script_builder.py`
|
||
- Modify: `backend/app/schemas/session_suggested_fix.py`
|
||
|
||
- [ ] **Step 1: Extend `ScriptBuilderCreateRequest`**
|
||
|
||
Open `backend/app/schemas/script_builder.py`. At the top, confirm `Literal` is imported from `typing` (add if missing). The `UUID` import is already present.
|
||
|
||
Replace the `ScriptBuilderCreateRequest` class with:
|
||
|
||
```python
|
||
class ScriptBuilderCreateRequest(BaseModel):
|
||
"""Request to start (or get-or-create, for inline origin) a builder session.
|
||
|
||
When `origin='pilot_inline'`, `ai_session_id` is REQUIRED and must
|
||
reference a pilot session owned by the current user. The endpoint's
|
||
get-or-create semantics kick in: if a pilot_inline session already
|
||
exists for (user_id, ai_session_id), that row is returned instead of
|
||
creating a duplicate.
|
||
"""
|
||
language: str = Field(
|
||
default="powershell",
|
||
pattern=r"^(powershell|bash|python)$",
|
||
description="Script language",
|
||
)
|
||
origin: Literal["standalone", "pilot_inline"] = "standalone"
|
||
ai_session_id: UUID | None = None
|
||
```
|
||
|
||
- [ ] **Step 2: Add `SessionSuggestedFixScriptRequest`**
|
||
|
||
Open `backend/app/schemas/session_suggested_fix.py`. After `SessionSuggestedFixOutcomeRequest` (near the end of the "decision / outcome" section), add:
|
||
|
||
```python
|
||
class SessionSuggestedFixScriptRequest(BaseModel):
|
||
"""Engineer-submitted drafted script for a suggested fix.
|
||
|
||
Called when the inline Script Builder tab's Submit action fires. The
|
||
fix must be non-terminal (still proposed/applied_partial). Setting
|
||
the script does NOT stamp applied_at — a draft is not an application.
|
||
"""
|
||
ai_drafted_script: str = Field(..., min_length=1, max_length=50_000)
|
||
ai_drafted_parameters: dict[str, Any] | None = None
|
||
```
|
||
|
||
(`Any` is already imported at the top of the file — confirmed during Phase 8.)
|
||
|
||
- [ ] **Step 3: Verify parse**
|
||
|
||
```bash
|
||
docker exec resolutionflow_backend sh -c "cd /app && python -c '
|
||
from app.schemas.script_builder import ScriptBuilderCreateRequest
|
||
from app.schemas.session_suggested_fix import SessionSuggestedFixScriptRequest
|
||
print(\"OK\")
|
||
'"
|
||
```
|
||
|
||
Expected: `OK`.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
cd /config/workspace/resolutionflow && git add backend/app/schemas/ \
|
||
&& git commit -m "$(cat <<'EOF'
|
||
feat(pilot): pydantic schemas for inline origin + script PATCH
|
||
|
||
- ScriptBuilderCreateRequest gains origin ('standalone' | 'pilot_inline')
|
||
and optional ai_session_id. Handler-side validation (next task) enforces
|
||
pilot_inline ⇒ ai_session_id required + owned by caller.
|
||
- SessionSuggestedFixScriptRequest added for the new PATCH /script
|
||
endpoint (Phase 9 Task 6).
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: `POST /script-builder/sessions` — idempotent pilot_inline + auth + service filter
|
||
|
||
**Files:**
|
||
- Modify: `backend/app/api/endpoints/script_builder.py`
|
||
- Modify: `backend/app/services/script_builder_service.py`
|
||
- Create: `backend/tests/test_script_builder_inline.py`
|
||
|
||
- [ ] **Step 1: Read existing handler + service to understand integration points**
|
||
|
||
Read `backend/app/api/endpoints/script_builder.py` and `backend/app/services/script_builder_service.py` around the `create_session` / `list_sessions` / `count_user_sessions` functions. You're adding behavior, not restructuring — keep the existing call shape where possible.
|
||
|
||
- [ ] **Step 2: Write the failing tests**
|
||
|
||
Create `backend/tests/test_script_builder_inline.py`:
|
||
|
||
```python
|
||
"""Integration tests for inline pilot_inline script_builder_session behavior.
|
||
|
||
Covers:
|
||
- Idempotent get-or-create for (user, ai_session_id) on origin='pilot_inline'
|
||
- Authorization: ai_session_id must belong to current user
|
||
- list_sessions + count_user_sessions default-scope to 'standalone'
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import pytest
|
||
from httpx import AsyncClient
|
||
from sqlalchemy import select, func
|
||
from uuid import uuid4
|
||
|
||
from app.models.ai_session import AISession
|
||
from app.models.script_builder_session import ScriptBuilderSession
|
||
|
||
|
||
async def _make_pilot_session(test_db, user) -> str:
|
||
"""Helper: create a minimal pilot session owned by `user`.
|
||
|
||
Matches the existing pattern used by test_fix_outcome_endpoint.py.
|
||
"""
|
||
session = AISession(
|
||
id=uuid4(), user_id=user.id, account_id=user.account_id,
|
||
session_type="tshoot", intake_type="ticket",
|
||
intake_content={}, title="QA",
|
||
status="active", confidence_tier="exploring", confidence_score=0.0,
|
||
)
|
||
test_db.add(session)
|
||
await test_db.commit()
|
||
return str(session.id)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_inline_create_is_idempotent(
|
||
client: AsyncClient, test_user, auth_headers, test_db
|
||
):
|
||
"""Second create with same (user, ai_session_id) returns the existing row."""
|
||
ai_session_id = await _make_pilot_session(test_db, test_user)
|
||
|
||
r1 = await client.post(
|
||
"/api/v1/script-builder/sessions",
|
||
json={"language": "powershell", "origin": "pilot_inline",
|
||
"ai_session_id": ai_session_id},
|
||
headers=auth_headers,
|
||
)
|
||
assert r1.status_code in (200, 201), r1.text
|
||
first_id = r1.json()["id"]
|
||
|
||
r2 = await client.post(
|
||
"/api/v1/script-builder/sessions",
|
||
json={"language": "powershell", "origin": "pilot_inline",
|
||
"ai_session_id": ai_session_id},
|
||
headers=auth_headers,
|
||
)
|
||
assert r2.status_code in (200, 201)
|
||
assert r2.json()["id"] == first_id
|
||
|
||
# DB confirms only one row
|
||
row_count = await test_db.scalar(
|
||
select(func.count()).select_from(ScriptBuilderSession).where(
|
||
ScriptBuilderSession.user_id == test_user.id,
|
||
ScriptBuilderSession.origin == "pilot_inline",
|
||
)
|
||
)
|
||
assert row_count == 1
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_inline_requires_ai_session_id(
|
||
client: AsyncClient, auth_headers
|
||
):
|
||
"""origin='pilot_inline' without ai_session_id is rejected."""
|
||
r = await client.post(
|
||
"/api/v1/script-builder/sessions",
|
||
json={"language": "powershell", "origin": "pilot_inline"},
|
||
headers=auth_headers,
|
||
)
|
||
assert r.status_code == 400
|
||
assert "ai_session_id" in r.text.lower()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_inline_ai_session_must_belong_to_caller(
|
||
client: AsyncClient, test_user, auth_headers, test_db
|
||
):
|
||
"""ai_session_id pointing at another user's session is rejected."""
|
||
# Create pilot session owned by a DIFFERENT user
|
||
from app.models.user import User
|
||
from app.models.account import Account
|
||
other_account = Account(id=uuid4(), name="other", plan="free", is_active=True)
|
||
test_db.add(other_account)
|
||
await test_db.flush()
|
||
other_user = User(
|
||
id=uuid4(), email="other@example.com",
|
||
password_hash="x", name="Other", role="engineer",
|
||
is_super_admin=False, is_team_admin=False, is_active=True,
|
||
is_service_account=False, must_change_password=False,
|
||
account_id=other_account.id, account_role="engineer",
|
||
)
|
||
test_db.add(other_user)
|
||
await test_db.flush()
|
||
other_session_id = await _make_pilot_session(test_db, other_user)
|
||
|
||
r = await client.post(
|
||
"/api/v1/script-builder/sessions",
|
||
json={"language": "powershell", "origin": "pilot_inline",
|
||
"ai_session_id": other_session_id},
|
||
headers=auth_headers,
|
||
)
|
||
assert r.status_code in (403, 404), r.text
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_list_sessions_excludes_inline(
|
||
client: AsyncClient, test_user, auth_headers, test_db
|
||
):
|
||
"""GET /script-builder/sessions returns only standalone rows."""
|
||
ai_session_id = await _make_pilot_session(test_db, test_user)
|
||
|
||
# Create one inline session
|
||
await client.post(
|
||
"/api/v1/script-builder/sessions",
|
||
json={"language": "powershell", "origin": "pilot_inline",
|
||
"ai_session_id": ai_session_id},
|
||
headers=auth_headers,
|
||
)
|
||
# Create one standalone session
|
||
await client.post(
|
||
"/api/v1/script-builder/sessions",
|
||
json={"language": "powershell"},
|
||
headers=auth_headers,
|
||
)
|
||
|
||
r = await client.get("/api/v1/script-builder/sessions", headers=auth_headers)
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
# Depending on response shape, this may be a list or {"sessions": [...]}.
|
||
items = body if isinstance(body, list) else body.get("sessions", body.get("items", []))
|
||
assert len(items) == 1
|
||
assert items[0].get("origin", "standalone") == "standalone"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_inline_sessions_do_not_count_against_cap(
|
||
client: AsyncClient, test_user, auth_headers, test_db
|
||
):
|
||
"""Creating 5 pilot_inline sessions does not block a subsequent standalone."""
|
||
# Create 5 distinct pilot sessions and attach inline builder sessions to each
|
||
for _ in range(5):
|
||
ai_session_id = await _make_pilot_session(test_db, test_user)
|
||
r = await client.post(
|
||
"/api/v1/script-builder/sessions",
|
||
json={"language": "powershell", "origin": "pilot_inline",
|
||
"ai_session_id": ai_session_id},
|
||
headers=auth_headers,
|
||
)
|
||
assert r.status_code in (200, 201), r.text
|
||
|
||
# A standalone create should still succeed — inline sessions don't count
|
||
r = await client.post(
|
||
"/api/v1/script-builder/sessions",
|
||
json={"language": "powershell"},
|
||
headers=auth_headers,
|
||
)
|
||
assert r.status_code in (200, 201), r.text
|
||
```
|
||
|
||
- [ ] **Step 3: Run tests — confirm failure**
|
||
|
||
```bash
|
||
docker exec resolutionflow_backend sh -c "cd /app && pytest tests/test_script_builder_inline.py -v --override-ini='addopts='"
|
||
```
|
||
|
||
Expected: failures — endpoint doesn't yet accept `origin` / `ai_session_id`; list endpoint returns inline rows; the cap hits at 5.
|
||
|
||
- [ ] **Step 4: Update the service — `list_sessions` and `count_user_sessions` filter to `origin='standalone'`**
|
||
|
||
In `backend/app/services/script_builder_service.py`, find `list_sessions` and `count_user_sessions`. Add `include_inline: bool = False` keyword arg to each; when False (the default), add `.where(ScriptBuilderSession.origin == 'standalone')` to the query.
|
||
|
||
Example shape:
|
||
|
||
```python
|
||
async def list_sessions(
|
||
db: AsyncSession, user_id: uuid.UUID, *, include_inline: bool = False,
|
||
) -> list[ScriptBuilderSession]:
|
||
stmt = select(ScriptBuilderSession).where(
|
||
ScriptBuilderSession.user_id == user_id,
|
||
ScriptBuilderSession.deleted_at.is_(None), # or whatever existing guard
|
||
)
|
||
if not include_inline:
|
||
stmt = stmt.where(ScriptBuilderSession.origin == "standalone")
|
||
stmt = stmt.order_by(ScriptBuilderSession.updated_at.desc())
|
||
result = await db.execute(stmt)
|
||
return list(result.scalars())
|
||
|
||
async def count_user_sessions(
|
||
db: AsyncSession, user_id: uuid.UUID, *, include_inline: bool = False,
|
||
) -> int:
|
||
stmt = select(func.count()).select_from(ScriptBuilderSession).where(
|
||
ScriptBuilderSession.user_id == user_id,
|
||
ScriptBuilderSession.deleted_at.is_(None),
|
||
)
|
||
if not include_inline:
|
||
stmt = stmt.where(ScriptBuilderSession.origin == "standalone")
|
||
return await db.scalar(stmt) or 0
|
||
```
|
||
|
||
(Adapt to match the existing function signatures — `db` may be first or last positional arg; copy whatever pattern is already there.)
|
||
|
||
- [ ] **Step 5: Update the endpoint — auth + idempotency**
|
||
|
||
In `backend/app/api/endpoints/script_builder.py`, in the `create_session` handler, before calling the service:
|
||
|
||
```python
|
||
# Phase 9: inline origin validation + authorization
|
||
if body.origin == "pilot_inline":
|
||
if body.ai_session_id is None:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="ai_session_id is required when origin='pilot_inline'",
|
||
)
|
||
# Ownership check: the pilot session must belong to the current user.
|
||
# Reuse the existing _load_session_or_404 helper used elsewhere.
|
||
await _load_session_or_404(db, body.ai_session_id)
|
||
|
||
# Idempotent get-or-create: if a pilot_inline row exists for this
|
||
# (user, ai_session_id), return it.
|
||
existing = await db.scalar(
|
||
select(ScriptBuilderSession).where(
|
||
ScriptBuilderSession.user_id == current_user.id,
|
||
ScriptBuilderSession.ai_session_id == body.ai_session_id,
|
||
ScriptBuilderSession.origin == "pilot_inline",
|
||
)
|
||
)
|
||
if existing is not None:
|
||
return existing
|
||
```
|
||
|
||
After the ownership/idempotency short-circuit, continue into the service's existing create path. Pass `origin` + `ai_session_id` to the service's row creation. If the service has a helper that builds the row, add both kwargs there.
|
||
|
||
For the service creation path, handle the race (concurrent POSTs) via an integrity-error catch:
|
||
|
||
```python
|
||
try:
|
||
session = await script_builder_service.create_session(
|
||
db, user_id=current_user.id, language=body.language,
|
||
origin=body.origin, ai_session_id=body.ai_session_id,
|
||
)
|
||
except IntegrityError:
|
||
await db.rollback()
|
||
# Race: another request won the unique index. Re-read.
|
||
existing = await db.scalar(
|
||
select(ScriptBuilderSession).where(
|
||
ScriptBuilderSession.user_id == current_user.id,
|
||
ScriptBuilderSession.ai_session_id == body.ai_session_id,
|
||
ScriptBuilderSession.origin == "pilot_inline",
|
||
)
|
||
)
|
||
if existing is None:
|
||
raise
|
||
return existing
|
||
```
|
||
|
||
Add imports as needed: `from sqlalchemy.exc import IntegrityError`, `from sqlalchemy import select`, the model import, `_load_session_or_404` (grep for where other pilot endpoints import it).
|
||
|
||
Also: update the cap-check in `create_session` to pass `include_inline=False` (which is the default now, but be explicit for future readers).
|
||
|
||
- [ ] **Step 6: Run tests — expect passing**
|
||
|
||
```bash
|
||
docker exec resolutionflow_backend sh -c "cd /app && pytest tests/test_script_builder_inline.py -v --override-ini='addopts='"
|
||
```
|
||
|
||
Expected: 5 passed.
|
||
|
||
- [ ] **Step 7: Sanity-check that unrelated tests still pass**
|
||
|
||
```bash
|
||
docker exec resolutionflow_backend sh -c "cd /app && pytest tests/test_script_builder.py tests/test_session_suggested_fixes_api.py tests/test_fix_outcome_endpoint.py tests/test_fix_outcome_marker.py -v --override-ini='addopts='"
|
||
```
|
||
|
||
Expected: pass except for the pre-existing `test_record_decision_persists_and_bumps_state_version` (documented as Phase 8 Issue #4).
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
cd /config/workspace/resolutionflow && git add backend/ \
|
||
&& git commit -m "$(cat <<'EOF'
|
||
feat(pilot): inline Script Builder session — idempotent create + auth + filtered list
|
||
|
||
POST /script-builder/sessions now supports origin='pilot_inline':
|
||
- Requires ai_session_id; validates it against current user ownership.
|
||
- Get-or-create: returns existing row for (user, ai_session_id) pair.
|
||
- Partial unique index on the DB backs the invariant; races resolve to
|
||
the single winner row.
|
||
|
||
list_sessions + count_user_sessions default-scope to origin='standalone'
|
||
so inline scratch sessions don't pollute the /script-builder dashboard
|
||
or count against the 5-session cap.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: `PATCH /suggested-fixes/:id/script` — new endpoint
|
||
|
||
**Files:**
|
||
- Modify: `backend/app/api/endpoints/session_suggested_fixes.py`
|
||
- Create: `backend/tests/test_fix_script_endpoint.py`
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
Create `backend/tests/test_fix_script_endpoint.py`:
|
||
|
||
```python
|
||
"""Integration tests for PATCH /ai-sessions/{sid}/suggested-fixes/{fid}/script."""
|
||
from __future__ import annotations
|
||
|
||
import pytest
|
||
from httpx import AsyncClient
|
||
from sqlalchemy import select
|
||
from uuid import uuid4
|
||
from datetime import datetime, timezone
|
||
|
||
from app.models.ai_session import AISession
|
||
from app.models.session_suggested_fix import SessionSuggestedFix
|
||
|
||
|
||
async def _make_session_with_fix(
|
||
test_db, user, *, status: str = "proposed", with_script: bool = False,
|
||
) -> tuple[str, str]:
|
||
"""Create a pilot session + suggested fix for tests. Returns (sid, fid)."""
|
||
session = AISession(
|
||
id=uuid4(), user_id=user.id, account_id=user.account_id,
|
||
session_type="tshoot", intake_type="ticket",
|
||
intake_content={}, title="QA",
|
||
status="active", confidence_tier="exploring", confidence_score=0.0,
|
||
)
|
||
test_db.add(session)
|
||
await test_db.flush()
|
||
fix = SessionSuggestedFix(
|
||
id=uuid4(), session_id=session.id, account_id=user.account_id,
|
||
title="QA: test fix", description="desc", confidence_pct=80,
|
||
status=status,
|
||
ai_drafted_script="pre-existing" if with_script else None,
|
||
)
|
||
test_db.add(fix)
|
||
await test_db.commit()
|
||
return str(session.id), str(fix.id)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_patch_script_happy_path(
|
||
client: AsyncClient, test_user, auth_headers, test_db
|
||
):
|
||
sid, fid = await _make_session_with_fix(test_db, test_user)
|
||
r = await client.patch(
|
||
f"/api/v1/ai-sessions/{sid}/suggested-fixes/{fid}/script",
|
||
json={"ai_drafted_script": "Write-Host 'hello'"},
|
||
headers=auth_headers,
|
||
)
|
||
assert r.status_code == 200, r.text
|
||
body = r.json()
|
||
assert body["ai_drafted_script"] == "Write-Host 'hello'"
|
||
assert body["applied_at"] is None # draft ≠ apply
|
||
assert body["status"] == "proposed"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_patch_script_bumps_state_version(
|
||
client: AsyncClient, test_user, auth_headers, test_db
|
||
):
|
||
sid, fid = await _make_session_with_fix(test_db, test_user)
|
||
before = await test_db.scalar(
|
||
select(AISession.state_version).where(AISession.id == uuid4()).where(
|
||
AISession.id == __import__("uuid").UUID(sid)
|
||
)
|
||
)
|
||
r = await client.patch(
|
||
f"/api/v1/ai-sessions/{sid}/suggested-fixes/{fid}/script",
|
||
json={"ai_drafted_script": "echo hi"},
|
||
headers=auth_headers,
|
||
)
|
||
assert r.status_code == 200
|
||
after = await test_db.scalar(
|
||
select(AISession.state_version).where(
|
||
AISession.id == __import__("uuid").UUID(sid)
|
||
)
|
||
)
|
||
assert after == (before or 0) + 1
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_patch_script_rejects_terminal_fix(
|
||
client: AsyncClient, test_user, auth_headers, test_db
|
||
):
|
||
sid, fid = await _make_session_with_fix(test_db, test_user, status="applied_success")
|
||
r = await client.patch(
|
||
f"/api/v1/ai-sessions/{sid}/suggested-fixes/{fid}/script",
|
||
json={"ai_drafted_script": "echo hi"},
|
||
headers=auth_headers,
|
||
)
|
||
assert r.status_code == 409
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_patch_script_rejects_empty_body(
|
||
client: AsyncClient, test_user, auth_headers, test_db
|
||
):
|
||
sid, fid = await _make_session_with_fix(test_db, test_user)
|
||
r = await client.patch(
|
||
f"/api/v1/ai-sessions/{sid}/suggested-fixes/{fid}/script",
|
||
json={"ai_drafted_script": ""},
|
||
headers=auth_headers,
|
||
)
|
||
assert r.status_code == 422 # pydantic min_length=1
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_patch_script_404_on_wrong_session(
|
||
client: AsyncClient, test_user, auth_headers, test_db
|
||
):
|
||
_, fid = await _make_session_with_fix(test_db, test_user)
|
||
wrong_sid = str(uuid4())
|
||
r = await client.patch(
|
||
f"/api/v1/ai-sessions/{wrong_sid}/suggested-fixes/{fid}/script",
|
||
json={"ai_drafted_script": "echo hi"},
|
||
headers=auth_headers,
|
||
)
|
||
assert r.status_code == 404
|
||
```
|
||
|
||
- [ ] **Step 2: Run — confirm 404/405 (endpoint doesn't exist)**
|
||
|
||
```bash
|
||
docker exec resolutionflow_backend sh -c "cd /app && pytest tests/test_fix_script_endpoint.py -v --override-ini='addopts='"
|
||
```
|
||
|
||
Expected: 5 failures, likely 404 or 405 on PATCH.
|
||
|
||
- [ ] **Step 3: Add the endpoint**
|
||
|
||
In `backend/app/api/endpoints/session_suggested_fixes.py`, near the existing `patch_suggested_fix_outcome` handler, add:
|
||
|
||
```python
|
||
@router.patch(
|
||
"/ai-sessions/{session_id}/suggested-fixes/{fix_id}/script",
|
||
response_model=SessionSuggestedFixResponse,
|
||
)
|
||
async def patch_suggested_fix_script(
|
||
session_id: UUID,
|
||
fix_id: UUID,
|
||
body: SessionSuggestedFixScriptRequest,
|
||
current_user: User = Depends(require_engineer_or_admin),
|
||
db: AsyncSession = Depends(get_db),
|
||
) -> SessionSuggestedFixResponse:
|
||
"""Attach an engineer-drafted script to a suggested fix.
|
||
|
||
Called by the inline Script Builder tab on Submit. Does NOT stamp
|
||
applied_at — a draft is not an application. Bumps state_version so
|
||
the Resolve/Escalate preview bundles regenerate.
|
||
"""
|
||
await _load_session_or_404(db, session_id)
|
||
|
||
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")
|
||
|
||
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.ai_drafted_script = body.ai_drafted_script
|
||
fix.ai_drafted_parameters = body.ai_drafted_parameters
|
||
|
||
# Bump state_version on the parent session — previews cached by
|
||
# (session_id, state_version) must regenerate to reflect the new draft.
|
||
await db.execute(
|
||
update(AISession)
|
||
.where(AISession.id == session_id)
|
||
.values(state_version=AISession.state_version + 1)
|
||
)
|
||
|
||
await db.commit()
|
||
await db.refresh(fix)
|
||
return SessionSuggestedFixResponse.model_validate(fix)
|
||
```
|
||
|
||
Add imports as needed: `SessionSuggestedFixScriptRequest`, `AISession` (if not already imported), and `update` from sqlalchemy.
|
||
|
||
- [ ] **Step 4: Run — expect 5 passing**
|
||
|
||
```bash
|
||
docker exec resolutionflow_backend sh -c "cd /app && pytest tests/test_fix_script_endpoint.py -v --override-ini='addopts='"
|
||
```
|
||
|
||
Expected: 5 passed.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /config/workspace/resolutionflow && git add backend/ \
|
||
&& git commit -m "$(cat <<'EOF'
|
||
feat(pilot): PATCH /suggested-fixes/:id/script endpoint
|
||
|
||
Called by the inline Script Builder tab on Submit. Writes
|
||
ai_drafted_script + ai_drafted_parameters to the fix without stamping
|
||
applied_at (a draft is not an application — that's §5 of the Phase 9
|
||
spec). Bumps state_version so Resolve/Escalate preview bundles
|
||
regenerate.
|
||
|
||
409 on terminal fix status. 404 on wrong session. 422 on empty script.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Frontend API client — `patchScript` + extended `createSession`
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/api/sessionSuggestedFixes.ts`
|
||
- Modify: `frontend/src/api/scriptBuilder.ts` (or equivalent — check the existing file name)
|
||
|
||
- [ ] **Step 1: Add `patchScript` to `sessionSuggestedFixesApi`**
|
||
|
||
In `frontend/src/api/sessionSuggestedFixes.ts`, after the `patchOutcome` method (and before `clearAIProposal`), add:
|
||
|
||
```ts
|
||
/**
|
||
* Attach an engineer-drafted script to a suggested fix (inline Script
|
||
* Builder Submit path). Does NOT stamp applied_at — the server treats
|
||
* a draft as non-terminal progress. Bumps state_version so the
|
||
* Resolve/Escalate preview regenerates.
|
||
*/
|
||
async patchScript(
|
||
sessionId: string,
|
||
fixId: string,
|
||
aiDraftedScript: string,
|
||
aiDraftedParameters?: Record<string, unknown>,
|
||
): Promise<SessionSuggestedFix> {
|
||
const r = await apiClient.patch<SessionSuggestedFix>(
|
||
`/ai-sessions/${sessionId}/suggested-fixes/${fixId}/script`,
|
||
{
|
||
ai_drafted_script: aiDraftedScript,
|
||
ai_drafted_parameters: aiDraftedParameters,
|
||
},
|
||
)
|
||
return r.data
|
||
},
|
||
```
|
||
|
||
- [ ] **Step 2: Extend `createSession` with inline-origin args**
|
||
|
||
Find the script-builder API client. Grep:
|
||
```bash
|
||
grep -rn "script-builder/sessions" /config/workspace/resolutionflow/frontend/src/api/
|
||
```
|
||
|
||
In that file (likely `frontend/src/api/scriptBuilder.ts`), update `createSession` to accept an optional options bag:
|
||
|
||
```ts
|
||
export interface CreateSessionOptions {
|
||
origin?: 'standalone' | 'pilot_inline'
|
||
aiSessionId?: string
|
||
}
|
||
|
||
export async function createSession(
|
||
language: string,
|
||
options?: CreateSessionOptions,
|
||
): Promise<ScriptBuilderSession> {
|
||
const r = await apiClient.post<ScriptBuilderSession>(
|
||
'/script-builder/sessions',
|
||
{
|
||
language,
|
||
origin: options?.origin,
|
||
ai_session_id: options?.aiSessionId,
|
||
},
|
||
)
|
||
return r.data
|
||
}
|
||
```
|
||
|
||
Existing callers pass only `language` — they continue to work (origin defaults to `'standalone'` on the server).
|
||
|
||
- [ ] **Step 3: Verify types**
|
||
|
||
```bash
|
||
docker exec resolutionflow_frontend sh -c "cd /app && npx tsc -b"
|
||
```
|
||
|
||
Expected: clean.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
cd /config/workspace/resolutionflow && git add frontend/src/api/ \
|
||
&& git commit -m "$(cat <<'EOF'
|
||
feat(pilot): frontend API client — patchScript + inline createSession
|
||
|
||
sessionSuggestedFixesApi.patchScript(sessionId, fixId, script, params?)
|
||
hits the new PATCH /script endpoint.
|
||
|
||
scriptBuilder.createSession accepts an optional options bag with
|
||
origin + aiSessionId, defaulting to standalone when omitted so legacy
|
||
callers stay behavior-preserving.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: `ChatTabStrip` component
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/components/pilot/ChatTabStrip.tsx`
|
||
|
||
- [ ] **Step 1: Write the component**
|
||
|
||
```tsx
|
||
/**
|
||
* ChatTabStrip — two-tab strip at the top of the chat region:
|
||
* [Chat] [Script Builder ●]
|
||
*
|
||
* Visibility is controlled by the parent (AssistantChatPage) — this
|
||
* component renders whenever it's mounted. The parent decides whether
|
||
* to mount it based on fix state.
|
||
*
|
||
* Tab switching uses onChange; the parent toggles display:none on the
|
||
* tab contents so state is preserved across switches.
|
||
*/
|
||
import { cn } from '@/lib/utils'
|
||
|
||
export type ChatTab = 'chat' | 'script_builder'
|
||
|
||
export interface ChatTabStripProps {
|
||
active: ChatTab
|
||
onChange: (tab: ChatTab) => void
|
||
/** When true, shows the amber indicator dot on the Script Builder tab. */
|
||
scriptBuilderHasProgress?: boolean
|
||
}
|
||
|
||
export function ChatTabStrip({
|
||
active, onChange, scriptBuilderHasProgress,
|
||
}: ChatTabStripProps) {
|
||
return (
|
||
<div
|
||
role="tablist"
|
||
className="flex gap-1 px-4 pt-2 border-b border-default bg-bg-sidebar"
|
||
>
|
||
<TabButton
|
||
label="Chat"
|
||
active={active === 'chat'}
|
||
onClick={() => onChange('chat')}
|
||
/>
|
||
<TabButton
|
||
label="Script Builder"
|
||
active={active === 'script_builder'}
|
||
onClick={() => onChange('script_builder')}
|
||
indicator={scriptBuilderHasProgress}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function TabButton({
|
||
label, active, onClick, indicator,
|
||
}: {
|
||
label: string
|
||
active: boolean
|
||
onClick: () => void
|
||
indicator?: boolean
|
||
}) {
|
||
return (
|
||
<button
|
||
role="tab"
|
||
aria-selected={active}
|
||
onClick={onClick}
|
||
className={cn(
|
||
'relative px-3 py-[7px] text-[12.5px] font-medium rounded-t-md transition-colors',
|
||
'border-b-2 -mb-px',
|
||
active
|
||
? 'text-heading border-accent bg-bg-page'
|
||
: 'text-muted-foreground border-transparent hover:text-primary hover:bg-white/[0.04]',
|
||
)}
|
||
>
|
||
{label}
|
||
{indicator && (
|
||
<span
|
||
className="ml-1.5 inline-block w-1.5 h-1.5 rounded-full bg-warning align-middle"
|
||
aria-label="unsaved progress"
|
||
/>
|
||
)}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
export default ChatTabStrip
|
||
```
|
||
|
||
- [ ] **Step 2: Build**
|
||
|
||
```bash
|
||
docker exec resolutionflow_frontend sh -c "cd /app && npx tsc -b"
|
||
```
|
||
|
||
Expected: clean.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
cd /config/workspace/resolutionflow && git add frontend/src/components/pilot/ChatTabStrip.tsx \
|
||
&& git commit -m "$(cat <<'EOF'
|
||
feat(pilot): ChatTabStrip component — [Chat] [Script Builder ●]
|
||
|
||
Two-tab strip for the chat region. Parent controls mounting (strip only
|
||
appears when the fix needs a script drafted). Indicator dot signals
|
||
in-progress draft state. Tab switching via onChange callback; parent
|
||
handles display:none toggling so tab contents preserve state.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: `ScriptBuilderTab` controller
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/components/pilot/ScriptBuilderTab.tsx`
|
||
|
||
- [ ] **Step 1: Inspect existing pieces we're stitching together**
|
||
|
||
Read these files to understand the surfaces:
|
||
- `frontend/src/components/script-builder/ScriptBuilderChat.tsx` — the presentational chat (takes `messages`, `language`, `onViewScript`, `onSaveScript`, `isLoading`).
|
||
- `frontend/src/pages/ScriptBuilderPage.tsx` — how `ScriptBuilderChat` is wired today; see how messages are sent via `sendMessage` / how `onSaveScript` is handled.
|
||
- `frontend/src/components/tree-editor/code-mode/CodeModeEditor.tsx` — the existing Monaco wrapper.
|
||
- `frontend/src/api/scriptBuilder.ts` — existing API functions.
|
||
|
||
Take note of:
|
||
- The message-send function signature.
|
||
- The "latest script" field from a session (likely `latest_script` + `latest_script_filename`).
|
||
- How `CodeModeEditor` is mounted (props, theme, language).
|
||
|
||
- [ ] **Step 2: Write the controller**
|
||
|
||
```tsx
|
||
/**
|
||
* ScriptBuilderTab — inline Script Builder controller mounted in the
|
||
* FlowPilot chat region when a fix needs a script drafted.
|
||
*
|
||
* Owns:
|
||
* - The inline `script_builder_sessions` row (get-or-create via the
|
||
* POST endpoint with origin='pilot_inline' + ai_session_id).
|
||
* - AI message state (reuses existing ScriptBuilderChat + sendMessage API).
|
||
* - Monaco buffer for the "Write it myself" mode.
|
||
* - Submit → PATCH /suggested-fixes/:id/script (no applied_at stamp).
|
||
*
|
||
* ScriptBuilderChat stays a pure display component — this controller
|
||
* wires its onSaveScript to the inline submit path.
|
||
*/
|
||
import { useEffect, useRef, useState } from 'react'
|
||
import { Sparkles, Pencil } from 'lucide-react'
|
||
import { cn } from '@/lib/utils'
|
||
import { ScriptBuilderChat } from '@/components/script-builder/ScriptBuilderChat'
|
||
import { sessionSuggestedFixesApi, type SessionSuggestedFix } from '@/api/sessionSuggestedFixes'
|
||
import {
|
||
createSession as createScriptBuilderSession,
|
||
sendMessage as sendScriptBuilderMessage,
|
||
listMessages as listScriptBuilderMessages,
|
||
} from '@/api/scriptBuilder'
|
||
import { CodeModeEditor } from '@/components/tree-editor/code-mode/CodeModeEditor'
|
||
import type { ScriptBuilderMessage } from '@/types'
|
||
|
||
export interface ScriptBuilderTabProps {
|
||
fix: SessionSuggestedFix
|
||
pilotSessionId: string
|
||
/** Fires whenever in-progress state changes (for the ChatTabStrip dot). */
|
||
onProgressChange: (hasProgress: boolean) => void
|
||
/** Fires on successful submit; parent uses this to refresh the fix and hide the tab. */
|
||
onScriptDrafted: (updated: SessionSuggestedFix) => void
|
||
}
|
||
|
||
type Mode = 'ai' | 'editor'
|
||
|
||
export function ScriptBuilderTab({
|
||
fix, pilotSessionId, onProgressChange, onScriptDrafted,
|
||
}: ScriptBuilderTabProps) {
|
||
const [builderSessionId, setBuilderSessionId] = useState<string | null>(null)
|
||
const [mode, setMode] = useState<Mode>('ai')
|
||
const [messages, setMessages] = useState<ScriptBuilderMessage[]>([])
|
||
const [editorBuffer, setEditorBuffer] = useState<string>(
|
||
() => scaffoldForLanguage('powershell', fix.description),
|
||
)
|
||
const [aiLoading, setAiLoading] = useState(false)
|
||
const [submitting, setSubmitting] = useState(false)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [latestScript, setLatestScript] = useState<string | null>(null)
|
||
|
||
// Track indicator-dot state and relay to parent.
|
||
useEffect(() => {
|
||
const hasProgress = messages.length > 0 || editorBuffer.trim() !== scaffoldForLanguage('powershell', fix.description).trim()
|
||
onProgressChange(hasProgress)
|
||
}, [messages.length, editorBuffer, fix.description, onProgressChange])
|
||
|
||
// Get-or-create the inline session on mount.
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
;(async () => {
|
||
try {
|
||
const s = await createScriptBuilderSession('powershell', {
|
||
origin: 'pilot_inline',
|
||
aiSessionId: pilotSessionId,
|
||
})
|
||
if (cancelled) return
|
||
setBuilderSessionId(s.id)
|
||
// Fetch any existing messages for resume.
|
||
const existing = await listScriptBuilderMessages(s.id)
|
||
if (cancelled) return
|
||
setMessages(existing)
|
||
if (s.latest_script) setLatestScript(s.latest_script)
|
||
} catch (e) {
|
||
if (!cancelled) setError('Failed to start Script Builder session.')
|
||
}
|
||
})()
|
||
return () => { cancelled = true }
|
||
}, [pilotSessionId])
|
||
|
||
const handleSendMessage = async (content: string) => {
|
||
if (!builderSessionId) return
|
||
setAiLoading(true)
|
||
try {
|
||
const reply = await sendScriptBuilderMessage(builderSessionId, content)
|
||
setMessages((prev) => [...prev, ...reply.messages])
|
||
if (reply.latest_script) setLatestScript(reply.latest_script)
|
||
} finally {
|
||
setAiLoading(false)
|
||
}
|
||
}
|
||
|
||
const handleSubmit = async (script: string) => {
|
||
if (!script.trim()) return
|
||
setSubmitting(true)
|
||
setError(null)
|
||
try {
|
||
const updated = await sessionSuggestedFixesApi.patchScript(
|
||
pilotSessionId, fix.id, script,
|
||
)
|
||
onScriptDrafted(updated)
|
||
} catch {
|
||
setError('Failed to save the drafted script.')
|
||
} finally {
|
||
setSubmitting(false)
|
||
}
|
||
}
|
||
|
||
// AI mode uses whatever latestScript the AI produced as the body on save.
|
||
const handleAiSave = () => {
|
||
if (latestScript) void handleSubmit(latestScript)
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col h-full bg-bg-page">
|
||
<div className="flex items-center justify-between px-4 py-2 border-b border-default">
|
||
<div className="text-[12.5px] text-heading font-medium">
|
||
Script Builder · {fix.title}
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
{mode === 'ai' ? (
|
||
<button
|
||
onClick={() => setMode('editor')}
|
||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded text-[11.5px] text-muted-foreground border border-default hover:text-primary hover:border-hover"
|
||
>
|
||
<Pencil size={11} />
|
||
Write it myself
|
||
</button>
|
||
) : (
|
||
<button
|
||
onClick={() => setMode('ai')}
|
||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded text-[11.5px] text-muted-foreground border border-default hover:text-primary hover:border-hover"
|
||
>
|
||
<Sparkles size={11} />
|
||
Back to AI
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{error && (
|
||
<div className="mx-4 mt-2 text-[11.5px] text-danger bg-danger-dim border border-danger/30 rounded px-2 py-1">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex-1 min-h-0">
|
||
<div className={cn('h-full', mode === 'ai' ? 'block' : 'hidden')}>
|
||
<ScriptBuilderChat
|
||
messages={messages}
|
||
language="powershell"
|
||
onViewScript={() => { /* optional future: inline preview */ }}
|
||
onSaveScript={handleAiSave}
|
||
isLoading={aiLoading}
|
||
/>
|
||
</div>
|
||
<div className={cn('h-full', mode === 'editor' ? 'block' : 'hidden')}>
|
||
<CodeModeEditor
|
||
value={editorBuffer}
|
||
onChange={setEditorBuffer}
|
||
language="powershell"
|
||
readOnly={false}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{mode === 'editor' && (
|
||
<div className="px-4 py-2 border-t border-default flex justify-end">
|
||
<button
|
||
onClick={() => handleSubmit(editorBuffer)}
|
||
disabled={submitting || !editorBuffer.trim()}
|
||
className={cn(
|
||
'px-3.5 py-[7px] rounded text-[12.5px] font-semibold',
|
||
'bg-accent text-[#0a0d14] hover:brightness-110 disabled:opacity-50 disabled:cursor-not-allowed',
|
||
)}
|
||
>
|
||
{submitting ? 'Saving…' : 'Submit script'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function scaffoldForLanguage(language: string, fixDescription: string): string {
|
||
if (language === 'bash' || language === 'python') {
|
||
return `# ${fixDescription}\n\n`
|
||
}
|
||
return `# ${fixDescription}\r\n\r\n`
|
||
}
|
||
|
||
export default ScriptBuilderTab
|
||
```
|
||
|
||
**Note on shape drift:** The actual signatures of `sendScriptBuilderMessage` / `listScriptBuilderMessages` / the returned `ScriptBuilderSession` may differ from what I wrote above (field names, shape of the reply). Read the existing `frontend/src/api/scriptBuilder.ts` + `ScriptBuilderPage.tsx` and adapt the controller's calls to match — the structure of the component is what matters, not the exact wire names.
|
||
|
||
If any function (e.g. `listMessages`) doesn't exist in the current API client, check how `ScriptBuilderPage` fetches existing messages and copy that pattern.
|
||
|
||
- [ ] **Step 3: Build**
|
||
|
||
```bash
|
||
docker exec resolutionflow_frontend sh -c "cd /app && npx tsc -b && npm run build"
|
||
```
|
||
|
||
Expected: clean. Adapt to any real-world API mismatches revealed by the build.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
cd /config/workspace/resolutionflow && git add frontend/src/components/pilot/ScriptBuilderTab.tsx \
|
||
&& git commit -m "$(cat <<'EOF'
|
||
feat(pilot): ScriptBuilderTab controller
|
||
|
||
Owns the inline Script Builder session lifecycle:
|
||
- Get-or-create (origin='pilot_inline', ai_session_id) on mount.
|
||
- Renders ScriptBuilderChat in AI mode and CodeModeEditor (Monaco) in
|
||
'Write it myself' mode. Mode toggles via display:none so buffer and
|
||
messages persist across switches.
|
||
- Submit → sessionSuggestedFixesApi.patchScript; emits onScriptDrafted
|
||
to parent, which refreshes the fix and hides the tab strip.
|
||
- Relays in-progress state to the parent via onProgressChange for the
|
||
ChatTabStrip's indicator dot.
|
||
|
||
ScriptBuilderChat is untouched (stays presentational). Persistence
|
||
semantics live on the controller, not the display component.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: `InlineNoTemplateDialog` wrapper — chat-region placement
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/components/pilot/InlineNoTemplateDialog.tsx`
|
||
|
||
- [ ] **Step 1: Inspect the existing dialog + decide reuse strategy**
|
||
|
||
Read `frontend/src/components/pilot/script/NoTemplateDialog.tsx`. The three-card UX is already built. We need to render it in the chat region (above the composer, like `ProposalBanner`), not inside the TaskLane's narrow `bottomSlot`.
|
||
|
||
Strategy: a thin wrapper that positions the existing component appropriately. `NoTemplateDialog`'s internal grid is `grid-cols-1 sm:grid-cols-3`; in the chat region the `sm:` breakpoint will engage at its viewport-based trigger, and the chat region's width is sufficient for three cards. No internal change to `NoTemplateDialog` is required.
|
||
|
||
- [ ] **Step 2: Write the wrapper**
|
||
|
||
```tsx
|
||
/**
|
||
* InlineNoTemplateDialog — chat-region placement wrapper for
|
||
* NoTemplateDialog. Renders above the composer (slide-up animation
|
||
* matching ProposalBanner), using the full chat-region width so the
|
||
* three decision cards fit side-by-side.
|
||
*/
|
||
import { NoTemplateDialog } from '@/components/pilot/script/NoTemplateDialog'
|
||
import type { ComponentProps } from 'react'
|
||
|
||
type Props = ComponentProps<typeof NoTemplateDialog>
|
||
|
||
export function InlineNoTemplateDialog(props: Props) {
|
||
return (
|
||
<div className="border-t border-default bg-bg-page animate-slide-up">
|
||
<div className="px-5 py-3">
|
||
<NoTemplateDialog {...props} />
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default InlineNoTemplateDialog
|
||
```
|
||
|
||
- [ ] **Step 3: Build**
|
||
|
||
```bash
|
||
docker exec resolutionflow_frontend sh -c "cd /app && npx tsc -b"
|
||
```
|
||
|
||
Expected: clean.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
cd /config/workspace/resolutionflow && git add frontend/src/components/pilot/InlineNoTemplateDialog.tsx \
|
||
&& git commit -m "$(cat <<'EOF'
|
||
feat(pilot): InlineNoTemplateDialog — chat-region placement wrapper
|
||
|
||
Slide-up wrapper around the existing NoTemplateDialog for rendering
|
||
in the chat region above the composer (parallel to ProposalBanner).
|
||
The chat region's width lets grid-cols-3 finally work as intended.
|
||
|
||
No change to NoTemplateDialog itself; decision callbacks and card
|
||
copy stay identical.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: `TemplateMatchPanel` — new "I ran this" action
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/components/pilot/script/TemplateMatchPanel.tsx`
|
||
|
||
- [ ] **Step 1: Read the current component to find the existing button row**
|
||
|
||
Open `frontend/src/components/pilot/script/TemplateMatchPanel.tsx`. Locate the section that renders the Generate / Copy / Edit Parameters buttons — you'll add a new primary button adjacent to these.
|
||
|
||
- [ ] **Step 2: Add a new prop + button**
|
||
|
||
Extend the props interface:
|
||
|
||
```ts
|
||
export interface TemplateMatchPanelProps {
|
||
// ... existing props ...
|
||
/** Fires when the engineer declares the script was run. Parent calls
|
||
* applyFix() to stamp applied_at. */
|
||
onMarkRun?: () => void
|
||
}
|
||
```
|
||
|
||
Find the button-row JSX. Add a new button, styled as accent-colored primary:
|
||
|
||
```tsx
|
||
{onMarkRun && (
|
||
<button
|
||
onClick={onMarkRun}
|
||
className="inline-flex items-center gap-1.5 px-3 py-[7px] rounded text-[12px] font-semibold bg-accent text-[#0a0d14] hover:brightness-110"
|
||
aria-label="Mark the script as applied to start verifying the fix"
|
||
>
|
||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||
<polyline points="20 6 9 17 4 12" />
|
||
</svg>
|
||
I ran this
|
||
</button>
|
||
)}
|
||
```
|
||
|
||
Position it as the right-most action (prominent terminal action) in the existing row.
|
||
|
||
- [ ] **Step 3: Destructure the new prop**
|
||
|
||
Make sure the component destructures `onMarkRun` from its props, or update the props pattern consistently with existing props.
|
||
|
||
- [ ] **Step 4: Build**
|
||
|
||
```bash
|
||
docker exec resolutionflow_frontend sh -c "cd /app && npx tsc -b"
|
||
```
|
||
|
||
Expected: clean (no call sites provide `onMarkRun` yet, but it's optional).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /config/workspace/resolutionflow && git add frontend/src/components/pilot/script/TemplateMatchPanel.tsx \
|
||
&& git commit -m "$(cat <<'EOF'
|
||
feat(pilot): TemplateMatchPanel — explicit 'I ran this' action
|
||
|
||
Generate and Copy alone don't declare a run — the engineer can walk
|
||
away after copying. Phase 9 §5 defines an explicit run-declaration
|
||
affordance so applied_at only stamps on the engineer's positive
|
||
commitment. Wiring from AssistantChatPage lands in Task 13.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: `EscalateInterceptDialog` — fourth "partial" choice
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/components/pilot/EscalateInterceptDialog.tsx`
|
||
|
||
- [ ] **Step 1: Add the fourth button**
|
||
|
||
Open the file. Locate the three existing option buttons ("The fix didn't work", "It worked — escalating for another reason", "Never actually applied it"). Add a fourth option button after the "didn't work" autofocus button:
|
||
|
||
```tsx
|
||
<button
|
||
onClick={() => onChoose('applied_partial')}
|
||
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"
|
||
>
|
||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<circle cx="12" cy="12" r="10" />
|
||
<line x1="12" y1="8" x2="12" y2="12" />
|
||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||
</svg>
|
||
<span className="flex-1">I applied some of it — partial</span>
|
||
</button>
|
||
```
|
||
|
||
The `InterceptChoice` type already includes `'applied_partial'` via its `FixOutcome | 'never_applied'` definition — no type changes needed.
|
||
|
||
- [ ] **Step 2: Build**
|
||
|
||
```bash
|
||
docker exec resolutionflow_frontend sh -c "cd /app && npx tsc -b"
|
||
```
|
||
|
||
Expected: clean.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
cd /config/workspace/resolutionflow && git add frontend/src/components/pilot/EscalateInterceptDialog.tsx \
|
||
&& git commit -m "$(cat <<'EOF'
|
||
feat(pilot): EscalateInterceptDialog — fourth 'partial' choice
|
||
|
||
Closes the gap Phase 8 final review flagged. When a fix is in
|
||
applied_partial state and the engineer escalates, the intercept no
|
||
longer forces them to approximate with didn't-work/worked/never-applied.
|
||
|
||
AssistantChatPage's handleInterceptChoice (Task 13) already dispatches
|
||
to patchOutcome for any FixOutcome value, so no handler change is
|
||
needed — the type already supports applied_partial.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: `AssistantChatPage` — tab strip + tab mount + banner routing
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/pages/AssistantChatPage.tsx`
|
||
|
||
This task scaffolds the new UI surface in the parent. Task 13 handles the trickier call-site moves.
|
||
|
||
- [ ] **Step 1: Add imports + state**
|
||
|
||
Open `frontend/src/pages/AssistantChatPage.tsx`. Add imports:
|
||
|
||
```tsx
|
||
import { ChatTabStrip, type ChatTab } from '@/components/pilot/ChatTabStrip'
|
||
import { ScriptBuilderTab } from '@/components/pilot/ScriptBuilderTab'
|
||
import { InlineNoTemplateDialog } from '@/components/pilot/InlineNoTemplateDialog'
|
||
```
|
||
|
||
Near the other Phase 8 state declarations (e.g., `bannerCollapsed`, `postApplyMsgCount`), add:
|
||
|
||
```tsx
|
||
const [chatTab, setChatTab] = useState<ChatTab>('chat')
|
||
const [scriptBuilderHasProgress, setScriptBuilderHasProgress] = useState(false)
|
||
```
|
||
|
||
Update `resetSessionDerivedState`:
|
||
|
||
```tsx
|
||
setChatTab('chat')
|
||
setScriptBuilderHasProgress(false)
|
||
```
|
||
|
||
- [ ] **Step 2: Derive `showTabStrip` from fix state**
|
||
|
||
Near the other derived values (probably close to `bannerMode`):
|
||
|
||
```tsx
|
||
const showTabStrip =
|
||
activeFix != null
|
||
&& activeFix.status !== 'dismissed'
|
||
&& activeFix.status !== 'applied_success'
|
||
&& activeFix.status !== 'applied_failed'
|
||
&& !activeFix.script_template_id
|
||
&& !activeFix.ai_drafted_script
|
||
|
||
// Defensive: if the strip hides, snap back to the Chat tab.
|
||
useEffect(() => {
|
||
if (!showTabStrip && chatTab === 'script_builder') setChatTab('chat')
|
||
}, [showTabStrip, chatTab])
|
||
```
|
||
|
||
- [ ] **Step 3: Mount the strip + tab content in the chat region**
|
||
|
||
Find the chat region JSX (the scrollable messages list + composer). Wrap it so the tab strip renders above the messages area and the Script Builder tab renders beside (via `display:none` toggling):
|
||
|
||
```tsx
|
||
{showTabStrip && (
|
||
<ChatTabStrip
|
||
active={chatTab}
|
||
onChange={setChatTab}
|
||
scriptBuilderHasProgress={scriptBuilderHasProgress}
|
||
/>
|
||
)}
|
||
|
||
<div className={cn('flex-1 min-h-0', chatTab === 'chat' ? 'block' : 'hidden')}>
|
||
{/* existing chat messages + composer JSX remains here unchanged */}
|
||
</div>
|
||
|
||
{showTabStrip && activeFix && activeChatId && (
|
||
<div className={cn('flex-1 min-h-0', chatTab === 'script_builder' ? 'block' : 'hidden')}>
|
||
<ScriptBuilderTab
|
||
fix={activeFix}
|
||
pilotSessionId={activeChatId}
|
||
onProgressChange={setScriptBuilderHasProgress}
|
||
onScriptDrafted={(updated) => {
|
||
setActiveFix(updated)
|
||
setChatTab('chat')
|
||
setScriptBuilderHasProgress(false)
|
||
}}
|
||
/>
|
||
</div>
|
||
)}
|
||
```
|
||
|
||
- [ ] **Step 4: Banner Apply routing — route no-draft case to the Script Builder tab**
|
||
|
||
Find `handleApplyFix`. Update the routing:
|
||
|
||
```tsx
|
||
const handleApplyFix = useCallback(() => {
|
||
if (!activeFix) return
|
||
if (activeFix.script_template_id) {
|
||
setScriptPanelOpen(true) // existing TemplateMatchPanel flow, task lane
|
||
return
|
||
}
|
||
if (activeFix.ai_drafted_script) {
|
||
setScriptPanelOpen(true) // existing dialog flow, NOW rendering in chat region (Step 5)
|
||
return
|
||
}
|
||
// No draft, no template — route to the Script Builder tab.
|
||
setChatTab('script_builder')
|
||
}, [activeFix])
|
||
```
|
||
|
||
**IMPORTANT:** do NOT call `sessionSuggestedFixesApi.applyFix(...)` here anymore. That move happens in Task 13.
|
||
|
||
- [ ] **Step 5: Render `InlineNoTemplateDialog` in the chat region**
|
||
|
||
Today's dialog renders inside `TaskLane.bottomSlot` (for both draft and template cases). For the drafted-script case, render it in the chat region instead. Find the existing `NoTemplateDialog` render (likely near the task-lane bottomSlot). Wrap the draft case and move it outside the TaskLane slot:
|
||
|
||
```tsx
|
||
{/* In the chat region, just above the composer */}
|
||
{scriptPanelOpen && activeFix && activeChatId && !activeFix.script_template_id && activeFix.ai_drafted_script && (
|
||
<InlineNoTemplateDialog
|
||
fix={activeFix}
|
||
onClose={() => setScriptPanelOpen(false)}
|
||
onDecide={handleScriptDecision}
|
||
busy={scriptDecisionBusy}
|
||
/>
|
||
)}
|
||
```
|
||
|
||
In the existing `TaskLane.bottomSlot` render, REMOVE the `NoTemplateDialog` branch (the no-template-but-drafted case). Keep the `TemplateMatchPanel` branch there — it stays in the task lane.
|
||
|
||
- [ ] **Step 6: Build + smoke-test**
|
||
|
||
```bash
|
||
docker exec resolutionflow_frontend sh -c "cd /app && npx tsc -b && npm run build"
|
||
```
|
||
|
||
Expected: clean.
|
||
|
||
Start the dev stack and smoke-test:
|
||
- Open a session where the AI has emitted `[SUGGEST_FIX]` without a drafted script. Banner appears.
|
||
- Click Apply → the tab strip shows, Script Builder tab is active, tab contents render (you can verify the get-or-create endpoint fires in the Network panel).
|
||
- Switch between Chat and Script Builder tabs — chat scroll position preserved.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
cd /config/workspace/resolutionflow && git add frontend/src/pages/AssistantChatPage.tsx \
|
||
&& git commit -m "$(cat <<'EOF'
|
||
feat(pilot): mount ChatTabStrip + ScriptBuilderTab + InlineNoTemplateDialog
|
||
|
||
Wires the three new components into AssistantChatPage:
|
||
- ChatTabStrip renders when the active fix needs a script drafted.
|
||
- ScriptBuilderTab sits alongside chat via display:none toggling so
|
||
chat scroll position + builder state both persist.
|
||
- InlineNoTemplateDialog replaces the task-lane bottomSlot render for
|
||
the drafted-script evaluation case; three cards finally fit.
|
||
- Banner Apply routing updated: no-draft/no-template → Script Builder
|
||
tab; drafted → InlineNoTemplateDialog; template → unchanged path.
|
||
|
||
applyFix() call site moves land in the next task.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 13: `applyFix` call-site relocation — stamp only on run-declaring actions
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/pages/AssistantChatPage.tsx`
|
||
|
||
- [ ] **Step 1: Remove the `applyFix` call from `handleApplyFix`**
|
||
|
||
Open `frontend/src/pages/AssistantChatPage.tsx`. Locate `handleApplyFix`. Remove the line that calls `sessionSuggestedFixesApi.applyFix(...)` (it was added in Phase 8 Issue #2). The handler should now only route to the right surface (you already did this in Task 12 Step 4).
|
||
|
||
- [ ] **Step 2: Add `applyFix` to `handleScriptDecision` for `one_off` and `draft_template`**
|
||
|
||
Find `handleScriptDecision`. Both `one_off` and `draft_template` declare a run (their labels: "Run now, no template" and "Run now, templatize after"). Stamp `applied_at` in both branches:
|
||
|
||
```tsx
|
||
const handleScriptDecision = useCallback(
|
||
async (decision: UserDecision, options?: { editedScript?: string; parametersUsed?: Record<string, unknown> }) => {
|
||
if (!activeFix || !activeChatId) return
|
||
setScriptDecisionBusy(true)
|
||
try {
|
||
const result = await sessionSuggestedFixesApi.recordDecision(
|
||
activeChatId, activeFix.id, decision, options,
|
||
)
|
||
// Phase 9 §5: one_off and draft_template declare a run. Stamp applied_at
|
||
// to transition the fix into Verifying. build_template does NOT run.
|
||
if (decision === 'one_off' || decision === 'draft_template') {
|
||
try {
|
||
const updated = await sessionSuggestedFixesApi.applyFix(
|
||
activeChatId, activeFix.id,
|
||
)
|
||
setActiveFix(updated)
|
||
} catch { /* non-fatal: engineer can still mark outcome later */ }
|
||
}
|
||
// ... existing side-effects (redirect to script builder for build_template, etc.) ...
|
||
} finally {
|
||
setScriptDecisionBusy(false)
|
||
}
|
||
},
|
||
[activeFix, activeChatId],
|
||
)
|
||
```
|
||
|
||
(Keep whatever existing post-recordDecision logic is already in the handler — this diff adds the stamp, doesn't replace anything else.)
|
||
|
||
- [ ] **Step 3: Wire `onMarkRun` on `TemplateMatchPanel`**
|
||
|
||
Find the `TemplateMatchPanel` render site. Add the `onMarkRun` prop:
|
||
|
||
```tsx
|
||
<TemplateMatchPanel
|
||
fix={activeFix}
|
||
sessionId={activeChatId}
|
||
onClose={() => setScriptPanelOpen(false)}
|
||
onMarkRun={async () => {
|
||
try {
|
||
const updated = await sessionSuggestedFixesApi.applyFix(
|
||
activeChatId, activeFix.id,
|
||
)
|
||
setActiveFix(updated)
|
||
setScriptPanelOpen(false)
|
||
} catch { /* non-fatal */ }
|
||
}}
|
||
/>
|
||
```
|
||
|
||
- [ ] **Step 4: Build + smoke test**
|
||
|
||
```bash
|
||
docker exec resolutionflow_frontend sh -c "cd /app && npx tsc -b && npm run build"
|
||
```
|
||
|
||
Manual browser QA:
|
||
1. Fix with `script_template_id` → Apply → `TemplateMatchPanel` opens, banner stays on Proposed state. Click "I ran this" → banner flips to Verifying.
|
||
2. Fix with `ai_drafted_script` → Apply → `InlineNoTemplateDialog` opens, banner stays on Proposed. Click `one_off` card → banner flips to Verifying. (Repeat with `draft_template` — same result.) Click `build_template` → navigates; banner stays on Proposed.
|
||
3. Fix with neither → Apply → Script Builder tab opens, banner stays on Proposed. Draft + submit → tab disappears. Engineer clicks Apply again → `InlineNoTemplateDialog` opens. Pick `one_off` → banner flips to Verifying.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /config/workspace/resolutionflow && git add frontend/src/pages/AssistantChatPage.tsx \
|
||
&& git commit -m "$(cat <<'EOF'
|
||
fix(pilot): applied_at stamps on run-declaring actions, not Apply click
|
||
|
||
Per Phase 9 §5. Before: banner Apply click stamped applied_at
|
||
regardless of whether the engineer had committed to running anything,
|
||
starting the Verifying timer prematurely. After:
|
||
|
||
- handleApplyFix no longer calls applyFix(). It just routes to the
|
||
right surface (TemplateMatchPanel / InlineNoTemplateDialog / Script
|
||
Builder tab).
|
||
- handleScriptDecision stamps applied_at for one_off + draft_template
|
||
(both labels are 'Run now, …' — the click is the declaration).
|
||
build_template does not stamp.
|
||
- TemplateMatchPanel's new 'I ran this' button calls applyFix via a
|
||
new onMarkRun prop.
|
||
- Script Builder tab Submit does not stamp (a draft is not a run).
|
||
|
||
No backend change — the /apply endpoint is unchanged. Only call sites
|
||
move.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 14: Cleanup — TaskLane `bottomSlot` audit + handoff docs
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/components/pilot/TaskLane.tsx`
|
||
- Modify: `docs/handoff/2026-04-22-flowpilot-migration.md`
|
||
- Modify: `docs/FlowAssist_Migration/FLOWPILOT-MIGRATION.md`
|
||
|
||
- [ ] **Step 1: Verify TaskLane `bottomSlot` is still needed**
|
||
|
||
Grep:
|
||
```bash
|
||
grep -n "bottomSlot" /config/workspace/resolutionflow/frontend/src/components/pilot/TaskLane.tsx \
|
||
/config/workspace/resolutionflow/frontend/src/pages/AssistantChatPage.tsx
|
||
```
|
||
|
||
If `AssistantChatPage` now only passes `bottomSlot` with the `TemplateMatchPanel` branch (no `NoTemplateDialog`), the slot is still used but narrower in purpose. Leave the prop definition alone (keep API stable).
|
||
|
||
If `AssistantChatPage` no longer passes `bottomSlot` at all, remove the prop from `TaskLane.tsx`'s interface + destructure + render site.
|
||
|
||
- [ ] **Step 2: Update the handoff doc**
|
||
|
||
`docs/handoff/2026-04-22-flowpilot-migration.md`:
|
||
- `last_commit` bump to the Phase 9 tip.
|
||
- Update frontmatter status line to reflect Phase 9 shipped ("Sprint 9/9 phases…" or equivalent).
|
||
- Add a Phase 9 row to the "What shipped" table listing the tab strip, ScriptBuilderTab, PATCH /script endpoint, migration, and the applied_at semantics correction.
|
||
- Mark open items #1 (NoTemplateDialog narrow-lane) and #3 (Tabbed Script Builder) as RESOLVED with a pointer to this plan + the spec.
|
||
- Loose ends: `/ultrareview` still not run on the final branch; PR still not opened; browser QA of Phase 9 states not done from this branch.
|
||
|
||
- [ ] **Step 3: Update the migration spec**
|
||
|
||
`docs/FlowAssist_Migration/FLOWPILOT-MIGRATION.md`:
|
||
- Status line + "last updated" header bumped.
|
||
- New Phase 9 section (5–10 lines) describing: tab strip + ScriptBuilderTab, InlineNoTemplateDialog relocation, PATCH /script endpoint, EscalateInterceptDialog partial choice, applied_at semantics correction. Link to the spec + implementation plan files.
|
||
|
||
- [ ] **Step 4: Run full test suite as a final guard**
|
||
|
||
```bash
|
||
docker exec resolutionflow_backend sh -c "cd /app && pytest tests/ --override-ini='addopts=' -q"
|
||
```
|
||
|
||
Expected: everything passes except the pre-existing `test_record_decision_persists_and_bumps_state_version` failure (Phase 8 Issue #4, separately documented).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
cd /config/workspace/resolutionflow && git add -A \
|
||
&& git commit -m "$(cat <<'EOF'
|
||
docs(pilot): Phase 9 handoff + migration spec update
|
||
|
||
Marks open items #1 (NoTemplateDialog narrow-lane) and #3 (Tabbed
|
||
Script Builder) as resolved. Records the applied_at semantics
|
||
correction as shipped. Final Phase 9 row added to the 'What shipped'
|
||
table.
|
||
|
||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
---
|
||
|
||
## Self-Review
|
||
|
||
**Spec coverage:**
|
||
|
||
| Spec section | Covered by |
|
||
|---|---|
|
||
| §1 Chat region tab strip | Tasks 7, 12 |
|
||
| §2 Script Builder tab content + controller | Tasks 8, 12 |
|
||
| §3 NoTemplateDialog relocation | Tasks 9, 12 |
|
||
| §4 Banner Apply routing (updated) | Task 12 step 4 |
|
||
| §5 Apply lifecycle (`applied_at` correction) | Tasks 10, 13 |
|
||
| §6 EscalateInterceptDialog partial | Task 11 |
|
||
| New migration (origin + partial unique index) | Task 1 |
|
||
| Model column | Task 2 |
|
||
| ScriptBuilderCreateRequest extended | Task 3 |
|
||
| SessionSuggestedFixScriptRequest | Task 3 |
|
||
| POST /script-builder/sessions idempotent + auth | Task 4 |
|
||
| list/count filter | Task 4 step 4 |
|
||
| PATCH /script endpoint + tests | Task 5 |
|
||
| Frontend API client | Task 6 |
|
||
| ChatTabStrip | Task 7 |
|
||
| ScriptBuilderTab | Task 8 |
|
||
| InlineNoTemplateDialog wrapper | Task 9 |
|
||
| TemplateMatchPanel "I ran this" | Task 10 |
|
||
| EscalateInterceptDialog fourth choice | Task 11 |
|
||
| `applied_at` call-site moves | Task 13 |
|
||
| Task-lane cleanup + docs | Task 14 |
|
||
|
||
No gaps.
|
||
|
||
**Placeholder scan:** No TBD/TODO entries; no "add error handling" placeholders; every code step has full code. One intentional placeholder: `<hash-alembic-generated>` in Task 1 (standard Alembic workflow). The Task 8 code contains a paragraph acknowledging that `sendScriptBuilderMessage` / `listScriptBuilderMessages` API shapes may need adaptation — that's a knowable-by-reading note, not a "TBD" the implementer has to invent.
|
||
|
||
**Type consistency:** `SessionSuggestedFix` (interface), `FixOutcome` (type), `InterceptChoice` (type), `ChatTab` (type), `ScriptBuilderSession` (interface), `ChatTabStripProps` / `ScriptBuilderTabProps` / `TemplateMatchPanelProps` — all spelled identically across tasks. `patchScript` / `applyFix` / `createSession` / `patchOutcome` method names match between API client tasks and consumer tasks.
|
||
|
||
**Risks called out inline:**
|
||
- Task 4 Step 5 may need minor adaptation if `_load_session_or_404`'s signature has drifted or if the existing service has a subtle ownership check elsewhere.
|
||
- Task 8's controller assumes API-client function names that might not exactly match the existing codebase — the task includes an explicit adapt-to-reality instruction.
|
||
- Task 12 Step 5's "remove the draft case from TaskLane bottomSlot" is the only destructive change; guarded by the preceding render moving first.
|
||
|
||
---
|
||
|
||
**Plan complete and saved to `docs/FlowAssist_Migration/phase-9-implementation-plan.md`. Two execution options:**
|
||
|
||
**1. Subagent-Driven (recommended)** — 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?**
|