feat(pilot): Phase 6 — post-resolve templatize prompt + draft accept/reject
All checks were successful
Mirror to GitHub / mirror (push) Successful in 11s
All checks were successful
Mirror to GitHub / mirror (push) Successful in 11s
Closes the loop on the Phase 5 "Run now, templatize after resolve" path.
After a session resolves, drafts queued by the three-option dialog surface
as a modal that lets the engineer review the AI-proposed parameterization
and either save as a reusable team template or skip. A "don't ask again"
toggle writes to account_settings.preferences so the next resolve won't
pop the modal.
Backend:
- /api/v1/draft-templates:
* GET — list account drafts (pending_only default true; pass false for
audit view including accepted/rejected)
* GET /{id} — single draft
* POST /{id}/accept — promotes to a new script_templates row with
source_session_id / source_user_id / source_ticket_ref populated
(drives the Script Library "generated from CW #X · resolved by Y"
provenance chip). Draft flips to status=accepted,
promoted_template_id set, resolved_at stamped. 409 on re-accept /
already-rejected. 400 on unknown category_id.
* POST /{id}/reject — flips to status=rejected. 409 on re-reject.
- /api/v1/accounts/me/preferences (GET/PATCH) — thin wrapper over
AccountSettings.get_setting/set_setting. PATCH merges keys into the
JSONB column, preserving existing keys the client didn't touch.
Used by the "Don't ask again for this team" checkbox
(templatize_prompt_enabled=false) and, forward-looking, by
cw_resolved_status_id / cw_escalated_status_id from Phase 4.
- 13 tests: list filter, accept with/without edited_body, provenance
copy-through, reject, 409 on re-accept / re-reject, 400 on unknown
category, prefs round-trip with merge semantics.
Frontend:
- src/components/pilot/script/TemplatizePrompt.tsx — modal showing the
drafted script with proposed parameters in the Phase 5
ParameterizationPreview, editable name/category/description, an
individual-parameter remove button, and the "don't ask again" opt-out.
Accept posts to /draft-templates/{id}/accept + optionally PATCHes
preferences. Skip posts /reject.
- src/api/draftTemplates.ts — typed client plus accountPreferencesApi.
- AssistantChatPage: after a successful Resolve (external OR local),
fetches preferences + pending drafts for the session and queues the
modal one draft at a time. Escalate does not trigger this flow.
- Sidebar: Scripts nav shows the pending-draft count as a badge. Fetched
independently of the main sidebar stats so endpoint flakes don't
break the rest of the sidebar.
Verified live 2026-04-22: seed two drafts → GET sees both pending →
accept draft A (template created, provenance CW #99123 populated) →
reject draft B → pending count drops → PATCH opt-out → GET confirms
persistence.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ from app.models.refresh_token import RefreshToken
|
||||
from app.core.email import EmailService
|
||||
from app.models.account import Account
|
||||
from app.models.account_invite import AccountInvite
|
||||
from app.models.account_settings import AccountSettings
|
||||
from app.models.subscription import Subscription
|
||||
from app.models.user import User
|
||||
from app.schemas.account import AccountResponse, AccountUpdate, AccountInviteCreate, AccountInviteResponse, TransferOwnershipRequest
|
||||
@@ -559,3 +560,65 @@ async def get_sso_status(
|
||||
sso_enabled=account.sso_enabled,
|
||||
sso_provider=account.sso_provider,
|
||||
)
|
||||
|
||||
|
||||
# ─── Account Preferences (FlowPilot Phase 6) ──────────────────────────────────
|
||||
#
|
||||
# Preferences live in `account_settings.preferences` as a JSONB grab-bag
|
||||
# (per FLOWPILOT-MIGRATION.md Section 4.6). Rows are lazily created on first
|
||||
# write. Any engineer-role user can read + update preferences because the
|
||||
# keys stored here (templatize_prompt_enabled, cw_resolved_status_id, etc.)
|
||||
# are team-level toggles rather than account-owner-gated admin settings.
|
||||
|
||||
|
||||
class AccountPreferencesResponse(BaseModel):
|
||||
preferences: dict
|
||||
|
||||
|
||||
class AccountPreferencesUpdate(BaseModel):
|
||||
"""Merge-style update — each key in `preferences` overwrites that key in
|
||||
the stored JSONB, other keys are preserved. Omit the body entirely to
|
||||
no-op.
|
||||
"""
|
||||
preferences: dict
|
||||
|
||||
|
||||
@router.get("/me/preferences", response_model=AccountPreferencesResponse)
|
||||
async def get_my_preferences(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
"""Return the current account's preferences JSONB (empty dict if no row)."""
|
||||
result = await db.execute(
|
||||
select(AccountSettings.preferences).where(
|
||||
AccountSettings.account_id == current_user.account_id
|
||||
)
|
||||
)
|
||||
prefs = result.scalar_one_or_none() or {}
|
||||
return AccountPreferencesResponse(preferences=prefs)
|
||||
|
||||
|
||||
@router.patch("/me/preferences", response_model=AccountPreferencesResponse)
|
||||
async def update_my_preferences(
|
||||
data: AccountPreferencesUpdate,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
"""Upsert preference keys. Existing keys not present in the payload are kept.
|
||||
|
||||
Example: posting `{"preferences": {"templatize_prompt_enabled": false}}`
|
||||
from the post-resolve "Don't ask me again for this team" checkbox sets
|
||||
just that key without clobbering any other preferences.
|
||||
"""
|
||||
for key, value in data.preferences.items():
|
||||
await AccountSettings.set_setting(db, current_user.account_id, key, value)
|
||||
await db.commit()
|
||||
|
||||
# Return the merged state so the client doesn't need a second GET.
|
||||
result = await db.execute(
|
||||
select(AccountSettings.preferences).where(
|
||||
AccountSettings.account_id == current_user.account_id
|
||||
)
|
||||
)
|
||||
prefs = result.scalar_one_or_none() or {}
|
||||
return AccountPreferencesResponse(preferences=prefs)
|
||||
|
||||
221
backend/app/api/endpoints/draft_templates.py
Normal file
221
backend/app/api/endpoints/draft_templates.py
Normal file
@@ -0,0 +1,221 @@
|
||||
"""Draft template endpoints — Phase 6 post-resolve templatization flow.
|
||||
|
||||
Engineers who picked "Run now, templatize after resolve" on the three-option
|
||||
dialog (Phase 5) generate a `draft_templates` row at decision time. After
|
||||
the session resolves, the TemplatizePrompt component lets them either:
|
||||
- Accept → promotes the draft to a real `script_templates` row
|
||||
- Reject → marks the draft rejected, no library entry created
|
||||
|
||||
The Script Library sidebar uses the list endpoint to surface a
|
||||
"X drafts ready to review" badge for the account.
|
||||
|
||||
See FLOWPILOT-MIGRATION.md Section 5.3.
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_active_user, get_db, require_engineer_or_admin
|
||||
from app.models.ai_session import AISession
|
||||
from app.models.draft_template import DraftTemplate
|
||||
from app.models.script_template import ScriptCategory, ScriptTemplate
|
||||
from app.models.user import User
|
||||
from app.schemas.draft_template import (
|
||||
DraftTemplateAcceptRequest,
|
||||
DraftTemplateAcceptResponse,
|
||||
DraftTemplateListResponse,
|
||||
DraftTemplateRejectResponse,
|
||||
DraftTemplateResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/draft-templates", tags=["draft-templates"])
|
||||
|
||||
|
||||
def _slugify(name: str) -> str:
|
||||
"""Same slug rule as scripts.create_template — lowercase, kebab-case, ASCII."""
|
||||
return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
|
||||
|
||||
|
||||
# ── List ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("", response_model=DraftTemplateListResponse)
|
||||
async def list_drafts(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
pending_only: bool = True,
|
||||
) -> DraftTemplateListResponse:
|
||||
"""List drafts for the current user's account.
|
||||
|
||||
Defaults to pending-only — that's what the Script Library badge counts
|
||||
and what the post-resolve TemplatizePrompt iterates over. Pass
|
||||
`pending_only=false` to include accepted/rejected for an audit view.
|
||||
"""
|
||||
stmt = select(DraftTemplate).order_by(DraftTemplate.created_at.desc())
|
||||
if pending_only:
|
||||
stmt = stmt.where(DraftTemplate.status == "pending")
|
||||
result = await db.execute(stmt)
|
||||
drafts = list(result.scalars().all())
|
||||
return DraftTemplateListResponse(
|
||||
drafts=[DraftTemplateResponse.model_validate(d) for d in drafts]
|
||||
)
|
||||
|
||||
|
||||
# ── Get one ──────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/{draft_id}", response_model=DraftTemplateResponse)
|
||||
async def get_draft(
|
||||
draft_id: UUID,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
) -> DraftTemplateResponse:
|
||||
draft = await _load_draft_or_404(db, draft_id)
|
||||
return DraftTemplateResponse.model_validate(draft)
|
||||
|
||||
|
||||
# ── Accept ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post(
|
||||
"/{draft_id}/accept",
|
||||
response_model=DraftTemplateAcceptResponse,
|
||||
status_code=201,
|
||||
)
|
||||
async def accept_draft(
|
||||
draft_id: UUID,
|
||||
body: DraftTemplateAcceptRequest,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
) -> DraftTemplateAcceptResponse:
|
||||
"""Promote a draft to a real `script_templates` row.
|
||||
|
||||
Provenance fields (`source_session_id`, `source_user_id`,
|
||||
`source_ticket_ref`) are copied so the Script Library can render the
|
||||
"generated from CW #X · resolved by Y · used N times" chip.
|
||||
|
||||
On success: draft.status='accepted', draft.promoted_template_id set,
|
||||
draft.resolved_at set. The new template is owned by the engineer's team
|
||||
(matches scripts.create_template's behavior).
|
||||
|
||||
Returns 409 if the draft is already accepted/rejected.
|
||||
"""
|
||||
draft = await _load_draft_or_404(db, draft_id)
|
||||
if draft.status != "pending":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Draft is already {draft.status}",
|
||||
)
|
||||
|
||||
# Validate the category exists and belongs to (or is global for) this account.
|
||||
cat_result = await db.execute(
|
||||
select(ScriptCategory).where(
|
||||
ScriptCategory.id == body.category_id,
|
||||
ScriptCategory.is_active == True, # noqa: E712
|
||||
)
|
||||
)
|
||||
if cat_result.scalar_one_or_none() is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="category_id does not reference an active script category",
|
||||
)
|
||||
|
||||
# Look up source-session ticket ref for the provenance chip. RLS makes
|
||||
# cross-account ai_session lookup impossible — the draft must belong to
|
||||
# the same account as the requesting user.
|
||||
source_session = (
|
||||
await db.execute(
|
||||
select(AISession).where(AISession.id == draft.source_session_id)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
source_ticket_ref = (
|
||||
f"CW #{source_session.psa_ticket_id}"
|
||||
if source_session and source_session.psa_ticket_id
|
||||
else None
|
||||
)
|
||||
|
||||
slug = _slugify(body.name)
|
||||
|
||||
template = ScriptTemplate(
|
||||
category_id=body.category_id,
|
||||
team_id=current_user.team_id,
|
||||
account_id=current_user.account_id,
|
||||
created_by=current_user.id,
|
||||
name=body.name,
|
||||
slug=slug,
|
||||
description=body.description,
|
||||
script_body=body.edited_body or draft.script_body,
|
||||
parameters_schema=body.parameters_schema,
|
||||
# FlowPilot provenance — drives the Script Library chip.
|
||||
source_session_id=draft.source_session_id,
|
||||
source_user_id=draft.source_user_id,
|
||||
source_ticket_ref=source_ticket_ref,
|
||||
)
|
||||
db.add(template)
|
||||
await db.flush() # populate template.id
|
||||
|
||||
draft.status = "accepted"
|
||||
draft.promoted_template_id = template.id
|
||||
draft.resolved_at = datetime.now(timezone.utc)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(template)
|
||||
|
||||
return DraftTemplateAcceptResponse(
|
||||
draft_id=draft.id,
|
||||
promoted_template_id=template.id,
|
||||
template_slug=template.slug,
|
||||
)
|
||||
|
||||
|
||||
# ── Reject ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/{draft_id}/reject", response_model=DraftTemplateRejectResponse)
|
||||
async def reject_draft(
|
||||
draft_id: UUID,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
) -> DraftTemplateRejectResponse:
|
||||
"""Mark a draft rejected.
|
||||
|
||||
No template is created. The row stays for audit (so a team admin can see
|
||||
the engineer reviewed and explicitly declined). Returns 409 on a draft
|
||||
that's already accepted/rejected.
|
||||
"""
|
||||
draft = await _load_draft_or_404(db, draft_id)
|
||||
if draft.status != "pending":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Draft is already {draft.status}",
|
||||
)
|
||||
draft.status = "rejected"
|
||||
draft.resolved_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
return DraftTemplateRejectResponse(draft_id=draft.id, status="rejected")
|
||||
|
||||
|
||||
# ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
async def _load_draft_or_404(
|
||||
db: AsyncSession, draft_id: UUID
|
||||
) -> DraftTemplate:
|
||||
"""RLS-scoped draft load. 404 covers missing + cross-tenant."""
|
||||
result = await db.execute(
|
||||
select(DraftTemplate).where(DraftTemplate.id == draft_id)
|
||||
)
|
||||
draft = result.scalar_one_or_none()
|
||||
if draft is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Draft template not found",
|
||||
)
|
||||
return draft
|
||||
@@ -25,6 +25,7 @@ from app.api.endpoints import (
|
||||
categories,
|
||||
copilot,
|
||||
device_types,
|
||||
draft_templates,
|
||||
feedback,
|
||||
flow_proposals,
|
||||
flowpilot_analytics,
|
||||
@@ -141,6 +142,7 @@ api_router.include_router(session_resolutions.router, dependencies=_tenant_deps)
|
||||
# so the {session_id}/facts subpaths take precedence over any future generic catchalls.
|
||||
api_router.include_router(session_facts.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(session_suggested_fixes.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(draft_templates.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(ai_sessions.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(flow_proposals.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(flowpilot_analytics.router, dependencies=_tenant_deps)
|
||||
|
||||
68
backend/app/schemas/draft_template.py
Normal file
68
backend/app/schemas/draft_template.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""Pydantic schemas for FlowPilot Phase 6 draft templates.
|
||||
|
||||
A draft is the engineer's "Run now, templatize after resolve" path output:
|
||||
the script ran for the ticket, and the AI proposed a parameterization.
|
||||
Post-resolve, the engineer accepts (promotes to a real template) or rejects.
|
||||
|
||||
See FLOWPILOT-MIGRATION.md Section 5.3.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
DraftStatus = Literal["pending", "accepted", "rejected"]
|
||||
|
||||
|
||||
class DraftTemplateResponse(BaseModel):
|
||||
id: UUID
|
||||
account_id: UUID
|
||||
source_session_id: UUID
|
||||
source_user_id: UUID
|
||||
script_body: str
|
||||
proposed_parameters: dict[str, Any]
|
||||
proposed_name: str | None
|
||||
proposed_category_id: UUID | None
|
||||
status: DraftStatus
|
||||
resolved_at: datetime | None
|
||||
promoted_template_id: UUID | None
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class DraftTemplateListResponse(BaseModel):
|
||||
drafts: list[DraftTemplateResponse]
|
||||
|
||||
|
||||
class DraftTemplateAcceptRequest(BaseModel):
|
||||
"""Engineer's confirmation that this draft should become a real template.
|
||||
|
||||
Engineer may override the AI's proposed name / category and edit the
|
||||
parameter schema before promotion. Body and parameters_schema are
|
||||
persisted to the new `script_templates` row.
|
||||
"""
|
||||
name: str = Field(..., min_length=1, max_length=200)
|
||||
category_id: UUID
|
||||
description: str | None = Field(None, max_length=2000)
|
||||
# Final parameter schema in the Script Generator's standard shape.
|
||||
# See ScriptTemplate.parameters_schema for the contract.
|
||||
parameters_schema: dict[str, Any]
|
||||
# Optional last-minute edits to the script body. Defaults to the draft's
|
||||
# `script_body` (which TemplateExtractionService produced as the templated
|
||||
# form with `{{ key }}` placeholders).
|
||||
edited_body: str | None = Field(None, min_length=1, max_length=50_000)
|
||||
|
||||
|
||||
class DraftTemplateAcceptResponse(BaseModel):
|
||||
draft_id: UUID
|
||||
promoted_template_id: UUID
|
||||
template_slug: str
|
||||
|
||||
|
||||
class DraftTemplateRejectResponse(BaseModel):
|
||||
draft_id: UUID
|
||||
status: Literal["rejected"]
|
||||
295
backend/tests/test_phase6_draft_templates.py
Normal file
295
backend/tests/test_phase6_draft_templates.py
Normal file
@@ -0,0 +1,295 @@
|
||||
"""API tests for the FlowPilot Phase 6 post-resolve templatization flow.
|
||||
|
||||
Covers:
|
||||
- GET /api/v1/draft-templates list with pending_only filter.
|
||||
- POST /{id}/accept → creates script_templates row with provenance fields,
|
||||
marks draft accepted + promoted_template_id set.
|
||||
- POST /{id}/reject → marks rejected.
|
||||
- 409 when accepting or rejecting a non-pending draft.
|
||||
- Category validation (400 on unknown category_id).
|
||||
- GET/PATCH /accounts/me/preferences round-trip.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.account_settings import AccountSettings
|
||||
from app.models.ai_session import AISession
|
||||
from app.models.draft_template import DraftTemplate
|
||||
from app.models.script_template import ScriptCategory, ScriptTemplate
|
||||
|
||||
|
||||
async def _make_draft(
|
||||
test_db,
|
||||
user,
|
||||
*,
|
||||
proposed_name: str = "Test draft",
|
||||
status_: str = "pending",
|
||||
with_psa_ticket: bool = False,
|
||||
) -> tuple[AISession, DraftTemplate]:
|
||||
session = AISession(
|
||||
user_id=user["user_data"]["id"],
|
||||
account_id=user["user_data"]["account_id"],
|
||||
session_type="chat",
|
||||
intake_type="free_text",
|
||||
intake_content={"text": "phase 6 test"},
|
||||
status="resolved",
|
||||
confidence_tier="discovery",
|
||||
conversation_messages=[],
|
||||
psa_ticket_id="48307" if with_psa_ticket else None,
|
||||
)
|
||||
test_db.add(session)
|
||||
await test_db.flush()
|
||||
|
||||
draft = DraftTemplate(
|
||||
account_id=user["user_data"]["account_id"],
|
||||
source_session_id=session.id,
|
||||
source_user_id=user["user_data"]["id"],
|
||||
script_body='Do-Something -Target {{ target_name }}\n',
|
||||
proposed_parameters={
|
||||
"parameters": [
|
||||
{"key": "target_name", "label": "Target Name", "type": "text"},
|
||||
],
|
||||
},
|
||||
proposed_name=proposed_name,
|
||||
status=status_,
|
||||
)
|
||||
test_db.add(draft)
|
||||
await test_db.commit()
|
||||
await test_db.refresh(draft)
|
||||
return session, draft
|
||||
|
||||
|
||||
async def _make_category(test_db) -> ScriptCategory:
|
||||
cat = ScriptCategory(
|
||||
name="Phase 6 Test Category",
|
||||
slug=f"phase-6-test-{datetime.now(timezone.utc).timestamp()}",
|
||||
description="test",
|
||||
is_active=True,
|
||||
)
|
||||
test_db.add(cat)
|
||||
await test_db.commit()
|
||||
await test_db.refresh(cat)
|
||||
return cat
|
||||
|
||||
|
||||
# ── List ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_pending_only_default(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
await _make_draft(test_db, test_user, proposed_name="pending-a", status_="pending")
|
||||
await _make_draft(test_db, test_user, proposed_name="accepted-b", status_="accepted")
|
||||
|
||||
r = await client.get("/api/v1/draft-templates", headers=auth_headers)
|
||||
assert r.status_code == 200
|
||||
drafts = r.json()["drafts"]
|
||||
names = {d["proposed_name"] for d in drafts}
|
||||
assert "pending-a" in names
|
||||
assert "accepted-b" not in names
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_with_pending_only_false_includes_all(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
await _make_draft(test_db, test_user, proposed_name="pending-c", status_="pending")
|
||||
await _make_draft(test_db, test_user, proposed_name="rejected-d", status_="rejected")
|
||||
|
||||
r = await client.get(
|
||||
"/api/v1/draft-templates?pending_only=false", headers=auth_headers,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
names = {d["proposed_name"] for d in r.json()["drafts"]}
|
||||
assert {"pending-c", "rejected-d"}.issubset(names)
|
||||
|
||||
|
||||
# ── Accept ───────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_accept_creates_template_with_provenance(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
session, draft = await _make_draft(test_db, test_user, with_psa_ticket=True)
|
||||
cat = await _make_category(test_db)
|
||||
|
||||
r = await client.post(
|
||||
f"/api/v1/draft-templates/{draft.id}/accept",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"name": "Do Something On Target",
|
||||
"category_id": str(cat.id),
|
||||
"description": "promoted from phase 6 test",
|
||||
"parameters_schema": {
|
||||
"parameters": [
|
||||
{"key": "target_name", "label": "Target", "field_type": "text"},
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
body = r.json()
|
||||
assert body["draft_id"] == str(draft.id)
|
||||
assert body["promoted_template_id"] is not None
|
||||
assert body["template_slug"] == "do-something-on-target"
|
||||
|
||||
# Draft row is now accepted with the promoted template ID set.
|
||||
await test_db.refresh(draft)
|
||||
assert draft.status == "accepted"
|
||||
assert draft.promoted_template_id == UUID(body["promoted_template_id"])
|
||||
assert draft.resolved_at is not None
|
||||
|
||||
# New template row exists with provenance fields populated.
|
||||
tpl_result = await test_db.execute(
|
||||
select(ScriptTemplate).where(ScriptTemplate.id == UUID(body["promoted_template_id"]))
|
||||
)
|
||||
tpl = tpl_result.scalar_one()
|
||||
assert tpl.source_session_id == session.id
|
||||
assert tpl.source_user_id == UUID(test_user["user_data"]["id"])
|
||||
assert tpl.source_ticket_ref == "CW #48307"
|
||||
assert tpl.script_body == draft.script_body # edited_body was not supplied
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_accept_with_edited_body_overrides_draft(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
_, draft = await _make_draft(test_db, test_user)
|
||||
cat = await _make_category(test_db)
|
||||
|
||||
r = await client.post(
|
||||
f"/api/v1/draft-templates/{draft.id}/accept",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"name": "Edited Body Test",
|
||||
"category_id": str(cat.id),
|
||||
"parameters_schema": {"parameters": []},
|
||||
"edited_body": 'Write-Host "edited final version"\n',
|
||||
},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
tpl = (
|
||||
await test_db.execute(
|
||||
select(ScriptTemplate).where(
|
||||
ScriptTemplate.id == UUID(r.json()["promoted_template_id"])
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
assert tpl.script_body == 'Write-Host "edited final version"\n'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_accept_rejects_unknown_category(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
_, draft = await _make_draft(test_db, test_user)
|
||||
bogus_cat = "00000000-0000-0000-0000-000000000000"
|
||||
r = await client.post(
|
||||
f"/api/v1/draft-templates/{draft.id}/accept",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"name": "x",
|
||||
"category_id": bogus_cat,
|
||||
"parameters_schema": {"parameters": []},
|
||||
},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_accept_already_accepted_returns_409(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
_, draft = await _make_draft(test_db, test_user, status_="accepted")
|
||||
cat = await _make_category(test_db)
|
||||
r = await client.post(
|
||||
f"/api/v1/draft-templates/{draft.id}/accept",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"name": "x",
|
||||
"category_id": str(cat.id),
|
||||
"parameters_schema": {"parameters": []},
|
||||
},
|
||||
)
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
# ── Reject ───────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reject_marks_draft_rejected(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
_, draft = await _make_draft(test_db, test_user)
|
||||
r = await client.post(
|
||||
f"/api/v1/draft-templates/{draft.id}/reject", headers=auth_headers,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["status"] == "rejected"
|
||||
await test_db.refresh(draft)
|
||||
assert draft.status == "rejected"
|
||||
assert draft.resolved_at is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reject_already_accepted_returns_409(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
_, draft = await _make_draft(test_db, test_user, status_="accepted")
|
||||
r = await client.post(
|
||||
f"/api/v1/draft-templates/{draft.id}/reject", headers=auth_headers,
|
||||
)
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
# ── Preferences ──────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_preferences_empty_by_default(
|
||||
client: AsyncClient, auth_headers,
|
||||
):
|
||||
r = await client.get("/api/v1/accounts/me/preferences", headers=auth_headers)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["preferences"] == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_preferences_merges_keys(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
# First write: one key.
|
||||
r = await client.patch(
|
||||
"/api/v1/accounts/me/preferences",
|
||||
headers=auth_headers,
|
||||
json={"preferences": {"templatize_prompt_enabled": False}},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["preferences"]["templatize_prompt_enabled"] is False
|
||||
|
||||
# Second write: different key — first must be preserved (merge semantics).
|
||||
r2 = await client.patch(
|
||||
"/api/v1/accounts/me/preferences",
|
||||
headers=auth_headers,
|
||||
json={"preferences": {"cw_resolved_status_id": 42}},
|
||||
)
|
||||
assert r2.status_code == 200
|
||||
prefs = r2.json()["preferences"]
|
||||
assert prefs["templatize_prompt_enabled"] is False
|
||||
assert prefs["cw_resolved_status_id"] == 42
|
||||
|
||||
# Stored on the account_settings row.
|
||||
stored = (
|
||||
await test_db.execute(
|
||||
select(AccountSettings.preferences).where(
|
||||
AccountSettings.account_id == UUID(test_user["user_data"]["account_id"])
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
assert stored["templatize_prompt_enabled"] is False
|
||||
assert stored["cw_resolved_status_id"] == 42
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
> **Target:** Transform `/assistant` (ResolutionAssist) into the new unified `/pilot` (FlowPilot) surface.
|
||||
> **Audience:** Claude Code (implementation) and Codex (review) reviewed by Michael (owner).
|
||||
> **Status:** Phases 0–5 implemented and verified end-to-end against the dev stack. Phase 6 next.
|
||||
> **Last updated:** April 22, 2026 (Phase 5 — inline Script Generator integration — committed; live decision endpoint with Sonnet-driven TemplateExtractionService verified)
|
||||
> **Status:** Phases 0–6 implemented and verified end-to-end against the dev stack. Phase 7 (polish) next.
|
||||
> **Last updated:** April 22, 2026 (Phase 6 — post-resolve TemplatizePrompt — committed; draft accept → script_templates promotion with provenance verified live)
|
||||
|
||||
---
|
||||
|
||||
@@ -849,6 +849,22 @@ git commit -m "feat(pilot): integrate Script Generator inline with suggested fix
|
||||
- Skip the prompt → draft marked rejected, Script Library shows no new template.
|
||||
- Toggle "don't ask me again" → next session Resolve skips the prompt even with a pending draft.
|
||||
|
||||
**Verified on 2026-04-22:**
|
||||
- `GET /draft-templates?pending_only=true` returns pending rows; filter
|
||||
flips the set to include accepted/rejected for audit views.
|
||||
- `POST /{id}/accept` → creates `script_templates` row; `source_session_id`,
|
||||
`source_user_id`, `source_ticket_ref` (e.g. "CW #99123") copied from
|
||||
the source session so the Script Library provenance chip has its data.
|
||||
Draft flips to `status='accepted'`, `promoted_template_id` populated,
|
||||
`resolved_at` set. 409 on a re-accept.
|
||||
- `POST /{id}/reject` → flips to `status='rejected'`, `resolved_at` set.
|
||||
- `GET /accounts/me/preferences` → empty dict when no row; `PATCH`
|
||||
merges keys into `preferences` JSONB (verified round-trip persistence
|
||||
of `templatize_prompt_enabled: false`).
|
||||
- Sidebar Scripts nav gains a badge reflecting the pending draft count
|
||||
(fetched independently of the main sidebar stats endpoint so a
|
||||
draft-endpoint failure doesn't break the rest of the sidebar).
|
||||
|
||||
```
|
||||
git commit -m "feat(pilot): add post-resolve templatize prompt for draft templates"
|
||||
```
|
||||
|
||||
98
frontend/src/api/draftTemplates.ts
Normal file
98
frontend/src/api/draftTemplates.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Draft templates API — Phase 6 post-resolve templatization flow.
|
||||
*
|
||||
* A draft is produced when the engineer picks "Run now, templatize after
|
||||
* resolve" on the three-option dialog. After Resolve, the TemplatizePrompt
|
||||
* modal lists pending drafts and lets the engineer accept (→ real
|
||||
* script_templates row) or reject.
|
||||
*
|
||||
* Mirrors backend endpoints under /api/v1/draft-templates.
|
||||
*/
|
||||
import apiClient from './client'
|
||||
|
||||
export type DraftStatus = 'pending' | 'accepted' | 'rejected'
|
||||
|
||||
export interface DraftTemplate {
|
||||
id: string
|
||||
account_id: string
|
||||
source_session_id: string
|
||||
source_user_id: string
|
||||
script_body: string
|
||||
proposed_parameters: { parameters?: Array<Record<string, unknown>> } | Record<string, unknown>
|
||||
proposed_name: string | null
|
||||
proposed_category_id: string | null
|
||||
status: DraftStatus
|
||||
resolved_at: string | null
|
||||
promoted_template_id: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface DraftAcceptRequest {
|
||||
name: string
|
||||
category_id: string
|
||||
description?: string | null
|
||||
parameters_schema: { parameters: Array<Record<string, unknown>> } | Record<string, unknown>
|
||||
edited_body?: string | null
|
||||
}
|
||||
|
||||
export interface DraftAcceptResponse {
|
||||
draft_id: string
|
||||
promoted_template_id: string
|
||||
template_slug: string
|
||||
}
|
||||
|
||||
export interface DraftRejectResponse {
|
||||
draft_id: string
|
||||
status: 'rejected'
|
||||
}
|
||||
|
||||
export const draftTemplatesApi = {
|
||||
async list(pendingOnly = true): Promise<DraftTemplate[]> {
|
||||
const r = await apiClient.get<{ drafts: DraftTemplate[] }>('/draft-templates', {
|
||||
params: { pending_only: pendingOnly },
|
||||
})
|
||||
return r.data.drafts
|
||||
},
|
||||
|
||||
async get(id: string): Promise<DraftTemplate> {
|
||||
const r = await apiClient.get<DraftTemplate>(`/draft-templates/${id}`)
|
||||
return r.data
|
||||
},
|
||||
|
||||
async accept(id: string, data: DraftAcceptRequest): Promise<DraftAcceptResponse> {
|
||||
const r = await apiClient.post<DraftAcceptResponse>(
|
||||
`/draft-templates/${id}/accept`,
|
||||
data,
|
||||
)
|
||||
return r.data
|
||||
},
|
||||
|
||||
async reject(id: string): Promise<DraftRejectResponse> {
|
||||
const r = await apiClient.post<DraftRejectResponse>(
|
||||
`/draft-templates/${id}/reject`,
|
||||
)
|
||||
return r.data
|
||||
},
|
||||
}
|
||||
|
||||
// ── Account preferences (used by the "don't ask again" opt-out) ────────────
|
||||
|
||||
export interface AccountPreferences {
|
||||
preferences: Record<string, unknown>
|
||||
}
|
||||
|
||||
export const accountPreferencesApi = {
|
||||
async get(): Promise<AccountPreferences> {
|
||||
const r = await apiClient.get<AccountPreferences>('/accounts/me/preferences')
|
||||
return r.data
|
||||
},
|
||||
|
||||
async update(patch: Record<string, unknown>): Promise<AccountPreferences> {
|
||||
const r = await apiClient.patch<AccountPreferences>('/accounts/me/preferences', {
|
||||
preferences: patch,
|
||||
})
|
||||
return r.data
|
||||
},
|
||||
}
|
||||
|
||||
export default draftTemplatesApi
|
||||
@@ -44,6 +44,10 @@ export function Sidebar() {
|
||||
const toggleSidebarPinned = useUserPreferencesStore(s => s.toggleSidebarPinned)
|
||||
|
||||
const [stats, setStats] = useState<SidebarStatsResponse | null>(null)
|
||||
// Phase 6: pending-drafts badge on the Scripts nav. Fetched independently
|
||||
// of the main stats endpoint so backend changes aren't coupled — worst
|
||||
// case the badge doesn't show, rest of the sidebar still renders.
|
||||
const [pendingDraftCount, setPendingDraftCount] = useState<number>(0)
|
||||
const [flyoutIndex, setFlyoutIndex] = useState<string | null>(null)
|
||||
const flyoutTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const sidebarRef = useRef<HTMLElement>(null)
|
||||
@@ -56,6 +60,13 @@ export function Sidebar() {
|
||||
sidebarApi.getStats()
|
||||
.then(data => { if (requestId === statsRequestId.current) setStats(data) })
|
||||
.catch(() => {})
|
||||
// Phase 6: pending draft templates — soft-fail, optional import keeps
|
||||
// the sidebar robust if the endpoint is momentarily unavailable.
|
||||
import('@/api/draftTemplates').then(({ draftTemplatesApi }) => {
|
||||
draftTemplatesApi.list(true)
|
||||
.then(drafts => setPendingDraftCount(drafts.length))
|
||||
.catch(() => {})
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => { refreshStats() }, [location.pathname, refreshStats])
|
||||
@@ -97,9 +108,10 @@ export function Sidebar() {
|
||||
},
|
||||
{
|
||||
href: '/scripts', icon: FileText, label: 'Scripts', shortLabel: 'Scripts',
|
||||
badge: pendingDraftCount || undefined,
|
||||
matchPaths: ['/scripts', '/script-builder'],
|
||||
children: [
|
||||
{ href: '/scripts', label: 'Script Library' },
|
||||
{ href: '/scripts', label: 'Script Library', count: pendingDraftCount || undefined },
|
||||
{ href: '/script-builder', label: 'Script Builder' },
|
||||
],
|
||||
},
|
||||
|
||||
308
frontend/src/components/pilot/script/TemplatizePrompt.tsx
Normal file
308
frontend/src/components/pilot/script/TemplatizePrompt.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* TemplatizePrompt — Phase 6 post-resolve modal.
|
||||
*
|
||||
* Appears after a successful Resolve when ALL three of:
|
||||
* 1. account_settings.preferences.templatize_prompt_enabled !== false
|
||||
* (default true when absent — per FLOWPILOT-MIGRATION.md Section 14.2)
|
||||
* 2. The session has at least one pending draft_templates row
|
||||
* 3. (Implicit from #2) The engineer picked "Run now, templatize after"
|
||||
* on the original three-option dialog
|
||||
*
|
||||
* The engineer picks a category, optionally tweaks the name/description/
|
||||
* parameter list, and either:
|
||||
* - Saves → POST /draft-templates/{id}/accept → new script_templates row
|
||||
* - Skips → POST /draft-templates/{id}/reject
|
||||
* - Toggles "Don't ask me again for this team" → PATCH
|
||||
* /accounts/me/preferences with {templatize_prompt_enabled: false}
|
||||
*
|
||||
* Parent (AssistantChatPage) controls the open state and feeds one draft at
|
||||
* a time. When multiple drafts exist for the session, parent re-opens the
|
||||
* modal for the next one after save/skip.
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Loader2, Check, X, Trash2, Sparkles } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { scriptsApi } from '@/api/scripts'
|
||||
import type { ScriptCategoryResponse } from '@/types'
|
||||
import {
|
||||
draftTemplatesApi,
|
||||
accountPreferencesApi,
|
||||
type DraftTemplate,
|
||||
} from '@/api/draftTemplates'
|
||||
import { ParameterizationPreview } from './ParameterizationPreview'
|
||||
|
||||
interface TemplatizePromptProps {
|
||||
draft: DraftTemplate
|
||||
sourceTicketRef: string | null // "CW #48307" or null for local-only sessions
|
||||
onResolved: (outcome: 'accepted' | 'rejected') => void
|
||||
}
|
||||
|
||||
interface ParamEntry {
|
||||
key: string
|
||||
label: string
|
||||
field_type: string
|
||||
inferred_from?: string
|
||||
}
|
||||
|
||||
function normalizeParams(proposed: DraftTemplate['proposed_parameters']): ParamEntry[] {
|
||||
const raw = (proposed as { parameters?: unknown[] })?.parameters
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw
|
||||
.filter((p): p is Record<string, unknown> => typeof p === 'object' && p !== null)
|
||||
.map((p) => ({
|
||||
key: String(p.key ?? p.variable_name ?? ''),
|
||||
label: String(p.label ?? ''),
|
||||
field_type: String(p.field_type ?? p.type ?? 'text'),
|
||||
inferred_from: typeof p.inferred_from === 'string' ? p.inferred_from : undefined,
|
||||
}))
|
||||
.filter((p) => p.key.length > 0)
|
||||
}
|
||||
|
||||
export function TemplatizePrompt({ draft, sourceTicketRef, onResolved }: TemplatizePromptProps) {
|
||||
const [name, setName] = useState(draft.proposed_name ?? '')
|
||||
const [description, setDescription] = useState('')
|
||||
const [categoryId, setCategoryId] = useState(draft.proposed_category_id ?? '')
|
||||
const [params, setParams] = useState<ParamEntry[]>(() => normalizeParams(draft.proposed_parameters))
|
||||
const [dontAskAgain, setDontAskAgain] = useState(false)
|
||||
const [categories, setCategories] = useState<ScriptCategoryResponse[]>([])
|
||||
const [categoriesLoading, setCategoriesLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setCategoriesLoading(true)
|
||||
scriptsApi
|
||||
.getCategories()
|
||||
.then((cats) => {
|
||||
setCategories(cats)
|
||||
if (!categoryId && cats.length > 0) setCategoryId(cats[0].id)
|
||||
})
|
||||
.catch(() => toast.error('Could not load script categories'))
|
||||
.finally(() => setCategoriesLoading(false))
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const canSave = useMemo(
|
||||
() => Boolean(name.trim() && categoryId && !saving),
|
||||
[name, categoryId, saving],
|
||||
)
|
||||
|
||||
const removeParam = (keyToRemove: string) => {
|
||||
setParams((prev) => prev.filter((p) => p.key !== keyToRemove))
|
||||
}
|
||||
|
||||
const persistOptOutIfNeeded = async () => {
|
||||
if (!dontAskAgain) return
|
||||
try {
|
||||
await accountPreferencesApi.update({ templatize_prompt_enabled: false })
|
||||
} catch {
|
||||
// Soft-fail: the save itself succeeded; opt-out can be retried from
|
||||
// settings. Don't block the engineer on this.
|
||||
toast.warning("Could not save 'don't ask again' preference — retry in Settings")
|
||||
}
|
||||
}
|
||||
|
||||
const handleAccept = async () => {
|
||||
if (!canSave) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const parameters_schema = {
|
||||
parameters: params.map((p) => ({
|
||||
key: p.key,
|
||||
label: p.label || p.key,
|
||||
field_type: p.field_type,
|
||||
})),
|
||||
}
|
||||
await draftTemplatesApi.accept(draft.id, {
|
||||
name: name.trim(),
|
||||
category_id: categoryId,
|
||||
description: description.trim() || null,
|
||||
parameters_schema,
|
||||
})
|
||||
await persistOptOutIfNeeded()
|
||||
toast.success('Saved as team template')
|
||||
onResolved('accepted')
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { response?: { status?: number } })?.response?.status
|
||||
if (status === 409) toast.warning('This draft has already been handled')
|
||||
else toast.error('Could not save template')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSkip = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await draftTemplatesApi.reject(draft.id)
|
||||
await persistOptOutIfNeeded()
|
||||
onResolved('rejected')
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { response?: { status?: number } })?.response?.status
|
||||
if (status === 409) {
|
||||
// Already resolved — treat as a no-op, not an error.
|
||||
onResolved('rejected')
|
||||
} else {
|
||||
toast.error('Could not skip draft')
|
||||
}
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen
|
||||
onClose={handleSkip}
|
||||
title="Save as team template?"
|
||||
size="xl"
|
||||
footer={
|
||||
<div className="flex items-center justify-between gap-3 w-full">
|
||||
<label className="flex items-center gap-2 text-[0.75rem] text-muted-foreground cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={dontAskAgain}
|
||||
onChange={(e) => setDontAskAgain(e.target.checked)}
|
||||
className="accent-accent"
|
||||
/>
|
||||
Don't ask me again for this team
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
disabled={saving}
|
||||
className="rounded-md px-3 py-1.5 text-[0.8125rem] text-muted-foreground hover:text-heading hover:bg-elevated/40 transition-colors disabled:opacity-40"
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAccept}
|
||||
disabled={!canSave}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-md px-3 py-1.5 text-[0.8125rem] font-semibold transition-colors',
|
||||
canSave
|
||||
? 'bg-accent text-white hover:bg-accent-hover'
|
||||
: 'bg-elevated text-muted-foreground border border-default cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
{saving ? <Loader2 size={11} className="animate-spin" /> : <Check size={11} />}
|
||||
Save as team template
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4 text-[0.8125rem]">
|
||||
<div className="flex items-start gap-2 rounded-lg border border-accent/30 bg-accent-dim/15 px-3 py-2">
|
||||
<Sparkles size={13} className="text-accent mt-0.5 shrink-0" />
|
||||
<div className="text-[0.75rem] text-muted-foreground leading-relaxed">
|
||||
This script ran as a one-off during the resolved session
|
||||
{sourceTicketRef && (
|
||||
<> (<span className="font-mono text-accent-text">{sourceTicketRef}</span>)</>
|
||||
)}
|
||||
. Review the proposed parameters and save it to the Script Library
|
||||
so the team can reuse it.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-[0.6875rem] font-semibold text-heading mb-1">
|
||||
Template name
|
||||
</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full rounded-md border border-default bg-input px-2.5 py-1.5 text-[0.8125rem] text-heading focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
|
||||
placeholder="Short, descriptive"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[0.6875rem] font-semibold text-heading mb-1">
|
||||
Category
|
||||
</label>
|
||||
<select
|
||||
value={categoryId}
|
||||
onChange={(e) => setCategoryId(e.target.value)}
|
||||
disabled={categoriesLoading}
|
||||
className="w-full rounded-md border border-default bg-input px-2.5 py-1.5 text-[0.8125rem] text-heading focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
|
||||
>
|
||||
{categoriesLoading && <option>Loading…</option>}
|
||||
{!categoriesLoading && categories.length === 0 && (
|
||||
<option value="">No categories available</option>
|
||||
)}
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[0.6875rem] font-semibold text-heading mb-1">
|
||||
Description (optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="w-full rounded-md border border-default bg-input px-2.5 py-1.5 text-[0.8125rem] text-heading placeholder:text-muted-foreground resize-y min-h-[48px] max-h-[120px] focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
|
||||
rows={2}
|
||||
placeholder="What does this script do? When should someone reach for it?"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-[0.6875rem] font-semibold text-heading">
|
||||
Proposed parameters ({params.length})
|
||||
</label>
|
||||
<span className="text-[0.6875rem] text-muted-foreground italic">
|
||||
{params.length === 0
|
||||
? 'No parameters proposed — script will run as-is.'
|
||||
: 'Remove any that don\'t need to be parameterized.'}
|
||||
</span>
|
||||
</div>
|
||||
{params.length > 0 && (
|
||||
<div className="rounded-lg border border-default bg-card overflow-hidden">
|
||||
{params.map((p) => (
|
||||
<div
|
||||
key={p.key}
|
||||
className="flex items-center gap-2 px-2.5 py-1.5 border-b border-default last:border-b-0"
|
||||
>
|
||||
<span className="font-mono text-[0.75rem] text-accent-text">{`{{ ${p.key} }}`}</span>
|
||||
<span className="text-[0.75rem] text-heading">{p.label}</span>
|
||||
<span className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-semibold">
|
||||
{p.field_type}
|
||||
</span>
|
||||
{p.inferred_from && (
|
||||
<span className="text-[0.6875rem] text-muted-foreground italic truncate flex-1 min-w-0">
|
||||
from {p.inferred_from}
|
||||
</span>
|
||||
)}
|
||||
{!p.inferred_from && <span className="flex-1" />}
|
||||
<button
|
||||
onClick={() => removeParam(p.key)}
|
||||
disabled={saving}
|
||||
className="p-1 rounded text-muted-foreground hover:text-danger hover:bg-elevated/40 transition-colors"
|
||||
title="Remove parameter"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[0.6875rem] font-semibold text-heading mb-1">
|
||||
Script preview
|
||||
</label>
|
||||
<ParameterizationPreview body={draft.script_body} />
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default TemplatizePrompt
|
||||
@@ -18,6 +18,12 @@ import { SuggestedFix } from '@/components/pilot/sections/SuggestedFix'
|
||||
import { ResolutionNotePreview as ResolutionNotePreviewPopover } from '@/components/pilot/ResolutionNotePreview'
|
||||
import { TemplateMatchPanel } from '@/components/pilot/script/TemplateMatchPanel'
|
||||
import { NoTemplateDialog } from '@/components/pilot/script/NoTemplateDialog'
|
||||
import { TemplatizePrompt } from '@/components/pilot/script/TemplatizePrompt'
|
||||
import {
|
||||
draftTemplatesApi,
|
||||
accountPreferencesApi,
|
||||
type DraftTemplate,
|
||||
} from '@/api/draftTemplates'
|
||||
import { PILOT_INLINE_SCRIPT_EVENT } from '@/lib/pilotEvents'
|
||||
import { sessionFactsApi, type SessionFact } from '@/api/sessionFacts'
|
||||
import {
|
||||
@@ -109,6 +115,11 @@ export default function AssistantChatPage() {
|
||||
// whether the active fix has a script_template_id.
|
||||
const [scriptPanelOpen, setScriptPanelOpen] = useState(false)
|
||||
const [scriptDecisionBusy, setScriptDecisionBusy] = useState(false)
|
||||
// Phase 6: post-resolve "save as template?" queue. After Resolve succeeds
|
||||
// we fetch pending drafts for this session and show the modal one at a
|
||||
// time; the user accepts, rejects, or toggles "don't ask again", and we
|
||||
// advance to the next pending draft.
|
||||
const [templatizeQueue, setTemplatizeQueue] = useState<DraftTemplate[]>([])
|
||||
const [showOverflow, setShowOverflow] = useState(false)
|
||||
const toggleSidebarCollapse = () => {
|
||||
const next = !sidebarCollapsed
|
||||
@@ -505,6 +516,27 @@ export default function AssistantChatPage() {
|
||||
toast.success('Session escalated locally (no PSA ticket linked)')
|
||||
}
|
||||
handleClosePreview()
|
||||
|
||||
// Phase 6: on a successful Resolve (either external or local), check
|
||||
// for pending draft_templates rows created by the Phase 5 three-option
|
||||
// dialog. Show the TemplatizePrompt modal iff:
|
||||
// - the account preference hasn't opted out
|
||||
// - the session has at least one pending draft
|
||||
// Escalate doesn't trigger this flow — only resolution.
|
||||
if (out.outcome === 'resolved' || out.outcome === 'resolved_local') {
|
||||
try {
|
||||
const prefs = await accountPreferencesApi.get()
|
||||
if (prefs.preferences.templatize_prompt_enabled === false) return
|
||||
const drafts = await draftTemplatesApi.list(true)
|
||||
const forThisSession = drafts.filter(
|
||||
(d) => d.source_session_id === activeChatId,
|
||||
)
|
||||
if (forThisSession.length > 0) setTemplatizeQueue(forThisSession)
|
||||
} catch {
|
||||
// Soft-fail: the Resolve itself succeeded. A missing preference
|
||||
// or list fetch is not worth blocking the success toast.
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.error('[AssistantChat] confirm post failed:', err)
|
||||
const status = (err as { response?: { status?: number }; response?: { data?: { detail?: string } } })?.response?.status
|
||||
@@ -1431,6 +1463,16 @@ export default function AssistantChatPage() {
|
||||
context="status"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Phase 6: post-resolve "save as team template?" modal. Shown one draft
|
||||
at a time; onResolved advances the queue. */}
|
||||
{templatizeQueue.length > 0 && (
|
||||
<TemplatizePrompt
|
||||
draft={templatizeQueue[0]}
|
||||
sourceTicketRef={activePsaTicketId ? `CW #${activePsaTicketId}` : null}
|
||||
onResolved={() => setTemplatizeQueue((q) => q.slice(1))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user