Compare commits
41 Commits
8879f96fbf
...
d386d11af2
| Author | SHA1 | Date | |
|---|---|---|---|
| d386d11af2 | |||
| 65a831bf9a | |||
| faf1d8dd12 | |||
| 0386fa1fd5 | |||
| 82db1c78e4 | |||
| f930787200 | |||
| 5bcb7aa7c3 | |||
| 04fbfe3b8f | |||
| f92cbefed9 | |||
| c9306e40c9 | |||
| 1c855563ee | |||
| d4fae87236 | |||
| f2fce27f0d | |||
| 93c974466a | |||
| 8012668975 | |||
| 563bb1aa6f | |||
| 1d2d548fc8 | |||
| 3ee0101c6d | |||
| 861d082ff7 | |||
| 75b59123e6 | |||
| fcd224429c | |||
| 196c003876 | |||
| f2b9476edb | |||
| 70c5da0c75 | |||
| de2bef3175 | |||
| 362c7b1d79 | |||
| ec104dc8de | |||
| a47ce07326 | |||
| 2a54127a54 | |||
| 8582d24236 | |||
| bdb238a274 | |||
| 075b0fc1d8 | |||
| 217747f46e | |||
| 7fa1d6a32f | |||
| ac67e48500 | |||
| cdd29b460e | |||
| 2cde6673b0 | |||
| c0112f8bee | |||
| 8988dbc885 | |||
| 4a8e3ae954 | |||
| cdd8bb05cc |
@@ -0,0 +1,74 @@
|
||||
"""add fix outcome tracking columns to session_suggested_fixes
|
||||
|
||||
Adds: status, applied_at, verified_at, partial_notes, failure_reason,
|
||||
ai_outcome_proposal.
|
||||
|
||||
status is the outcome dimension (did the fix work?), orthogonal to the
|
||||
existing user_decision column (which script-path the engineer took).
|
||||
|
||||
Revision ID: 6492ec8d2d5b
|
||||
Revises: f07010f17b01
|
||||
Create Date: 2026-04-23 18:32:38.609719
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '6492ec8d2d5b'
|
||||
down_revision: Union[str, None] = 'f07010f17b01'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"session_suggested_fixes",
|
||||
sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'proposed'")),
|
||||
)
|
||||
op.add_column(
|
||||
"session_suggested_fixes",
|
||||
sa.Column("applied_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"session_suggested_fixes",
|
||||
sa.Column("verified_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"session_suggested_fixes",
|
||||
sa.Column("partial_notes", sa.Text(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"session_suggested_fixes",
|
||||
sa.Column("failure_reason", sa.Text(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"session_suggested_fixes",
|
||||
sa.Column("ai_outcome_proposal", postgresql.JSONB(), nullable=True),
|
||||
)
|
||||
# Backfill before constraint creation so dismissed rows satisfy the new CHECK.
|
||||
op.execute(
|
||||
"UPDATE session_suggested_fixes "
|
||||
"SET status = 'dismissed' "
|
||||
"WHERE user_decision = 'dismissed'"
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_session_suggested_fixes_status",
|
||||
"session_suggested_fixes",
|
||||
"status IN ('proposed', 'applied_success', 'applied_failed', 'applied_partial', 'dismissed')",
|
||||
)
|
||||
op.alter_column("session_suggested_fixes", "status", server_default=None)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("ck_session_suggested_fixes_status", "session_suggested_fixes", type_="check")
|
||||
op.drop_column("session_suggested_fixes", "ai_outcome_proposal")
|
||||
op.drop_column("session_suggested_fixes", "failure_reason")
|
||||
op.drop_column("session_suggested_fixes", "partial_notes")
|
||||
op.drop_column("session_suggested_fixes", "verified_at")
|
||||
op.drop_column("session_suggested_fixes", "applied_at")
|
||||
op.drop_column("session_suggested_fixes", "status")
|
||||
@@ -0,0 +1,70 @@
|
||||
"""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)
|
||||
|
||||
Revision ID: 71efd2102f49
|
||||
Revises: 6492ec8d2d5b
|
||||
Create Date: 2026-04-24 04:22:10.819809
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '71efd2102f49'
|
||||
down_revision = '6492ec8d2d5b'
|
||||
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")
|
||||
@@ -3,12 +3,14 @@ from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.rate_limit import limiter
|
||||
from app.api.deps import get_current_active_user
|
||||
from app.models.ai_session import AISession
|
||||
from app.models.user import User
|
||||
from app.models.script_builder_session import ScriptBuilderSession
|
||||
from app.schemas.script_builder import (
|
||||
@@ -67,15 +69,85 @@ async def create_session(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
) -> ScriptBuilderSessionDetail:
|
||||
"""Start a new Script Builder session."""
|
||||
"""Start a new Script Builder session.
|
||||
|
||||
When origin='pilot_inline', behaves as get-or-create: the same row is
|
||||
returned on repeated calls with the same (user, ai_session_id) pair.
|
||||
Inline sessions are excluded from the session cap and the list endpoint.
|
||||
"""
|
||||
# Phase 9: inline origin validation + authorization
|
||||
if data.origin == "pilot_inline":
|
||||
if data.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.
|
||||
ai_session = await db.scalar(
|
||||
select(AISession).where(
|
||||
AISession.id == data.ai_session_id,
|
||||
AISession.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
if ai_session is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Session not found",
|
||||
)
|
||||
|
||||
# Idempotent get-or-create: if a pilot_inline row already exists for
|
||||
# this (user, ai_session_id) pair, return it without creating a duplicate.
|
||||
existing = await db.scalar(
|
||||
select(ScriptBuilderSession).where(
|
||||
ScriptBuilderSession.user_id == current_user.id,
|
||||
ScriptBuilderSession.ai_session_id == data.ai_session_id,
|
||||
ScriptBuilderSession.origin == "pilot_inline",
|
||||
)
|
||||
)
|
||||
if existing is not None:
|
||||
# Re-fetch with message_records loaded
|
||||
session = await script_builder_service.get_session(db, existing.id, current_user.id)
|
||||
return _session_to_detail(session)
|
||||
|
||||
# Create the inline session — wrap in IntegrityError catch for races.
|
||||
try:
|
||||
session = await script_builder_service.create_session(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
account_id=current_user.account_id,
|
||||
team_id=current_user.team_id,
|
||||
language=data.language,
|
||||
origin=data.origin,
|
||||
ai_session_id=data.ai_session_id,
|
||||
)
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
# Race: another request won the unique index — re-read the winner row.
|
||||
existing = await db.scalar(
|
||||
select(ScriptBuilderSession).where(
|
||||
ScriptBuilderSession.user_id == current_user.id,
|
||||
ScriptBuilderSession.ai_session_id == data.ai_session_id,
|
||||
ScriptBuilderSession.origin == "pilot_inline",
|
||||
)
|
||||
)
|
||||
if existing is None:
|
||||
raise
|
||||
session = existing
|
||||
|
||||
# Re-fetch with message_records loaded
|
||||
session = await script_builder_service.get_session(db, session.id, current_user.id)
|
||||
return _session_to_detail(session)
|
||||
|
||||
# ── Standalone session ──────────────────────────────────────────────────
|
||||
# Acquire per-user advisory lock so concurrent create requests are serialized.
|
||||
# Without this, two simultaneous requests both read count < limit and both
|
||||
# insert, exceeding MAX_SESSIONS_PER_USER.
|
||||
user_lock_key = hash(str(current_user.id)) % (2**62)
|
||||
await db.execute(text("SELECT pg_advisory_xact_lock(:key)"), {"key": user_lock_key})
|
||||
|
||||
# Enforce max concurrent sessions
|
||||
count = await script_builder_service.count_user_sessions(db, current_user.id)
|
||||
# Enforce max concurrent sessions (inline sessions excluded from cap)
|
||||
count = await script_builder_service.count_user_sessions(db, current_user.id, include_inline=False)
|
||||
if count >= MAX_SESSIONS_PER_USER:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
@@ -88,6 +160,8 @@ async def create_session(
|
||||
account_id=current_user.account_id,
|
||||
team_id=current_user.team_id,
|
||||
language=data.language,
|
||||
origin=data.origin,
|
||||
ai_session_id=data.ai_session_id,
|
||||
)
|
||||
await db.commit()
|
||||
# Re-fetch with message_records loaded
|
||||
|
||||
@@ -30,7 +30,9 @@ from app.schemas.session_suggested_fix import (
|
||||
ResolutionPostResponse,
|
||||
SessionSuggestedFixDecisionRequest,
|
||||
SessionSuggestedFixDecisionResponse,
|
||||
SessionSuggestedFixOutcomeRequest,
|
||||
SessionSuggestedFixResponse,
|
||||
SessionSuggestedFixScriptRequest,
|
||||
)
|
||||
from app.models.draft_template import DraftTemplate
|
||||
from app.models.session_fact import SessionFact
|
||||
@@ -216,6 +218,240 @@ async def record_decision(
|
||||
)
|
||||
|
||||
|
||||
# ── Suggested fix: apply (stamp applied_at) ──────────────────────────────
|
||||
|
||||
@router.post(
|
||||
"/suggested-fixes/{fix_id}/apply",
|
||||
response_model=SessionSuggestedFixResponse,
|
||||
)
|
||||
async def apply_suggested_fix(
|
||||
session_id: UUID,
|
||||
fix_id: UUID,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
) -> SessionSuggestedFixResponse:
|
||||
"""Stamp applied_at when the engineer clicks Apply in the ProposalBanner.
|
||||
|
||||
This does NOT change status (fix remains 'proposed'). Status only flips
|
||||
when the engineer records an outcome via PATCH /outcome.
|
||||
|
||||
Rules:
|
||||
- Fix must be in 'proposed' status; any other status → 409.
|
||||
- Idempotent: if applied_at is already set, returns 200 with the unchanged row.
|
||||
- Bumps ai_sessions.state_version so resolve/escalate preview generators
|
||||
know the fix has entered the verifying phase.
|
||||
"""
|
||||
await _load_session_or_404(db, session_id)
|
||||
|
||||
result = await db.execute(
|
||||
select(SessionSuggestedFix).where(
|
||||
SessionSuggestedFix.id == fix_id,
|
||||
SessionSuggestedFix.session_id == session_id,
|
||||
)
|
||||
)
|
||||
fix = result.scalar_one_or_none()
|
||||
if fix is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Suggested fix not found"
|
||||
)
|
||||
|
||||
if fix.status != "proposed":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Apply is only valid from 'proposed'; fix is already '{fix.status}'",
|
||||
)
|
||||
|
||||
# Idempotent: already stamped → return as-is without bumping state_version again.
|
||||
if fix.applied_at is not None:
|
||||
return SessionSuggestedFixResponse.model_validate(fix)
|
||||
|
||||
fix.applied_at = datetime.now(timezone.utc)
|
||||
|
||||
# Bump state_version so preview generators see the verifying-phase signal.
|
||||
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)
|
||||
|
||||
|
||||
# ── Suggested fix: outcome ────────────────────────────────────────────────
|
||||
|
||||
@router.patch(
|
||||
"/suggested-fixes/{fix_id}/outcome",
|
||||
response_model=SessionSuggestedFixResponse,
|
||||
)
|
||||
async def patch_suggested_fix_outcome(
|
||||
session_id: UUID,
|
||||
fix_id: UUID,
|
||||
body: SessionSuggestedFixOutcomeRequest,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
) -> SessionSuggestedFixResponse:
|
||||
"""Record the engineer's outcome for an applied fix.
|
||||
|
||||
See `SessionSuggestedFixOutcomeRequest` for transition rules.
|
||||
"""
|
||||
await _load_session_or_404(db, session_id)
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
result = await db.execute(
|
||||
select(SessionSuggestedFix).where(
|
||||
SessionSuggestedFix.id == fix_id,
|
||||
SessionSuggestedFix.session_id == session_id,
|
||||
)
|
||||
)
|
||||
fix = result.scalar_one_or_none()
|
||||
if fix is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Suggested fix not found"
|
||||
)
|
||||
|
||||
if body.outcome == "applied_partial" and not (body.notes and body.notes.strip()):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="notes are required when outcome is applied_partial",
|
||||
)
|
||||
|
||||
TERMINAL = {"applied_success", "applied_failed", "dismissed"}
|
||||
if fix.status in TERMINAL:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Fix is already in terminal status {fix.status!r}",
|
||||
)
|
||||
|
||||
fix.status = body.outcome
|
||||
if body.outcome == "applied_partial":
|
||||
fix.partial_notes = (body.notes or "").strip() or None
|
||||
elif body.outcome == "applied_failed":
|
||||
fix.failure_reason = (body.notes or "").strip() or None
|
||||
fix.verified_at = now
|
||||
elif body.outcome == "applied_success":
|
||||
fix.verified_at = now
|
||||
# dismissed: no timestamp/notes stamping
|
||||
|
||||
if fix.applied_at is None and body.outcome != "dismissed":
|
||||
fix.applied_at = now
|
||||
|
||||
# Clear any pending AI outcome proposal — engineer has taken a terminal action.
|
||||
fix.ai_outcome_proposal = None
|
||||
|
||||
# Outcome changes the bundle that resolution-note/escalation-package
|
||||
# previews see, so bump state_version inside the same transaction —
|
||||
# mirrors the pattern in record_decision above.
|
||||
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)
|
||||
|
||||
|
||||
# ── Suggested fix: attach drafted script ─────────────────────────────────────
|
||||
|
||||
@router.patch(
|
||||
"/suggested-fixes/{fix_id}/script",
|
||||
response_model=SessionSuggestedFixResponse,
|
||||
)
|
||||
async def patch_suggested_fix_script(
|
||||
session_id: UUID,
|
||||
fix_id: UUID,
|
||||
body: SessionSuggestedFixScriptRequest,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
) -> 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=status.HTTP_404_NOT_FOUND, detail="Suggested fix not found")
|
||||
|
||||
TERMINAL = {"applied_success", "applied_failed", "dismissed"}
|
||||
if fix.status in TERMINAL:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
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)
|
||||
|
||||
|
||||
# ── Suggested fix: clear AI outcome proposal ("Not yet") ─────────────────────
|
||||
|
||||
@router.delete(
|
||||
"/suggested-fixes/{fix_id}/ai-outcome-proposal",
|
||||
response_model=SessionSuggestedFixResponse,
|
||||
)
|
||||
async def clear_ai_outcome_proposal(
|
||||
session_id: UUID,
|
||||
fix_id: UUID,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
) -> SessionSuggestedFixResponse:
|
||||
"""Explicitly dismiss the AI-proposed outcome banner ("Not yet").
|
||||
|
||||
Clears `ai_outcome_proposal` without touching status or state_version
|
||||
(this is pure UI state, not outcome data). Idempotent: returns 200 even
|
||||
when the field is already null. After this call the banner will not
|
||||
re-surface on the next refreshSessionDerived unless the AI emits a new
|
||||
proposal.
|
||||
"""
|
||||
await _load_session_or_404(db, session_id)
|
||||
|
||||
result = await db.execute(
|
||||
select(SessionSuggestedFix).where(
|
||||
SessionSuggestedFix.id == fix_id,
|
||||
SessionSuggestedFix.session_id == session_id,
|
||||
)
|
||||
)
|
||||
fix = result.scalar_one_or_none()
|
||||
if fix is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Suggested fix not found"
|
||||
)
|
||||
|
||||
fix.ai_outcome_proposal = None
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(fix)
|
||||
return SessionSuggestedFixResponse.model_validate(fix)
|
||||
|
||||
|
||||
async def _summarize_session_for_extraction(
|
||||
db: AsyncSession, session_id: UUID,
|
||||
) -> str:
|
||||
|
||||
@@ -3,7 +3,7 @@ import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import String, Text, Boolean, DateTime, ForeignKey
|
||||
from sqlalchemy import String, Text, Boolean, DateTime, ForeignKey, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
@@ -30,8 +30,8 @@ class NetworkDiagram(Base):
|
||||
client_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
asset_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
nodes: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False, server_default="'[]'")
|
||||
edges: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False, server_default="'[]'")
|
||||
nodes: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False, server_default=text("'[]'::jsonb"))
|
||||
edges: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False, server_default=text("'[]'::jsonb"))
|
||||
thumbnail_url: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
is_archived: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False,
|
||||
|
||||
@@ -62,6 +62,16 @@ class ScriptBuilderSession(Base):
|
||||
nullable=True,
|
||||
comment="Link to FlowPilot session if launched from there",
|
||||
)
|
||||
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."
|
||||
),
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
@@ -35,6 +35,11 @@ class SessionSuggestedFix(Base):
|
||||
"'one_off', 'draft_template', 'build_template', 'dismissed')",
|
||||
name="ck_session_suggested_fixes_user_decision",
|
||||
),
|
||||
CheckConstraint(
|
||||
"status IN ('proposed', 'applied_success', 'applied_failed', "
|
||||
"'applied_partial', 'dismissed')",
|
||||
name="ck_session_suggested_fixes_status",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
@@ -65,6 +70,21 @@ class SessionSuggestedFix(Base):
|
||||
JSONB, nullable=True
|
||||
)
|
||||
user_decision: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
# Outcome dimension — did the fix work? Orthogonal to user_decision.
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="proposed"
|
||||
)
|
||||
applied_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
verified_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
partial_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
failure_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
ai_outcome_proposal: Mapped[dict[str, Any] | None] = mapped_column(
|
||||
JSONB, nullable=True
|
||||
)
|
||||
# Set when a newer suggested fix supersedes this one.
|
||||
superseded_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
"""Pydantic schemas for the AI Script Builder."""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from typing import Literal, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ScriptBuilderCreateRequest(BaseModel):
|
||||
"""Request to start a new builder session."""
|
||||
"""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
|
||||
|
||||
|
||||
class ScriptBuilderMessageRequest(BaseModel):
|
||||
|
||||
@@ -12,6 +12,17 @@ from pydantic import BaseModel, Field
|
||||
|
||||
UserDecision = Literal["one_off", "draft_template", "build_template", "dismissed"]
|
||||
|
||||
# "dismissed" here is the outcome dimension — orthogonal to UserDecision's
|
||||
# "dismissed" (script-path choice), though the migration backfill aligns
|
||||
# them for pre-existing rows.
|
||||
FixStatus = Literal[
|
||||
"proposed",
|
||||
"applied_success",
|
||||
"applied_failed",
|
||||
"applied_partial",
|
||||
"dismissed",
|
||||
]
|
||||
|
||||
|
||||
class SessionSuggestedFixResponse(BaseModel):
|
||||
id: UUID
|
||||
@@ -25,6 +36,12 @@ class SessionSuggestedFixResponse(BaseModel):
|
||||
user_decision: UserDecision | None
|
||||
superseded_at: datetime | None
|
||||
created_at: datetime
|
||||
status: FixStatus
|
||||
applied_at: datetime | None
|
||||
verified_at: datetime | None
|
||||
partial_notes: str | None
|
||||
failure_reason: str | None
|
||||
ai_outcome_proposal: dict[str, Any] | None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@@ -71,6 +88,43 @@ class SessionSuggestedFixDecisionResponse(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
# Subset of FixStatus that the engineer can set via the outcome endpoint —
|
||||
# `proposed` is excluded because you can't un-decide a fix back to "proposed".
|
||||
FixOutcome = Literal[
|
||||
"applied_success", "applied_failed", "applied_partial", "dismissed"
|
||||
]
|
||||
|
||||
|
||||
class SessionSuggestedFixOutcomeRequest(BaseModel):
|
||||
"""Engineer-reported outcome of applying a suggested fix.
|
||||
|
||||
Writes to session_suggested_fixes.status and companion columns. This is
|
||||
orthogonal to `user_decision` (which records which script-path the
|
||||
engineer took); outcome captures whether the fix actually worked.
|
||||
|
||||
Allowed transitions:
|
||||
- from `proposed` or `applied_partial`: any outcome is valid
|
||||
(partial is parked, not terminal — the engineer may update notes,
|
||||
abandon via dismiss, or advance to success/failed)
|
||||
- from any terminal outcome (`applied_success`, `applied_failed`,
|
||||
`dismissed`): server returns 409
|
||||
"""
|
||||
outcome: FixOutcome
|
||||
# Required for applied_partial, optional for applied_failed, ignored otherwise.
|
||||
notes: str | None = Field(None, max_length=500)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ── Resolution note preview ────────────────────────────────────────────────
|
||||
|
||||
class ResolutionNotePreviewResponse(BaseModel):
|
||||
|
||||
@@ -198,6 +198,44 @@ for the drafted script
|
||||
The marker is stripped from display — the engineer sees the suggested fix as \
|
||||
an interactive card with confidence badge, not raw JSON.
|
||||
|
||||
## Reporting fix outcome with [FIX_OUTCOME]
|
||||
|
||||
When the engineer clearly indicates in chat that a previously proposed fix
|
||||
worked, didn't work, or was partially applied, emit a [FIX_OUTCOME] marker
|
||||
on its own lines. This surfaces a "confirm outcome?" banner in the UI — it
|
||||
does NOT mark the fix resolved on its own; the engineer confirms via the UI.
|
||||
|
||||
**When to emit [FIX_OUTCOME]:**
|
||||
- The engineer states the user's problem is resolved after applying the fix
|
||||
(affirmative resolution language → outcome="success")
|
||||
- The engineer states the issue persists after applying the fix
|
||||
(→ outcome="failure")
|
||||
- The engineer describes applying only part of the fix
|
||||
(→ outcome="partial")
|
||||
|
||||
**When NOT to emit [FIX_OUTCOME]:**
|
||||
- The engineer is still verifying (user rebooting, testing, etc.)
|
||||
- The outcome is ambiguous or inferred rather than stated
|
||||
- No [SUGGEST_FIX] has been emitted this session
|
||||
|
||||
**[FIX_OUTCOME] marker format (one block per response, on its own lines).**
|
||||
Schema below — DO NOT copy these placeholders into your real response, fill \
|
||||
each field with content specific to the actual ticket:
|
||||
|
||||
[FIX_OUTCOME]
|
||||
{"fix_id": "<uuid-of-the-active-suggested-fix>",
|
||||
"outcome": "<success|failure|partial>",
|
||||
"reason": "<one-line-quote-or-paraphrase-of-what-the-engineer-said>"}
|
||||
[/FIX_OUTCOME]
|
||||
|
||||
- `fix_id`: the UUID of the active suggested fix (provided in session context)
|
||||
- `outcome`: one of `"success"`, `"failure"`, or `"partial"`
|
||||
- `reason`: one-line paraphrase of what the engineer said — derived from \
|
||||
their CURRENT message, not invented
|
||||
|
||||
The marker is stripped from display — the engineer sees a "confirm outcome?" \
|
||||
banner in the UI, not raw JSON.
|
||||
|
||||
## Using the Team's Flow Library
|
||||
Your team has built troubleshooting flows in ResolutionFlow. When relevant flows \
|
||||
appear in the context below, reference them by name so the engineer can launch them \
|
||||
@@ -269,6 +307,8 @@ the originating item's `id` into `source_ref` verbatim.
|
||||
[SUGGEST_FIX] is OPTIONAL — emit one at most per response, only when you have a \
|
||||
concrete proposed resolution at ~50%+ confidence. A new [SUGGEST_FIX] supersedes \
|
||||
any prior suggested fix.
|
||||
[FIX_OUTCOME] is OPTIONAL — emit one at most per response, only when the engineer \
|
||||
has clearly stated the outcome in their current message.
|
||||
|
||||
ANTI-PARROT RULE: The schemas above use placeholders in `<angle brackets>` to show \
|
||||
the SHAPE of valid output. Your real questions, actions, facts, and suggested fixes \
|
||||
|
||||
@@ -55,22 +55,45 @@ header.>
|
||||
If there are no facts, write "Nothing confirmed yet." and continue.>
|
||||
|
||||
## What we've tried
|
||||
<bulleted list of diagnostic checks run (from the [diagnostic_check] facts) \
|
||||
and scripts generated during the session. State what each revealed or did, \
|
||||
not what was attempted without an outcome. If nothing has been tried, write \
|
||||
"No diagnostic actions run yet." and continue.>
|
||||
<Bulleted list of diagnostic checks run and scripts generated during the \
|
||||
session. The content of this section also depends on the outcome recorded for \
|
||||
the active suggested fix, as given in the input bundle under "Outcome status":>
|
||||
|
||||
- applied_failed: List the fix as a tried path. Include the failure reason if \
|
||||
provided. State that it did not resolve the issue.
|
||||
- applied_partial: Include the fix as a partially tried path. Include partial \
|
||||
notes if provided. Indicate it was not fully completed or not verified.
|
||||
- applied_success: Note that the fix was applied and verified but escalation \
|
||||
is still needed for another reason (unusual — reflect this accurately).
|
||||
- dismissed: Do not mention the fix as a tried path; it was only considered.
|
||||
- proposed (no outcome yet): Do not list it here; it goes in Current hypothesis.
|
||||
|
||||
If nothing has been tried at all (no checks, no scripts, no applied/partial \
|
||||
fix), write "No diagnostic actions run yet." and continue.
|
||||
|
||||
## Current hypothesis
|
||||
<one short paragraph naming the active suggested fix and its confidence. If \
|
||||
confidence is below 60% or there is no active fix, say so plainly: "No leading \
|
||||
hypothesis yet — symptoms are still being narrowed.">
|
||||
<The content depends on the outcome recorded for the active suggested fix:>
|
||||
|
||||
- proposed (no outcome yet): State the fix title and confidence. If confidence \
|
||||
is below 60% or there is no active fix, say "No leading hypothesis yet — \
|
||||
symptoms are still being narrowed."
|
||||
- applied_failed or dismissed: Say the proposed fix did not hold or was set \
|
||||
aside. State any remaining uncertainty.
|
||||
- applied_partial: Note the partial application and what remains open.
|
||||
- applied_success: Unusual in an escalate path — state the fix resolved the \
|
||||
original symptom but a new or related issue requires escalation.
|
||||
|
||||
## Suggested next steps
|
||||
<bulleted list of 2-4 concrete next actions the receiving engineer should \
|
||||
take. Prefer specifics: commands to run, tickets to check, people to contact. \
|
||||
Derive from the gap between confirmed facts and a complete resolution. If the \
|
||||
active suggested fix is high confidence (>80%), the first bullet is "Try the \
|
||||
suggested fix: <title>.">
|
||||
Derive from the gap between confirmed facts and a complete resolution. \
|
||||
If the active suggested fix failed (applied_failed), inform the next steps \
|
||||
accordingly — e.g. suggest alternatives or deeper investigation paths, \
|
||||
drawing on the failure reason if provided. \
|
||||
If the fix is partially applied (applied_partial), the first step is typically \
|
||||
to complete or verify it. \
|
||||
If the fix is still proposed (no outcome), the first step is to try it if \
|
||||
confidence is high (>80%).>
|
||||
|
||||
Strict rules:
|
||||
- Use ONLY the input I provide. Never invent command names, KB articles, or \
|
||||
@@ -269,6 +292,15 @@ class EscalationPackageGeneratorService:
|
||||
lines.append(f"Title: {active_fix.title}")
|
||||
lines.append(f"Confidence: {active_fix.confidence_pct}%")
|
||||
lines.append(f"Description: {active_fix.description}")
|
||||
lines.append(f"Outcome status: {active_fix.status}")
|
||||
if active_fix.applied_at:
|
||||
lines.append(f"Applied at: {active_fix.applied_at.isoformat()}")
|
||||
if active_fix.verified_at:
|
||||
lines.append(f"Verified at: {active_fix.verified_at.isoformat()}")
|
||||
if active_fix.partial_notes:
|
||||
lines.append(f"Partial notes: {active_fix.partial_notes}")
|
||||
if active_fix.failure_reason:
|
||||
lines.append(f"Failure reason: {active_fix.failure_reason}")
|
||||
|
||||
lines.append("")
|
||||
lines.append(
|
||||
|
||||
@@ -69,11 +69,24 @@ say "Root cause not definitively isolated." and explain what is suspected based
|
||||
on facts.>
|
||||
|
||||
## Resolution
|
||||
<one short paragraph describing the resolution applied. If a script ran during \
|
||||
the session, mention it (e.g. "Cleared cached credentials via the \
|
||||
clear-outlook-credentials script."). If no resolution has been performed yet, \
|
||||
write "Resolution not yet applied — fix proposed: <fix title>." Pull verbatim \
|
||||
script names and template references when available.>
|
||||
<The content of this section depends on the outcome recorded for the active \
|
||||
suggested fix, as given in the input bundle under "fix.status":>
|
||||
|
||||
- applied_success: Write in past tense using closure language. State that the \
|
||||
fix was applied and verified as working. If verified_at is provided, you may \
|
||||
reference it as the time resolution was confirmed. Example phrasing: \
|
||||
"Applied <fix title>; confirmed working."
|
||||
- applied_failed: Acknowledge that the proposed fix did not resolve the issue \
|
||||
and was discarded. If failure_reason is provided, include it. Then describe \
|
||||
the actual resolution path taken (derived from facts and scripts run). This \
|
||||
state means the engineer resolved the issue another way; the note should cover \
|
||||
that actual resolution, not just the failed attempt.
|
||||
- applied_partial: Note that the fix was partially applied. If partial_notes \
|
||||
are provided, include them. Then describe the final resolution path taken.
|
||||
- dismissed: Treat the fix as considered and set aside. Do not center the note \
|
||||
on it. Describe the resolution based on what was actually confirmed and done.
|
||||
- proposed (no outcome yet): Write "Resolution not yet applied — fix proposed: \
|
||||
<fix title>." Pull verbatim script names and template references when available.
|
||||
|
||||
Strict rules:
|
||||
- Use ONLY the facts and state I provide. Never invent specifics that are not \
|
||||
@@ -302,6 +315,15 @@ class ResolutionNoteGeneratorService:
|
||||
lines.append(f"Description: {active_fix.description}")
|
||||
if active_fix.user_decision:
|
||||
lines.append(f"Engineer decision: {active_fix.user_decision}")
|
||||
lines.append(f"Outcome status: {active_fix.status}")
|
||||
if active_fix.applied_at:
|
||||
lines.append(f"Applied at: {active_fix.applied_at.isoformat()}")
|
||||
if active_fix.verified_at:
|
||||
lines.append(f"Verified at: {active_fix.verified_at.isoformat()}")
|
||||
if active_fix.partial_notes:
|
||||
lines.append(f"Partial notes: {active_fix.partial_notes}")
|
||||
if active_fix.failure_reason:
|
||||
lines.append(f"Failure reason: {active_fix.failure_reason}")
|
||||
|
||||
lines.append("")
|
||||
lines.append("# Scripts run during the session (passwords redacted)")
|
||||
|
||||
@@ -148,6 +148,8 @@ async def create_session(
|
||||
team_id: UUID | None,
|
||||
language: str,
|
||||
initial_prompt: str | None = None,
|
||||
origin: str = "standalone",
|
||||
ai_session_id: UUID | None = None,
|
||||
) -> ScriptBuilderSession:
|
||||
"""Create a new Script Builder session."""
|
||||
session = ScriptBuilderSession(
|
||||
@@ -155,6 +157,8 @@ async def create_session(
|
||||
account_id=account_id,
|
||||
team_id=team_id,
|
||||
language=language,
|
||||
origin=origin,
|
||||
ai_session_id=ai_session_id,
|
||||
)
|
||||
db.add(session)
|
||||
await db.flush()
|
||||
@@ -295,15 +299,22 @@ async def list_sessions(
|
||||
user_id: UUID,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
*,
|
||||
include_inline: bool = False,
|
||||
) -> list[ScriptBuilderSession]:
|
||||
"""List user's builder sessions ordered by updated_at desc."""
|
||||
result = await db.execute(
|
||||
"""List user's builder sessions ordered by updated_at desc.
|
||||
|
||||
By default (include_inline=False) excludes pilot_inline sessions so the
|
||||
/script-builder dashboard only shows standalone sessions.
|
||||
"""
|
||||
stmt = (
|
||||
select(ScriptBuilderSession)
|
||||
.where(ScriptBuilderSession.user_id == user_id)
|
||||
.order_by(ScriptBuilderSession.updated_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
if not include_inline:
|
||||
stmt = stmt.where(ScriptBuilderSession.origin == "standalone")
|
||||
stmt = stmt.order_by(ScriptBuilderSession.updated_at.desc()).limit(limit).offset(offset)
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@@ -321,13 +332,23 @@ async def delete_session(
|
||||
return True
|
||||
|
||||
|
||||
async def count_user_sessions(db: AsyncSession, user_id: UUID) -> int:
|
||||
"""Count active builder sessions for a user."""
|
||||
result = await db.execute(
|
||||
select(func.count(ScriptBuilderSession.id)).where(
|
||||
ScriptBuilderSession.user_id == user_id
|
||||
)
|
||||
async def count_user_sessions(
|
||||
db: AsyncSession,
|
||||
user_id: UUID,
|
||||
*,
|
||||
include_inline: bool = False,
|
||||
) -> int:
|
||||
"""Count active builder sessions for a user.
|
||||
|
||||
By default (include_inline=False) excludes pilot_inline sessions so they
|
||||
don't consume slots against the MAX_SESSIONS_PER_USER cap.
|
||||
"""
|
||||
stmt = select(func.count(ScriptBuilderSession.id)).where(
|
||||
ScriptBuilderSession.user_id == user_id
|
||||
)
|
||||
if not include_inline:
|
||||
stmt = stmt.where(ScriptBuilderSession.origin == "standalone")
|
||||
result = await db.execute(stmt)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
|
||||
@@ -354,6 +354,56 @@ def _parse_suggest_fix_marker(
|
||||
return cleaned, parsed
|
||||
|
||||
|
||||
def _parse_fix_outcome_marker(
|
||||
ai_content: str,
|
||||
) -> tuple[str, dict[str, Any] | None]:
|
||||
"""Extract a single [FIX_OUTCOME]...[/FIX_OUTCOME] JSON block.
|
||||
|
||||
Block shape:
|
||||
{"fix_id": "<uuid>", "outcome": "success"|"failure"|"partial",
|
||||
"reason": "<one-line>"}
|
||||
|
||||
Emitted by the AI when the engineer clearly indicates in chat that a
|
||||
prior suggested fix worked, didn't work, or was partially applied.
|
||||
The marker PROPOSES an outcome — the engineer confirms via the UI.
|
||||
Only the last block in a response is honored.
|
||||
"""
|
||||
blocks = list(re.finditer(
|
||||
r"\[FIX_OUTCOME\]\s*([\s\S]*?)\s*\[/FIX_OUTCOME\]", ai_content,
|
||||
))
|
||||
if not blocks:
|
||||
return ai_content, None
|
||||
|
||||
last = blocks[-1]
|
||||
raw = last.group(1).strip()
|
||||
if raw.startswith("```"):
|
||||
raw = re.sub(r"^```(?:json)?\s*", "", raw)
|
||||
raw = re.sub(r"\s*```$", "", raw)
|
||||
|
||||
cleaned = re.sub(
|
||||
r"\[FIX_OUTCOME\]\s*[\s\S]*?\s*\[/FIX_OUTCOME\]", "", ai_content,
|
||||
).strip()
|
||||
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
logger.warning("Failed to parse [FIX_OUTCOME] block: %s", e)
|
||||
return cleaned, None
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return cleaned, None
|
||||
|
||||
fix_id = str(data.get("fix_id") or "").strip()
|
||||
outcome = str(data.get("outcome") or "").strip().lower()
|
||||
reason = str(data.get("reason") or "").strip()
|
||||
|
||||
if not fix_id or outcome not in {"success", "failure", "partial"}:
|
||||
logger.warning("[FIX_OUTCOME] missing/invalid fields, dropping")
|
||||
return cleaned, None
|
||||
|
||||
return cleaned, {"fix_id": fix_id, "outcome": outcome, "reason": reason}
|
||||
|
||||
|
||||
async def _persist_suggested_fix(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
@@ -415,6 +465,39 @@ async def _persist_suggested_fix(
|
||||
await db.flush()
|
||||
|
||||
|
||||
async def _record_ai_outcome_proposal(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
session: AISession,
|
||||
proposal: dict[str, Any],
|
||||
) -> None:
|
||||
"""Persist the AI's proposed outcome on the active fix.
|
||||
|
||||
Writes to session_suggested_fixes.ai_outcome_proposal. Frontend polls
|
||||
the active fix and renders the AI-confirming banner state when this is
|
||||
non-null. Does NOT mutate the fix's status — the engineer's confirmation
|
||||
click via PATCH /outcome is what changes the status.
|
||||
|
||||
Drops silently when the fix_id isn't a valid UUID or doesn't belong to
|
||||
this session.
|
||||
"""
|
||||
try:
|
||||
fix_uuid = UUID(proposal["fix_id"])
|
||||
except (ValueError, KeyError, TypeError):
|
||||
logger.warning("[FIX_OUTCOME] invalid fix_id, dropping")
|
||||
return
|
||||
|
||||
await db.execute(
|
||||
update(SessionSuggestedFix)
|
||||
.where(
|
||||
SessionSuggestedFix.id == fix_uuid,
|
||||
SessionSuggestedFix.session_id == session.id,
|
||||
)
|
||||
.values(ai_outcome_proposal=proposal)
|
||||
)
|
||||
await db.flush()
|
||||
|
||||
|
||||
async def _persist_promote_items(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
@@ -566,6 +649,7 @@ async def send_chat_message(
|
||||
branch_display, branch_questions_data = _parse_questions_marker(branch_display)
|
||||
branch_display, branch_promote_items = _parse_promote_marker(branch_display)
|
||||
branch_display, branch_suggest_fix = _parse_suggest_fix_marker(branch_display)
|
||||
branch_display, branch_outcome_proposal = _parse_fix_outcome_marker(branch_display)
|
||||
if branch_display != ai_content:
|
||||
# Store stripped content in branch history
|
||||
msgs[-1] = {"role": "assistant", "content": branch_display}
|
||||
@@ -629,6 +713,12 @@ async def send_chat_message(
|
||||
db=db, session=session, fix=branch_suggest_fix,
|
||||
)
|
||||
|
||||
# Persist a [FIX_OUTCOME] proposal if the branch turn included one.
|
||||
if branch_outcome_proposal is not None:
|
||||
await _record_ai_outcome_proposal(
|
||||
db=db, session=session, proposal=branch_outcome_proposal,
|
||||
)
|
||||
|
||||
suggested_flows = extract_suggested_flows(
|
||||
await rag_search(query=message, account_id=account_id, db=db, limit=8)
|
||||
)
|
||||
@@ -681,11 +771,16 @@ async def send_chat_message(
|
||||
# Check for a [SUGGEST_FIX] marker — supersedes the prior active fix.
|
||||
display_content, suggest_fix_data = _parse_suggest_fix_marker(display_content)
|
||||
|
||||
# Check for a [FIX_OUTCOME] proposal — AI confirms a prior fix's outcome.
|
||||
display_content, outcome_proposal = _parse_fix_outcome_marker(display_content)
|
||||
|
||||
logger.info(
|
||||
"Marker parsing results — actions: %s, questions: %s, fork: %s, "
|
||||
"promote: %d, suggest_fix: %s, raw_length: %d, display_length: %d",
|
||||
"promote: %d, suggest_fix: %s, outcome_proposal: %s, "
|
||||
"raw_length: %d, display_length: %d",
|
||||
bool(actions_data), bool(questions_data), bool(fork_data),
|
||||
len(promote_items or []), bool(suggest_fix_data),
|
||||
bool(outcome_proposal),
|
||||
len(ai_content), len(display_content),
|
||||
)
|
||||
|
||||
@@ -774,6 +869,12 @@ async def send_chat_message(
|
||||
if suggest_fix_data:
|
||||
await _persist_suggested_fix(db=db, session=session, fix=suggest_fix_data)
|
||||
|
||||
# Persist a [FIX_OUTCOME] proposal if this turn included one.
|
||||
if outcome_proposal is not None:
|
||||
await _record_ai_outcome_proposal(
|
||||
db=db, session=session, proposal=outcome_proposal,
|
||||
)
|
||||
|
||||
suggested_flows = extract_suggested_flows(rag_results)
|
||||
|
||||
return display_content, suggested_flows, session, fork_metadata, actions_data, questions_data
|
||||
|
||||
536
backend/tests/test_fix_outcome_endpoint.py
Normal file
536
backend/tests/test_fix_outcome_endpoint.py
Normal file
@@ -0,0 +1,536 @@
|
||||
"""Integration tests for PATCH /ai-sessions/{sid}/suggested-fixes/{fid}/outcome.
|
||||
|
||||
Fixture style follows test_session_suggested_fixes_api.py:
|
||||
client, test_user, auth_headers, test_db
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, call, patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.api.endpoints.session_suggested_fixes import _clear_preview_cache_for_tests
|
||||
from app.models.ai_session import AISession
|
||||
from app.models.session_suggested_fix import SessionSuggestedFix
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_preview_cache():
|
||||
_clear_preview_cache_for_tests()
|
||||
yield
|
||||
_clear_preview_cache_for_tests()
|
||||
|
||||
|
||||
# ── shared helper ────────────────────────────────────────────────────────────
|
||||
|
||||
async def _make_session_with_fix(test_db, user) -> tuple[str, str]:
|
||||
"""Create an AISession + active proposed SessionSuggestedFix.
|
||||
|
||||
Returns (session_id_str, fix_id_str).
|
||||
"""
|
||||
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": "outcome test"},
|
||||
status="active",
|
||||
confidence_tier="discovery",
|
||||
conversation_messages=[],
|
||||
)
|
||||
test_db.add(session)
|
||||
await test_db.flush()
|
||||
|
||||
fix = SessionSuggestedFix(
|
||||
session_id=session.id,
|
||||
account_id=session.account_id,
|
||||
title="Reset credential cache",
|
||||
description="Clear stale credentials from the domain cache.",
|
||||
confidence_pct=82,
|
||||
)
|
||||
test_db.add(fix)
|
||||
await test_db.commit()
|
||||
await test_db.refresh(fix)
|
||||
|
||||
return str(session.id), str(fix.id)
|
||||
|
||||
|
||||
# ── tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_outcome_marks_success(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
|
||||
|
||||
r = await client.patch(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||
headers=auth_headers,
|
||||
json={"outcome": "applied_success"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["status"] == "applied_success"
|
||||
assert body["verified_at"] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_outcome_partial_requires_notes(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
|
||||
|
||||
r = await client.patch(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||
headers=auth_headers,
|
||||
json={"outcome": "applied_partial"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "notes" in r.text.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_partial_to_success_allowed(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
|
||||
|
||||
r1 = await client.patch(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||
headers=auth_headers,
|
||||
json={"outcome": "applied_partial", "notes": "ran cred clear only"},
|
||||
)
|
||||
assert r1.status_code == 200, r1.text
|
||||
|
||||
r2 = await client.patch(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||
headers=auth_headers,
|
||||
json={"outcome": "applied_success"},
|
||||
)
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["status"] == "applied_success"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_terminal_outcome_is_locked(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
|
||||
|
||||
r1 = await client.patch(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||
headers=auth_headers,
|
||||
json={"outcome": "applied_failed", "notes": "no change"},
|
||||
)
|
||||
assert r1.status_code == 200
|
||||
|
||||
r2 = await client.patch(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||
headers=auth_headers,
|
||||
json={"outcome": "applied_success"},
|
||||
)
|
||||
assert r2.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_partial_notes_can_be_updated(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
"""partial→partial with new notes updates the stored notes."""
|
||||
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
|
||||
|
||||
r1 = await client.patch(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||
json={"outcome": "applied_partial", "notes": "ran cred clear only"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert r1.status_code == 200
|
||||
assert r1.json()["partial_notes"] == "ran cred clear only"
|
||||
|
||||
r2 = await client.patch(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||
json={"outcome": "applied_partial", "notes": "also finished the rebuild; not verified yet"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["partial_notes"] == "also finished the rebuild; not verified yet"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dismissed_sets_no_timestamps(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
"""dismissed outcome does not stamp applied_at or verified_at."""
|
||||
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
|
||||
r = await client.patch(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||
json={"outcome": "dismissed"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["status"] == "dismissed"
|
||||
assert body["applied_at"] is None
|
||||
assert body["verified_at"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_applied_at_auto_stamped_on_first_outcome(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
"""If applied_at is null when the engineer sets outcome, server stamps it."""
|
||||
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
|
||||
r = await client.patch(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||
json={"outcome": "applied_success"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["applied_at"] is not None
|
||||
assert body["verified_at"] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_outcome_stores_notes_as_failure_reason(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
"""applied_failed stores notes under failure_reason (not partial_notes)."""
|
||||
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
|
||||
r = await client.patch(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||
json={"outcome": "applied_failed", "notes": "user reports no change"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["failure_reason"] == "user reports no change"
|
||||
assert body["partial_notes"] is None
|
||||
|
||||
|
||||
# ── state_version bump ────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_outcome_patch_bumps_state_version(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
"""PATCH /outcome must increment ai_sessions.state_version (like record_decision)."""
|
||||
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
|
||||
|
||||
# Capture the initial state_version from DB.
|
||||
from uuid import UUID
|
||||
result = await test_db.execute(
|
||||
select(AISession).where(AISession.id == UUID(session_id))
|
||||
)
|
||||
session_obj = result.scalar_one()
|
||||
initial_version = session_obj.state_version
|
||||
|
||||
r = await client.patch(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||
json={"outcome": "applied_success"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
await test_db.refresh(session_obj)
|
||||
assert session_obj.state_version == initial_version + 1, (
|
||||
"Outcome patch must bump state_version so preview cache is invalidated"
|
||||
)
|
||||
|
||||
|
||||
# ── outcome propagation into preview bundle ───────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolution_note_preview_reflects_outcome_after_patch(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
"""End-to-end: preview before outcome != preview after outcome; new preview
|
||||
bundle includes failure_reason; state_version was bumped between the two.
|
||||
|
||||
The LLM is stubbed so the test is deterministic. The stub returns whatever
|
||||
the user-message content is, which means the captured call args reflect
|
||||
what the bundle actually contained.
|
||||
"""
|
||||
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
|
||||
|
||||
distinct_failure_reason = "DISTINCT-FAILURE-REASON-XYZZY-42"
|
||||
|
||||
calls_made: list[str] = []
|
||||
|
||||
async def fake_generate_text(system_prompt, messages, max_tokens):
|
||||
user_content = messages[0]["content"]
|
||||
calls_made.append(user_content)
|
||||
# Return markdown that includes the user-message bundle verbatim so we
|
||||
# can assert the bundle shape without inspecting mock internals.
|
||||
return (
|
||||
f"## Problem\ntest\n\n## What we confirmed\n(none)\n\n"
|
||||
f"## Root cause\ntest\n\n## Resolution\nBUNDLE_CONTENT={user_content}",
|
||||
100,
|
||||
50,
|
||||
)
|
||||
|
||||
fake_provider = AsyncMock()
|
||||
fake_provider.generate_text = AsyncMock(side_effect=fake_generate_text)
|
||||
|
||||
with patch(
|
||||
"app.services.resolution_note_generator.get_ai_provider",
|
||||
return_value=fake_provider,
|
||||
):
|
||||
# Preview A — before any outcome recorded (status = "proposed").
|
||||
r_a = await client.post(
|
||||
f"/api/v1/ai-sessions/{session_id}/resolution-note/preview",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert r_a.status_code == 200
|
||||
markdown_a = r_a.json()["markdown"]
|
||||
version_a = r_a.json()["state_version"]
|
||||
assert r_a.json()["from_cache"] is False
|
||||
|
||||
# Record an applied_failed outcome with a distinctive reason.
|
||||
r_patch = await client.patch(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||
json={"outcome": "applied_failed", "notes": distinct_failure_reason},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert r_patch.status_code == 200
|
||||
|
||||
# Preview B — must be a cache miss because state_version changed.
|
||||
r_b = await client.post(
|
||||
f"/api/v1/ai-sessions/{session_id}/resolution-note/preview",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert r_b.status_code == 200
|
||||
markdown_b = r_b.json()["markdown"]
|
||||
version_b = r_b.json()["state_version"]
|
||||
assert r_b.json()["from_cache"] is False, (
|
||||
"Preview after outcome patch must be a cache miss (state_version changed)"
|
||||
)
|
||||
|
||||
# State version increased between the two previews.
|
||||
assert version_b > version_a, (
|
||||
f"state_version should have increased; got {version_a} → {version_b}"
|
||||
)
|
||||
|
||||
# Markdown differs between the two previews.
|
||||
assert markdown_a != markdown_b, (
|
||||
"Regenerated preview after outcome patch should differ from pre-outcome preview"
|
||||
)
|
||||
|
||||
# The bundle passed to the LLM for preview B includes the outcome fields.
|
||||
assert len(calls_made) == 2, f"Expected 2 LLM calls (one per preview); got {len(calls_made)}"
|
||||
bundle_b = calls_made[1]
|
||||
assert "applied_failed" in bundle_b, (
|
||||
"Bundle for second preview should include 'Outcome status: applied_failed'"
|
||||
)
|
||||
assert distinct_failure_reason in bundle_b, (
|
||||
"Bundle for second preview should include the failure_reason text"
|
||||
)
|
||||
|
||||
|
||||
# ── Apply endpoint ─────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_stamps_applied_at(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
"""POST /apply stamps applied_at and bumps state_version."""
|
||||
from uuid import UUID
|
||||
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
|
||||
|
||||
result = await test_db.execute(
|
||||
select(AISession).where(AISession.id == UUID(session_id))
|
||||
)
|
||||
session_obj = result.scalar_one()
|
||||
initial_version = session_obj.state_version
|
||||
|
||||
r = await client.post(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/apply",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["applied_at"] is not None, "applied_at must be set after /apply"
|
||||
assert body["status"] == "proposed", "status must remain 'proposed' after /apply"
|
||||
|
||||
await test_db.refresh(session_obj)
|
||||
assert session_obj.state_version == initial_version + 1, (
|
||||
"/apply must bump state_version so preview cache is invalidated"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_is_idempotent(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
"""Second POST /apply returns 200 with applied_at unchanged (no double-bump)."""
|
||||
from uuid import UUID
|
||||
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
|
||||
|
||||
r1 = await client.post(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/apply",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert r1.status_code == 200, r1.text
|
||||
applied_at_first = r1.json()["applied_at"]
|
||||
|
||||
result = await test_db.execute(
|
||||
select(AISession).where(AISession.id == UUID(session_id))
|
||||
)
|
||||
session_obj = result.scalar_one()
|
||||
version_after_first = session_obj.state_version
|
||||
|
||||
r2 = await client.post(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/apply",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert r2.status_code == 200, r2.text
|
||||
assert r2.json()["applied_at"] == applied_at_first, (
|
||||
"applied_at must not change on second /apply call"
|
||||
)
|
||||
|
||||
await test_db.refresh(session_obj)
|
||||
assert session_obj.state_version == version_after_first, (
|
||||
"state_version must not be bumped a second time on idempotent /apply"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_rejects_non_proposed(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
"""POST /apply returns 409 when fix status is 'applied_success'."""
|
||||
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
|
||||
|
||||
# Advance the fix to a terminal status via the outcome endpoint.
|
||||
r_outcome = await client.patch(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||
headers=auth_headers,
|
||||
json={"outcome": "applied_success"},
|
||||
)
|
||||
assert r_outcome.status_code == 200
|
||||
|
||||
r = await client.post(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/apply",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert r.status_code == 409, r.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_rejects_dismissed(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
"""POST /apply returns 409 when fix status is 'dismissed'."""
|
||||
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
|
||||
|
||||
r_outcome = await client.patch(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||
headers=auth_headers,
|
||||
json={"outcome": "dismissed"},
|
||||
)
|
||||
assert r_outcome.status_code == 200
|
||||
|
||||
r = await client.post(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/apply",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert r.status_code == 409, r.text
|
||||
|
||||
|
||||
# ── AI outcome proposal: clear / reject ───────────────────────────────────────
|
||||
|
||||
async def _make_session_with_fix_and_proposal(test_db, user) -> tuple[str, str]:
|
||||
"""Create an AISession + fix with a populated ai_outcome_proposal."""
|
||||
from uuid import UUID as _UUID
|
||||
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": "proposal clear test"},
|
||||
status="active",
|
||||
confidence_tier="discovery",
|
||||
conversation_messages=[],
|
||||
)
|
||||
test_db.add(session)
|
||||
await test_db.flush()
|
||||
|
||||
fix = SessionSuggestedFix(
|
||||
session_id=session.id,
|
||||
account_id=session.account_id,
|
||||
title="Flush DNS cache",
|
||||
description="Run ipconfig /flushdns on the affected host.",
|
||||
confidence_pct=74,
|
||||
ai_outcome_proposal={"fix_id": str(session.id), "outcome": "success", "reason": "User confirmed resolved"},
|
||||
)
|
||||
test_db.add(fix)
|
||||
await test_db.commit()
|
||||
await test_db.refresh(fix)
|
||||
|
||||
return str(session.id), str(fix.id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_outcome_patch_clears_ai_proposal(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
"""PATCH /outcome clears ai_outcome_proposal regardless of which outcome is written."""
|
||||
session_id, fix_id = await _make_session_with_fix_and_proposal(test_db, test_user)
|
||||
|
||||
# Verify the proposal is set before the patch.
|
||||
from uuid import UUID
|
||||
result = await test_db.execute(
|
||||
select(SessionSuggestedFix).where(SessionSuggestedFix.id == UUID(fix_id))
|
||||
)
|
||||
fix_before = result.scalar_one()
|
||||
assert fix_before.ai_outcome_proposal is not None
|
||||
|
||||
r = await client.patch(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||
headers=auth_headers,
|
||||
json={"outcome": "applied_success"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["ai_outcome_proposal"] is None, (
|
||||
"PATCH /outcome must clear ai_outcome_proposal on any terminal action"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_ai_proposal_clears_field(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
"""DELETE /ai-outcome-proposal clears the field without changing status."""
|
||||
session_id, fix_id = await _make_session_with_fix_and_proposal(test_db, test_user)
|
||||
|
||||
r = await client.delete(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/ai-outcome-proposal",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["ai_outcome_proposal"] is None, (
|
||||
"DELETE /ai-outcome-proposal must clear the field"
|
||||
)
|
||||
assert body["status"] == "proposed", (
|
||||
"DELETE /ai-outcome-proposal must not change fix status"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_ai_proposal_when_none_is_idempotent(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
"""DELETE /ai-outcome-proposal returns 200 even when the field is already null."""
|
||||
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
|
||||
|
||||
# Fix created by _make_session_with_fix has ai_outcome_proposal=None.
|
||||
r = await client.delete(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/ai-outcome-proposal",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["ai_outcome_proposal"] is None
|
||||
91
backend/tests/test_fix_outcome_marker.py
Normal file
91
backend/tests/test_fix_outcome_marker.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Unit tests for the [FIX_OUTCOME] marker parser."""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.services.unified_chat_service import _parse_fix_outcome_marker
|
||||
|
||||
|
||||
def test_parses_success_outcome():
|
||||
ai = (
|
||||
"Great news — that confirms the root cause.\n\n"
|
||||
"[FIX_OUTCOME]\n"
|
||||
'{"fix_id":"11111111-1111-1111-1111-111111111111",'
|
||||
'"outcome":"success","reason":"user said the fix worked"}\n'
|
||||
"[/FIX_OUTCOME]\n"
|
||||
)
|
||||
cleaned, parsed = _parse_fix_outcome_marker(ai)
|
||||
assert "[FIX_OUTCOME]" not in cleaned
|
||||
assert "confirms the root cause" in cleaned
|
||||
assert parsed == {
|
||||
"fix_id": "11111111-1111-1111-1111-111111111111",
|
||||
"outcome": "success",
|
||||
"reason": "user said the fix worked",
|
||||
}
|
||||
|
||||
|
||||
def test_parses_failure_outcome():
|
||||
ai = (
|
||||
"[FIX_OUTCOME]\n"
|
||||
'{"fix_id":"22222222-2222-2222-2222-222222222222",'
|
||||
'"outcome":"failure","reason":"user reports still broken"}\n'
|
||||
"[/FIX_OUTCOME]"
|
||||
)
|
||||
cleaned, parsed = _parse_fix_outcome_marker(ai)
|
||||
assert "[FIX_OUTCOME]" not in cleaned
|
||||
assert parsed["outcome"] == "failure"
|
||||
|
||||
|
||||
def test_missing_marker_returns_none():
|
||||
ai = "no marker here"
|
||||
cleaned, parsed = _parse_fix_outcome_marker(ai)
|
||||
assert cleaned == ai
|
||||
assert parsed is None
|
||||
|
||||
|
||||
def test_invalid_json_is_dropped():
|
||||
ai = "[FIX_OUTCOME]\nnot-json\n[/FIX_OUTCOME]"
|
||||
cleaned, parsed = _parse_fix_outcome_marker(ai)
|
||||
assert "[FIX_OUTCOME]" not in cleaned
|
||||
assert parsed is None
|
||||
|
||||
|
||||
def test_unknown_outcome_rejected():
|
||||
ai = (
|
||||
"[FIX_OUTCOME]\n"
|
||||
'{"fix_id":"33333333-3333-3333-3333-333333333333",'
|
||||
'"outcome":"maybe","reason":"x"}\n'
|
||||
"[/FIX_OUTCOME]"
|
||||
)
|
||||
_, parsed = _parse_fix_outcome_marker(ai)
|
||||
assert parsed is None
|
||||
|
||||
|
||||
def test_last_block_wins_when_multiple():
|
||||
ai = (
|
||||
"[FIX_OUTCOME]\n"
|
||||
'{"fix_id":"44444444-4444-4444-4444-444444444444",'
|
||||
'"outcome":"failure","reason":"first"}\n'
|
||||
"[/FIX_OUTCOME]\n"
|
||||
"[FIX_OUTCOME]\n"
|
||||
'{"fix_id":"55555555-5555-5555-5555-555555555555",'
|
||||
'"outcome":"success","reason":"second"}\n'
|
||||
"[/FIX_OUTCOME]"
|
||||
)
|
||||
cleaned, parsed = _parse_fix_outcome_marker(ai)
|
||||
assert "[FIX_OUTCOME]" not in cleaned
|
||||
assert parsed["fix_id"] == "55555555-5555-5555-5555-555555555555"
|
||||
assert parsed["outcome"] == "success"
|
||||
|
||||
|
||||
def test_parses_partial_outcome():
|
||||
ai = (
|
||||
"[FIX_OUTCOME]\n"
|
||||
'{"fix_id":"66666666-6666-6666-6666-666666666666",'
|
||||
'"outcome":"partial","reason":"user ran cred clear only"}\n'
|
||||
"[/FIX_OUTCOME]"
|
||||
)
|
||||
_, parsed = _parse_fix_outcome_marker(ai)
|
||||
assert parsed == {
|
||||
"fix_id": "66666666-6666-6666-6666-666666666666",
|
||||
"outcome": "partial",
|
||||
"reason": "user ran cred clear only",
|
||||
}
|
||||
120
backend/tests/test_fix_script_endpoint.py
Normal file
120
backend/tests/test_fix_script_endpoint.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""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 UUID, uuid4
|
||||
|
||||
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["user_data"]["id"],
|
||||
account_id=user["user_data"]["account_id"],
|
||||
session_type="tshoot",
|
||||
intake_type="psa_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["user_data"]["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 == 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 == 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
|
||||
@@ -87,7 +87,7 @@ _FORBIDDEN_LITERAL_TOKENS: tuple[str, ...] = (
|
||||
# so prose blocks (like the closing-tag-distance regex match across
|
||||
# markdown headings) are excluded
|
||||
_MARKER_BLOCK_RE = re.compile(
|
||||
r"(?:^|\n)\[(QUESTIONS|ACTIONS|SUGGEST_FIX|PROMOTE|FORK|TREE_UPDATE|STEPS_UPDATE|INTAKE_FORM|METADATA|DELTA)\]"
|
||||
r"(?:^|\n)\[(QUESTIONS|ACTIONS|SUGGEST_FIX|FIX_OUTCOME|PROMOTE|FORK|TREE_UPDATE|STEPS_UPDATE|INTAKE_FORM|METADATA|DELTA)\]"
|
||||
r"\s*\n" # forced newline before content
|
||||
r"(\s*[\[{][\s\S]*?)" # content must start with [ or {
|
||||
r"\s*\n\[/\1\]"
|
||||
|
||||
176
backend/tests/test_script_builder_inline.py
Normal file
176
backend/tests/test_script_builder_inline.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""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.
|
||||
`user` is the dict returned by the test_user fixture:
|
||||
{"email": ..., "password": ..., "user_data": {"id": ..., "account_id": ..., ...}}
|
||||
"""
|
||||
user_id = user["user_data"]["id"]
|
||||
account_id = user["user_data"]["account_id"]
|
||||
session = AISession(
|
||||
id=uuid4(), user_id=user_id, account_id=account_id,
|
||||
session_type="tshoot", intake_type="psa_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/scripts/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/scripts/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["user_data"]["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/scripts/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", display_code="OTH-0001")
|
||||
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()
|
||||
# Build user dict in the same shape as the test_user fixture
|
||||
other_user_dict = {
|
||||
"user_data": {"id": str(other_user.id), "account_id": str(other_account.id)}
|
||||
}
|
||||
other_session_id = await _make_pilot_session(test_db, other_user_dict)
|
||||
|
||||
r = await client.post(
|
||||
"/api/v1/scripts/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 /scripts/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/scripts/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/scripts/builder/sessions",
|
||||
json={"language": "powershell"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
|
||||
r = await client.get("/api/v1/scripts/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", []))
|
||||
# Response schema does not surface `origin`; len==1 is the only meaningful guard:
|
||||
# inline row would push this to 2.
|
||||
assert len(items) == 1
|
||||
|
||||
|
||||
@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/scripts/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/scripts/builder/sessions",
|
||||
json={"language": "powershell"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert r.status_code in (200, 201), r.text
|
||||
@@ -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–7 implemented. Phase 7 delivered polish: fact-synthesis loading indicator in `WhatWeKnow`, "thinking" pip in the task-lane header, quiet-state hint when questions/checks/fix are all absent, keyboard shortcuts (`⌘K` palette already present, `⌘↵` send, `⌘G` toggle script panel, `?` help overlay), and responsive bottom-drawer lane on viewports <1200px with a floating "Tasks" toggle. `tsc -b` and `npm run build` both clean.
|
||||
> **Last updated:** April 22, 2026 (Phase 6 — post-resolve TemplatizePrompt — committed; draft accept → script_templates promotion with provenance verified live)
|
||||
> **Status:** Phases 0–9 implemented. Phase 9 shipped the tabbed Script Builder integration (chat-region tab strip, `ScriptBuilderTab` controller with AI + Monaco editor modes, `InlineNoTemplateDialog` chat-region relocation, `PATCH /script` endpoint, `origin` discriminator migration reusing the existing `ai_session_id` FK, `applied_at` semantics correction, and `EscalateInterceptDialog` fourth "partial" choice). `tsc -b` and `npm run build` both clean.
|
||||
> **Last updated:** April 24, 2026 (Phase 9 — Tabbed Script Builder — committed; handoff and migration spec updated)
|
||||
|
||||
---
|
||||
|
||||
@@ -891,6 +891,56 @@ git commit -m "feat(pilot): add post-resolve templatize prompt for draft templat
|
||||
git commit -m "feat(pilot): visual polish, empty/loading states, keyboard shortcuts"
|
||||
```
|
||||
|
||||
### Phase 8 — Fix Outcome Banner
|
||||
|
||||
**Plan and rationale:** [phase-8-fix-outcome-banner.md](phase-8-fix-outcome-banner.md)
|
||||
|
||||
**Mockups:** [mockups/06-slide-up-banner.html](mockups/06-slide-up-banner.html), [mockups/07-verify-states.html](mockups/07-verify-states.html)
|
||||
|
||||
**What this phase does:** Removes the `SuggestedFix` card as the primary interaction point for fix application. Replaces it with a chat-composer-anchored slide-up banner (`ProposalBanner`) that stays visible at the bottom of the conversation column regardless of task-lane scroll depth. Addresses the user-reported discoverability problem: *"the task lane fills up pretty quick … the suggested fix … is easily missed."*
|
||||
|
||||
**Key backend additions:**
|
||||
- Six new columns on `session_suggested_fixes`: `status`, `applied_at`, `verified_at`, `partial_notes`, `failure_reason`, `ai_outcome_proposal`
|
||||
- `PATCH /api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome` endpoint to record the engineer's decision
|
||||
- `[FIX_OUTCOME]` marker in the FlowPilot system prompt, parsed by `unified_chat_service.py` to trigger the banner
|
||||
|
||||
**Key frontend additions:**
|
||||
- `ProposalBanner` component (`frontend/src/components/pilot/ProposalBanner.tsx`) — slide-up banner anchored above the chat composer; shows fix title, confidence, and Accept / Dismiss / Escalate actions; auto-collapses after session resolves
|
||||
- `EscalateInterceptDialog` — intercepts the Escalate action when a fix proposal is active, asking whether the engineer wants to note that the fix was attempted before escalating
|
||||
|
||||
**Commit range:** `cdd8bb0` (Phase 8 Task 1 start) through `8582d24`
|
||||
|
||||
```
|
||||
git commit -m "feat(pilot): Phase 8 — fix outcome banner replaces task-lane SuggestedFix CTA"
|
||||
```
|
||||
|
||||
### Phase 9 — Tabbed Script Builder
|
||||
|
||||
**Spec:** [phase-9-script-builder-tab.md](phase-9-script-builder-tab.md)
|
||||
|
||||
**Implementation plan:** [phase-9-implementation-plan.md](phase-9-implementation-plan.md)
|
||||
|
||||
**What this phase does:** Resolves open items #1 (NoTemplateDialog narrow-lane bug) and #3 (Tabbed Script Builder) from the Phase 6/7 backlog. The chat region gains a `[Chat] [Script Builder ●]` tab strip (`ChatTabStrip` + a new `ScriptBuilderTab` controller) that hosts two modes: an AI path reusing the existing (untouched) `ScriptBuilderChat`, and a "Write it myself" path using `ScriptBodyEditor` (Monaco). Engineer submit writes the drafted script back to `session_suggested_fixes.ai_drafted_script` via a new PATCH endpoint — `applied_at` is NOT stamped (a draft is not an application). Tabs use `display: none` toggling so chat scroll position, draft message, AI history, and Monaco buffer are all preserved across switches. `InlineNoTemplateDialog` is relocated from the task-lane `bottomSlot` into a dedicated chat-region placement wrapper, eliminating the narrow-lane viewport-breakpoint collision that made the three-option grid unusable.
|
||||
|
||||
**Key backend additions:**
|
||||
- `PATCH /api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/script` — writes `ai_drafted_script` + `ai_drafted_parameters` without stamping `applied_at`; bumps `state_version` so Resolve/Escalate preview bundles regenerate; 409 on terminal fix status
|
||||
- Alembic migration adds `origin VARCHAR(20) NOT NULL DEFAULT 'standalone'` to `script_builder_sessions` (CHECK enum `'standalone'|'pilot_inline'` + invariant `origin='pilot_inline' ⇒ ai_session_id IS NOT NULL`); reuses the pre-existing `ai_session_id` FK rather than adding a new parent column; partial unique index `ux_script_builder_sessions_pilot_inline` on `(user_id, ai_session_id) WHERE origin='pilot_inline'` backs get-or-create idempotency
|
||||
- `POST /api/v1/scripts/builder/sessions` extended: accepts `origin` + `ai_session_id` with auth (pilot session must belong to caller); returns existing row on duplicate; race-safe via `IntegrityError` + re-read fallback; `list_sessions` and `count_user_sessions` default-scope to `origin='standalone'` so inline sessions don't pollute the dashboard or count against the 5-session cap
|
||||
- `applied_at` semantics corrected: stamps only on run-declaring actions — `TemplateMatchPanel` "I ran this" click via new `onMarkRun` prop, and `NoTemplateDialog` decisions `one_off`/`draft_template` (both labelled "Run now, …"). `build_template` does NOT stamp. Script Builder tab Submit does NOT stamp. Banner `Apply` click no longer stamps directly
|
||||
|
||||
**Key frontend additions:**
|
||||
- `ChatTabStrip` — `[Chat] [Script Builder ●]` header strip in the chat region when the active fix needs a drafted script (status proposed/applied_partial, no template, no drafted script)
|
||||
- `ScriptBuilderTab` — new controller wrapping `ScriptBuilderChat` (AI mode) + `ScriptBodyEditor` (Monaco, "Write it myself" mode); get-or-create on mount; Submit calls `sessionSuggestedFixesApi.patchScript`
|
||||
- `InlineNoTemplateDialog` — chat-region slide-up wrapper around the existing `NoTemplateDialog`; replaces the previous task-lane `bottomSlot` rendering of the drafted-script three-card decision
|
||||
- `TemplateMatchPanel` gains `onMarkRun` optional prop + "✓ I ran this" primary button
|
||||
- `EscalateInterceptDialog` gains a fourth "I applied some of it — partial" choice (dispatches `applied_partial` via the existing `FixOutcome` pass-through)
|
||||
|
||||
**Commit range:** `5bcb7aa` (Phase 9 Task 1 start) through `faf1d8d`
|
||||
|
||||
```
|
||||
git commit -m "feat(pilot): Phase 9 — tabbed Script Builder + InlineNoTemplateDialog relocation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Design system reference
|
||||
|
||||
165
docs/FlowAssist_Migration/Issues/phase-8-review-issues.md
Normal file
165
docs/FlowAssist_Migration/Issues/phase-8-review-issues.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# Phase 8 Review Issues
|
||||
|
||||
Date: 2026-04-23
|
||||
|
||||
Scope reviewed:
|
||||
- `backend/app/api/endpoints/session_suggested_fixes.py`
|
||||
- `backend/app/services/unified_chat_service.py`
|
||||
- `frontend/src/pages/AssistantChatPage.tsx`
|
||||
- `frontend/src/components/pilot/ProposalBanner.tsx`
|
||||
- `frontend/src/components/pilot/EscalateInterceptDialog.tsx`
|
||||
|
||||
## 1. Outcome writes do not invalidate Resolve/Escalate preview cache
|
||||
|
||||
Severity: High
|
||||
|
||||
`PATCH /suggested-fixes/{fix_id}/outcome` updates the fix row but does not bump
|
||||
`ai_sessions.state_version`. Even after adding that bump, the preview input
|
||||
bundle also needs to include the fix outcome fields; otherwise a regenerated
|
||||
preview still cannot distinguish proposed, partially applied, failed, or
|
||||
successful fixes.
|
||||
|
||||
Relevant files:
|
||||
- `backend/app/api/endpoints/session_suggested_fixes.py:226`
|
||||
- `backend/app/api/endpoints/session_suggested_fixes.py:146`
|
||||
- `backend/app/services/resolution_note_generator.py:13`
|
||||
- `backend/app/services/escalation_package_generator.py:14`
|
||||
|
||||
Why this matters:
|
||||
- Resolve and Escalate previews are cached by `(session_id, state_version)`.
|
||||
- The decision endpoint already bumps `state_version`.
|
||||
- The new outcome endpoint does not.
|
||||
- A user can record `applied_success` / `applied_failed` / `applied_partial`
|
||||
and still see markdown generated from the pre-outcome session state.
|
||||
- The preview generators currently pass only the active fix title,
|
||||
confidence, description, and user decision into the LLM bundle. They do not
|
||||
pass `status`, `applied_at`, `verified_at`, `partial_notes`, or
|
||||
`failure_reason`.
|
||||
- Therefore a cache miss alone is not enough: the generated markdown may still
|
||||
describe the fix as merely proposed because the outcome is absent from the
|
||||
prompt input.
|
||||
|
||||
Recommended fix:
|
||||
- Bump `AISession.state_version` inside the outcome endpoint transaction.
|
||||
- Include suggested-fix outcome state in both preview bundles:
|
||||
- `status`
|
||||
- `applied_at`
|
||||
- `verified_at`
|
||||
- `partial_notes`
|
||||
- `failure_reason`
|
||||
- Update the resolution-note prompt expectations so `applied_success` produces
|
||||
closure language, `applied_failed` states that the proposed fix did not
|
||||
resolve the issue, and `applied_partial` includes the engineer's partial
|
||||
notes.
|
||||
- Update the escalation-package prompt expectations so failed/partial outcomes
|
||||
appear under "What we've tried" and inform "Suggested next steps."
|
||||
- Add a test proving a preview generated before an outcome change is
|
||||
invalidated after the outcome patch and that the regenerated preview input
|
||||
includes the recorded outcome.
|
||||
|
||||
## 2. "Apply" is not persisted, so Verifying state is lost on reload/reselect
|
||||
|
||||
Severity: High
|
||||
|
||||
Phase 8 introduces a Verifying lifecycle in the UI, but clicking Apply only
|
||||
sets local React state.
|
||||
|
||||
Relevant files:
|
||||
- `frontend/src/pages/AssistantChatPage.tsx:142`
|
||||
- `frontend/src/pages/AssistantChatPage.tsx:516`
|
||||
- `backend/app/api/endpoints/session_suggested_fixes.py:276`
|
||||
|
||||
Why this matters:
|
||||
- `bannerApplied` is a client-side-only flag.
|
||||
- `handleApplyFix()` opens the script panel and flips local state, but does not
|
||||
persist anything.
|
||||
- `applied_at` is only stamped later when an outcome is patched.
|
||||
- After refresh, chat reselect, or multi-tab use, a fix that had entered
|
||||
Verifying falls back to `proposed`.
|
||||
- Nudge timing, resolve auto-success, and escalate interception therefore do
|
||||
not survive normal session resume.
|
||||
|
||||
Recommended fix:
|
||||
- Persist "apply started" as part of the fix lifecycle.
|
||||
- Either add an explicit backend transition for apply/start-verifying, or
|
||||
persist `applied_at` when Apply is clicked.
|
||||
- Add a test or browser regression check covering refresh/reselect continuity.
|
||||
|
||||
## 3. Rejecting an AI outcome proposal is only local and will reappear
|
||||
|
||||
Severity: Medium
|
||||
|
||||
Rejecting the AI-confirming banner clears `ai_outcome_proposal` only in local
|
||||
component state.
|
||||
|
||||
Relevant files:
|
||||
- `frontend/src/pages/AssistantChatPage.tsx:571`
|
||||
- `frontend/src/pages/AssistantChatPage.tsx:431`
|
||||
|
||||
Why this matters:
|
||||
- `handleRejectAIProposal()` only updates local `activeFix`.
|
||||
- The server-side `ai_outcome_proposal` remains unchanged.
|
||||
- The proposal comes back on the next `refreshSessionDerived()` call, which
|
||||
happens after sends, task submissions, and chat selection.
|
||||
- "Not yet" is therefore a temporary hide, not a real rejection/correction.
|
||||
|
||||
Recommended fix:
|
||||
- Add a backend way to clear or reject `ai_outcome_proposal`.
|
||||
- Make the reject action persist so the banner does not immediately re-arm on
|
||||
the next refetch.
|
||||
|
||||
## 4. Pre-existing failing decision test
|
||||
|
||||
Severity: Low (test gap, no runtime regression)
|
||||
|
||||
`tests/test_session_suggested_fixes_api.py::test_record_decision_persists_and_bumps_state_version`
|
||||
was authored in Phase 3 (`66e5920`) when the `decision` endpoint had no
|
||||
validation on `ai_drafted_script`. Phase 5 (`fa61376`) added a 400 guard:
|
||||
when the decision is `one_off`, `draft_template`, or `build_template` and the
|
||||
fix has no `ai_drafted_script` (and the caller provides no `edited_script` in
|
||||
the request body), the endpoint returns 400 with the message "Suggested fix has
|
||||
no ai_drafted_script — use /api/v1/scripts/generate for template-matched
|
||||
fixes."
|
||||
|
||||
The test creates a fix without an `ai_drafted_script` and posts
|
||||
`{"decision": "draft_template"}` naked, so the guard fires and returns 400. The
|
||||
test still asserts 200. This was already broken before Phase 8 began — commit
|
||||
`cdd8bb0` (first Phase 8 commit) is 8 commits after `fa61376`.
|
||||
|
||||
Root cause: test was never updated to match the Phase 5 contract change.
|
||||
|
||||
Recommended fix for the next branch:
|
||||
- Option A (minimal): supply `ai_drafted_script="echo hello"` when creating the
|
||||
fix fixture, or add `edited_script` to the POST body. Validates the happy path
|
||||
for `draft_template` with a real drafted body.
|
||||
- Option B (comprehensive): add a separate test case asserting the 400 when
|
||||
`ai_drafted_script` is null and no `edited_script` is provided, then fix the
|
||||
existing test as in Option A. The 400-guard already has coverage in the
|
||||
Phase 5 test file; the main gap is just the missing fixture update here.
|
||||
|
||||
No Phase 8 code change required — this is a test-fixture gap from Phase 3/5
|
||||
drift, not a regression introduced in this branch.
|
||||
|
||||
## Test Context
|
||||
|
||||
Relevant backend suites were run serially from `backend/`:
|
||||
|
||||
```bash
|
||||
pytest tests/test_fix_outcome_endpoint.py tests/test_fix_outcome_marker.py tests/test_session_suggested_fixes_api.py -q
|
||||
```
|
||||
|
||||
Observed result:
|
||||
- `28 passed`
|
||||
- `1 failed`
|
||||
|
||||
Remaining failure:
|
||||
- `tests/test_session_suggested_fixes_api.py::test_record_decision_persists_and_bumps_state_version`
|
||||
|
||||
Notes:
|
||||
- That failing test is in the older decision-path suite and expects
|
||||
`draft_template` to succeed without a drafted script.
|
||||
- The new outcome endpoint tests and marker parser tests passed in the serial
|
||||
run.
|
||||
- The three issues above are based on code inspection and remain valid
|
||||
regardless of that separate failing test.
|
||||
- Full root cause analysis documented in section 4 above.
|
||||
679
docs/FlowAssist_Migration/mockups/05-resolve-cta-merge.html
Normal file
679
docs/FlowAssist_Migration/mockups/05-resolve-cta-merge.html
Normal file
@@ -0,0 +1,679 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>FlowPilot — Suggested Fix → Resolve CTA merge (Option A)</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=Bricolage+Grotesque:wght@500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg-sidebar: #0e1016;
|
||||
--bg-page: #16181f;
|
||||
--bg-card: #1e2028;
|
||||
--bg-elevated: #2a2d38;
|
||||
--border-default: rgba(148, 163, 184, 0.12);
|
||||
--border-hover: rgba(148, 163, 184, 0.22);
|
||||
--text-heading: #f1f5f9;
|
||||
--text-primary: #e2e8f0;
|
||||
--text-muted-foreground: #94a3b8;
|
||||
--text-muted: #64748b;
|
||||
--accent: #60a5fa;
|
||||
--accent-dim: rgba(96, 165, 250, 0.10);
|
||||
--accent-border: rgba(96, 165, 250, 0.30);
|
||||
--warning: #fbbf24;
|
||||
--warning-dim: rgba(251, 191, 36, 0.10);
|
||||
--warning-border: rgba(251, 191, 36, 0.28);
|
||||
--success: #34d399;
|
||||
--success-dim: rgba(52, 211, 153, 0.10);
|
||||
--success-border: rgba(52, 211, 153, 0.28);
|
||||
--danger: #f87171;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body {
|
||||
background: var(--bg-sidebar);
|
||||
color: var(--text-primary);
|
||||
font-family: 'IBM Plex Sans', system-ui, -apple-system, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 1680px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 24px 64px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.page-title {
|
||||
font-family: 'Bricolage Grotesque', sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 22px;
|
||||
color: var(--text-heading);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.page-sub {
|
||||
margin-top: 6px;
|
||||
color: var(--text-muted-foreground);
|
||||
font-size: 13px;
|
||||
max-width: 840px;
|
||||
}
|
||||
|
||||
.columns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* ----- Column scaffold (pretending to be the task-lane rail) ----- */
|
||||
.col {
|
||||
background: var(--bg-page);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 760px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.col-head {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
background: var(--bg-sidebar);
|
||||
}
|
||||
.col-head-label {
|
||||
font-family: 'Bricolage Grotesque', sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--text-heading);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
.col-head-tag {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1.2px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.col-head-tag.today { color: var(--text-muted-foreground); }
|
||||
.col-head-tag.opt-a { color: var(--accent); }
|
||||
.col-head-tag.opt-a-disabled { color: var(--warning); }
|
||||
|
||||
.lane-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 14px 14px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* ----- Section labels (match current component styling) ----- */
|
||||
.section-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1.2px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted-foreground);
|
||||
padding: 0 2px 8px;
|
||||
}
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
.dot-accent { background: var(--accent); }
|
||||
.dot-warning { background: var(--warning); }
|
||||
.dot-success { background: var(--success); }
|
||||
.section-meta {
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
}
|
||||
.conf-high { color: var(--success); font-variant-numeric: tabular-nums; letter-spacing: 0; text-transform: none; }
|
||||
|
||||
/* ----- What-we-know facts ----- */
|
||||
.fact {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-default);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.fact + .fact { margin-top: 8px; }
|
||||
.fact-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 3px;
|
||||
background: var(--accent-dim);
|
||||
border: 1px solid var(--accent-border);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.fact-body { min-width: 0; flex: 1; }
|
||||
.fact-title {
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
color: var(--text-heading);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.fact-meta {
|
||||
margin-top: 3px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
/* ----- Suggested fix card (today only) ----- */
|
||||
.fix-card {
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--warning-border);
|
||||
border-left: 3px solid var(--warning);
|
||||
background: var(--warning-dim);
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.fix-spark {
|
||||
color: var(--warning);
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.fix-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-heading);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.fix-desc {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted-foreground);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.fix-hint {
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--success);
|
||||
}
|
||||
.fix-x {
|
||||
margin-left: auto;
|
||||
color: var(--text-muted);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ----- Action bar at bottom ----- */
|
||||
.action-bar {
|
||||
border-top: 1px solid var(--border-default);
|
||||
padding: 12px 14px 14px;
|
||||
background: var(--bg-sidebar);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.action-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
appearance: none;
|
||||
border: 1px solid var(--border-default);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s ease, background-color 0.12s ease, color 0.12s ease;
|
||||
}
|
||||
.btn:hover { border-color: var(--border-hover); background: var(--bg-elevated); }
|
||||
|
||||
.btn-secondary {
|
||||
flex: 0 0 auto;
|
||||
min-width: 96px;
|
||||
}
|
||||
|
||||
.btn-resolve-today {
|
||||
flex: 1;
|
||||
background: var(--accent);
|
||||
color: #0a0d14;
|
||||
border-color: transparent;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-resolve-today:hover { background: #7ab4fb; color: #0a0d14; }
|
||||
|
||||
/* Option A — Resolve w/ embedded fix */
|
||||
.btn-resolve-merged {
|
||||
flex: 1;
|
||||
background: var(--accent);
|
||||
color: #0a0d14;
|
||||
border-color: transparent;
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
justify-content: flex-start;
|
||||
min-height: 52px;
|
||||
text-align: left;
|
||||
}
|
||||
.btn-resolve-merged:hover { background: #7ab4fb; color: #0a0d14; }
|
||||
.btn-resolve-merged .rc-leading {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
color: rgba(10, 13, 20, 0.72);
|
||||
font-family: 'Bricolage Grotesque', sans-serif;
|
||||
}
|
||||
.btn-resolve-merged .rc-title {
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: #0a0d14;
|
||||
line-height: 1.25;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.btn-resolve-merged .rc-body {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.btn-resolve-merged .rc-conf {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(10, 13, 20, 0.14);
|
||||
color: #0a0d14;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.btn-resolve-merged .rc-chevron {
|
||||
color: rgba(10, 13, 20, 0.55);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Disabled (no proposal yet) */
|
||||
.btn-resolve-disabled {
|
||||
flex: 1;
|
||||
background: var(--bg-card);
|
||||
color: var(--text-muted-foreground);
|
||||
border: 1px dashed var(--border-hover);
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
justify-content: flex-start;
|
||||
min-height: 52px;
|
||||
cursor: not-allowed;
|
||||
text-align: left;
|
||||
}
|
||||
.btn-resolve-disabled .rc-leading {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
font-family: 'Bricolage Grotesque', sans-serif;
|
||||
}
|
||||
.btn-resolve-disabled .rc-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted-foreground);
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
/* Escalate / overflow */
|
||||
.btn-escalate {
|
||||
background: transparent;
|
||||
color: var(--text-muted-foreground);
|
||||
}
|
||||
.btn-escalate:hover { color: var(--text-primary); }
|
||||
|
||||
/* tiny spinner dot for the waiting state */
|
||||
.pulse {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--warning);
|
||||
box-shadow: 0 0 0 0 rgba(251, 191, 36, 0.5);
|
||||
animation: pulse 1.6s infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(251, 191, 36, 0.45); }
|
||||
70% { box-shadow: 0 0 0 8px rgba(251, 191, 36, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(251, 191, 36, 0); }
|
||||
}
|
||||
|
||||
/* Annotation callouts beneath the columns */
|
||||
.callout {
|
||||
margin-top: 14px;
|
||||
padding: 12px 14px;
|
||||
background: var(--bg-page);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted-foreground);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.callout strong { color: var(--text-heading); font-weight: 600; }
|
||||
.callout.note-accent { border-left: 3px solid var(--accent); }
|
||||
.callout.note-warning { border-left: 3px solid var(--warning); }
|
||||
.callout.note-muted { border-left: 3px solid var(--border-hover); }
|
||||
|
||||
.legend {
|
||||
margin-top: 40px;
|
||||
padding: 18px 20px;
|
||||
background: var(--bg-page);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 12px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px 32px;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-muted-foreground);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.legend h4 {
|
||||
font-family: 'Bricolage Grotesque', sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-heading);
|
||||
margin-bottom: 6px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.legend li { margin-top: 4px; }
|
||||
|
||||
/* subtle faux scrollbar hint */
|
||||
.lane-body::-webkit-scrollbar { width: 6px; }
|
||||
.lane-body::-webkit-scrollbar-thumb { background: var(--border-hover); border-radius: 3px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<div class="page-header">
|
||||
<div class="page-title">Option A — Suggested Fix merges into the Resolve CTA</div>
|
||||
<div class="page-sub">
|
||||
Three versions of the same task lane. <strong style="color:var(--text-primary)">Today</strong> keeps Suggested Fix as a separate card that gets pushed down by a long facts list. <strong style="color:var(--text-primary)">Option A (armed)</strong> deletes the card — the Resolve button at the bottom becomes the proposal. <strong style="color:var(--text-primary)">Option A (waiting)</strong> is what the same bar looks like before the AI emits a proposal.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="columns">
|
||||
|
||||
<!-- ============== COLUMN 1: TODAY ============== -->
|
||||
<div>
|
||||
<div class="col">
|
||||
<div class="col-head">
|
||||
<div class="col-head-label">Today</div>
|
||||
<div class="col-head-tag today">Baseline</div>
|
||||
</div>
|
||||
<div class="lane-body">
|
||||
|
||||
<!-- What we know -->
|
||||
<section>
|
||||
<div class="section-label">
|
||||
<span class="dot dot-accent"></span>
|
||||
What we know
|
||||
<span class="section-meta">· 5 facts</span>
|
||||
</div>
|
||||
<div class="fact">
|
||||
<span class="fact-icon"></span>
|
||||
<div class="fact-body">
|
||||
<div class="fact-title">User cannot authenticate to Outlook; repeated 401s from Exchange Online.</div>
|
||||
<div class="fact-meta">promoted 14:02 · from ticket</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fact">
|
||||
<span class="fact-icon"></span>
|
||||
<div class="fact-body">
|
||||
<div class="fact-title">Cached credentials in Credential Manager reference a prior tenant the user migrated off six months ago.</div>
|
||||
<div class="fact-meta">promoted 14:07 · from chat</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fact">
|
||||
<span class="fact-icon"></span>
|
||||
<div class="fact-body">
|
||||
<div class="fact-title">MFA prompt appears then fails silently — no authenticator notification, no error code surfaced to the user.</div>
|
||||
<div class="fact-meta">promoted 14:11 · from chat</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fact">
|
||||
<span class="fact-icon"></span>
|
||||
<div class="fact-body">
|
||||
<div class="fact-title">Other devices under same account authenticate successfully, isolating the problem to this workstation.</div>
|
||||
<div class="fact-meta">promoted 14:14 · from chat</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fact">
|
||||
<span class="fact-icon"></span>
|
||||
<div class="fact-body">
|
||||
<div class="fact-title">Office 365 client last updated three weeks ago; local profile not recreated since migration.</div>
|
||||
<div class="fact-meta">promoted 14:18 · from chat</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Suggested Fix card (this is the thing that gets buried) -->
|
||||
<section>
|
||||
<div class="section-label">
|
||||
<span class="dot dot-warning"></span>
|
||||
Suggested fix
|
||||
<span class="section-meta">·</span>
|
||||
<span class="conf-high">94% confidence</span>
|
||||
</div>
|
||||
<div class="fix-card">
|
||||
<svg class="fix-spark" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/></svg>
|
||||
<div style="min-width:0;flex:1">
|
||||
<div class="fix-title">Clear cached credentials + rebuild Outlook profile</div>
|
||||
<div class="fix-desc">Remove stale entries from Credential Manager referencing the prior tenant, then rebuild the local Outlook profile so the client re-authenticates cleanly against the current tenant.</div>
|
||||
<div class="fix-hint">✓ Matches an existing Script Library template — click to use</div>
|
||||
</div>
|
||||
<button class="fix-x" aria-label="Dismiss">✕</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="action-bar">
|
||||
<div class="action-row">
|
||||
<button class="btn btn-escalate btn-secondary">Escalate</button>
|
||||
<button class="btn btn-resolve-today">Resolve</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="callout note-muted">
|
||||
<strong>Baseline problem.</strong> The Suggested Fix card sits after What-we-know. With 5+ facts (common by mid-session) it's below the fold. The generic <em>Resolve</em> button at the bottom doesn't surface what would be resolved, so the engineer has to scroll up, read the card, then scroll back down to act.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============== COLUMN 2: OPTION A — ARMED ============== -->
|
||||
<div>
|
||||
<div class="col">
|
||||
<div class="col-head">
|
||||
<div class="col-head-label">Option A — armed</div>
|
||||
<div class="col-head-tag opt-a">Proposal ready</div>
|
||||
</div>
|
||||
<div class="lane-body">
|
||||
|
||||
<!-- Same facts, but no Suggested Fix card -->
|
||||
<section>
|
||||
<div class="section-label">
|
||||
<span class="dot dot-accent"></span>
|
||||
What we know
|
||||
<span class="section-meta">· 5 facts</span>
|
||||
</div>
|
||||
<div class="fact">
|
||||
<span class="fact-icon"></span>
|
||||
<div class="fact-body">
|
||||
<div class="fact-title">User cannot authenticate to Outlook; repeated 401s from Exchange Online.</div>
|
||||
<div class="fact-meta">promoted 14:02 · from ticket</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fact">
|
||||
<span class="fact-icon"></span>
|
||||
<div class="fact-body">
|
||||
<div class="fact-title">Cached credentials in Credential Manager reference a prior tenant the user migrated off six months ago.</div>
|
||||
<div class="fact-meta">promoted 14:07 · from chat</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fact">
|
||||
<span class="fact-icon"></span>
|
||||
<div class="fact-body">
|
||||
<div class="fact-title">MFA prompt appears then fails silently — no authenticator notification, no error code surfaced to the user.</div>
|
||||
<div class="fact-meta">promoted 14:11 · from chat</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fact">
|
||||
<span class="fact-icon"></span>
|
||||
<div class="fact-body">
|
||||
<div class="fact-title">Other devices under same account authenticate successfully, isolating the problem to this workstation.</div>
|
||||
<div class="fact-meta">promoted 14:14 · from chat</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fact">
|
||||
<span class="fact-icon"></span>
|
||||
<div class="fact-body">
|
||||
<div class="fact-title">Office 365 client last updated three weeks ago; local profile not recreated since migration.</div>
|
||||
<div class="fact-meta">promoted 14:18 · from chat</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- NO Suggested Fix card here — it lives on the button -->
|
||||
|
||||
</div>
|
||||
|
||||
<div class="action-bar">
|
||||
<div class="action-row">
|
||||
<button class="btn btn-escalate btn-secondary">Escalate</button>
|
||||
<button class="btn btn-resolve-merged" aria-label="Resolve with: Clear cached credentials + rebuild Outlook profile (94% confidence)">
|
||||
<div class="rc-body">
|
||||
<div class="rc-leading">Resolve with</div>
|
||||
<div class="rc-title">Clear cached credentials + rebuild Outlook profile</div>
|
||||
</div>
|
||||
<span class="rc-conf">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/></svg>
|
||||
94%
|
||||
</span>
|
||||
<svg class="rc-chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="callout note-accent">
|
||||
<strong>What changes.</strong> The Suggested Fix card is gone. Its content moved onto the Resolve button, which is always in view. One click = accept the fix + open the existing <code style="font-family:'JetBrains Mono',monospace;font-size:11.5px;background:var(--bg-card);padding:1px 5px;border-radius:3px;">ResolutionNotePreview</code> popover pre-filled. No card-then-button two-step.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============== COLUMN 3: OPTION A — WAITING ============== -->
|
||||
<div>
|
||||
<div class="col">
|
||||
<div class="col-head">
|
||||
<div class="col-head-label">Option A — waiting</div>
|
||||
<div class="col-head-tag opt-a-disabled">No proposal yet</div>
|
||||
</div>
|
||||
<div class="lane-body">
|
||||
|
||||
<section>
|
||||
<div class="section-label">
|
||||
<span class="dot dot-accent"></span>
|
||||
What we know
|
||||
<span class="section-meta">· 2 facts</span>
|
||||
</div>
|
||||
<div class="fact">
|
||||
<span class="fact-icon"></span>
|
||||
<div class="fact-body">
|
||||
<div class="fact-title">User cannot authenticate to Outlook; repeated 401s from Exchange Online.</div>
|
||||
<div class="fact-meta">promoted 14:02 · from ticket</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fact">
|
||||
<span class="fact-icon"></span>
|
||||
<div class="fact-body">
|
||||
<div class="fact-title">Cached credentials in Credential Manager reference a prior tenant.</div>
|
||||
<div class="fact-meta">promoted 14:07 · from chat</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="action-bar">
|
||||
<div class="action-row">
|
||||
<button class="btn btn-escalate btn-secondary">Escalate</button>
|
||||
<button class="btn btn-resolve-disabled" disabled aria-label="Resolve (waiting for AI proposal)">
|
||||
<span class="pulse" aria-hidden="true"></span>
|
||||
<div class="rc-body">
|
||||
<div class="rc-leading">Resolve</div>
|
||||
<div class="rc-title">Waiting for proposal…</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="callout note-warning">
|
||||
<strong>Before confidence threshold.</strong> Same slot, disabled state. Amber pulse signals the AI is still reasoning. Below threshold or no proposal yet → same visual — the engineer can still use <em>Escalate</em> at any time.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ============== LEGEND / TRADE-OFFS ============== -->
|
||||
<div class="legend">
|
||||
<div>
|
||||
<h4>Why this helps discoverability</h4>
|
||||
<ul style="padding-left:18px;list-style:disc">
|
||||
<li>Proposal is in the place the engineer looks to <em>act</em>, not in the scrolling lane above.</li>
|
||||
<li>Resolve bar is already sticky at the bottom — no new sticky patterns needed (preserves the <code style="font-family:'JetBrains Mono',monospace;font-size:11px">8879f96</code> fix).</li>
|
||||
<li>Accepting a fix and resolving the session collapse into one click instead of two.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4>What you give up</h4>
|
||||
<ul style="padding-left:18px;list-style:disc">
|
||||
<li>No space for secondary info on the button (reasoning, alternative fixes). Would need an expand/chevron or hover tooltip.</li>
|
||||
<li>No standalone "dismiss this fix" affordance — need to decide where dismiss/reject lives (chevron menu? secondary button?).</li>
|
||||
<li>If the AI proposes multiple candidates, only the top one fits the button. Need a "▾ 2 other candidates" menu.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
849
docs/FlowAssist_Migration/mockups/06-slide-up-banner.html
Normal file
849
docs/FlowAssist_Migration/mockups/06-slide-up-banner.html
Normal file
@@ -0,0 +1,849 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>FlowPilot — Suggested Fix as slide-up composer banner</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=Bricolage+Grotesque:wght@500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg-sidebar: #0e1016;
|
||||
--bg-page: #16181f;
|
||||
--bg-card: #1e2028;
|
||||
--bg-elevated: #2a2d38;
|
||||
--border-default: rgba(148, 163, 184, 0.12);
|
||||
--border-hover: rgba(148, 163, 184, 0.22);
|
||||
--text-heading: #f1f5f9;
|
||||
--text-primary: #e2e8f0;
|
||||
--text-muted-foreground: #94a3b8;
|
||||
--text-muted: #64748b;
|
||||
--accent: #60a5fa;
|
||||
--accent-dim: rgba(96, 165, 250, 0.10);
|
||||
--accent-border: rgba(96, 165, 250, 0.30);
|
||||
--warning: #fbbf24;
|
||||
--warning-dim: rgba(251, 191, 36, 0.10);
|
||||
--warning-dim-strong: rgba(251, 191, 36, 0.16);
|
||||
--warning-border: rgba(251, 191, 36, 0.32);
|
||||
--success: #34d399;
|
||||
--success-dim: rgba(52, 211, 153, 0.10);
|
||||
--success-border: rgba(52, 211, 153, 0.28);
|
||||
--danger: #f87171;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body {
|
||||
background: var(--bg-sidebar);
|
||||
color: var(--text-primary);
|
||||
font-family: 'IBM Plex Sans', system-ui, -apple-system, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 1680px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 24px 72px;
|
||||
}
|
||||
|
||||
.page-header { margin-bottom: 24px; }
|
||||
.page-title {
|
||||
font-family: 'Bricolage Grotesque', sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 22px;
|
||||
color: var(--text-heading);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.page-sub {
|
||||
margin-top: 6px;
|
||||
color: var(--text-muted-foreground);
|
||||
font-size: 13px;
|
||||
max-width: 980px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
/* =================== Main frame =================== */
|
||||
.frame {
|
||||
background: var(--bg-page);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 380px;
|
||||
height: 780px;
|
||||
}
|
||||
|
||||
/* ------ Chat area ------ */
|
||||
.chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-page);
|
||||
min-width: 0;
|
||||
}
|
||||
.chat-head {
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
background: var(--bg-sidebar);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.chat-head-title {
|
||||
font-family: 'Bricolage Grotesque', sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--text-heading);
|
||||
}
|
||||
.chat-head-sub {
|
||||
font-size: 11.5px;
|
||||
color: var(--text-muted);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
.chat-head-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px 28px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.msg {
|
||||
max-width: 640px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.msg.user { align-self: flex-end; }
|
||||
.msg-av {
|
||||
width: 26px; height: 26px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.msg.user .msg-av {
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--accent-border);
|
||||
}
|
||||
.msg.ai .msg-av {
|
||||
background: var(--warning-dim);
|
||||
color: var(--warning);
|
||||
border: 1px solid var(--warning-border);
|
||||
}
|
||||
.msg-body {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 10px;
|
||||
padding: 10px 13px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.msg.user .msg-body {
|
||||
background: var(--accent-dim);
|
||||
border-color: var(--accent-border);
|
||||
color: var(--text-heading);
|
||||
}
|
||||
.msg-meta {
|
||||
margin-top: 4px;
|
||||
font-size: 10.5px;
|
||||
color: var(--text-muted);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
/* ------ Composer area (sticky bottom of chat) ------ */
|
||||
.composer-wrap {
|
||||
border-top: 1px solid var(--border-default);
|
||||
background: var(--bg-page);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ------ Slide-up banner ------ */
|
||||
.proposal-banner {
|
||||
margin: 0;
|
||||
border-top: 1px solid var(--warning-border);
|
||||
background: linear-gradient(180deg, var(--warning-dim-strong) 0%, var(--warning-dim) 100%);
|
||||
padding: 12px 20px 14px;
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
position: relative;
|
||||
animation: slideUp 320ms cubic-bezier(.22, .9, .28, 1) both;
|
||||
}
|
||||
.proposal-banner::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 3px;
|
||||
background: var(--warning);
|
||||
}
|
||||
@keyframes slideUp {
|
||||
from { transform: translateY(14px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.proposal-icon {
|
||||
width: 28px; height: 28px;
|
||||
border-radius: 7px;
|
||||
background: var(--warning-dim-strong);
|
||||
border: 1px solid var(--warning-border);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: var(--warning);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.proposal-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.proposal-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1.2px;
|
||||
text-transform: uppercase;
|
||||
color: var(--warning);
|
||||
font-family: 'Bricolage Grotesque', sans-serif;
|
||||
}
|
||||
.proposal-head .pill {
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
background: rgba(251, 191, 36, 0.20);
|
||||
color: var(--warning);
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
font-family: 'IBM Plex Sans', sans-serif;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.proposal-title {
|
||||
margin-top: 3px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-heading);
|
||||
line-height: 1.35;
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
.proposal-desc {
|
||||
margin-top: 3px;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-muted-foreground);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.proposal-hint {
|
||||
margin-top: 6px;
|
||||
font-size: 11.5px;
|
||||
color: var(--success);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.proposal-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
appearance: none;
|
||||
border: 1px solid var(--border-default);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
font-size: 12.5px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s, background-color 0.12s, color 0.12s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn:hover { border-color: var(--border-hover); background: var(--bg-elevated); }
|
||||
|
||||
.btn-apply {
|
||||
background: var(--warning);
|
||||
color: #1a1200;
|
||||
border-color: transparent;
|
||||
font-weight: 600;
|
||||
padding: 9px 14px;
|
||||
}
|
||||
.btn-apply:hover { background: #ffce4f; color: #1a1200; }
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-muted-foreground);
|
||||
border-color: transparent;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.btn-ghost:hover {
|
||||
background: rgba(148, 163, 184, 0.08);
|
||||
color: var(--text-primary);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 30px; height: 30px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: var(--text-muted-foreground);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.icon-btn:hover {
|
||||
background: rgba(148, 163, 184, 0.08);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ------ Composer ------ */
|
||||
.composer {
|
||||
padding: 14px 20px 16px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
.composer-input {
|
||||
flex: 1;
|
||||
min-height: 44px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 10px;
|
||||
padding: 10px 14px;
|
||||
color: var(--text-muted-foreground);
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.composer-send {
|
||||
width: 44px; height: 44px;
|
||||
border-radius: 10px;
|
||||
background: var(--accent);
|
||||
color: #0a0d14;
|
||||
border: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ------ Task lane (right rail) ------ */
|
||||
.lane {
|
||||
border-left: 1px solid var(--border-default);
|
||||
background: var(--bg-sidebar);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.lane-head {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.lane-head-label {
|
||||
font-family: 'Bricolage Grotesque', sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--text-heading);
|
||||
}
|
||||
.lane-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 14px 14px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1.2px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted-foreground);
|
||||
padding: 0 2px 8px;
|
||||
}
|
||||
.dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; }
|
||||
.dot-accent { background: var(--accent); }
|
||||
.dot-muted { background: var(--text-muted); }
|
||||
.section-meta {
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.fact {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-default);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.fact + .fact { margin-top: 8px; }
|
||||
.fact-title {
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
color: var(--text-heading);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.fact-meta {
|
||||
margin-top: 3px;
|
||||
font-size: 10.5px;
|
||||
color: var(--text-muted);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.dismissed-pill {
|
||||
padding: 8px 10px;
|
||||
background: var(--bg-card);
|
||||
border: 1px dashed var(--border-hover);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-muted-foreground);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s, color 0.12s;
|
||||
}
|
||||
.dismissed-pill:hover { border-color: var(--warning-border); color: var(--warning); }
|
||||
|
||||
.action-bar {
|
||||
border-top: 1px solid var(--border-default);
|
||||
padding: 12px 14px 14px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.btn-escalate { flex: 0 0 auto; min-width: 96px; background: transparent; color: var(--text-muted-foreground); }
|
||||
.btn-resolve {
|
||||
flex: 1;
|
||||
background: var(--accent);
|
||||
color: #0a0d14;
|
||||
border-color: transparent;
|
||||
font-weight: 600;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.btn-resolve:hover { background: #7ab4fb; color: #0a0d14; }
|
||||
|
||||
/* =================== Callouts =================== */
|
||||
.callout {
|
||||
margin-top: 20px;
|
||||
padding: 14px 16px;
|
||||
background: var(--bg-page);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted-foreground);
|
||||
line-height: 1.55;
|
||||
border-left: 3px solid var(--warning);
|
||||
}
|
||||
.callout strong { color: var(--text-heading); font-weight: 600; }
|
||||
|
||||
/* =================== State detail row =================== */
|
||||
.states-title {
|
||||
margin-top: 48px;
|
||||
font-family: 'Bricolage Grotesque', sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
color: var(--text-heading);
|
||||
}
|
||||
.states-sub {
|
||||
margin-top: 4px;
|
||||
color: var(--text-muted-foreground);
|
||||
font-size: 13px;
|
||||
}
|
||||
.states {
|
||||
margin-top: 16px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
.state {
|
||||
background: var(--bg-page);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.state-label {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
font-family: 'Bricolage Grotesque', sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-heading);
|
||||
background: var(--bg-sidebar);
|
||||
}
|
||||
.state-body {
|
||||
padding: 0;
|
||||
background: var(--bg-page);
|
||||
min-height: 220px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.state-mini-chat {
|
||||
flex: 1;
|
||||
padding: 14px;
|
||||
opacity: 0.55;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
/* Collapsed banner variant */
|
||||
.banner-collapsed {
|
||||
border-top: 1px solid var(--warning-border);
|
||||
background: var(--warning-dim);
|
||||
padding: 8px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
position: relative;
|
||||
}
|
||||
.banner-collapsed::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 3px;
|
||||
background: var(--warning);
|
||||
}
|
||||
.banner-collapsed-title {
|
||||
font-weight: 500;
|
||||
color: var(--text-heading);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
}
|
||||
.banner-collapsed .pill {
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
background: rgba(251, 191, 36, 0.20);
|
||||
color: var(--warning);
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.banner-collapsed .expand {
|
||||
color: var(--text-muted-foreground);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* mini composer for the detail states */
|
||||
.mini-composer {
|
||||
border-top: 1px solid var(--border-default);
|
||||
padding: 10px 14px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.mini-input {
|
||||
flex: 1;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 8px;
|
||||
padding: 7px 10px;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.mini-send {
|
||||
width: 28px; height: 28px;
|
||||
border-radius: 7px;
|
||||
background: var(--accent);
|
||||
color: #0a0d14;
|
||||
border: 0;
|
||||
font-size: 14px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
|
||||
/* pill in chat stream (replaced state) */
|
||||
.replaced-note {
|
||||
align-self: flex-end;
|
||||
font-size: 10.5px;
|
||||
color: var(--text-muted);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
padding: 4px 8px;
|
||||
background: var(--bg-card);
|
||||
border: 1px dashed var(--border-hover);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* annotation captions under each state */
|
||||
.state-caption {
|
||||
padding: 10px 14px 12px;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-muted-foreground);
|
||||
line-height: 1.5;
|
||||
border-top: 1px solid var(--border-default);
|
||||
background: var(--bg-sidebar);
|
||||
}
|
||||
.state-caption strong { color: var(--text-heading); font-weight: 600; }
|
||||
|
||||
.lane-body::-webkit-scrollbar { width: 6px; }
|
||||
.lane-body::-webkit-scrollbar-thumb { background: var(--border-hover); border-radius: 3px; }
|
||||
.chat-scroll::-webkit-scrollbar { width: 6px; }
|
||||
.chat-scroll::-webkit-scrollbar-thumb { background: var(--border-hover); border-radius: 3px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<div class="page-header">
|
||||
<div class="page-title">Option C — Suggested Fix slides up from the chat composer</div>
|
||||
<div class="page-sub">
|
||||
The AI's proposal docks as a persistent banner just above the chat composer — right where the engineer's eyes already are. Apply lives on the banner (warning amber). <em>Resolve</em> stays generic at the bottom of the task lane, so there's no false-resolve risk. The Suggested Fix card is removed from the task lane entirely.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ MAIN: Armed + expanded ============ -->
|
||||
<div class="frame">
|
||||
|
||||
<div class="chat">
|
||||
<div class="chat-head">
|
||||
<div>
|
||||
<div class="chat-head-title">Outlook won't authenticate after tenant migration</div>
|
||||
<div class="chat-head-sub">ticket #48213 · in progress · 14:22</div>
|
||||
</div>
|
||||
<div class="chat-head-actions">
|
||||
<button class="btn btn-ghost">Share update</button>
|
||||
<button class="btn icon-btn" aria-label="More">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="19" cy="12" r="1.5"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-scroll">
|
||||
<div class="msg ai">
|
||||
<div class="msg-av">AI</div>
|
||||
<div>
|
||||
<div class="msg-body">
|
||||
A few things we know line up with a stale-credential pattern rather than an MFA or network issue. Can you confirm whether the user has other Microsoft 365 services (Teams, SharePoint) working on the same workstation?
|
||||
</div>
|
||||
<div class="msg-meta">14:16</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="msg user">
|
||||
<div>
|
||||
<div class="msg-body">Teams works fine. SharePoint in browser is fine too. It's just Outlook.</div>
|
||||
<div class="msg-meta">14:17</div>
|
||||
</div>
|
||||
<div class="msg-av">ME</div>
|
||||
</div>
|
||||
|
||||
<div class="msg ai">
|
||||
<div class="msg-av">AI</div>
|
||||
<div>
|
||||
<div class="msg-body">
|
||||
That narrows it to the Outlook profile specifically. Given Credential Manager still has entries pointing at the prior tenant, the cleanest path is to clear those entries and rebuild the local Outlook profile — the client will re-auth against the current tenant from scratch.
|
||||
</div>
|
||||
<div class="msg-meta">14:22</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ Slide-up banner ============ -->
|
||||
<div class="composer-wrap">
|
||||
|
||||
<div class="proposal-banner" role="region" aria-label="AI proposed fix">
|
||||
<div class="proposal-icon">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/></svg>
|
||||
</div>
|
||||
<div class="proposal-body">
|
||||
<div class="proposal-head">
|
||||
<span>Suggested Fix</span>
|
||||
<span class="pill">94% confidence</span>
|
||||
</div>
|
||||
<div class="proposal-title">Clear cached credentials + rebuild Outlook profile</div>
|
||||
<div class="proposal-desc">
|
||||
Remove stale Credential Manager entries referencing the prior tenant, then rebuild the local Outlook profile so the client re-authenticates cleanly against the current tenant.
|
||||
</div>
|
||||
<div class="proposal-hint">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
Matches an existing Script Library template — one-click apply
|
||||
</div>
|
||||
</div>
|
||||
<div class="proposal-actions">
|
||||
<button class="btn btn-ghost" aria-label="Collapse banner">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</button>
|
||||
<button class="btn btn-ghost" aria-label="Dismiss fix">Dismiss</button>
|
||||
<button class="btn btn-apply">
|
||||
Apply fix
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="composer">
|
||||
<div class="composer-input">Ask a follow-up, paste an error, drop a screenshot…</div>
|
||||
<button class="composer-send" aria-label="Send">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ Task lane (no Suggested Fix card) ============ -->
|
||||
<div class="lane">
|
||||
<div class="lane-head">
|
||||
<div class="lane-head-label">Task lane</div>
|
||||
</div>
|
||||
<div class="lane-body">
|
||||
<section>
|
||||
<div class="section-label">
|
||||
<span class="dot dot-accent"></span>
|
||||
What we know
|
||||
<span class="section-meta">· 5 facts</span>
|
||||
</div>
|
||||
<div class="fact">
|
||||
<div class="fact-title">User cannot authenticate to Outlook; repeated 401s from Exchange Online.</div>
|
||||
<div class="fact-meta">promoted 14:02 · from ticket</div>
|
||||
</div>
|
||||
<div class="fact">
|
||||
<div class="fact-title">Credential Manager still references the prior tenant from six months ago.</div>
|
||||
<div class="fact-meta">promoted 14:07 · from chat</div>
|
||||
</div>
|
||||
<div class="fact">
|
||||
<div class="fact-title">MFA prompt appears but fails silently — no authenticator notification.</div>
|
||||
<div class="fact-meta">promoted 14:11 · from chat</div>
|
||||
</div>
|
||||
<div class="fact">
|
||||
<div class="fact-title">Other devices under same account authenticate successfully.</div>
|
||||
<div class="fact-meta">promoted 14:14 · from chat</div>
|
||||
</div>
|
||||
<div class="fact">
|
||||
<div class="fact-title">Teams + SharePoint work on same workstation — isolated to Outlook.</div>
|
||||
<div class="fact-meta">promoted 14:22 · from chat</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="action-bar">
|
||||
<button class="btn btn-escalate">Escalate</button>
|
||||
<button class="btn btn-resolve">Resolve</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="callout">
|
||||
<strong>How it reads.</strong> Proposal arrives with a 320ms slide-up from below the composer, docks as a persistent banner until applied, dismissed, or replaced. Apply is amber (not accent-blue) so it visually belongs to the proposal, not the chat send button. Resolve in the task lane stays generic — there's no false-resolve risk because the two actions are spatially and visually separate.
|
||||
</div>
|
||||
|
||||
<!-- ============ State detail row ============ -->
|
||||
<div class="states-title">Banner states</div>
|
||||
<div class="states-sub">What the same region looks like in the other three states — collapsed to save chat space, after the engineer dismisses it, and when a new proposal replaces an existing one.</div>
|
||||
|
||||
<div class="states">
|
||||
|
||||
<!-- STATE 1: Collapsed -->
|
||||
<div class="state">
|
||||
<div class="state-label">Collapsed (saves chat space)</div>
|
||||
<div class="state-body">
|
||||
<div class="state-mini-chat">…earlier messages…</div>
|
||||
<div class="banner-collapsed">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--warning)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/></svg>
|
||||
<span class="banner-collapsed-title">Clear cached credentials + rebuild Outlook profile</span>
|
||||
<span class="pill">94%</span>
|
||||
<span class="expand">▸ expand</span>
|
||||
</div>
|
||||
<div class="mini-composer">
|
||||
<div class="mini-input">Type a message…</div>
|
||||
<button class="mini-send">↑</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="state-caption">
|
||||
<strong>~28px strip.</strong> Auto-collapses after 30s of no interaction, or when the engineer clicks the chevron. Title + confidence still visible. Click strip → expands. Apply still reachable via the expanded state.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- STATE 2: Dismissed (pill in lane) -->
|
||||
<div class="state">
|
||||
<div class="state-label">Dismissed — parked in the task lane</div>
|
||||
<div class="state-body">
|
||||
<div class="state-mini-chat">chat unobstructed · banner gone</div>
|
||||
<div class="mini-composer">
|
||||
<div class="mini-input">Type a message…</div>
|
||||
<button class="mini-send">↑</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding: 12px 14px; background: var(--bg-sidebar); border-top: 1px solid var(--border-default);">
|
||||
<div class="section-label" style="padding-bottom: 6px">
|
||||
<span class="dot dot-muted"></span>
|
||||
Dismissed proposals
|
||||
<span class="section-meta">· 1</span>
|
||||
</div>
|
||||
<div class="dismissed-pill">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="var(--warning)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/></svg>
|
||||
<span style="flex:1;color:var(--text-heading)">Clear cached credentials…</span>
|
||||
<span style="color:var(--text-muted)">restore ↺</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="state-caption">
|
||||
<strong>Recoverable, out of the way.</strong> Dismissing the banner parks the proposal as a pill in the task lane. Clicking restore → banner slides back in. Prevents accidental loss.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- STATE 3: Replaced -->
|
||||
<div class="state">
|
||||
<div class="state-label">Replaced — new proposal overrides old</div>
|
||||
<div class="state-body">
|
||||
<div class="state-mini-chat" style="flex-direction:column;align-items:flex-end;gap:8px;justify-content:flex-end;">
|
||||
<span class="replaced-note">previous: "Rebuild Outlook profile" — didn't resolve, new proposal below</span>
|
||||
</div>
|
||||
<div class="proposal-banner" style="padding:10px 14px;gap:10px;">
|
||||
<div class="proposal-icon" style="width:22px;height:22px;border-radius:6px">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/></svg>
|
||||
</div>
|
||||
<div class="proposal-body">
|
||||
<div class="proposal-head" style="font-size:9px">
|
||||
<span>New suggested fix</span>
|
||||
<span class="pill" style="font-size:9.5px;padding:1px 6px">78%</span>
|
||||
</div>
|
||||
<div class="proposal-title" style="font-size:12.5px">Reset Autodiscover registry entries for this user</div>
|
||||
</div>
|
||||
<button class="btn btn-apply" style="padding:6px 10px;font-size:11.5px">Apply</button>
|
||||
</div>
|
||||
<div class="mini-composer">
|
||||
<div class="mini-input">Type a message…</div>
|
||||
<button class="mini-send">↑</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="state-caption">
|
||||
<strong>Old proposal cross-fades out, new one slides in.</strong> 200ms cross-fade, same slot. A tiny footnote in chat ("previous didn't resolve") preserves the audit trail without re-stacking banners.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
805
docs/FlowAssist_Migration/mockups/07-verify-states.html
Normal file
805
docs/FlowAssist_Migration/mockups/07-verify-states.html
Normal file
@@ -0,0 +1,805 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>FlowPilot — Post-apply outcome states</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=Bricolage+Grotesque:wght@500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg-sidebar: #0e1016;
|
||||
--bg-page: #16181f;
|
||||
--bg-card: #1e2028;
|
||||
--bg-elevated: #2a2d38;
|
||||
--border-default: rgba(148, 163, 184, 0.12);
|
||||
--border-hover: rgba(148, 163, 184, 0.22);
|
||||
--text-heading: #f1f5f9;
|
||||
--text-primary: #e2e8f0;
|
||||
--text-muted-foreground: #94a3b8;
|
||||
--text-muted: #64748b;
|
||||
--accent: #60a5fa;
|
||||
--accent-dim: rgba(96, 165, 250, 0.10);
|
||||
--accent-dim-strong: rgba(96, 165, 250, 0.16);
|
||||
--accent-border: rgba(96, 165, 250, 0.30);
|
||||
--warning: #fbbf24;
|
||||
--warning-dim: rgba(251, 191, 36, 0.10);
|
||||
--warning-dim-strong: rgba(251, 191, 36, 0.16);
|
||||
--warning-border: rgba(251, 191, 36, 0.32);
|
||||
--success: #34d399;
|
||||
--success-dim: rgba(52, 211, 153, 0.10);
|
||||
--success-dim-strong: rgba(52, 211, 153, 0.16);
|
||||
--success-border: rgba(52, 211, 153, 0.30);
|
||||
--info: #67e8f9;
|
||||
--info-dim: rgba(103, 232, 249, 0.10);
|
||||
--info-dim-strong: rgba(103, 232, 249, 0.16);
|
||||
--info-border: rgba(103, 232, 249, 0.30);
|
||||
--danger: #f87171;
|
||||
--danger-dim: rgba(248, 113, 113, 0.10);
|
||||
--danger-dim-strong: rgba(248, 113, 113, 0.16);
|
||||
--danger-border: rgba(248, 113, 113, 0.30);
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body {
|
||||
background: var(--bg-sidebar);
|
||||
color: var(--text-primary);
|
||||
font-family: 'IBM Plex Sans', system-ui, -apple-system, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 1680px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 24px 72px;
|
||||
}
|
||||
.page-header { margin-bottom: 24px; }
|
||||
.page-title {
|
||||
font-family: 'Bricolage Grotesque', sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 22px;
|
||||
color: var(--text-heading);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.page-sub {
|
||||
margin-top: 6px;
|
||||
color: var(--text-muted-foreground);
|
||||
font-size: 13px;
|
||||
max-width: 1020px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
/* ====== Shared button styles ====== */
|
||||
.btn {
|
||||
appearance: none;
|
||||
border: 1px solid var(--border-default);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
font-size: 12.5px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s, background-color 0.12s, color 0.12s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn:hover { border-color: var(--border-hover); background: var(--bg-elevated); }
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
color: var(--text-muted-foreground);
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.btn-ghost:hover {
|
||||
background: rgba(148, 163, 184, 0.08);
|
||||
color: var(--text-primary);
|
||||
border-color: transparent;
|
||||
}
|
||||
.icon-btn {
|
||||
width: 30px; height: 30px; padding: 0;
|
||||
background: transparent; border: 1px solid transparent;
|
||||
color: var(--text-muted-foreground);
|
||||
}
|
||||
.icon-btn:hover { background: rgba(148, 163, 184, 0.08); color: var(--text-primary); }
|
||||
|
||||
.btn-success {
|
||||
background: var(--success); color: #0a1a12; border-color: transparent; font-weight: 600;
|
||||
}
|
||||
.btn-success:hover { background: #55e0af; color: #0a1a12; }
|
||||
.btn-danger-outline {
|
||||
background: transparent; color: var(--danger); border-color: var(--danger-border);
|
||||
}
|
||||
.btn-danger-outline:hover { background: var(--danger-dim); color: var(--danger); border-color: var(--danger); }
|
||||
.btn-danger {
|
||||
background: var(--danger); color: #180808; border-color: transparent; font-weight: 600;
|
||||
}
|
||||
.btn-danger:hover { background: #fa8a8a; color: #180808; }
|
||||
|
||||
/* ====== Frame ====== */
|
||||
.frame {
|
||||
background: var(--bg-page);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 380px;
|
||||
height: 760px;
|
||||
}
|
||||
.chat {
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--bg-page);
|
||||
min-width: 0;
|
||||
}
|
||||
.chat-head {
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
background: var(--bg-sidebar);
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||
}
|
||||
.chat-head-title {
|
||||
font-family: 'Bricolage Grotesque', sans-serif;
|
||||
font-weight: 600; font-size: 14px; color: var(--text-heading);
|
||||
}
|
||||
.chat-head-sub {
|
||||
font-size: 11.5px; color: var(--text-muted);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
.chat-scroll {
|
||||
flex: 1; overflow-y: auto;
|
||||
padding: 24px 28px 16px;
|
||||
display: flex; flex-direction: column; gap: 16px;
|
||||
}
|
||||
.msg { max-width: 640px; display: flex; gap: 10px; align-items: flex-start; }
|
||||
.msg.user { align-self: flex-end; }
|
||||
.msg-av {
|
||||
width: 26px; height: 26px; border-radius: 50%;
|
||||
flex-shrink: 0; font-size: 11px; font-weight: 600;
|
||||
display: flex; align-items: center; justify-content: center; margin-top: 2px;
|
||||
}
|
||||
.msg.user .msg-av { background: var(--accent-dim); color: var(--accent); border: 1px solid var(--accent-border); }
|
||||
.msg.ai .msg-av { background: var(--warning-dim); color: var(--warning); border: 1px solid var(--warning-border); }
|
||||
.msg.system .msg-av { background: rgba(148,163,184,0.08); color: var(--text-muted); border: 1px solid var(--border-default); }
|
||||
.msg-body {
|
||||
background: var(--bg-card); border: 1px solid var(--border-default);
|
||||
border-radius: 10px; padding: 10px 13px; font-size: 13px; color: var(--text-primary);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.msg.user .msg-body { background: var(--accent-dim); border-color: var(--accent-border); color: var(--text-heading); }
|
||||
.msg.system .msg-body { background: transparent; border-style: dashed; color: var(--text-muted); font-size: 12px; font-style: italic; }
|
||||
.msg-meta {
|
||||
margin-top: 4px; font-size: 10.5px; color: var(--text-muted);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.composer-wrap { border-top: 1px solid var(--border-default); background: var(--bg-page); position: relative; }
|
||||
.composer { padding: 14px 20px 16px; display: flex; align-items: flex-end; gap: 10px; }
|
||||
.composer-input {
|
||||
flex: 1; min-height: 44px; background: var(--bg-card);
|
||||
border: 1px solid var(--border-default); border-radius: 10px;
|
||||
padding: 10px 14px; color: var(--text-muted-foreground);
|
||||
font-size: 13px; line-height: 1.4;
|
||||
display: flex; align-items: center;
|
||||
}
|
||||
.composer-send {
|
||||
width: 44px; height: 44px; border-radius: 10px;
|
||||
background: var(--accent); color: #0a0d14; border: 0;
|
||||
display: flex; align-items: center; justify-content: center; cursor: pointer;
|
||||
}
|
||||
|
||||
/* ====== Banner generic ====== */
|
||||
.banner {
|
||||
position: relative;
|
||||
padding: 12px 20px 14px;
|
||||
display: flex; gap: 14px; align-items: flex-start;
|
||||
border-top-width: 1px; border-top-style: solid;
|
||||
animation: fadeIn 260ms ease-out both;
|
||||
}
|
||||
.banner::before {
|
||||
content: '';
|
||||
position: absolute; left: 0; top: 0; bottom: 0;
|
||||
width: 3px;
|
||||
}
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
.banner-icon {
|
||||
width: 28px; height: 28px; border-radius: 7px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0; margin-top: 2px;
|
||||
}
|
||||
.banner-body { flex: 1; min-width: 0; }
|
||||
.banner-head {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
font-size: 10px; font-weight: 600; letter-spacing: 1.2px;
|
||||
text-transform: uppercase; font-family: 'Bricolage Grotesque', sans-serif;
|
||||
}
|
||||
.banner-title {
|
||||
margin-top: 3px; font-size: 14px; font-weight: 600;
|
||||
color: var(--text-heading); line-height: 1.35; letter-spacing: -0.005em;
|
||||
}
|
||||
.banner-note {
|
||||
margin-top: 3px; font-size: 12.5px; color: var(--text-muted-foreground);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.banner-actions {
|
||||
display: flex; gap: 8px; align-items: center;
|
||||
flex-shrink: 0; padding-top: 2px;
|
||||
}
|
||||
.pill {
|
||||
padding: 2px 7px; border-radius: 999px;
|
||||
font-size: 10.5px; font-weight: 700; letter-spacing: 0.5px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Verifying — amber pulse, mirrors the proposed color but with pulse */
|
||||
.banner-verify {
|
||||
background: linear-gradient(180deg, var(--warning-dim-strong) 0%, var(--warning-dim) 100%);
|
||||
border-top-color: var(--warning-border);
|
||||
}
|
||||
.banner-verify::before { background: var(--warning); }
|
||||
.banner-verify .banner-icon {
|
||||
background: var(--warning-dim-strong); border: 1px solid var(--warning-border); color: var(--warning);
|
||||
position: relative;
|
||||
}
|
||||
.banner-verify .banner-icon::after {
|
||||
content: ''; position: absolute; inset: -3px; border-radius: 9px;
|
||||
box-shadow: 0 0 0 0 rgba(251, 191, 36, 0.45);
|
||||
animation: pulseAmber 1.6s infinite;
|
||||
}
|
||||
@keyframes pulseAmber {
|
||||
0% { box-shadow: 0 0 0 0 rgba(251, 191, 36, 0.45); }
|
||||
70% { box-shadow: 0 0 0 10px rgba(251, 191, 36, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(251, 191, 36, 0); }
|
||||
}
|
||||
.banner-verify .banner-head { color: var(--warning); }
|
||||
.banner-verify .pill { background: rgba(251, 191, 36, 0.20); color: var(--warning); }
|
||||
|
||||
/* Partial — muted info/cyan to communicate "parked, outcome unknown" */
|
||||
.banner-partial {
|
||||
background: linear-gradient(180deg, var(--info-dim-strong) 0%, var(--info-dim) 100%);
|
||||
border-top-color: var(--info-border);
|
||||
}
|
||||
.banner-partial::before { background: var(--info); }
|
||||
.banner-partial .banner-icon { background: var(--info-dim-strong); border: 1px solid var(--info-border); color: var(--info); }
|
||||
.banner-partial .banner-head { color: var(--info); }
|
||||
.banner-partial .pill { background: rgba(103, 232, 249, 0.18); color: var(--info); }
|
||||
|
||||
/* AI-inferred — accent blue, AI-sourced */
|
||||
.banner-ai {
|
||||
background: linear-gradient(180deg, var(--accent-dim-strong) 0%, var(--accent-dim) 100%);
|
||||
border-top-color: var(--accent-border);
|
||||
}
|
||||
.banner-ai::before { background: var(--accent); }
|
||||
.banner-ai .banner-icon { background: var(--accent-dim-strong); border: 1px solid var(--accent-border); color: var(--accent); }
|
||||
.banner-ai .banner-head { color: var(--accent); }
|
||||
.banner-ai .pill { background: rgba(96, 165, 250, 0.20); color: var(--accent); }
|
||||
|
||||
/* Nudge — compact strip */
|
||||
.banner-nudge {
|
||||
padding: 8px 20px;
|
||||
background: var(--warning-dim);
|
||||
border-top-color: var(--warning-border);
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.banner-nudge::before { background: var(--warning); }
|
||||
.banner-nudge .nudge-icon {
|
||||
width: 16px; height: 16px; flex-shrink: 0; color: var(--warning);
|
||||
}
|
||||
.banner-nudge .nudge-title {
|
||||
flex: 1; font-size: 12.5px; color: var(--text-primary); font-weight: 500;
|
||||
}
|
||||
|
||||
/* ====== Task lane ====== */
|
||||
.lane {
|
||||
border-left: 1px solid var(--border-default);
|
||||
background: var(--bg-sidebar);
|
||||
display: flex; flex-direction: column; min-height: 0;
|
||||
}
|
||||
.lane-head {
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.lane-head-label {
|
||||
font-family: 'Bricolage Grotesque', sans-serif;
|
||||
font-weight: 600; font-size: 13px; color: var(--text-heading);
|
||||
}
|
||||
.lane-body {
|
||||
flex: 1; overflow-y: auto;
|
||||
padding: 14px 14px 10px;
|
||||
display: flex; flex-direction: column; gap: 16px;
|
||||
}
|
||||
.section-label {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
font-size: 10px; font-weight: 600; letter-spacing: 1.2px;
|
||||
text-transform: uppercase; color: var(--text-muted-foreground);
|
||||
padding: 0 2px 8px;
|
||||
}
|
||||
.dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; }
|
||||
.dot-accent { background: var(--accent); }
|
||||
.dot-danger { background: var(--danger); }
|
||||
.section-meta {
|
||||
color: var(--text-muted); font-weight: 500; letter-spacing: 0; text-transform: none;
|
||||
}
|
||||
.fact {
|
||||
background: var(--bg-card); border: 1px solid var(--border-default);
|
||||
border-left: 3px solid var(--accent); border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.fact + .fact { margin-top: 8px; }
|
||||
.fact-title { font-size: 12.5px; font-weight: 500; color: var(--text-heading); line-height: 1.4; }
|
||||
.fact-meta { margin-top: 3px; font-size: 10.5px; color: var(--text-muted); font-family: 'JetBrains Mono', monospace; }
|
||||
|
||||
.failed-pill {
|
||||
padding: 9px 11px; background: var(--bg-card);
|
||||
border: 1px dashed var(--danger-border); border-radius: 8px;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
font-size: 11.5px; color: var(--text-muted-foreground);
|
||||
}
|
||||
.failed-pill-title { flex: 1; color: var(--text-heading); font-weight: 500; }
|
||||
.failed-pill-badge {
|
||||
padding: 1px 6px; border-radius: 4px; font-size: 9.5px;
|
||||
font-weight: 700; letter-spacing: 0.4px;
|
||||
background: var(--danger-dim); color: var(--danger);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
border-top: 1px solid var(--border-default);
|
||||
padding: 12px 14px 14px;
|
||||
display: flex; gap: 8px;
|
||||
position: relative;
|
||||
}
|
||||
.btn-escalate { flex: 0 0 auto; min-width: 96px; background: transparent; color: var(--text-muted-foreground); }
|
||||
.btn-resolve {
|
||||
flex: 1; background: var(--accent); color: #0a0d14;
|
||||
border-color: transparent; font-weight: 600; padding: 10px 12px;
|
||||
}
|
||||
.btn-resolve:hover { background: #7ab4fb; color: #0a0d14; }
|
||||
|
||||
/* ====== Callouts ====== */
|
||||
.callout {
|
||||
margin-top: 20px; padding: 14px 16px;
|
||||
background: var(--bg-page); border: 1px solid var(--border-default);
|
||||
border-radius: 10px; font-size: 13px; color: var(--text-muted-foreground);
|
||||
line-height: 1.55; border-left: 3px solid var(--warning);
|
||||
}
|
||||
.callout strong { color: var(--text-heading); font-weight: 600; }
|
||||
|
||||
/* ====== State detail panels ====== */
|
||||
.states-title {
|
||||
margin-top: 48px; font-family: 'Bricolage Grotesque', sans-serif;
|
||||
font-weight: 600; font-size: 18px; color: var(--text-heading);
|
||||
}
|
||||
.states-sub { margin-top: 4px; color: var(--text-muted-foreground); font-size: 13px; }
|
||||
|
||||
.states {
|
||||
margin-top: 16px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
.state {
|
||||
background: var(--bg-page); border: 1px solid var(--border-default);
|
||||
border-radius: 10px; overflow: hidden;
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.state-label {
|
||||
padding: 10px 14px; border-bottom: 1px solid var(--border-default);
|
||||
font-family: 'Bricolage Grotesque', sans-serif;
|
||||
font-weight: 600; font-size: 12.5px; color: var(--text-heading);
|
||||
background: var(--bg-sidebar);
|
||||
}
|
||||
.state-body {
|
||||
padding: 0; background: var(--bg-page);
|
||||
min-height: 280px;
|
||||
display: flex; flex-direction: column; justify-content: flex-end;
|
||||
position: relative;
|
||||
}
|
||||
.state-mini-chat {
|
||||
flex: 1; padding: 14px 16px;
|
||||
font-size: 11px; color: var(--text-muted);
|
||||
display: flex; align-items: flex-end; gap: 6px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.mini-composer {
|
||||
border-top: 1px solid var(--border-default);
|
||||
padding: 10px 14px; display: flex; gap: 8px; align-items: center;
|
||||
}
|
||||
.mini-input {
|
||||
flex: 1; background: var(--bg-card);
|
||||
border: 1px solid var(--border-default); border-radius: 8px;
|
||||
padding: 7px 10px; font-size: 11.5px; color: var(--text-muted);
|
||||
}
|
||||
.mini-send {
|
||||
width: 28px; height: 28px; border-radius: 7px;
|
||||
background: var(--accent); color: #0a0d14; border: 0; font-size: 14px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
|
||||
.state-caption {
|
||||
padding: 10px 14px 12px; font-size: 11.5px;
|
||||
color: var(--text-muted-foreground); line-height: 1.5;
|
||||
border-top: 1px solid var(--border-default); background: var(--bg-sidebar);
|
||||
}
|
||||
.state-caption strong { color: var(--text-heading); font-weight: 600; }
|
||||
|
||||
/* ====== Escalate intercept popover ====== */
|
||||
.intercept-wrap {
|
||||
position: relative;
|
||||
padding: 24px 14px 14px;
|
||||
background: var(--bg-page);
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.intercept-popover {
|
||||
position: absolute;
|
||||
bottom: 70px;
|
||||
left: 14px;
|
||||
width: 340px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-hover);
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
box-shadow: 0 18px 40px rgba(0,0,0,0.55), 0 0 0 1px rgba(96,165,250,0.15);
|
||||
}
|
||||
.intercept-popover::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -7px; left: 40px;
|
||||
width: 14px; height: 14px;
|
||||
background: var(--bg-card);
|
||||
border-right: 1px solid var(--border-hover);
|
||||
border-bottom: 1px solid var(--border-hover);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.intercept-head {
|
||||
font-family: 'Bricolage Grotesque', sans-serif;
|
||||
font-weight: 600; font-size: 13px; color: var(--text-heading);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.intercept-sub {
|
||||
font-size: 12px; color: var(--text-muted-foreground);
|
||||
line-height: 1.5; margin-bottom: 12px;
|
||||
}
|
||||
.intercept-options {
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
}
|
||||
.intercept-option {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 12px; border-radius: 8px;
|
||||
background: var(--bg-elevated); border: 1px solid var(--border-default);
|
||||
font-size: 12.5px; color: var(--text-primary);
|
||||
cursor: pointer; text-align: left; width: 100%;
|
||||
transition: border-color 0.12s, background-color 0.12s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.intercept-option:hover { border-color: var(--border-hover); background: var(--bg-sidebar); }
|
||||
.intercept-option.primary {
|
||||
border-color: var(--danger-border); background: var(--danger-dim);
|
||||
}
|
||||
.intercept-option.primary:hover { border-color: var(--danger); background: var(--danger-dim-strong); }
|
||||
.intercept-kbd {
|
||||
margin-left: auto; font-size: 10.5px; color: var(--text-muted);
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
background: rgba(148,163,184,0.08);
|
||||
padding: 2px 6px; border-radius: 4px;
|
||||
}
|
||||
|
||||
.mock-btn-row {
|
||||
display: flex; gap: 8px;
|
||||
padding: 12px 14px 14px;
|
||||
border-top: 1px solid var(--border-default);
|
||||
}
|
||||
.mock-escalate {
|
||||
background: transparent; color: var(--text-muted-foreground);
|
||||
border: 1px solid var(--border-default); padding: 9px 14px;
|
||||
border-radius: 8px; font-size: 12.5px; min-width: 96px;
|
||||
position: relative;
|
||||
}
|
||||
.mock-escalate.active {
|
||||
border-color: var(--danger-border); color: var(--danger);
|
||||
background: var(--danger-dim);
|
||||
}
|
||||
.mock-resolve {
|
||||
flex: 1; background: var(--accent); color: #0a0d14;
|
||||
border: 0; font-weight: 600; padding: 9px 12px;
|
||||
border-radius: 8px; font-size: 12.5px;
|
||||
}
|
||||
|
||||
/* Partial inline input row */
|
||||
.partial-note {
|
||||
margin-top: 4px;
|
||||
padding: 6px 10px;
|
||||
background: rgba(103, 232, 249, 0.08);
|
||||
border: 1px solid var(--info-border);
|
||||
border-radius: 6px;
|
||||
font-size: 12px; color: var(--text-primary);
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
font-style: italic;
|
||||
}
|
||||
.partial-note-label {
|
||||
font-style: normal; color: var(--info);
|
||||
font-size: 10.5px; font-weight: 700; letter-spacing: 0.6px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.lane-body::-webkit-scrollbar,
|
||||
.chat-scroll::-webkit-scrollbar { width: 6px; }
|
||||
.lane-body::-webkit-scrollbar-thumb,
|
||||
.chat-scroll::-webkit-scrollbar-thumb { background: var(--border-hover); border-radius: 3px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<div class="page-header">
|
||||
<div class="page-title">Post-apply outcome states — how we recognize whether a fix worked</div>
|
||||
<div class="page-sub">
|
||||
Hero frame shows the <strong style="color:var(--text-primary)">Verifying</strong> state — what the banner becomes the moment the engineer clicks Apply. Below, four detail panels show the other outcome paths: <strong style="color:var(--text-primary)">Partial apply</strong>, <strong style="color:var(--text-primary)">AI-inferred outcome</strong> from chat, <strong style="color:var(--text-primary)">Escalate-intercept</strong>, and the <strong style="color:var(--text-primary)">Nudge</strong> that appears when the engineer keeps chatting without confirming.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ HERO: VERIFYING ============ -->
|
||||
<div class="frame">
|
||||
|
||||
<div class="chat">
|
||||
<div class="chat-head">
|
||||
<div>
|
||||
<div class="chat-head-title">Outlook won't authenticate after tenant migration</div>
|
||||
<div class="chat-head-sub">ticket #48213 · in progress · 14:26</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-scroll">
|
||||
<div class="msg ai">
|
||||
<div class="msg-av">AI</div>
|
||||
<div>
|
||||
<div class="msg-body">Given Credential Manager still has entries for the prior tenant, the cleanest path is to clear those and rebuild the local Outlook profile.</div>
|
||||
<div class="msg-meta">14:22</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="msg user">
|
||||
<div>
|
||||
<div class="msg-body">Okay, I'll run the script now.</div>
|
||||
<div class="msg-meta">14:24</div>
|
||||
</div>
|
||||
<div class="msg-av">ME</div>
|
||||
</div>
|
||||
<div class="msg system">
|
||||
<div class="msg-av">✓</div>
|
||||
<div>
|
||||
<div class="msg-body">Applied fix: Clear cached credentials + rebuild Outlook profile — script completed without errors at 14:24.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VERIFY BANNER (persistent after Apply) -->
|
||||
<div class="composer-wrap">
|
||||
<div class="banner banner-verify" role="region" aria-label="Verify fix outcome">
|
||||
<div class="banner-icon">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
</div>
|
||||
<div class="banner-body">
|
||||
<div class="banner-head">
|
||||
<span>Verifying</span>
|
||||
<span class="pill">Applied 14:24 · 2m ago</span>
|
||||
</div>
|
||||
<div class="banner-title">Did "Clear cached credentials + rebuild Outlook profile" work?</div>
|
||||
<div class="banner-note">Mark the outcome so the AI can either close the session with this as the resolution, or propose something else.</div>
|
||||
</div>
|
||||
<div class="banner-actions">
|
||||
<button class="btn btn-ghost" aria-label="More options" title="Mark partial apply, re-open details">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="5" cy="12" r="1.6"/><circle cx="12" cy="12" r="1.6"/><circle cx="19" cy="12" r="1.6"/></svg>
|
||||
</button>
|
||||
<button class="btn btn-danger-outline">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
Didn't work
|
||||
</button>
|
||||
<button class="btn btn-success">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
It worked
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="composer">
|
||||
<div class="composer-input">Tell the AI what happened — or click an outcome above</div>
|
||||
<button class="composer-send" aria-label="Send">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Task lane: fix is now in "verifying" status — no longer a standalone suggested fix -->
|
||||
<div class="lane">
|
||||
<div class="lane-head">
|
||||
<div class="lane-head-label">Task lane</div>
|
||||
</div>
|
||||
<div class="lane-body">
|
||||
<section>
|
||||
<div class="section-label">
|
||||
<span class="dot dot-accent"></span>
|
||||
What we know
|
||||
<span class="section-meta">· 5 facts</span>
|
||||
</div>
|
||||
<div class="fact">
|
||||
<div class="fact-title">User cannot authenticate to Outlook; repeated 401s from Exchange Online.</div>
|
||||
<div class="fact-meta">promoted 14:02 · from ticket</div>
|
||||
</div>
|
||||
<div class="fact">
|
||||
<div class="fact-title">Credential Manager still references the prior tenant from six months ago.</div>
|
||||
<div class="fact-meta">promoted 14:07 · from chat</div>
|
||||
</div>
|
||||
<div class="fact">
|
||||
<div class="fact-title">Teams + SharePoint work on same workstation — isolated to Outlook.</div>
|
||||
<div class="fact-meta">promoted 14:22 · from chat</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="action-bar">
|
||||
<button class="btn btn-escalate">Escalate</button>
|
||||
<button class="btn btn-resolve">Resolve</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="callout">
|
||||
<strong>How Verifying works.</strong> Clicking Apply transitions the banner into this state instead of dismissing it. No timeout — the banner stays pinned until the engineer marks <em>Worked</em>, <em>Didn't work</em>, or <em>Partial</em> (overflow). If they ignore it and keep chatting, the Nudge state (panel D below) appears after a few messages. If they hit the task lane's <em>Resolve</em> button without clicking either outcome, we auto-stamp <code style="font-family:'JetBrains Mono',monospace;font-size:11.5px;background:var(--bg-card);padding:1px 5px;border-radius:3px;">applied_success</code>. If they hit <em>Escalate</em>, panel C intercepts.
|
||||
</div>
|
||||
|
||||
<!-- ============ DETAIL PANELS ============ -->
|
||||
<div class="states-title">Outcome branches</div>
|
||||
<div class="states-sub">Four paths from Verifying to a final status. Each one writes to <code style="font-family:'JetBrains Mono',monospace;font-size:12px;background:var(--bg-card);padding:1px 6px;border-radius:3px;color:var(--text-primary)">session_suggested_fixes.status</code> so the AI's next turn has ground truth about what's been tried.</div>
|
||||
|
||||
<div class="states">
|
||||
|
||||
<!-- A. PARTIAL -->
|
||||
<div class="state">
|
||||
<div class="state-label">A. Partial apply — "I did some of it"</div>
|
||||
<div class="state-body">
|
||||
<div class="state-mini-chat">…engineer picked "Mark partial…" from the verify banner's overflow menu</div>
|
||||
<div class="banner banner-partial">
|
||||
<div class="banner-icon">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="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>
|
||||
</div>
|
||||
<div class="banner-body">
|
||||
<div class="banner-head">
|
||||
<span>Partially applied</span>
|
||||
<span class="pill">Parked</span>
|
||||
</div>
|
||||
<div class="banner-title">Clear cached credentials + rebuild Outlook profile</div>
|
||||
<div class="partial-note">
|
||||
<span class="partial-note-label">Note</span>
|
||||
<span>Ran cred clear — skipped profile rebuild, user in a meeting. Back at 3:30.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="banner-actions">
|
||||
<button class="btn btn-ghost">Edit note</button>
|
||||
<button class="btn btn-danger-outline">Didn't work</button>
|
||||
<button class="btn">Finish it ›</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mini-composer">
|
||||
<div class="mini-input">Type a message…</div>
|
||||
<button class="mini-send">↑</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="state-caption">
|
||||
<strong>Status:</strong> <code style="font-family:'JetBrains Mono',monospace;font-size:11.5px">applied_partial</code>, with <code style="font-family:'JetBrains Mono',monospace;font-size:11.5px">partial_notes</code> free-text. Not terminal — banner stays pinned until engineer marks a terminal outcome, or clicks <em>Finish it</em> to re-run the remainder and flip back to Verifying. AI treats partial as "tried but uncertain" — doesn't re-propose, but doesn't assume failure either.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- B. AI-INFERRED CONFIRM -->
|
||||
<div class="state">
|
||||
<div class="state-label">B. AI-inferred outcome — from chat</div>
|
||||
<div class="state-body">
|
||||
<div class="state-mini-chat" style="flex-direction:column;align-items:flex-end;gap:8px;opacity:0.8">
|
||||
<div style="background:var(--bg-card);border:1px solid var(--border-default);border-radius:10px;padding:8px 12px;font-size:12px;color:var(--text-heading);font-style:normal;font-family:inherit;max-width:80%;"><strong style="font-weight:500">Engineer:</strong> "yep that fixed it, thanks"</div>
|
||||
<div style="font-size:10.5px;color:var(--text-muted);padding-right:2px;">14:31 · user message triggered <code style="font-family:'JetBrains Mono',monospace;font-size:10.5px">[FIX_OUTCOME]</code></div>
|
||||
</div>
|
||||
<div class="banner banner-ai">
|
||||
<div class="banner-icon">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/></svg>
|
||||
</div>
|
||||
<div class="banner-body">
|
||||
<div class="banner-head">
|
||||
<span>AI detected outcome</span>
|
||||
<span class="pill">Success · 92%</span>
|
||||
</div>
|
||||
<div class="banner-title">AI thinks the fix resolved the issue — confirm?</div>
|
||||
<div class="banner-note">Based on your message at 14:31. One click closes the session with this fix as the documented resolution.</div>
|
||||
</div>
|
||||
<div class="banner-actions">
|
||||
<button class="btn btn-ghost">Not yet</button>
|
||||
<button class="btn btn-danger-outline">No, didn't work</button>
|
||||
<button class="btn btn-success">Confirm · Resolve</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mini-composer">
|
||||
<div class="mini-input">Type a message…</div>
|
||||
<button class="mini-send">↑</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="state-caption">
|
||||
<strong>Triggered by</strong> the new <code style="font-family:'JetBrains Mono',monospace;font-size:11.5px">[FIX_OUTCOME fix_id=… outcome=success]</code> marker from the system prompt. Engineer stays in the loop — the AI <em>proposes</em> the outcome, doesn't set it. One-click accept fires the normal Resolve flow. Works for failure too ("still broken" → <em>No, didn't work</em> pre-selected, with the AI's reasoning shown).
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- C. ESCALATE INTERCEPT -->
|
||||
<div class="state">
|
||||
<div class="state-label">C. Escalate-intercept — capture outcome before handoff</div>
|
||||
<div class="state-body">
|
||||
<div class="intercept-wrap">
|
||||
<div class="intercept-popover">
|
||||
<div class="intercept-head">Before escalating — what happened with the fix?</div>
|
||||
<div class="intercept-sub">"Clear cached credentials" is still in the Verifying state. Tag its outcome so the senior picking this up knows what's been tried.</div>
|
||||
<div class="intercept-options">
|
||||
<button class="intercept-option primary">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--danger)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
The fix didn't work
|
||||
<span class="intercept-kbd">↵</span>
|
||||
</button>
|
||||
<button class="intercept-option">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="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>
|
||||
It worked — escalating for another reason
|
||||
</button>
|
||||
<button class="intercept-option">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
||||
Never actually applied it
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mock-btn-row">
|
||||
<button class="mock-escalate active">Escalate</button>
|
||||
<button class="mock-resolve">Resolve</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="state-caption">
|
||||
<strong>Fires when</strong> engineer clicks Escalate while a fix is in Verifying (or Partial). Defaults to <em>Didn't work</em> on Enter — common case. <em>Escalating for another reason</em> preserves success; <em>Never applied</em> flips to <code style="font-family:'JetBrains Mono',monospace;font-size:11.5px">dismissed</code>. Takes 1s and makes the escalation narrative honest for whoever picks it up.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- D. NUDGE -->
|
||||
<div class="state">
|
||||
<div class="state-label">D. Nudge — passive prompt after a few messages</div>
|
||||
<div class="state-body">
|
||||
<div class="state-mini-chat" style="flex-direction:column;align-items:flex-end;gap:6px;opacity:0.8;">
|
||||
<div style="background:var(--bg-card);border:1px solid var(--border-default);border-radius:10px;padding:7px 11px;font-size:11.5px;color:var(--text-heading);font-style:normal;font-family:inherit;max-width:70%;">"user is rebooting"</div>
|
||||
<div style="background:var(--bg-card);border:1px solid var(--border-default);border-radius:10px;padding:7px 11px;font-size:11.5px;color:var(--text-heading);font-style:normal;font-family:inherit;max-width:75%;">"okay it's back up, signing in now"</div>
|
||||
<div style="background:var(--bg-card);border:1px solid var(--border-default);border-radius:10px;padding:7px 11px;font-size:11.5px;color:var(--text-heading);font-style:normal;font-family:inherit;max-width:75%;">"going to try opening Outlook"</div>
|
||||
</div>
|
||||
<div class="banner banner-nudge">
|
||||
<svg class="nudge-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v4"/><path d="M12 16h.01"/></svg>
|
||||
<span class="nudge-title">Did <strong style="color:var(--text-heading)">"Clear cached credentials"</strong> work?</span>
|
||||
<button class="btn btn-ghost" style="padding:4px 10px">Still checking</button>
|
||||
<button class="btn btn-danger-outline" style="padding:4px 10px">No</button>
|
||||
<button class="btn btn-success" style="padding:4px 10px">Yes</button>
|
||||
</div>
|
||||
<div class="mini-composer">
|
||||
<div class="mini-input">Type a message…</div>
|
||||
<button class="mini-send">↑</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="state-caption">
|
||||
<strong>Appears after</strong> ~3 post-apply engineer messages with no outcome click. Collapses the verify banner into this thin nudge strip above it so chat space isn't eaten. Passive — never auto-marks anything. <em>Still checking</em> silences the nudge for another 3 messages. Yes/No route to the normal Success / Failed flows.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
1673
docs/FlowAssist_Migration/phase-8-fix-outcome-banner.md
Normal file
1673
docs/FlowAssist_Migration/phase-8-fix-outcome-banner.md
Normal file
File diff suppressed because it is too large
Load Diff
1897
docs/FlowAssist_Migration/phase-9-implementation-plan.md
Normal file
1897
docs/FlowAssist_Migration/phase-9-implementation-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
384
docs/FlowAssist_Migration/phase-9-script-builder-tab.md
Normal file
384
docs/FlowAssist_Migration/phase-9-script-builder-tab.md
Normal file
@@ -0,0 +1,384 @@
|
||||
# FlowPilot Phase 9 — Tabbed Script Builder + NoTemplateDialog relocation
|
||||
|
||||
**Date:** 2026-04-23
|
||||
**Branch target:** `feat/flowpilot-migration` (continuation of Phases 0–8)
|
||||
**Depends on:** Phase 8 (ProposalBanner in chat region)
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Close the two remaining open items from the FlowPilot migration handoff:
|
||||
|
||||
1. **NoTemplateDialog narrow-lane bug** — today the dialog renders in the task lane (~340px) and its `grid-cols-1 sm:grid-cols-3` layout crushes the three option cards. When the AI proposes a fix with no drafted script, all three cards render disabled, producing a dead end.
|
||||
2. **Tabbed Script Builder inside the chat** — give the engineer a way to draft the missing script without leaving the session (either by chatting with the AI or hand-writing in a code editor), then feed the draft back into the existing fix lifecycle.
|
||||
|
||||
Plus two Phase 8 cleanup items flagged during code review:
|
||||
|
||||
3. **`EscalateInterceptDialog` missing the Partial choice** — if a fix is in `applied_partial` when the engineer escalates, the intercept dialog's current three choices (worked / didn't work / never applied) don't match. Add a fourth choice for partial.
|
||||
4. **`applied_at` semantics correction** — today Phase 8's `handleApplyFix` stamps `applied_at` on every banner Apply click, starting the Verifying timer even when the engineer is only opening a drafting/evaluation surface. Move the stamp to the actual run-action handlers (see §5).
|
||||
|
||||
This phase depends on Phase 8's `ProposalBanner` already being in the chat region — it reuses the same "chat-region-owns-Apply-flow" philosophy.
|
||||
|
||||
---
|
||||
|
||||
## Architectural decisions (settled during brainstorming)
|
||||
|
||||
| # | Decision | Rationale |
|
||||
|---|---|---|
|
||||
| 1 | When a fix has no `ai_drafted_script`, the banner's Apply button routes **directly** to the Script Builder tab (bypassing `NoTemplateDialog` entirely). | Banner is the single entry point for Apply. `NoTemplateDialog` stays narrowly scoped to evaluating a draft that actually exists. |
|
||||
| 2 | Inside the Script Builder tab, the default experience is AI-driven — a new `ScriptBuilderTab` controller owns session lifecycle + submit, and *renders* `ScriptBuilderChat` (which stays purely presentational). A "✎ Write it myself" button in the tab's header toolbar swaps the controller's render into a Monaco editor. | AI is the common path. Persistence semantics belong on the controller, not the chat display component (`ScriptBuilderChat` already exposes `onSaveScript` as its seam — the controller wires that callback). |
|
||||
| 3 | The manual editor uses **Monaco**, reusing the pattern from `frontend/src/components/tree-editor/code-mode/CodeModeEditor.tsx`. | Monaco is already a dependency (`@monaco-editor/react` + `monaco-editor`). No bundle cost, proven pattern. |
|
||||
| 4 | The Script Builder tab is **always present while the fix is non-terminal** (no close affordance). An **indicator dot** on the tab signals in-progress draft state. | Matches Phase 8's `display: none` philosophy — engineers move freely between chat and draft without tracking a separate open/close state. |
|
||||
| 5 | `NoTemplateDialog` (draft-exists case) moves from `TaskLane.bottomSlot` to the **chat region** (sibling of `ProposalBanner`, slides up above composer). | Script evaluation is an action surface, not a context surface — belongs with the other action surfaces. Chat region is wide enough for the three cards to actually fit side-by-side. |
|
||||
| 6 | `EscalateInterceptDialog` gains a **fourth "Partial" choice** that writes `applied_partial` with a notes prompt. | Closes the gap flagged in Phase 8 final review. Minimal incremental cost since the dialog is already getting touched. |
|
||||
| 7 | `applied_at` is stamped only when the engineer commits to an action that **runs or triggers** a script — not on banner Apply click. Opening a drafting/evaluation surface no longer starts the Verifying timer. | Prevents false "applied" state when the engineer is still authoring. Corrects a Phase 8 over-eager stamp that this phase would otherwise multiply across three surfaces. |
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### 1. Chat region gets a tab strip
|
||||
|
||||
A two-tab strip at the top of the chat region:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ [Chat] [Script Builder ●] │
|
||||
├──────────────────────────────────────┤
|
||||
│ │
|
||||
│ (content per active tab, │
|
||||
│ via display:none toggling) │
|
||||
│ │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **When the strip renders:** only when an `activeFix` exists AND the fix is non-terminal AND (`fix.ai_drafted_script` is null AND `fix.script_template_id` is null) — i.e., the fix genuinely needs a script drafted. Otherwise the chat region shows without tabs.
|
||||
- **Tab switching uses `display: none`**, not unmount. Chat scroll position, draft message, and Script Builder state all persist across switches.
|
||||
- **Indicator dot** on the Script Builder tab fires when there's in-progress draft state: at least one AI message sent in the `ScriptBuilderChat`, or non-empty Monaco buffer. Clears when the draft is submitted.
|
||||
- **Session switch** clears tab state via the existing `resetSessionDerivedState` helper.
|
||||
|
||||
### 2. Script Builder tab content
|
||||
|
||||
A new controller component `ScriptBuilderTab` owns the inline lifecycle:
|
||||
- Creates / resumes a `script_builder_sessions` row with `origin='pilot_inline'` + `ai_session_id = <pilot session id>`.
|
||||
- Manages AI-chat message state (via the existing script-builder message endpoints) and the Monaco editor buffer.
|
||||
- On submit, fires `PATCH /ai-sessions/{sid}/suggested-fixes/{fid}/script`.
|
||||
|
||||
`ScriptBuilderChat` itself is **unchanged** — it stays a pure display component taking `messages`, `language`, `onViewScript`, `onSaveScript`, `isLoading`. The controller wires `onSaveScript` to its submit path instead of the template-creation path the standalone `/script-builder` page uses.
|
||||
|
||||
A header toolbar above the controller's render area hosts the mode toggle:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ Script Builder · Outlook fix │
|
||||
│ [✎ Write myself]│
|
||||
├──────────────────────────────────────┤
|
||||
│ (mode-specific content) │
|
||||
│ │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Clicking **✎ Write myself** flips `scriptBuilderMode` to `'editor'` — the controller renders Monaco in place of `ScriptBuilderChat`, pre-loaded with a scaffold (fix description as a language-appropriate comment header + an empty body).
|
||||
- A reciprocal **✨ Back to AI** button in editor mode returns to the chat.
|
||||
- Switching modes **does not discard** work. The Monaco buffer and the script-builder session both persist across toggles. This matters when an engineer drafts with AI, switches to editor to tweak a line, then considers going back.
|
||||
- Both modes share a single terminal action: the controller's **Submit → `PATCH /ai-sessions/{sid}/suggested-fixes/{fid}/script`**. On success the fix gains `ai_drafted_script`; the tab strip disappears (since the fix no longer needs a script) and the banner's Apply button now routes to `NoTemplateDialog` in the chat region.
|
||||
- **Submit does NOT stamp `applied_at`.** A draft is not an application — see §5 Apply lifecycle below.
|
||||
|
||||
### 3. NoTemplateDialog relocation to chat region
|
||||
|
||||
- Removed from `TaskLane.bottomSlot`. Renders in the chat region, slide-up-above-composer (same mechanical placement as `ProposalBanner`).
|
||||
- The three-card layout (`grid-cols-3` at the chat region's natural width) actually fits — no `grid-cols-1` regression needed.
|
||||
- Opens when the engineer clicks Apply on the banner AND `fix.ai_drafted_script` is non-empty.
|
||||
- Decision semantics unchanged (still `one_off` / `draft_template` / `build_template` with the same server-side effects) except for the moved apply stamp — see §5. Only the render location changes beyond that.
|
||||
|
||||
### 4. Banner Apply routing (updated)
|
||||
|
||||
Three mutually-exclusive outcomes based on the fix's shape:
|
||||
|
||||
```
|
||||
handleApplyFix():
|
||||
if fix.script_template_id:
|
||||
open TemplateMatchPanel (unchanged — still renders in task lane for now)
|
||||
elif fix.ai_drafted_script:
|
||||
open NoTemplateDialog in chat region (new location, Chat tab)
|
||||
else:
|
||||
open Script Builder tab in chat region (new tab)
|
||||
```
|
||||
|
||||
The NoTemplateDialog-in-chat-region path lives on the **Chat tab** (slides up above composer; the tab strip only renders for the no-draft case, so when NoTemplateDialog shows, the tab strip is not on screen). The Script Builder tab path is the opposite — tab strip renders, engineer is on the Script Builder tab.
|
||||
|
||||
`TemplateMatchPanel` stays in the task lane for this phase — it's a different surface with different interactions and it's not broken. Moving it is possible future work.
|
||||
|
||||
### 5. Apply lifecycle — `applied_at` semantics correction
|
||||
|
||||
**Problem.** Today (Phase 8) `handleApplyFix` calls `POST /apply` the moment the banner's Apply button is clicked, stamping `applied_at` regardless of what happens next. This starts the Verifying timer (nudge countdown, Resolve auto-success, Escalate intercept) even if the engineer is only opening a drafting surface and hasn't actually run anything yet. For the no-draft path introduced in this phase, that's clearly wrong — opening the Script Builder tab is the start of *authoring*, not the start of *verifying*.
|
||||
|
||||
**Rule.** `applied_at` is stamped **only when the engineer commits to an action that produces or triggers a run**, not when they open a surface:
|
||||
|
||||
| Banner Apply click → routes to... | Stamps `applied_at`? |
|
||||
|---|---|
|
||||
| `TemplateMatchPanel` (existing flow) | Only when the engineer clicks a new explicit **"✓ I ran this"** action inside the panel (see below) |
|
||||
| `NoTemplateDialog` → `one_off` card ("Run now, no template") | **Yes** — the card click declares "I'm running this now" |
|
||||
| `NoTemplateDialog` → `draft_template` card ("Run now, templatize after") | **Yes** — same declaration, the template proposal is a side effect |
|
||||
| `NoTemplateDialog` → `build_template` card ("Just open the builder") | No — no run is declared; the engineer is going off to author a proper template |
|
||||
| Script Builder tab → Submit | No — just produces a draft. Engineer then clicks Apply again, gets `NoTemplateDialog`, picks `one_off` or `draft_template` to declare the run |
|
||||
|
||||
**New explicit "I ran this" action in `TemplateMatchPanel`.** Today the panel has Generate, Copy, and Edit Parameters — none of which commit to running. Copying doesn't imply running; the engineer can walk away. This phase adds a distinct primary button (accent-colored, below Copy) labeled **"✓ I ran this"** or **"Mark as applied"**. Click → calls `applyFix` → fix transitions to Verifying. Until clicked, the fix stays in `proposed`.
|
||||
|
||||
**Implementation.**
|
||||
- Remove `sessionSuggestedFixesApi.applyFix(...)` call from `handleApplyFix`. Move it to the three run-declaring call sites: `NoTemplateDialog`'s `handleScriptDecision('one_off' | 'draft_template')` paths AND the new `TemplateMatchPanel` "I ran this" button. The `applyFix` endpoint itself (from Phase 8 Issue #2) stays unchanged — only its call sites move.
|
||||
- Until `applied_at` is stamped, the fix remains in `proposed`. `bannerMode` computation already returns `'proposed'` when `applied_at` is null, so the banner naturally stays on Proposed state through the entire drafting phase.
|
||||
- **Phase 8 consequence.** This is a semantic revision of Phase 8, not just Phase 9 behavior. Tests must assert: opening `TemplateMatchPanel` does NOT stamp `applied_at`; clicking "I ran this" DOES; `NoTemplateDialog` `one_off` AND `draft_template` both DO; `build_template` does NOT.
|
||||
|
||||
### 6. EscalateInterceptDialog partial choice
|
||||
|
||||
Adds a fourth button to the existing popover:
|
||||
|
||||
| Existing choices | New choice |
|
||||
|---|---|
|
||||
| The fix didn't work | (existing) |
|
||||
| It worked — escalating for another reason | (existing) |
|
||||
| Never actually applied it | (existing) |
|
||||
| **I applied some of it — partial** | **NEW** |
|
||||
|
||||
- When clicked: prompts for partial notes (same pattern as the banner's Partial path — `window.prompt` for now, matching Phase 8's interim), then calls `patchOutcome('applied_partial', notes)`.
|
||||
- `handleInterceptChoice` gains an `applied_partial` branch. The `InterceptChoice` type already includes `'applied_partial'` via `FixOutcome | 'never_applied'`, so no type changes needed.
|
||||
- When a fix enters the dialog already in `applied_partial` state, the fourth button is hidden (can't transition partial → partial with different semantics). The "didn't work" button remains available to progress to `applied_failed`.
|
||||
|
||||
---
|
||||
|
||||
## Data model
|
||||
|
||||
### New migration
|
||||
|
||||
`script_builder_sessions` **already has** `ai_session_id` (FK → `ai_sessions.id`, nullable, `ON DELETE SET NULL`) with the comment "Link to FlowPilot session if launched from there." The existing column is the link we need — no new FK is added. The migration introduces only the `origin` discriminator plus a uniqueness guard for inline sessions:
|
||||
|
||||
```sql
|
||||
ALTER TABLE script_builder_sessions
|
||||
ADD COLUMN origin VARCHAR(20) NOT NULL DEFAULT 'standalone';
|
||||
|
||||
ALTER TABLE script_builder_sessions
|
||||
ADD CONSTRAINT ck_script_builder_sessions_origin
|
||||
CHECK (origin IN ('standalone', 'pilot_inline'));
|
||||
|
||||
-- Invariant: pilot_inline rows must be linked to a pilot session.
|
||||
-- Standalone rows may or may not be linked (legacy back-channel).
|
||||
ALTER TABLE script_builder_sessions
|
||||
ADD CONSTRAINT ck_script_builder_sessions_origin_ai_session
|
||||
CHECK (origin <> 'pilot_inline' OR ai_session_id IS NOT NULL);
|
||||
|
||||
-- Uniqueness: at most one pilot_inline session per (user, pilot session).
|
||||
-- Required to back the get-or-create semantics on the endpoint and prevent
|
||||
-- duplicate scratch rows on remount. Partial index scoped to pilot_inline
|
||||
-- so standalone rows are unaffected.
|
||||
CREATE UNIQUE INDEX ux_script_builder_sessions_pilot_inline
|
||||
ON script_builder_sessions (user_id, ai_session_id)
|
||||
WHERE origin = 'pilot_inline';
|
||||
```
|
||||
|
||||
`origin = 'standalone'` → existing `/script-builder` page usage (existing rows backfill to this default). `origin = 'pilot_inline'` → new Script Builder tab; `ai_session_id` is populated at row creation.
|
||||
|
||||
`origin` earns its keep as an explicit discriminator for:
|
||||
- Filtering (`list_sessions` / `count_user_sessions` exclude `pilot_inline` by default — see §Data model filter changes below).
|
||||
- Future split-quota billing (decided to count as one billable session for now, but tagged for analytics).
|
||||
|
||||
### Data-model filter changes — `script_builder_sessions` list + count
|
||||
|
||||
Inline sessions would otherwise pollute the standalone `/script-builder` dashboard and count against the per-user 5-session cap enforced by the `POST /script-builder/sessions` endpoint. Required changes:
|
||||
|
||||
- `script_builder_service.list_sessions(user_id)` → default scope `origin = 'standalone'`. Callers that genuinely want all rows (e.g., an admin dashboard in a future phase) can pass an explicit `include_inline=True` flag, but no current caller needs it.
|
||||
- `script_builder_service.count_user_sessions(user_id)` → same scope.
|
||||
- Both changes covered by tests:
|
||||
- 5 `pilot_inline` sessions should still leave the engineer free to create 5 standalone sessions (no cap interaction).
|
||||
- `list_sessions` returns only `standalone` rows.
|
||||
|
||||
### New backend endpoint
|
||||
|
||||
```
|
||||
PATCH /api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/script
|
||||
```
|
||||
|
||||
Request:
|
||||
```json
|
||||
{
|
||||
"ai_drafted_script": "string (required, 1..50_000 chars)",
|
||||
"ai_drafted_parameters": { /* optional JSONB */ }
|
||||
}
|
||||
```
|
||||
|
||||
Behavior:
|
||||
- Auth: `require_engineer_or_admin` + `_load_session_or_404`.
|
||||
- 404 if fix not found on that session.
|
||||
- 409 if fix is in a terminal status (`applied_success`, `applied_failed`, `dismissed`) — a drafted script can't be attached after the fix is done.
|
||||
- Sets `fix.ai_drafted_script` + `fix.ai_drafted_parameters`.
|
||||
- **Does NOT stamp `fix.applied_at`.** A draft is not an application — see §5 above.
|
||||
- **Bumps `ai_sessions.state_version`** — the fix just transitioned from "needs drafting" to "has draft", which affects Resolve/Escalate preview regeneration.
|
||||
- Returns `SessionSuggestedFixResponse`.
|
||||
|
||||
### ScriptBuilderTab controller (frontend) — no changes to `ScriptBuilderChat`
|
||||
|
||||
`ScriptBuilderChat` (`frontend/src/components/script-builder/ScriptBuilderChat.tsx`) is a presentational component taking `messages`, `language`, `onViewScript`, `onSaveScript`, `isLoading`. **It does not need a `mode` prop** — adding persistence semantics to a display component would be wrong.
|
||||
|
||||
Instead, introduce a new controller component `frontend/src/components/pilot/ScriptBuilderTab.tsx` that owns the inline lifecycle:
|
||||
|
||||
- On mount: **get-or-create** the single inline `script_builder_sessions` row for `(current user, current pilot session)` via the existing `POST /script-builder/sessions` endpoint, passing `origin: 'pilot_inline'` and the current pilot session id for `ai_session_id`. The endpoint becomes idempotent for `origin='pilot_inline'` — if a row exists for that `(user_id, ai_session_id)` pair, it's returned; otherwise created. The partial unique index on the DB backs the invariant independent of endpoint code. Remounting (tab hide/show, page refresh) resumes the same session — no duplicates, no lost draft continuity.
|
||||
- Holds local state for the AI message list, the Monaco buffer, and `scriptBuilderMode`.
|
||||
- Renders `ScriptBuilderChat` in AI mode with `onSaveScript` wired to the inline submit path (PATCH /script), NOT the standalone template-creation path.
|
||||
- Renders Monaco (via existing `CodeModeEditor` pattern) in `'editor'` mode with its own Save button that triggers the same submit.
|
||||
- Emits an `onScriptDrafted` event to `AssistantChatPage` on success so the page can `setActiveFix(updated)`, hide the tab strip, and return the engineer to Chat tab.
|
||||
|
||||
The standalone `/script-builder` page retains its current behavior unchanged — it continues to create `script_templates` rows on submit. The split happens cleanly at the controller layer, not inside `ScriptBuilderChat`.
|
||||
|
||||
### `POST /script-builder/sessions` — changes for inline origin
|
||||
|
||||
The existing endpoint is extended in three ways:
|
||||
|
||||
1. **Accepts `origin`** in the request body (`Literal['standalone', 'pilot_inline']`, default `'standalone'`). Legacy callers unchanged.
|
||||
2. **Authorization on `ai_session_id`.** When `origin='pilot_inline'` is passed AND `ai_session_id` is provided, the handler MUST verify the referenced `ai_sessions` row is owned by the current user (or within their account — whichever guard `_load_session_or_404(db, ai_session_id, current_user)` already enforces for the pilot endpoints). Without this check, a caller could attach an inline scratch session to an arbitrary pilot session. The check fires before any row lookup or creation.
|
||||
3. **Idempotent for `origin='pilot_inline'`.** If a row with `(user_id = current, ai_session_id = provided, origin = 'pilot_inline')` already exists, the handler returns that row (200) instead of creating a new one (201). The unique partial index enforces at-most-one at the DB layer; a race between two concurrent POSTs surfaces as an integrity error that the handler catches and re-reads.
|
||||
|
||||
For `origin='standalone'`, behavior is unchanged — always creates, still subject to the 5-session cap.
|
||||
|
||||
The 5-session cap applies only to `standalone` rows (see §Data-model filter changes). Inline sessions are out of that accounting entirely.
|
||||
|
||||
---
|
||||
|
||||
## State
|
||||
|
||||
### Frontend state (AssistantChatPage)
|
||||
|
||||
New local state on the page:
|
||||
- `chatTab: 'chat' | 'script_builder'` — which tab is visible. Defaults to `'chat'`.
|
||||
- `scriptBuilderHasProgress: boolean` — drives the indicator dot. Set by `ScriptBuilderTab` via an `onProgressChange` callback.
|
||||
|
||||
Reset in `resetSessionDerivedState`: both back to defaults.
|
||||
|
||||
`scriptBuilderMode` ('ai' | 'editor') lives **inside `ScriptBuilderTab`**, not on the page — the parent never needs to drive the AI/editor toggle. The controller resets it naturally via unmount/remount when the page switches sessions.
|
||||
|
||||
Banner's Apply handler (`handleApplyFix`) updated:
|
||||
- If no script + no template → set `chatTab = 'script_builder'` (and show tab strip).
|
||||
- If drafted script → open NoTemplateDialog in the chat region (new state or existing `scriptPanelOpen` reused).
|
||||
- If template → open `TemplateMatchPanel` in the task lane (render location unchanged); run stamping happens via the new "I ran this" action inside the panel (see §5), not on Apply click.
|
||||
|
||||
### Tab strip visibility
|
||||
|
||||
The tab strip is derived, not state:
|
||||
```ts
|
||||
const showTabStrip =
|
||||
activeFix != null &&
|
||||
activeFix.status !== 'dismissed' &&
|
||||
activeFix.status !== 'applied_success' &&
|
||||
activeFix.status !== 'applied_failed' &&
|
||||
!activeFix.script_template_id &&
|
||||
!activeFix.ai_drafted_script
|
||||
```
|
||||
|
||||
When the strip hides (e.g., after script is drafted), `chatTab` resets to `'chat'` to avoid stuck state.
|
||||
|
||||
### Tab switching guard
|
||||
|
||||
The existing `currentChatRef` pattern (Async-select-load-apply guard) applies: when the engineer switches chats, any in-flight tab-derived state is discarded.
|
||||
|
||||
---
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **NoTemplateDialog grid fix.** Moved to the chat region (wide enough), so the `grid-cols-1 sm:grid-cols-3` layout now works as intended. No grid edit required.
|
||||
- **`window.prompt` replacement** for partial-notes / failure-reason capture. Still the Phase 8 interim pattern; replacement is deferred to a later design debt pass.
|
||||
- **TemplateMatchPanel relocation** to the chat region. Different surface, different interactions, not broken today. Possible future work.
|
||||
- **Dedicated "clear AI outcome proposal" button in the UI.** Already covered by Phase 8 Issue #3 fix (DELETE endpoint + clear-on-outcome-write).
|
||||
- **Task lane bottom-slot audit.** With NoTemplateDialog removed from the slot, it may be empty on most sessions. Keep the slot API stable; any cleanup is out of scope.
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
### Backend
|
||||
|
||||
- **Migration:** forward + downgrade reversibility; existing rows backfill to `origin='standalone'`; the `origin='pilot_inline' ⇒ ai_session_id IS NOT NULL` invariant is enforced by the check constraint.
|
||||
- **PATCH /script endpoint** (new test file `test_fix_script_endpoint.py`):
|
||||
- happy path — 200, `ai_drafted_script` set, `state_version` bumped, `applied_at` untouched.
|
||||
- 404 on wrong session.
|
||||
- 409 on terminal status.
|
||||
- 400 on empty body.
|
||||
- **list/count filter changes** (extend `test_script_builder.py` or nearby):
|
||||
- 5 `pilot_inline` sessions + subsequent `standalone` session creation succeeds (does not hit the 5-cap).
|
||||
- `list_sessions` returns only `standalone` rows by default.
|
||||
- **Apply lifecycle correction** (extend `test_fix_outcome_endpoint.py`):
|
||||
- Banner Apply click that routes to a drafting/evaluation surface does NOT stamp `applied_at`.
|
||||
- `one_off` decision from `NoTemplateDialog` DOES stamp `applied_at`.
|
||||
- `draft_template` decision from `NoTemplateDialog` DOES stamp `applied_at` (it still runs the script).
|
||||
- `build_template` decision from `NoTemplateDialog` does NOT stamp (no run).
|
||||
- `TemplateMatchPanel` "I ran this" action DOES stamp `applied_at`; Generate / Copy alone do NOT.
|
||||
- **Script Builder session create — inline semantics** (extend `test_script_builder.py` or equivalent):
|
||||
- First `POST /script-builder/sessions` with `origin='pilot_inline', ai_session_id=X` creates and returns a row.
|
||||
- Second `POST` with the same `(ai_session_id, user)` returns the SAME row (no duplicate created); DB row count confirms.
|
||||
- `POST` with `origin='pilot_inline'` and `ai_session_id` pointing at another user's pilot session is rejected (403/404).
|
||||
- Race: two concurrent `POST`s for the same `(user, ai_session_id)` resolve to the same row id (one winner, one returns the existing).
|
||||
|
||||
### Frontend
|
||||
|
||||
Manual verification (no component test harness in this codebase per CLAUDE.md):
|
||||
- No-draft fix → Apply click opens Script Builder tab.
|
||||
- AI path: chat with AI, submit, tab disappears, NoTemplateDialog becomes eligible.
|
||||
- Manual path: ✎ Write myself → Monaco loads with scaffold → edit → submit → tab disappears.
|
||||
- Drafted fix → Apply click opens NoTemplateDialog in chat region (three cards side-by-side).
|
||||
- Tab indicator dot appears on first AI message / non-empty Monaco buffer; clears on submit.
|
||||
- Session switch with open Script Builder tab → tab/mode state resets.
|
||||
- EscalateInterceptDialog partial choice → applied_partial written with notes.
|
||||
|
||||
### Build discipline
|
||||
|
||||
- `tsc -b` clean
|
||||
- `npm run build` clean
|
||||
- `docker exec resolutionflow_backend pytest` — all pre-existing suites still pass, no regression from the new endpoint.
|
||||
|
||||
---
|
||||
|
||||
## Files to touch (rough inventory)
|
||||
|
||||
**Backend — new:**
|
||||
- `backend/alembic/versions/<hash>_script_builder_origin.py`
|
||||
- `backend/tests/test_fix_script_endpoint.py`
|
||||
|
||||
**Backend — modified:**
|
||||
- `backend/app/models/script_builder_session.py` — add `origin` column only (`ai_session_id` already exists).
|
||||
- `backend/app/schemas/session_suggested_fix.py` — add `SessionSuggestedFixScriptRequest`.
|
||||
- `backend/app/schemas/script_builder.py` — extend `ScriptBuilderCreateRequest` with two new optional fields: `origin: Literal['standalone', 'pilot_inline'] = 'standalone'` and `ai_session_id: UUID | None = None`. Handler-side validation: when `origin='pilot_inline'`, `ai_session_id` is required (not null) AND must pass the current-user ownership check. Legacy callers pass neither and continue to create standalone sessions as before.
|
||||
- `backend/app/api/endpoints/session_suggested_fixes.py` — add PATCH /script endpoint. Move the existing `applied_at` stamp out of the apply path and into `handleScriptDecision('one_off' | 'draft_template')` plus `TemplateMatchPanel`'s new "I ran this" handler (server side: no change to `/apply`; callers shift instead).
|
||||
- `backend/app/api/endpoints/script_builder.py` — accept `origin` on session creation; enforce the `pilot_inline ⇒ ai_session_id` invariant at the handler level.
|
||||
- `backend/app/services/script_builder_service.py` — persist `origin`; `list_sessions` + `count_user_sessions` filter to `origin='standalone'` by default.
|
||||
- `backend/app/models/session_suggested_fix.py` — unchanged (schema already has `ai_drafted_script`).
|
||||
|
||||
**Frontend — new:**
|
||||
- `frontend/src/components/pilot/ChatTabStrip.tsx` — renders the `[Chat] [Script Builder ●]` strip.
|
||||
- `frontend/src/components/pilot/ScriptBuilderTab.tsx` — controller that owns session lifecycle, AI message state, Monaco buffer, mode toggle, and submit. Renders `ScriptBuilderChat` in AI mode and Monaco in editor mode.
|
||||
- `frontend/src/components/pilot/NoTemplateDialogInline.tsx` (or reuse existing `NoTemplateDialog` with a new wrapper for chat-region styling).
|
||||
|
||||
**Frontend — modified:**
|
||||
- `frontend/src/api/sessionSuggestedFixes.ts` — add `patchScript(sessionId, fixId, body, parameters)` method.
|
||||
- `frontend/src/api/scriptBuilder.ts` (or equivalent) — `createSession` accepts optional `origin` and `ai_session_id` arguments (both required together when the caller is `ScriptBuilderTab`; both omitted for the legacy standalone caller).
|
||||
- `frontend/src/components/script-builder/ScriptBuilderChat.tsx` — **unchanged**. Stays a pure display component.
|
||||
- `frontend/src/pages/ScriptBuilderPage.tsx` — **unchanged on the session-creation path** (defaults to `origin='standalone'`).
|
||||
- `frontend/src/pages/AssistantChatPage.tsx` — wire tab strip, mount `ScriptBuilderTab`, banner Apply routing (no `applied_at` stamp on click), NoTemplateDialog chat-region render. Move the `sessionSuggestedFixesApi.applyFix(...)` call from `handleApplyFix` to `handleScriptDecision('one_off' | 'draft_template')` and `TemplateMatchPanel`'s new "I ran this" handler.
|
||||
- `frontend/src/components/pilot/EscalateInterceptDialog.tsx` — add fourth choice.
|
||||
- `frontend/src/components/pilot/TaskLane.tsx` — remove `bottomSlot` usage of NoTemplateDialog (leave prop API stable).
|
||||
|
||||
**Frontend — deleted:**
|
||||
- None (existing components get refactored, not deleted).
|
||||
|
||||
---
|
||||
|
||||
## Rollout
|
||||
|
||||
- Single branch, merged as part of the in-flight `feat/flowpilot-migration` PR (same as Phase 8).
|
||||
- No feature flag — the new surface is strictly additive to the banner's Apply flow; old behavior for drafted-script fixes is preserved (just renders in a different location).
|
||||
|
||||
---
|
||||
|
||||
## Open deferrals (acknowledged, not in this phase)
|
||||
|
||||
- `window.prompt` → inline input migration for partial notes / failure reasons.
|
||||
- Anti-parrot compliance check for the inline `ScriptBuilderTab` flow — verify it reuses the existing script-builder AI system prompt (no new prompt content introduced; the controller only changes what `onSaveScript` does, not what the AI sees).
|
||||
- Telemetry events for tab opens / AI→editor toggles / script submissions from tab — add in the Phase 9 implementation plan if we want them.
|
||||
88
docs/handoff/2026-04-22-flowpilot-migration.md
Normal file
88
docs/handoff/2026-04-22-flowpilot-migration.md
Normal file
@@ -0,0 +1,88 @@
|
||||
---
|
||||
date: 2026-04-22
|
||||
branch: feat/flowpilot-migration
|
||||
remote: ssh://gitea.resolutionflow.com/chihlasm/resolutionflow.git
|
||||
last_commit: faf1d8d fix(pilot): applied_at stamps on run-declaring actions, not Apply click
|
||||
status: Sprint 9/9 phases complete and pushed; PR not yet opened. Open items #1 and #3 resolved by Phase 9.
|
||||
---
|
||||
|
||||
# FlowPilot Migration — Session Handoff
|
||||
|
||||
## Where the work lives
|
||||
|
||||
- Branch: `feat/flowpilot-migration` (pushed to Gitea, mirrors to GitHub)
|
||||
- Spec: [docs/FlowAssist_Migration/FLOWPILOT-MIGRATION.md](../FlowAssist_Migration/FLOWPILOT-MIGRATION.md)
|
||||
- Mockups: [docs/FlowAssist_Migration/mockups/](../FlowAssist_Migration/mockups/) (PNG + HTML reference)
|
||||
|
||||
## What shipped
|
||||
|
||||
All nine migration phases are merged onto the branch and verified against the live dev stack (`resolutionflow_frontend` / `resolutionflow_backend` / `resolutionflow_postgres` containers).
|
||||
|
||||
| Phase | Commit | What landed |
|
||||
|---|---|---|
|
||||
| 0 — baseline telemetry | (pre-branch) | analytics events for funnel deltas |
|
||||
| 1 — `/assistant` → `/pilot` rename | early commits | route redirects, sidebar updates |
|
||||
| 2 — What we know (facts) | (mid) | `session_facts` table, `[PROMOTE]` marker, fact CRUD endpoints, `WhatWeKnow` section |
|
||||
| 3 — Suggested fix + Resolve preview | `7ccf4c6` and prior | `session_suggested_fixes`, `[SUGGEST_FIX]` marker, `ResolutionNotePreview` popover |
|
||||
| 4 — Escalate + PSA writeback | `8fd2c1b` | `psa_writeback_service` with status verification, kind-parameterized preview |
|
||||
| 5 — inline Script Generator | `fa61376` | `TemplateMatchPanel`, `NoTemplateDialog` three-option dialog |
|
||||
| 6 — post-resolve templatize | `4aaf57a` | `draft_templates` table, accept/reject endpoints, `TemplatizePrompt` modal, account preferences |
|
||||
| 7 — polish | `8a242f5` | loading/empty states, keyboard shortcuts (`⌘↵`, `⌘G`, `?` overlay), responsive bottom-drawer <1200px |
|
||||
| 8 — Fix Outcome Banner | `cdd8bb0`..`a47ce07` | Six outcome columns on `session_suggested_fixes` (`status`, `applied_at`, `verified_at`, `partial_notes`, `failure_reason`, `ai_outcome_proposal`) + `PATCH /api/v1/ai-sessions/{sid}/suggested-fixes/{fid}/outcome` endpoint + `[FIX_OUTCOME]` marker; replaces task-lane `SuggestedFix` card with a chat-composer-anchored `ProposalBanner` (5 states: proposed/verifying/partial/ai_confirming/nudge + collapsed); `EscalateInterceptDialog` captures outcome before handoff; Resolve-while-verifying auto-marks success; 17 new tests (8 endpoint + 7 marker + 2 anti-parrot) |
|
||||
| 9 — Tabbed Script Builder | `5bcb7aa`..`faf1d8d` | Chat-region tab strip (`[Chat] [Script Builder ●]`) with `ChatTabStrip` + new `ScriptBuilderTab` controller wrapping the existing `ScriptBuilderChat` + Monaco editor (`ScriptBodyEditor`); `InlineNoTemplateDialog` relocates the existing `NoTemplateDialog` from the narrow task-lane `bottomSlot` to a chat-region placement wrapper; `EscalateInterceptDialog` gains a fourth "partial" choice; `PATCH /api/v1/ai-sessions/{sid}/suggested-fixes/{fid}/script` endpoint for engineer-drafted scripts (does not stamp `applied_at`); Alembic migration adds `origin VARCHAR(20)` to `script_builder_sessions` (reuses existing `ai_session_id` FK) + partial unique index on `(user_id, ai_session_id) WHERE origin='pilot_inline'` for idempotent get-or-create; `applied_at` semantics corrected to stamp only on run-declaring actions (`handleScriptDecision` for `one_off`/`draft_template`; new `onMarkRun` on `TemplateMatchPanel`) — not the Apply click |
|
||||
|
||||
Plus the structural fixes that came up along the way:
|
||||
- `50215b9` + `d0ebdef` — full sweep removing literal payloads from AI system prompts; new `tests/test_prompt_anti_parrot.py` guardrail
|
||||
- `ce7c8ac` + `ddae171` — task-lane state-leak across chats (centralized `resetSessionDerivedState()` helper)
|
||||
- `8879f96` — dropped `sticky top-0` from all four lane section headers (they were orphaning over unrelated content on scroll)
|
||||
|
||||
## How to resume
|
||||
|
||||
1. `git checkout feat/flowpilot-migration`
|
||||
2. `docker compose -f docker-compose.dev.yml up -d` (if the stack isn't running)
|
||||
3. Verify: `docker exec resolutionflow_frontend sh -c "cd /app && npx tsc -b"` should be clean
|
||||
4. Live URL: <http://localhost:5173/pilot> (or `<host-ip>:5173/pilot`)
|
||||
5. Test users (password `TestPass123!`): `engineer@resolutionflow.example.com`
|
||||
|
||||
## Open work — pick one
|
||||
|
||||
Items #1 and #3 were discovered during Phase 6/7 verification. Item #2 was resolved by Phase 8. Items #1 and #3 are **resolved by Phase 9** (see below).
|
||||
|
||||
### 1. NoTemplateDialog narrow-lane bug
|
||||
**Status: RESOLVED by Phase 9.**
|
||||
|
||||
Phase 9 relocated `InlineNoTemplateDialog` from the task-lane `bottomSlot` into a dedicated chat-region placement wrapper (`InlineNoTemplateDialog.tsx`). The dialog no longer renders inside the narrow 380px task lane, eliminating the `sm:grid-cols-3` viewport-breakpoint collision. The disabled-cards bug (when no `ai_drafted_script` is present) is also resolved: when no draft exists, the engineer is routed into the new `ScriptBuilderTab` inline chat instead of reaching the three-option dialog with disabled cards.
|
||||
|
||||
See [docs/FlowAssist_Migration/phase-9-implementation-plan.md](../FlowAssist_Migration/phase-9-implementation-plan.md) and [docs/FlowAssist_Migration/phase-9-script-builder-tab.md](../FlowAssist_Migration/phase-9-script-builder-tab.md) for full implementation details.
|
||||
|
||||
### 2. Task lane crowding / Suggested Fix discoverability
|
||||
**Status: RESOLVED by Phase 8.** The `SuggestedFix` card no longer lives inside the scrollable task lane. Phase 8 replaced it with a chat-composer-anchored slide-up banner (`ProposalBanner`) that is always visible at the bottom of the conversation column regardless of how far the task lane has scrolled. The banner is the primary entry point for fix application; the task lane retains a compact read-only summary of the active fix for reference.
|
||||
|
||||
See [docs/FlowAssist_Migration/phase-8-fix-outcome-banner.md](../FlowAssist_Migration/phase-8-fix-outcome-banner.md) for the implementation plan and design rationale. Because the banner is now the primary entry point, the NoTemplateDialog narrow-lane bug (open item #1) is considerably less visible — the three-option dialog is only reached after the engineer opts in via the banner, at which point they have already acknowledged the fix.
|
||||
|
||||
### 3. Tabbed Script Builder inside the chat (Option A from the modal-vs-tab discussion)
|
||||
**Status: RESOLVED by Phase 9.**
|
||||
|
||||
Phase 9 shipped the complete tabbed Script Builder integration. The chat region now has a `[Chat] [Script Builder ●]` tab strip (`ChatTabStrip`) powered by a new `ScriptBuilderTab` controller that wraps the existing (untouched) `ScriptBuilderChat` for AI mode and `ScriptBodyEditor` (Monaco) for a "Write it myself" editor mode. `display: none` toggling preserves chat scroll position, draft message, and editor buffer across tab switches.
|
||||
|
||||
The `PATCH /api/v1/ai-sessions/{sid}/suggested-fixes/{fid}/script` endpoint writes `ai_drafted_script` + `ai_drafted_parameters` back to the fix record without stamping `applied_at` — a draft is not an application. Bumps `state_version` so cached Resolve/Escalate previews regenerate.
|
||||
|
||||
The migration added `origin VARCHAR(20) NOT NULL DEFAULT 'standalone'` (with CHECK constraint on the two valid values + invariant that `origin='pilot_inline'` requires `ai_session_id IS NOT NULL`) to `script_builder_sessions`. It reuses the pre-existing `ai_session_id` FK rather than adding a new parent column. A partial unique index on `(user_id, ai_session_id) WHERE origin='pilot_inline'` backs get-or-create idempotency from the inline tab.
|
||||
|
||||
See [docs/FlowAssist_Migration/phase-9-implementation-plan.md](../FlowAssist_Migration/phase-9-implementation-plan.md) and [docs/FlowAssist_Migration/phase-9-script-builder-tab.md](../FlowAssist_Migration/phase-9-script-builder-tab.md) for full implementation details.
|
||||
|
||||
## Loose ends / things to verify on resume
|
||||
|
||||
- **PR not opened.** Branch is pushed but no Gitea PR yet. When ready: `gh pr create` works against the GitHub mirror, but the actual review happens in Gitea.
|
||||
- **`/ultrareview` not run** on the final state of the branch (including Phase 9). Worth doing before PR creation.
|
||||
- **Phase 9 browser QA not done.** The new tab strip, `ScriptBuilderTab` (AI + editor modes), `InlineNoTemplateDialog` chat-region placement, and `EscalateInterceptDialog` fourth-choice flow have not been exercised in a headless-browser session. Key states to cover: tab strip renders and toggles without unmounting chat or losing editor buffer; Script Builder tab Submit persists script via PATCH without stamping `applied_at`; `one_off`/`draft_template` decisions DO stamp; `build_template` does NOT stamp; `TemplateMatchPanel` "I ran this" stamps via `onMarkRun`; partial-attempt choice in `EscalateInterceptDialog` is recorded correctly.
|
||||
- **Phase 8 browser QA not done.** The `ProposalBanner` and `EscalateInterceptDialog` (three-choice variant) have not been exercised in a headless-browser session. Key states: banner appears on `[FIX_OUTCOME]` marker; banner dismisses correctly; escalate mid-fix triggers dialog; banner auto-collapses after session resolved. Use `/qa` or `/design-review` against `mockups/06-slide-up-banner.html` and `mockups/07-verify-states.html`.
|
||||
- **Phase 7 visual verification was structural only** — `tsc -b` and `npm run build` both clean, HMR applied each change without error, but no headless-browser screenshot comparison against the mockup PNGs. If you want pixel-level verification, `/qa` or `/design-review` would catch deltas.
|
||||
- **Anti-parrot test runs as part of `pytest`** but is not enforced in any specific CI step yet — verify `tests/test_prompt_anti_parrot.py` is discovered by the existing pytest run, and consider failing CI explicitly on regression.
|
||||
|
||||
## Files most likely to need attention next
|
||||
|
||||
- [frontend/src/pages/AssistantChatPage.tsx](../../frontend/src/pages/AssistantChatPage.tsx) — 1500+ lines, the central pilot orchestrator. Most state-leak and rendering bugs surface here first. Search for `resetSessionDerivedState` to see the chat-switch reset pattern.
|
||||
- [frontend/src/components/assistant/TaskLane.tsx](../../frontend/src/components/assistant/TaskLane.tsx) — accepts `whatWeKnowSlot` / `bottomSlot` from the parent, plus a `variant: 'side' | 'drawer'` for responsive. `bottomSlot` remains active (carries `TemplateMatchPanel` + resolve/escalate preview buttons in both side and drawer variants).
|
||||
- [backend/app/services/unified_chat_service.py](../../backend/app/services/unified_chat_service.py) — owns marker parsing for `[PROMOTE]`, `[SUGGEST_FIX]`, `[QUESTIONS]`, `[ACTIONS]`, `[FORK]`, `[TREE_UPDATE]`. If markers stop firing in chat, this is the first place to check.
|
||||
- [backend/app/services/assistant_chat_service.py](../../backend/app/services/assistant_chat_service.py) — `ASSISTANT_SYSTEM_PROMPT` constant. Anti-parrot test enforces no literal payloads here; use `<placeholder>` syntax only.
|
||||
@@ -7,9 +7,21 @@ import type {
|
||||
} from '@/types'
|
||||
import type { ScriptTemplateDetail } from '@/types'
|
||||
|
||||
export interface CreateSessionOptions {
|
||||
origin?: 'standalone' | 'pilot_inline'
|
||||
aiSessionId?: string
|
||||
}
|
||||
|
||||
export const scriptBuilderApi = {
|
||||
async createSession(language: string): Promise<ScriptBuilderSessionDetail> {
|
||||
const { data } = await apiClient.post('/scripts/builder/sessions', { language })
|
||||
async createSession(
|
||||
language: string,
|
||||
options?: CreateSessionOptions,
|
||||
): Promise<ScriptBuilderSessionDetail> {
|
||||
const { data } = await apiClient.post('/scripts/builder/sessions', {
|
||||
language,
|
||||
origin: options?.origin,
|
||||
ai_session_id: options?.aiSessionId,
|
||||
})
|
||||
return data
|
||||
},
|
||||
|
||||
|
||||
@@ -8,6 +8,25 @@ import apiClient from './client'
|
||||
|
||||
export type UserDecision = 'one_off' | 'draft_template' | 'build_template' | 'dismissed'
|
||||
|
||||
export type FixStatus =
|
||||
| 'proposed'
|
||||
| 'applied_success'
|
||||
| 'applied_failed'
|
||||
| 'applied_partial'
|
||||
| 'dismissed'
|
||||
|
||||
export type FixOutcome =
|
||||
| 'applied_success'
|
||||
| 'applied_failed'
|
||||
| 'applied_partial'
|
||||
| 'dismissed'
|
||||
|
||||
export interface AIOutcomeProposal {
|
||||
fix_id: string
|
||||
outcome: 'success' | 'failure' | 'partial'
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface SessionSuggestedFix {
|
||||
id: string
|
||||
session_id: string
|
||||
@@ -18,6 +37,12 @@ export interface SessionSuggestedFix {
|
||||
ai_drafted_script: string | null
|
||||
ai_drafted_parameters: Record<string, unknown> | null
|
||||
user_decision: UserDecision | null
|
||||
status: FixStatus
|
||||
applied_at: string | null
|
||||
verified_at: string | null
|
||||
partial_notes: string | null
|
||||
failure_reason: string | null
|
||||
ai_outcome_proposal: AIOutcomeProposal | null
|
||||
superseded_at: string | null
|
||||
created_at: string
|
||||
}
|
||||
@@ -86,6 +111,40 @@ export const sessionSuggestedFixesApi = {
|
||||
return r.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Stamp applied_at when the engineer clicks Apply in the ProposalBanner.
|
||||
* Does NOT change status (fix remains 'proposed'). Status flips only on
|
||||
* a subsequent PATCH /outcome. Idempotent if applied_at is already set.
|
||||
* Returns 409 if the fix is no longer in 'proposed' status.
|
||||
*/
|
||||
async applyFix(sessionId: string, fixId: string): Promise<SessionSuggestedFix> {
|
||||
const r = await apiClient.post<SessionSuggestedFix>(
|
||||
`/ai-sessions/${sessionId}/suggested-fixes/${fixId}/apply`,
|
||||
)
|
||||
return r.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Record the outcome of applying a suggested fix. Transition rules:
|
||||
* - from `proposed` or `applied_partial`: any outcome is valid (partial is
|
||||
* parked, not terminal — engineer may update notes, abandon via dismiss,
|
||||
* or advance to success/failed).
|
||||
* - from a terminal status (`applied_success`, `applied_failed`, `dismissed`):
|
||||
* server returns 409.
|
||||
*/
|
||||
async patchOutcome(
|
||||
sessionId: string,
|
||||
fixId: string,
|
||||
outcome: FixOutcome,
|
||||
notes?: string,
|
||||
): Promise<SessionSuggestedFix> {
|
||||
const r = await apiClient.patch<SessionSuggestedFix>(
|
||||
`/ai-sessions/${sessionId}/suggested-fixes/${fixId}/outcome`,
|
||||
{ outcome, notes },
|
||||
)
|
||||
return r.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch (or get cached) draft markdown for the Resolve note. Backend cache
|
||||
* is keyed on state_version, so calling this back-to-back without intervening
|
||||
@@ -137,6 +196,40 @@ export const sessionSuggestedFixesApi = {
|
||||
)
|
||||
return r.data
|
||||
},
|
||||
|
||||
/**
|
||||
* 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
|
||||
},
|
||||
|
||||
/**
|
||||
* Explicitly dismiss the AI-proposed outcome banner ("Not yet").
|
||||
* Clears ai_outcome_proposal on the server without touching status or
|
||||
* state_version. Idempotent: returns 200 even when the field is already null.
|
||||
*/
|
||||
async clearAIProposal(sessionId: string, fixId: string): Promise<SessionSuggestedFix> {
|
||||
const r = await apiClient.delete<SessionSuggestedFix>(
|
||||
`/ai-sessions/${sessionId}/suggested-fixes/${fixId}/ai-outcome-proposal`,
|
||||
)
|
||||
return r.data
|
||||
},
|
||||
}
|
||||
|
||||
export default sessionSuggestedFixesApi
|
||||
|
||||
@@ -43,8 +43,6 @@ interface TaskLaneProps {
|
||||
// shape lets the parent own fact-fetching and state-version polling without
|
||||
// pulling that concern into TaskLane.
|
||||
whatWeKnowSlot?: React.ReactNode
|
||||
// Phase 3: Suggested fix card, rendered below Diagnostic Checks.
|
||||
suggestedFixSlot?: React.ReactNode
|
||||
// Phase 3: bottom-of-lane slot for the Resolve action bar + preview popover
|
||||
// (parent owns state). Renders inside the scrollable body so the popover
|
||||
// stays anchored as the lane scrolls.
|
||||
@@ -79,7 +77,7 @@ export function clearTaskState(sessionId: string) {
|
||||
|
||||
// ── Component ──
|
||||
|
||||
export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loading, whatWeKnowSlot, suggestedFixSlot, bottomSlot, variant = 'side' }: TaskLaneProps) {
|
||||
export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loading, whatWeKnowSlot, bottomSlot, variant = 'side' }: TaskLaneProps) {
|
||||
const isDrawer = variant === 'drawer'
|
||||
const [tasks, setTasks] = useState<TaskResponse[]>(() => {
|
||||
// Try to restore saved state for this session (preserves user's in-progress answers)
|
||||
@@ -535,15 +533,11 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── Suggested fix (Phase 3) ── */}
|
||||
{suggestedFixSlot}
|
||||
|
||||
{/* Quiet-state hint: lane is open (facts exist), but AI hasn't
|
||||
proposed a next step yet. Keeps the lane from feeling "finished"
|
||||
when the engineer still expects a question / fix to arrive. */}
|
||||
{questionTasks.length === 0
|
||||
&& actionTasks.length === 0
|
||||
&& !suggestedFixSlot
|
||||
&& !loading && (
|
||||
<div className="text-[0.6875rem] italic text-muted-foreground px-1 py-2">
|
||||
No open questions — send a message or add a note; the AI will follow up.
|
||||
|
||||
79
frontend/src/components/pilot/ChatTabStrip.tsx
Normal file
79
frontend/src/components/pilot/ChatTabStrip.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* 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.08]',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{indicator && (
|
||||
<span
|
||||
role="img"
|
||||
aria-label="unsaved progress"
|
||||
className="ml-1.5 inline-block w-1.5 h-1.5 rounded-full bg-warning align-middle"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatTabStrip
|
||||
83
frontend/src/components/pilot/EscalateInterceptDialog.tsx
Normal file
83
frontend/src/components/pilot/EscalateInterceptDialog.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* EscalateInterceptDialog — popover anchored above the Escalate button.
|
||||
*
|
||||
* Fires when the engineer clicks Escalate while a fix is in Verifying or
|
||||
* Partial state. Captures the fix outcome before the escalation so the
|
||||
* handoff narrative is honest for whoever picks up the ticket.
|
||||
*
|
||||
* Visual reference: docs/FlowAssist_Migration/mockups/07-verify-states.html
|
||||
* (panel C).
|
||||
*/
|
||||
import { X, AlertCircle, Check, Info } from 'lucide-react'
|
||||
import type { FixOutcome } from '@/api/sessionSuggestedFixes'
|
||||
|
||||
export type InterceptChoice = FixOutcome | 'never_applied'
|
||||
|
||||
export interface EscalateInterceptDialogProps {
|
||||
fixTitle: string
|
||||
onChoose: (choice: InterceptChoice) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function EscalateInterceptDialog({
|
||||
fixTitle,
|
||||
onChoose,
|
||||
onClose,
|
||||
}: EscalateInterceptDialogProps) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label="Capture fix outcome before escalating"
|
||||
className="absolute bottom-full mb-2 left-0 z-50 w-[340px] rounded-lg border border-white/15 bg-card p-3.5 shadow-[0_18px_40px_rgba(0,0,0,0.55)]"
|
||||
>
|
||||
<div className="font-heading font-semibold text-[13px] text-heading mb-1">
|
||||
Before escalating — what happened with the fix?
|
||||
</div>
|
||||
<div className="text-[12px] text-muted-foreground leading-[1.5] mb-3">
|
||||
“{fixTitle}” is still in the Verifying state. Tag its outcome so
|
||||
the senior picking this up knows what's been tried.
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<button
|
||||
autoFocus
|
||||
onClick={() => onChoose('applied_failed')}
|
||||
className="flex items-center gap-2.5 px-3 py-2.5 rounded-lg border border-danger/30 bg-danger-dim text-[12.5px] text-primary hover:bg-danger-dim/80 hover:border-danger transition-colors text-left"
|
||||
>
|
||||
<X size={13} strokeWidth={2.5} className="text-danger" />
|
||||
<span className="flex-1">The fix didn't work</span>
|
||||
<span className="text-[10.5px] text-muted-foreground font-mono px-1.5 py-[2px] rounded bg-white/[0.05]">↵</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onChoose('applied_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"
|
||||
>
|
||||
<Info size={13} strokeWidth={2} />
|
||||
<span className="flex-1">I applied some of it — partial</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onChoose('applied_success')}
|
||||
className="flex items-center gap-2.5 px-3 py-2.5 rounded-lg border border-white/10 bg-elevated text-[12.5px] text-primary hover:bg-sidebar transition-colors text-left"
|
||||
>
|
||||
<Check size={13} strokeWidth={2} />
|
||||
<span className="flex-1">It worked — escalating for another reason</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onChoose('never_applied')}
|
||||
className="flex items-center gap-2.5 px-3 py-2.5 rounded-lg border border-white/10 bg-elevated text-[12.5px] text-primary hover:bg-sidebar transition-colors text-left"
|
||||
>
|
||||
<AlertCircle size={13} strokeWidth={2} />
|
||||
<span className="flex-1">Never actually applied it</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default EscalateInterceptDialog
|
||||
22
frontend/src/components/pilot/InlineNoTemplateDialog.tsx
Normal file
22
frontend/src/components/pilot/InlineNoTemplateDialog.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* 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
|
||||
362
frontend/src/components/pilot/ProposalBanner.tsx
Normal file
362
frontend/src/components/pilot/ProposalBanner.tsx
Normal file
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* ProposalBanner — chat-composer-anchored banner that carries the lifecycle
|
||||
* of a suggested fix from Proposed → Verifying → terminal outcome.
|
||||
*
|
||||
* Replaces the task-lane SuggestedFix card (Phase 8). The banner renders
|
||||
* above the chat composer in AssistantChatPage. Parent owns the fix record
|
||||
* and the outcome mutations; this component renders + dispatches callbacks.
|
||||
*
|
||||
* Visual reference: docs/FlowAssist_Migration/mockups/06-slide-up-banner.html
|
||||
* + 07-verify-states.html.
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { Sparkles, Check, ChevronDown, X, MoreHorizontal, Info } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type {
|
||||
SessionSuggestedFix,
|
||||
FixOutcome,
|
||||
} from '@/api/sessionSuggestedFixes'
|
||||
|
||||
export type BannerMode =
|
||||
| 'proposed' // AI just proposed; engineer hasn't applied yet
|
||||
| 'verifying' // Engineer clicked Apply; awaiting outcome
|
||||
| 'partial' // Applied partially; awaiting finish or terminal outcome
|
||||
| 'ai_confirming' // AI emitted [FIX_OUTCOME]; engineer confirms
|
||||
| 'nudge' // Compact nudge shown after N post-apply messages
|
||||
|
||||
export interface ProposalBannerProps {
|
||||
fix: SessionSuggestedFix
|
||||
mode: BannerMode
|
||||
onApply: () => void
|
||||
onDismiss: () => void
|
||||
onOutcome: (outcome: FixOutcome, notes?: string) => void
|
||||
onAcceptAIProposal: () => void
|
||||
onRejectAIProposal: () => void
|
||||
/** Collapsed variant shown as a thin single-line strip. */
|
||||
collapsed?: boolean
|
||||
onToggleCollapsed?: () => void
|
||||
/** Silence the nudge without collapsing it (Task 11 wires this). */
|
||||
onSilenceNudge: () => void
|
||||
}
|
||||
|
||||
export function ProposalBanner(props: ProposalBannerProps) {
|
||||
if (props.collapsed) return <CollapsedBanner {...props} />
|
||||
switch (props.mode) {
|
||||
case 'proposed': return <ProposedBanner {...props} />
|
||||
case 'verifying': return <VerifyingBanner {...props} />
|
||||
case 'partial': return <PartialBanner {...props} />
|
||||
case 'ai_confirming': return <AIConfirmingBanner {...props} />
|
||||
case 'nudge': return <NudgeBanner {...props} />
|
||||
}
|
||||
}
|
||||
|
||||
function ProposedBanner({ fix, onApply, onDismiss, onToggleCollapsed }: ProposalBannerProps) {
|
||||
return (
|
||||
<div className="relative border-t border-warning/30 bg-gradient-to-b from-warning-dim/40 to-warning-dim/20 px-5 py-3 animate-slide-up">
|
||||
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-warning" />
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="shrink-0 mt-0.5 w-7 h-7 rounded-md border border-warning/30 bg-warning-dim flex items-center justify-center text-warning">
|
||||
<Sparkles size={15} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 font-heading text-[10px] font-semibold uppercase tracking-[1.2px] text-warning">
|
||||
<span>Suggested Fix</span>
|
||||
<span className="tabular-nums px-2 py-[2px] rounded-full bg-warning/20 text-warning text-[10.5px] font-bold">
|
||||
{fix.confidence_pct}% confidence
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[14px] font-semibold text-heading leading-snug">
|
||||
{fix.title}
|
||||
</div>
|
||||
<div className="mt-1 text-[12.5px] text-muted-foreground leading-relaxed">
|
||||
{fix.description}
|
||||
</div>
|
||||
{fix.script_template_id && (
|
||||
<div className="mt-1.5 inline-flex items-center gap-1.5 text-[11.5px] text-success">
|
||||
<Check size={11} />
|
||||
Matches an existing Script Library template — one-click apply
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 pt-0.5">
|
||||
{onToggleCollapsed && (
|
||||
<button
|
||||
onClick={onToggleCollapsed}
|
||||
className="p-1.5 rounded text-muted-foreground hover:bg-white/[0.08] hover:text-primary"
|
||||
aria-label="Collapse"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="px-2.5 py-1.5 rounded text-[12.5px] text-muted-foreground hover:bg-white/[0.08] hover:text-primary"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
<button
|
||||
onClick={onApply}
|
||||
className="px-3.5 py-[9px] rounded-lg bg-warning text-[#1a1200] font-semibold text-[12.5px] hover:brightness-110 inline-flex items-center gap-1.5"
|
||||
>
|
||||
Apply fix
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="9 18 15 12 9 6" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function VerifyingBanner({ fix, onOutcome }: ProposalBannerProps) {
|
||||
const [showOverflow, setShowOverflow] = useState(false)
|
||||
const appliedLabel = fix.applied_at
|
||||
? `Applied ${formatRelativeMinutes(fix.applied_at)}`
|
||||
: 'Applied'
|
||||
|
||||
return (
|
||||
<div className="relative border-t border-warning/30 bg-gradient-to-b from-warning-dim/40 to-warning-dim/20 px-5 py-3 animate-slide-up">
|
||||
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-warning" />
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="relative shrink-0 mt-0.5 w-7 h-7 rounded-md border border-warning/30 bg-warning-dim flex items-center justify-center text-warning">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
<span className="absolute inset-[-3px] rounded-lg animate-pulse-amber pointer-events-none" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 font-heading text-[10px] font-semibold uppercase tracking-[1.2px] text-warning">
|
||||
<span>Verifying</span>
|
||||
<span className="px-2 py-[2px] rounded-full bg-warning/20 text-warning text-[10.5px] font-bold normal-case tracking-normal">
|
||||
{appliedLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[14px] font-semibold text-heading leading-snug">
|
||||
Did "{fix.title}" work?
|
||||
</div>
|
||||
<div className="mt-1 text-[12.5px] text-muted-foreground leading-relaxed">
|
||||
Mark the outcome so the AI can either close the session with this as the resolution, or propose something else.
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 pt-0.5 relative">
|
||||
<button
|
||||
onClick={() => setShowOverflow((v) => !v)}
|
||||
className="p-1.5 rounded text-muted-foreground hover:bg-white/[0.08] hover:text-primary"
|
||||
aria-label="More options"
|
||||
>
|
||||
<MoreHorizontal size={14} />
|
||||
</button>
|
||||
{showOverflow && (
|
||||
<div className={cn(
|
||||
'absolute top-full right-0 mt-1 w-48 rounded-lg',
|
||||
'border border-white/10 bg-card shadow-xl py-1 z-10',
|
||||
)}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowOverflow(false)
|
||||
const notes = window.prompt('What did you run / skip?')
|
||||
if (notes && notes.trim()) onOutcome('applied_partial', notes.trim())
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-[12.5px] hover:bg-elevated text-primary"
|
||||
>
|
||||
Mark partial…
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
const reason = window.prompt("Why didn't it work? (optional)")
|
||||
onOutcome('applied_failed', reason?.trim() || undefined)
|
||||
}}
|
||||
className="px-3 py-[9px] rounded-lg border border-danger/30 text-danger text-[12.5px] font-medium hover:bg-danger-dim hover:border-danger inline-flex items-center gap-1.5"
|
||||
>
|
||||
<X size={12} strokeWidth={2.5} />
|
||||
Didn't work
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onOutcome('applied_success')}
|
||||
className="px-3 py-[9px] rounded-lg bg-success text-[#0a1a12] font-semibold text-[12.5px] hover:brightness-110 inline-flex items-center gap-1.5"
|
||||
>
|
||||
<Check size={12} strokeWidth={2.5} />
|
||||
It worked
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatRelativeMinutes(iso: string): string {
|
||||
const then = new Date(iso).getTime()
|
||||
const mins = Math.max(0, Math.round((Date.now() - then) / 60000))
|
||||
if (mins === 0) return 'just now'
|
||||
if (mins === 1) return '1m ago'
|
||||
return `${mins}m ago`
|
||||
}
|
||||
|
||||
function PartialBanner({ fix, onOutcome, onApply }: ProposalBannerProps) {
|
||||
return (
|
||||
<div className="relative border-t border-info/30 bg-gradient-to-b from-info-dim/40 to-info-dim/20 px-5 py-3 animate-slide-up">
|
||||
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-info" />
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="shrink-0 mt-0.5 w-7 h-7 rounded-md border border-info/30 bg-info-dim flex items-center justify-center text-info">
|
||||
<Info size={15} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 font-heading text-[10px] font-semibold uppercase tracking-[1.2px] text-info">
|
||||
<span>Partially applied</span>
|
||||
<span className="px-2 py-[2px] rounded-full bg-info/20 text-info text-[10.5px] font-bold normal-case tracking-normal">
|
||||
Parked
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[14px] font-semibold text-heading leading-snug">
|
||||
{fix.title}
|
||||
</div>
|
||||
{fix.partial_notes && (
|
||||
<div className="mt-1.5 flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-info/[0.08] border border-info/30 text-[12px] italic text-primary">
|
||||
<span className="not-italic font-bold text-info text-[10.5px] uppercase tracking-[0.6px]">Note</span>
|
||||
<span>{fix.partial_notes}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 pt-0.5">
|
||||
<button
|
||||
onClick={() => {
|
||||
const reason = window.prompt("Why didn't it work? (optional)")
|
||||
onOutcome('applied_failed', reason?.trim() || undefined)
|
||||
}}
|
||||
className="px-3 py-[9px] rounded-lg border border-danger/30 text-danger text-[12.5px] font-medium hover:bg-danger-dim hover:border-danger"
|
||||
>
|
||||
Didn't work
|
||||
</button>
|
||||
<button
|
||||
onClick={onApply}
|
||||
className="px-3 py-[9px] rounded-lg bg-card border border-white/10 text-primary text-[12.5px] font-medium hover:bg-elevated"
|
||||
>
|
||||
Finish it ›
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onOutcome('applied_success')}
|
||||
className="px-3 py-[9px] rounded-lg bg-success text-[#0a1a12] font-semibold text-[12.5px] hover:brightness-110"
|
||||
>
|
||||
It worked
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AIConfirmingBanner({ fix, onAcceptAIProposal, onRejectAIProposal }: ProposalBannerProps) {
|
||||
const proposal = fix.ai_outcome_proposal
|
||||
if (!proposal) return null
|
||||
const isSuccess = proposal.outcome === 'success'
|
||||
const isFailure = proposal.outcome === 'failure'
|
||||
|
||||
const headlineVerb = isSuccess
|
||||
? 'resolved the issue'
|
||||
: isFailure
|
||||
? "didn't work"
|
||||
: 'was partially applied'
|
||||
|
||||
return (
|
||||
<div className="relative border-t border-accent/30 bg-gradient-to-b from-accent-dim/40 to-accent-dim/20 px-5 py-3 animate-slide-up">
|
||||
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-accent" />
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="shrink-0 mt-0.5 w-7 h-7 rounded-md border border-accent/30 bg-accent-dim flex items-center justify-center text-accent">
|
||||
<Sparkles size={15} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 font-heading text-[10px] font-semibold uppercase tracking-[1.2px] text-accent">
|
||||
<span>AI detected outcome</span>
|
||||
<span className="px-2 py-[2px] rounded-full bg-accent/20 text-accent text-[10.5px] font-bold normal-case tracking-normal">
|
||||
{isSuccess ? 'Success' : isFailure ? 'Failure' : 'Partial'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[14px] font-semibold text-heading leading-snug">
|
||||
AI thinks the fix {headlineVerb} — confirm?
|
||||
</div>
|
||||
<div className="mt-1 text-[12.5px] text-muted-foreground leading-relaxed">
|
||||
{proposal.reason || 'Based on the recent chat. One click either confirms or corrects.'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 pt-0.5">
|
||||
<button
|
||||
onClick={onRejectAIProposal}
|
||||
className="px-3 py-[9px] rounded-lg text-muted-foreground text-[12.5px] hover:bg-white/[0.08] hover:text-primary"
|
||||
>
|
||||
Not yet
|
||||
</button>
|
||||
<button
|
||||
onClick={onAcceptAIProposal}
|
||||
className={cn(
|
||||
'px-3 py-[9px] rounded-lg font-semibold text-[12.5px] inline-flex items-center gap-1.5 hover:brightness-110',
|
||||
isSuccess
|
||||
? 'bg-success text-[#0a1a12]'
|
||||
: 'bg-danger text-[#180808]',
|
||||
)}
|
||||
>
|
||||
<Check size={12} strokeWidth={2.5} />
|
||||
Confirm{isSuccess ? ' · Resolve' : ''}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NudgeBanner({ fix, onOutcome, onSilenceNudge }: ProposalBannerProps) {
|
||||
return (
|
||||
<div className="relative border-t border-warning/30 bg-warning-dim/60 px-5 py-2 flex items-center gap-3">
|
||||
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-warning" />
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-warning shrink-0">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 8v4" />
|
||||
<path d="M12 16h.01" />
|
||||
</svg>
|
||||
<span className="flex-1 text-[12.5px] text-primary">
|
||||
Did <strong className="text-heading">"{fix.title}"</strong> work?
|
||||
</span>
|
||||
<button
|
||||
onClick={onSilenceNudge}
|
||||
className="px-2.5 py-1 rounded text-[12px] text-muted-foreground hover:bg-white/[0.08] hover:text-primary"
|
||||
>
|
||||
Still checking
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const reason = window.prompt("Why didn't it work? (optional)")
|
||||
onOutcome('applied_failed', reason?.trim() || undefined)
|
||||
}}
|
||||
className="px-2.5 py-1 rounded border border-danger/30 text-danger text-[12px] hover:bg-danger-dim"
|
||||
>
|
||||
No
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onOutcome('applied_success')}
|
||||
className="px-2.5 py-1 rounded bg-success text-[#0a1a12] font-semibold text-[12px] hover:brightness-110"
|
||||
>
|
||||
Yes
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsedBanner({ fix, onToggleCollapsed }: ProposalBannerProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onToggleCollapsed}
|
||||
className="relative w-full border-t border-warning/30 bg-warning-dim/40 px-5 py-2 flex items-center gap-2.5 hover:bg-warning-dim/60 transition-colors text-left"
|
||||
>
|
||||
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-warning" />
|
||||
<Sparkles size={12} className="text-warning shrink-0" />
|
||||
<span className="flex-1 text-[12px] font-medium text-heading truncate">{fix.title}</span>
|
||||
<span className="px-1.5 py-[1px] rounded-full bg-warning/20 text-warning text-[10.5px] font-bold tabular-nums">
|
||||
{fix.confidence_pct}%
|
||||
</span>
|
||||
<span className="text-muted-foreground text-[11px]">▸ expand</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProposalBanner
|
||||
264
frontend/src/components/pilot/ScriptBuilderTab.tsx
Normal file
264
frontend/src/components/pilot/ScriptBuilderTab.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* 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 + ScriptBuilderInput).
|
||||
* - Monaco buffer for the "Write it myself" mode (via ScriptBodyEditor).
|
||||
* - 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.
|
||||
*
|
||||
* NOTE: CodeModeEditor is NOT used here — it's tightly coupled to
|
||||
* treeEditorStore. ScriptBodyEditor is the generic Monaco wrapper.
|
||||
*
|
||||
* NOTE: `onProgressChange` is expected to be wrapped in useCallback at
|
||||
* the call site so the dependency array in the relay effect is stable.
|
||||
*
|
||||
* Language is hardcoded to 'powershell' for Phase 9 v1. Future: derive
|
||||
* from fix metadata or let the engineer pick.
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Sparkles, Pencil } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ScriptBuilderChat } from '@/components/script-builder/ScriptBuilderChat'
|
||||
import { ScriptBuilderInput } from '@/components/script-builder/ScriptBuilderInput'
|
||||
import { ScriptBodyEditor } from '@/components/script-editor/ScriptBodyEditor'
|
||||
import { sessionSuggestedFixesApi } from '@/api/sessionSuggestedFixes'
|
||||
import { scriptBuilderApi } from '@/api'
|
||||
import type { SessionSuggestedFix } from '@/api/sessionSuggestedFixes'
|
||||
import type { ScriptBuilderMessage } from '@/types'
|
||||
|
||||
export interface ScriptBuilderTabProps {
|
||||
fix: SessionSuggestedFix
|
||||
pilotSessionId: string
|
||||
/** Fires whenever in-progress state changes (for the ChatTabStrip dot).
|
||||
* Wrap in useCallback at the call site to keep the relay effect stable. */
|
||||
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'
|
||||
|
||||
const LANGUAGE = 'powershell'
|
||||
|
||||
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(LANGUAGE, 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)
|
||||
|
||||
// Relay in-progress state to parent. Stable as long as onProgressChange
|
||||
// is wrapped in useCallback at the call site.
|
||||
useEffect(() => {
|
||||
const initialScaffold = scaffoldForLanguage(LANGUAGE, fix.description).trim()
|
||||
const hasProgress =
|
||||
messages.length > 0 ||
|
||||
editorBuffer.trim() !== initialScaffold
|
||||
onProgressChange(hasProgress)
|
||||
}, [messages.length, editorBuffer, fix.description, onProgressChange])
|
||||
|
||||
// Get-or-create the inline session on mount (keyed to pilotSessionId so
|
||||
// a new pilot session doesn't reuse a stale builder session).
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
const s = await scriptBuilderApi.createSession(LANGUAGE, {
|
||||
origin: 'pilot_inline',
|
||||
aiSessionId: pilotSessionId,
|
||||
})
|
||||
if (cancelled) return
|
||||
setBuilderSessionId(s.id)
|
||||
// Resume existing messages if the session was already started
|
||||
// (e.g. page refresh). getSession() returns the detail with messages[].
|
||||
// listMessages() does NOT exist in the API client — use getSession instead.
|
||||
if (s.messages && s.messages.length > 0) {
|
||||
setMessages(s.messages)
|
||||
}
|
||||
if (s.latest_script) setLatestScript(s.latest_script)
|
||||
} catch {
|
||||
if (!cancelled) setError('Failed to start Script Builder session.')
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [pilotSessionId])
|
||||
|
||||
const handleSendMessage = async (content: string) => {
|
||||
if (!builderSessionId) return
|
||||
setAiLoading(true)
|
||||
setError(null)
|
||||
|
||||
// Optimistically add the user message to the list immediately.
|
||||
const userMsg: ScriptBuilderMessage = {
|
||||
role: 'user',
|
||||
content,
|
||||
created_at: new Date().toISOString(),
|
||||
}
|
||||
setMessages((prev) => [...prev, userMsg])
|
||||
|
||||
try {
|
||||
// sendMessage returns ScriptBuilderMessageResponse (single assistant reply).
|
||||
const reply = await scriptBuilderApi.sendMessage(builderSessionId, content)
|
||||
const assistantMsg: ScriptBuilderMessage = {
|
||||
role: 'assistant',
|
||||
content: reply.content,
|
||||
script: reply.script,
|
||||
script_filename: reply.script_filename,
|
||||
line_count: reply.line_count,
|
||||
created_at: reply.timestamp,
|
||||
}
|
||||
setMessages((prev) => [...prev, assistantMsg])
|
||||
if (reply.script) setLatestScript(reply.script)
|
||||
} catch {
|
||||
// Replace optimistic user message with an error reply so the user sees it.
|
||||
const errMsg: ScriptBuilderMessage = {
|
||||
role: 'assistant',
|
||||
content: 'Something went wrong generating the script. Please try again.',
|
||||
created_at: new Date().toISOString(),
|
||||
}
|
||||
setMessages((prev) => [...prev, errMsg])
|
||||
} finally {
|
||||
setAiLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (script: string) => {
|
||||
if (!script.trim() || submitting) 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: save the latest script produced by the AI.
|
||||
const handleAiSave = () => {
|
||||
if (latestScript) void handleSubmit(latestScript)
|
||||
}
|
||||
|
||||
// onViewScript is required by ScriptBuilderChat — provide a no-op for now
|
||||
// (inline preview is a future extension).
|
||||
const handleViewScript = (_script: string, _filename: string | null) => {
|
||||
// Future: open inline preview panel
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-bg-page">
|
||||
{/* Mode switcher header */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-default shrink-0">
|
||||
<div className="text-[12.5px] text-heading font-medium truncate pr-2">
|
||||
Script Builder · {fix.title}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{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 transition-colors"
|
||||
>
|
||||
<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 transition-colors"
|
||||
>
|
||||
<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 shrink-0">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content area — display:none on inactive mode so state persists */}
|
||||
<div className="flex-1 min-h-0">
|
||||
{/* AI chat mode */}
|
||||
<div className={cn('flex flex-col h-full', mode !== 'ai' && 'hidden')}>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col">
|
||||
<ScriptBuilderChat
|
||||
messages={messages}
|
||||
language={LANGUAGE}
|
||||
onViewScript={handleViewScript}
|
||||
onSaveScript={handleAiSave}
|
||||
isLoading={aiLoading}
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<ScriptBuilderInput
|
||||
onSend={handleSendMessage}
|
||||
disabled={aiLoading || !builderSessionId}
|
||||
placeholder="Describe the script you need…"
|
||||
showSuggestions={messages.length === 0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editor (Monaco) mode */}
|
||||
<div className={cn('flex flex-col h-full', mode !== 'editor' && 'hidden')}>
|
||||
<div className="flex-1 min-h-0 p-4">
|
||||
<ScriptBodyEditor
|
||||
value={editorBuffer}
|
||||
onChange={setEditorBuffer}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
<div className="px-4 py-2 border-t border-default flex justify-end shrink-0">
|
||||
<button
|
||||
onClick={() => void handleSubmit(editorBuffer)}
|
||||
disabled={submitting || !editorBuffer.trim()}
|
||||
className={cn(
|
||||
'px-3.5 py-[7px] rounded text-[12.5px] font-semibold transition-colors',
|
||||
'bg-accent text-[#0a0d14] hover:brightness-110 disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
{submitting ? 'Saving…' : 'Submit script'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function scaffoldForLanguage(language: string, fixDescription: string): string {
|
||||
if (language === 'bash' || language === 'python') {
|
||||
return `# ${fixDescription}\n\n`
|
||||
}
|
||||
// PowerShell uses CRLF line endings by convention
|
||||
return `# ${fixDescription}\r\n\r\n`
|
||||
}
|
||||
|
||||
export default ScriptBuilderTab
|
||||
@@ -28,6 +28,9 @@ interface TemplateMatchPanelProps {
|
||||
fix: SessionSuggestedFix
|
||||
sessionId: string
|
||||
onClose: () => void
|
||||
/** Fires when the engineer declares the script was run. Parent calls
|
||||
* applyFix() to stamp applied_at. */
|
||||
onMarkRun?: () => void
|
||||
}
|
||||
|
||||
interface ParamSchemaEntry {
|
||||
@@ -39,7 +42,7 @@ interface ParamSchemaEntry {
|
||||
options?: Array<{ label: string; value: string }>
|
||||
}
|
||||
|
||||
export function TemplateMatchPanel({ fix, sessionId, onClose }: TemplateMatchPanelProps) {
|
||||
export function TemplateMatchPanel({ fix, sessionId, onClose, onMarkRun }: TemplateMatchPanelProps) {
|
||||
const [template, setTemplate] = useState<ScriptTemplateDetail | null>(null)
|
||||
const [templateLoading, setTemplateLoading] = useState(true)
|
||||
const [templateError, setTemplateError] = useState<string | null>(null)
|
||||
@@ -243,6 +246,16 @@ export function TemplateMatchPanel({ fix, sessionId, onClose }: TemplateMatchPan
|
||||
>
|
||||
Edit parameters
|
||||
</button>
|
||||
{onMarkRun && (
|
||||
<button
|
||||
onClick={onMarkRun}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded text-[0.75rem] font-semibold bg-accent text-[#0a0d14] hover:brightness-110 transition-colors"
|
||||
aria-label="Mark the script as applied to start verifying the fix"
|
||||
>
|
||||
<Check size={12} aria-hidden="true" />
|
||||
I ran this
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
/**
|
||||
* SuggestedFix card — Phase 3 task-lane section.
|
||||
*
|
||||
* Renders the active AI-proposed resolution path for the session
|
||||
* (per FLOWPILOT-MIGRATION.md Section 3.1, "Suggested fix"). Amber-accented
|
||||
* to match the mockup; clicking opens the Script Generator flow in Phase 5.
|
||||
*
|
||||
* For Phase 3, the card is informational + a Dismiss action. The three-option
|
||||
* dialog (one_off / draft_template / build_template) is wired in Phase 5
|
||||
* via a separate component.
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { Sparkles, X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { SessionSuggestedFix } from '@/api/sessionSuggestedFixes'
|
||||
|
||||
interface SuggestedFixProps {
|
||||
fix: SessionSuggestedFix
|
||||
onDismiss: () => Promise<void> | void
|
||||
// Phase 5: clicking the card body opens the inline Script Generator panel
|
||||
// (TemplateMatchPanel for template-matched fixes, NoTemplateDialog otherwise).
|
||||
onActivate?: () => void
|
||||
// Whether the script panel is currently open for THIS fix — controls the
|
||||
// "Open" / "Close" affordance label on the card.
|
||||
panelOpen?: boolean
|
||||
}
|
||||
|
||||
function confidenceBucket(pct: number): { label: string; tone: string } {
|
||||
if (pct >= 80) return { label: 'high', tone: 'text-success' }
|
||||
if (pct >= 50) return { label: 'medium', tone: 'text-warning' }
|
||||
return { label: 'low', tone: 'text-muted-foreground' }
|
||||
}
|
||||
|
||||
export function SuggestedFix({ fix, onDismiss, onActivate, panelOpen }: SuggestedFixProps) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const conf = confidenceBucket(fix.confidence_pct)
|
||||
|
||||
const handleDismiss = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation() // don't trigger the card-body activation
|
||||
setBusy(true)
|
||||
try { await onDismiss() } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="pb-2">
|
||||
<div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[1.2px] text-muted-foreground pl-0.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-warning" />
|
||||
Suggested fix
|
||||
<span className="text-muted-foreground">·</span>
|
||||
<span className={`tabular-nums ${conf.tone}`}>{fix.confidence_pct}% confidence</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onClick={onActivate}
|
||||
className={cn(
|
||||
'rounded-lg border-l-[3px] border-l-warning border border-warning/25 bg-warning-dim/15 p-3 mb-2 transition-colors',
|
||||
onActivate && 'cursor-pointer hover:border-warning/50 hover:bg-warning-dim/25',
|
||||
panelOpen && 'border-warning/60 bg-warning-dim/30',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<Sparkles size={14} className="text-warning shrink-0 mt-0.5" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[0.8125rem] font-medium text-heading leading-snug">
|
||||
{fix.title}
|
||||
</div>
|
||||
<div className="mt-1 text-[0.75rem] text-muted-foreground leading-relaxed">
|
||||
{fix.description}
|
||||
</div>
|
||||
{fix.script_template_id && (
|
||||
<div className="mt-1.5 text-[0.6875rem] text-success">
|
||||
✓ Matches an existing Script Library template — click to use
|
||||
</div>
|
||||
)}
|
||||
{!fix.script_template_id && fix.ai_drafted_script && (
|
||||
<div className="mt-1.5 text-[0.6875rem] text-accent-text">
|
||||
Custom script drafted — click to review options
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
disabled={busy}
|
||||
className="p-1 rounded text-muted-foreground hover:text-danger hover:bg-elevated/40 transition-colors"
|
||||
title="Dismiss this suggestion"
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default SuggestedFix
|
||||
@@ -86,7 +86,18 @@
|
||||
--animate-slide-in-bottom: slide-in-from-bottom 200ms ease-out both;
|
||||
--animate-scale-in: scale-in 150ms ease-out both;
|
||||
--animate-fade: fadeIn 300ms ease both;
|
||||
--animate-slide-up: slide-up 320ms cubic-bezier(.22,.9,.28,1) both;
|
||||
--animate-pulse-amber: pulse-amber 1.6s infinite;
|
||||
|
||||
@keyframes slide-up {
|
||||
from { transform: translateY(14px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
@keyframes pulse-amber {
|
||||
0% { box-shadow: 0 0 0 0 rgba(251,191,36,0.45); }
|
||||
70% { box-shadow: 0 0 0 10px rgba(251,191,36,0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(251,191,36,0); }
|
||||
}
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; } to { opacity: 1; }
|
||||
}
|
||||
|
||||
@@ -14,11 +14,16 @@ import { ChatSidebar, ChatSidebarCollapsedBar } from '@/components/assistant/Cha
|
||||
import { ChatMessage } from '@/components/assistant/ChatMessage'
|
||||
import { TaskLane, clearTaskState } from '@/components/assistant/TaskLane'
|
||||
import { WhatWeKnow } from '@/components/pilot/sections/WhatWeKnow'
|
||||
import { SuggestedFix } from '@/components/pilot/sections/SuggestedFix'
|
||||
import { ResolutionNotePreview as ResolutionNotePreviewPopover } from '@/components/pilot/ResolutionNotePreview'
|
||||
import { ProposalBanner } from '@/components/pilot/ProposalBanner'
|
||||
import type { BannerMode } from '@/components/pilot/ProposalBanner'
|
||||
import { EscalateInterceptDialog } from '@/components/pilot/EscalateInterceptDialog'
|
||||
import type { InterceptChoice } from '@/components/pilot/EscalateInterceptDialog'
|
||||
import { TemplateMatchPanel } from '@/components/pilot/script/TemplateMatchPanel'
|
||||
import { NoTemplateDialog } from '@/components/pilot/script/NoTemplateDialog'
|
||||
import { TemplatizePrompt } from '@/components/pilot/script/TemplatizePrompt'
|
||||
import { ChatTabStrip, type ChatTab } from '@/components/pilot/ChatTabStrip'
|
||||
import { ScriptBuilderTab } from '@/components/pilot/ScriptBuilderTab'
|
||||
import { InlineNoTemplateDialog } from '@/components/pilot/InlineNoTemplateDialog'
|
||||
import { ShortcutsHelpOverlay } from '@/components/pilot/ShortcutsHelpOverlay'
|
||||
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery'
|
||||
@@ -34,6 +39,7 @@ import {
|
||||
type SessionSuggestedFix,
|
||||
type ResolutionNotePreview as ResolutionNotePreviewData,
|
||||
type UserDecision,
|
||||
type FixOutcome,
|
||||
} from '@/api/sessionSuggestedFixes'
|
||||
import { ConcludeSessionModal } from '@/components/assistant/ConcludeSessionModal'
|
||||
import { StatusUpdateModal } from '@/components/flowpilot/StatusUpdateModal'
|
||||
@@ -129,6 +135,46 @@ export default function AssistantChatPage() {
|
||||
// Phase 7: below 1200px the task lane collapses to a bottom drawer per the
|
||||
// migration spec. Above, it's the standard right-side panel.
|
||||
const isNarrow = useMediaQuery('(max-width: 1199px)')
|
||||
// Phase 8: ProposalBanner + EscalateInterceptDialog state.
|
||||
const [bannerCollapsed, setBannerCollapsed] = useState(false)
|
||||
const [postApplyMsgCount, setPostApplyMsgCount] = useState(0)
|
||||
const [nudgeSilenced, setNudgeSilenced] = useState(false)
|
||||
const [escalateIntercept, setEscalateIntercept] = useState<{ fixId: string; fixTitle: string } | null>(null)
|
||||
// Phase 9: ChatTabStrip + ScriptBuilderTab state.
|
||||
const [chatTab, setChatTab] = useState<ChatTab>('chat')
|
||||
const [scriptBuilderHasProgress, setScriptBuilderHasProgress] = useState(false)
|
||||
// Phase 8: compute the current banner mode from activeFix.
|
||||
// applied_at is now persisted on the server (stamped by POST /apply),
|
||||
// so bannerMode is derived entirely from server state — no client-side flag.
|
||||
const bannerMode: BannerMode | null = (() => {
|
||||
if (!activeFix) return null
|
||||
if (activeFix.status === 'dismissed') return null
|
||||
if (activeFix.ai_outcome_proposal) return 'ai_confirming'
|
||||
if (activeFix.status === 'applied_partial') return 'partial'
|
||||
if (activeFix.status === 'applied_success' || activeFix.status === 'applied_failed') return null
|
||||
if (activeFix.applied_at) {
|
||||
if (postApplyMsgCount >= 3 && !nudgeSilenced) return 'nudge'
|
||||
return 'verifying'
|
||||
}
|
||||
return 'proposed'
|
||||
})()
|
||||
|
||||
// Phase 9: show the tab strip when the fix needs a script drafted (no template,
|
||||
// no drafted script yet, and still in a live state).
|
||||
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 (fix resolved/dismissed/script-drafted),
|
||||
// snap back to the Chat tab so the user doesn't land on a blank panel.
|
||||
useEffect(() => {
|
||||
if (!showTabStrip && chatTab === 'script_builder') setChatTab('chat')
|
||||
}, [showTabStrip, chatTab])
|
||||
|
||||
const toggleSidebarCollapse = () => {
|
||||
const next = !sidebarCollapsed
|
||||
setSidebarCollapsed(next)
|
||||
@@ -313,6 +359,14 @@ export default function AssistantChatPage() {
|
||||
setPreviewError(null)
|
||||
setPreviewPosting(false)
|
||||
setScriptPanelOpen(false)
|
||||
// Phase 8: banner state reset
|
||||
setBannerCollapsed(false)
|
||||
setPostApplyMsgCount(0)
|
||||
setNudgeSilenced(false)
|
||||
setEscalateIntercept(null)
|
||||
// Phase 9: tab strip reset
|
||||
setChatTab('chat')
|
||||
setScriptBuilderHasProgress(false)
|
||||
}, [])
|
||||
|
||||
// Phase 2 facts — fetch + handlers. `refreshFacts` is called from selectChat
|
||||
@@ -434,19 +488,6 @@ export default function AssistantChatPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDismissFix = async () => {
|
||||
if (!activeChatId || !activeFix) return
|
||||
try {
|
||||
await sessionSuggestedFixesApi.recordDecision(activeChatId, activeFix.id, 'dismissed')
|
||||
setActiveFix(null)
|
||||
setScriptPanelOpen(false)
|
||||
// Dismissal bumps state_version on the server; reflect in preview.
|
||||
schedulePreviewRefresh(activeChatId)
|
||||
} catch {
|
||||
toast.error('Failed to dismiss suggestion')
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 5: handle a path choice from NoTemplateDialog. one_off and
|
||||
// draft_template just record the decision (returning the rendered script
|
||||
// for display); build_template returns a redirect_path to the Script
|
||||
@@ -474,6 +515,17 @@ export default function AssistantChatPage() {
|
||||
} else if (decision === 'draft_template') {
|
||||
toast.success('Draft template queued — review after Resolve')
|
||||
}
|
||||
// Phase 9 §5: one_off and draft_template declare a run ("Run now, …").
|
||||
// Stamp applied_at to transition the fix into Verifying.
|
||||
// build_template does NOT run — no stamp.
|
||||
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 */ }
|
||||
}
|
||||
// Keep the panel open so the engineer can copy the rendered script.
|
||||
} catch {
|
||||
toast.error('Failed to record decision')
|
||||
@@ -482,7 +534,9 @@ export default function AssistantChatPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenPreview = (kind: 'resolve' | 'escalate') => {
|
||||
// handleOpenPreview is declared before handleSetOutcome so it can be listed
|
||||
// as a useCallback dep without a temporal dead zone.
|
||||
const handleOpenPreview = useCallback((kind: 'resolve' | 'escalate') => {
|
||||
if (!activeChatId) return
|
||||
// Opening a different kind clobbers the cached markdown so the popover
|
||||
// doesn't flash stale content while the new kind fetches.
|
||||
@@ -490,7 +544,146 @@ export default function AssistantChatPage() {
|
||||
setPreviewKind(kind)
|
||||
setPreviewError(null)
|
||||
refreshPreview(activeChatId, kind)
|
||||
}
|
||||
}, [activeChatId, previewKind, refreshPreview])
|
||||
|
||||
// Phase 9: handleApplyFix — routes to the appropriate surface based on
|
||||
// fix state. applyFix() call site moves to Task 13 (handleScriptDecision
|
||||
// and TemplateMatchPanel.onMarkRun).
|
||||
const handleApplyFix = useCallback(() => {
|
||||
if (!activeFix) return
|
||||
if (activeFix.script_template_id) {
|
||||
setScriptPanelOpen(true) // existing TemplateMatchPanel flow in task lane
|
||||
return
|
||||
}
|
||||
if (activeFix.ai_drafted_script) {
|
||||
setScriptPanelOpen(true) // InlineNoTemplateDialog, now in chat region (Step 5)
|
||||
return
|
||||
}
|
||||
// No draft, no template — route to the Script Builder tab.
|
||||
setChatTab('script_builder')
|
||||
}, [activeFix])
|
||||
|
||||
// Phase 9 Task 13: TemplateMatchPanel "I ran this" — stamps applied_at so the
|
||||
// ProposalBanner transitions from Proposed to Verifying. Shared useCallback so
|
||||
// both render sites (narrow-drawer + side-panel) are identical.
|
||||
const handleMarkRun = useCallback(async () => {
|
||||
if (!activeFix || !activeChatId) return
|
||||
try {
|
||||
const updated = await sessionSuggestedFixesApi.applyFix(
|
||||
activeChatId, activeFix.id,
|
||||
)
|
||||
setActiveFix(updated)
|
||||
setScriptPanelOpen(false)
|
||||
} catch { /* non-fatal: engineer can still mark outcome later */ }
|
||||
}, [activeFix, activeChatId])
|
||||
|
||||
// Phase 8: record a terminal outcome for the active fix. Updates local state
|
||||
// on success. For applied_success also opens the Resolve preview.
|
||||
const handleSetOutcome = useCallback(async (outcome: FixOutcome, notes?: string) => {
|
||||
if (!activeChatId || !activeFix) return
|
||||
try {
|
||||
const updated = await sessionSuggestedFixesApi.patchOutcome(activeChatId, activeFix.id, outcome, notes)
|
||||
setActiveFix(updated)
|
||||
// Reset apply tracking state since we now have a terminal outcome.
|
||||
setPostApplyMsgCount(0)
|
||||
setNudgeSilenced(false)
|
||||
if (outcome === 'applied_success') {
|
||||
// Open the Resolve note preview so the engineer can post to PSA.
|
||||
handleOpenPreview('resolve')
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { response?: { status?: number; data?: { detail?: string } } })?.response?.status
|
||||
if (status === 409) {
|
||||
toast.warning('Outcome already recorded — session may already be in a terminal state.')
|
||||
} else {
|
||||
toast.error('Failed to record outcome')
|
||||
}
|
||||
}
|
||||
}, [activeChatId, activeFix, handleOpenPreview])
|
||||
|
||||
// Phase 8: accept the AI-proposed outcome. Translates AI proposal outcome
|
||||
// names to FixOutcome values, then delegates to handleSetOutcome.
|
||||
// For partial, a non-empty notes string is required by the backend (400 on
|
||||
// empty). Fall back to a generic note if the AI's reason is blank.
|
||||
const handleAcceptAIProposal = useCallback(async () => {
|
||||
if (!activeFix?.ai_outcome_proposal) return
|
||||
const { outcome, reason } = activeFix.ai_outcome_proposal
|
||||
const fixOutcome: FixOutcome =
|
||||
outcome === 'success' ? 'applied_success'
|
||||
: outcome === 'failure' ? 'applied_failed'
|
||||
: 'applied_partial'
|
||||
const notes = fixOutcome === 'applied_partial'
|
||||
? (reason?.trim() || 'Partially applied per AI detection')
|
||||
: fixOutcome === 'applied_failed'
|
||||
? reason?.trim() || undefined
|
||||
: undefined
|
||||
await handleSetOutcome(fixOutcome, notes)
|
||||
}, [activeFix, handleSetOutcome])
|
||||
|
||||
// Phase 8: reject the AI proposal — persist the rejection to the server so
|
||||
// the banner does not re-surface on the next refreshSessionDerived call.
|
||||
// Falls back to a local-state clear on error (non-fatal: banner may re-arm
|
||||
// on the next refetch, matching the previous behaviour).
|
||||
const handleRejectAIProposal = useCallback(async () => {
|
||||
if (!activeFix || !activeChatId) return
|
||||
try {
|
||||
const updated = await sessionSuggestedFixesApi.clearAIProposal(activeChatId, activeFix.id)
|
||||
setActiveFix(updated)
|
||||
} catch {
|
||||
// Non-fatal fallback: clear locally so the banner disappears immediately.
|
||||
setActiveFix({ ...activeFix, ai_outcome_proposal: null })
|
||||
}
|
||||
}, [activeFix, activeChatId])
|
||||
|
||||
// Phase 8: silence the nudge banner without recording an outcome.
|
||||
const handleSilenceNudge = useCallback(() => {
|
||||
setNudgeSilenced(true)
|
||||
setPostApplyMsgCount(0)
|
||||
}, [])
|
||||
|
||||
// Phase 8: Escalate intercept — capture fix outcome before proceeding.
|
||||
// Wraps the existing Escalate click (which opens ConcludeSessionModal).
|
||||
const handleEscalateClick = useCallback(() => {
|
||||
const inVerifyState =
|
||||
activeFix && (
|
||||
(!!activeFix.applied_at && activeFix.status === 'proposed') ||
|
||||
activeFix.status === 'applied_partial'
|
||||
)
|
||||
if (inVerifyState && activeFix) {
|
||||
setEscalateIntercept({ fixId: activeFix.id, fixTitle: activeFix.title })
|
||||
return
|
||||
}
|
||||
setShowConclude(true)
|
||||
}, [activeFix])
|
||||
|
||||
const handleInterceptChoice = useCallback(async (choice: InterceptChoice) => {
|
||||
const stored = escalateIntercept
|
||||
setEscalateIntercept(null)
|
||||
if (!stored || !activeChatId) return
|
||||
const outcomeToSend: FixOutcome =
|
||||
choice === 'never_applied' ? 'dismissed' : choice
|
||||
try {
|
||||
const updated = await sessionSuggestedFixesApi.patchOutcome(
|
||||
activeChatId, stored.fixId, outcomeToSend,
|
||||
)
|
||||
setActiveFix(updated)
|
||||
} catch { /* non-fatal — engineer can still escalate */ }
|
||||
setShowConclude(true)
|
||||
}, [activeChatId, escalateIntercept])
|
||||
|
||||
// Phase 8: Resolve click — auto-mark applied_success if in verifying state
|
||||
// before opening the resolution note preview.
|
||||
const handleResolveClick = useCallback(async () => {
|
||||
if (activeFix && activeFix.applied_at && activeFix.status === 'proposed' && activeChatId) {
|
||||
try {
|
||||
const updated = await sessionSuggestedFixesApi.patchOutcome(activeChatId, activeFix.id, 'applied_success')
|
||||
setActiveFix(updated)
|
||||
} catch {
|
||||
// Non-fatal; user can still resolve.
|
||||
}
|
||||
}
|
||||
setShowConclude(true)
|
||||
}, [activeChatId, activeFix])
|
||||
|
||||
const handleClosePreview = () => {
|
||||
setPreviewKind(null)
|
||||
@@ -644,8 +837,8 @@ export default function AssistantChatPage() {
|
||||
await aiSessionsApi.deleteSession(chatId)
|
||||
setChats(prev => prev.filter(c => c.id !== chatId))
|
||||
if (activeChatId === chatId) {
|
||||
resetSessionDerivedState()
|
||||
setActiveChatId(null)
|
||||
setMessages([])
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to delete chat')
|
||||
@@ -704,6 +897,13 @@ export default function AssistantChatPage() {
|
||||
setActiveActions(response.actions || [])
|
||||
setShowTaskLane(true)
|
||||
}
|
||||
// Phase 8: increment post-apply message counter for nudge logic.
|
||||
// Only increments when fix is still in 'proposed' (verifying) state —
|
||||
// partial/dismissed/terminal states don't render the nudge, and a
|
||||
// partial→verifying transition could inherit an already-saturated counter.
|
||||
if (activeFix && activeFix.applied_at && activeFix.status === 'proposed') {
|
||||
setPostApplyMsgCount(c => c + 1)
|
||||
}
|
||||
// Refetch facts + active fix; preview refreshes if open.
|
||||
refreshSessionDerived(sentForChatId)
|
||||
} catch (err: unknown) {
|
||||
@@ -774,6 +974,11 @@ export default function AssistantChatPage() {
|
||||
setActiveQuestions([])
|
||||
setActiveActions([])
|
||||
}
|
||||
// Phase 8: increment post-apply message counter for nudge logic (mirrors handleSend).
|
||||
// Only increments in 'proposed' (verifying) state — same rationale as handleSend.
|
||||
if (activeFix && activeFix.applied_at && activeFix.status === 'proposed') {
|
||||
setPostApplyMsgCount(c => c + 1)
|
||||
}
|
||||
// Refetch facts + active fix; answering tasks is the primary trigger.
|
||||
refreshSessionDerived(sentForChatId)
|
||||
} catch (err: unknown) {
|
||||
@@ -1080,7 +1285,7 @@ export default function AssistantChatPage() {
|
||||
{isActive && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setShowConclude(true)}
|
||||
onClick={handleResolveClick}
|
||||
disabled={!canAct}
|
||||
data-conclude-outcome="resolved"
|
||||
className="flex items-center gap-1.5 rounded-lg bg-success-dim border border-success/20 px-3 py-1.5 text-xs font-medium text-success hover:bg-success/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
|
||||
@@ -1088,8 +1293,9 @@ export default function AssistantChatPage() {
|
||||
<CheckCircle2 size={13} />
|
||||
Resolve
|
||||
</button>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowConclude(true)}
|
||||
onClick={handleEscalateClick}
|
||||
disabled={!canAct}
|
||||
data-conclude-outcome="escalated"
|
||||
className="flex items-center gap-1.5 rounded-lg bg-warning-dim border border-warning/20 px-3 py-1.5 text-xs font-medium text-warning hover:bg-warning/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
|
||||
@@ -1097,6 +1303,14 @@ export default function AssistantChatPage() {
|
||||
<ArrowUpRight size={13} />
|
||||
Escalate
|
||||
</button>
|
||||
{escalateIntercept && (
|
||||
<EscalateInterceptDialog
|
||||
fixTitle={escalateIntercept.fixTitle}
|
||||
onChoose={handleInterceptChoice}
|
||||
onClose={() => setEscalateIntercept(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{messages.length >= 2 && (
|
||||
@@ -1152,21 +1366,32 @@ export default function AssistantChatPage() {
|
||||
{isActive && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => { setShowOverflow(false); setShowConclude(true) }}
|
||||
onClick={() => { setShowOverflow(false); handleResolveClick() }}
|
||||
disabled={!canAct}
|
||||
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-success hover:bg-success-dim transition-colors disabled:opacity-40"
|
||||
>
|
||||
<CheckCircle2 size={14} />
|
||||
Resolve
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowOverflow(false); setShowConclude(true) }}
|
||||
disabled={!canAct}
|
||||
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-warning hover:bg-warning-dim transition-colors disabled:opacity-40"
|
||||
>
|
||||
<ArrowUpRight size={14} />
|
||||
Escalate
|
||||
</button>
|
||||
{/* Mobile Escalate: wrapped in relative so EscalateInterceptDialog anchors here */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => { setShowOverflow(false); handleEscalateClick() }}
|
||||
disabled={!canAct}
|
||||
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-warning hover:bg-warning-dim transition-colors disabled:opacity-40"
|
||||
>
|
||||
<ArrowUpRight size={14} />
|
||||
Escalate
|
||||
</button>
|
||||
{/* Mobile intercept dialog — mirrors desktop; only one is visible at a time */}
|
||||
{escalateIntercept && (
|
||||
<EscalateInterceptDialog
|
||||
fixTitle={escalateIntercept.fixTitle}
|
||||
onChoose={handleInterceptChoice}
|
||||
onClose={() => setEscalateIntercept(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
@@ -1195,6 +1420,19 @@ export default function AssistantChatPage() {
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Phase 9: ChatTabStrip — shown when the fix needs a script drafted */}
|
||||
{showTabStrip && (
|
||||
<ChatTabStrip
|
||||
active={chatTab}
|
||||
onChange={setChatTab}
|
||||
scriptBuilderHasProgress={scriptBuilderHasProgress}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Chat tab content — messages + banner + composer.
|
||||
Hidden (not unmounted) when Script Builder tab is active so
|
||||
scroll position and input state are preserved. */}
|
||||
<div className={cn('flex-1 min-h-0 flex flex-col', chatTab !== 'chat' && 'hidden')}>
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto px-4 sm:px-6 py-4 space-y-4">
|
||||
{messages.length === 0 && !loading && (
|
||||
@@ -1233,6 +1471,34 @@ export default function AssistantChatPage() {
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Phase 8: ProposalBanner — mounted above the composer */}
|
||||
{activeFix && bannerMode && (
|
||||
<ProposalBanner
|
||||
fix={activeFix}
|
||||
mode={bannerMode}
|
||||
collapsed={bannerCollapsed && bannerMode !== 'nudge' && bannerMode !== 'ai_confirming'}
|
||||
onToggleCollapsed={() => setBannerCollapsed(v => !v)}
|
||||
onApply={handleApplyFix}
|
||||
onDismiss={() => handleSetOutcome('dismissed')}
|
||||
onOutcome={handleSetOutcome}
|
||||
onAcceptAIProposal={handleAcceptAIProposal}
|
||||
onRejectAIProposal={handleRejectAIProposal}
|
||||
onSilenceNudge={handleSilenceNudge}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Phase 9: InlineNoTemplateDialog — drafted-script evaluation case,
|
||||
rendered in the chat region above the composer so all three
|
||||
option cards fit side-by-side without the TaskLane's narrow width. */}
|
||||
{scriptPanelOpen && activeFix && activeChatId && !activeFix.script_template_id && activeFix.ai_drafted_script && (
|
||||
<InlineNoTemplateDialog
|
||||
fix={activeFix}
|
||||
onClose={() => setScriptPanelOpen(false)}
|
||||
onDecide={handleScriptDecision}
|
||||
busy={scriptDecisionBusy}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Rich Input */}
|
||||
<div className="px-3 sm:px-6 py-3 shrink-0">
|
||||
<div
|
||||
@@ -1357,6 +1623,24 @@ export default function AssistantChatPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>{/* end chat-tab content wrapper */}
|
||||
|
||||
{/* Phase 9: Script Builder tab — mounted alongside chat via display:none
|
||||
so both scroll positions and state are preserved across tab switches. */}
|
||||
{showTabStrip && activeFix && activeChatId && (
|
||||
<div className={cn('flex-1 min-h-0 flex flex-col', chatTab !== 'script_builder' && 'hidden')}>
|
||||
<ScriptBuilderTab
|
||||
fix={activeFix}
|
||||
pilotSessionId={activeChatId}
|
||||
onProgressChange={setScriptBuilderHasProgress}
|
||||
onScriptDrafted={(updated) => {
|
||||
setActiveFix(updated)
|
||||
setChatTab('chat')
|
||||
setScriptBuilderHasProgress(false)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
@@ -1431,33 +1715,15 @@ export default function AssistantChatPage() {
|
||||
loading={loading}
|
||||
/>
|
||||
}
|
||||
suggestedFixSlot={
|
||||
activeFix && (
|
||||
<SuggestedFix
|
||||
fix={activeFix}
|
||||
onDismiss={handleDismissFix}
|
||||
onActivate={() => setScriptPanelOpen((prev) => !prev)}
|
||||
panelOpen={scriptPanelOpen}
|
||||
/>
|
||||
)
|
||||
}
|
||||
bottomSlot={
|
||||
<>
|
||||
{scriptPanelOpen && activeFix && activeChatId && (
|
||||
activeFix.script_template_id ? (
|
||||
<TemplateMatchPanel
|
||||
fix={activeFix}
|
||||
sessionId={activeChatId}
|
||||
onClose={() => setScriptPanelOpen(false)}
|
||||
/>
|
||||
) : (
|
||||
<NoTemplateDialog
|
||||
fix={activeFix}
|
||||
onClose={() => setScriptPanelOpen(false)}
|
||||
onDecide={handleScriptDecision}
|
||||
busy={scriptDecisionBusy}
|
||||
/>
|
||||
)
|
||||
{scriptPanelOpen && activeFix && activeChatId && activeFix.script_template_id && (
|
||||
<TemplateMatchPanel
|
||||
fix={activeFix}
|
||||
sessionId={activeChatId}
|
||||
onClose={() => setScriptPanelOpen(false)}
|
||||
onMarkRun={handleMarkRun}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center gap-3 px-3 mt-1">
|
||||
<button
|
||||
@@ -1520,33 +1786,15 @@ export default function AssistantChatPage() {
|
||||
loading={loading}
|
||||
/>
|
||||
}
|
||||
suggestedFixSlot={
|
||||
activeFix && (
|
||||
<SuggestedFix
|
||||
fix={activeFix}
|
||||
onDismiss={handleDismissFix}
|
||||
onActivate={() => setScriptPanelOpen((prev) => !prev)}
|
||||
panelOpen={scriptPanelOpen}
|
||||
/>
|
||||
)
|
||||
}
|
||||
bottomSlot={
|
||||
<>
|
||||
{scriptPanelOpen && activeFix && activeChatId && (
|
||||
activeFix.script_template_id ? (
|
||||
<TemplateMatchPanel
|
||||
fix={activeFix}
|
||||
sessionId={activeChatId}
|
||||
onClose={() => setScriptPanelOpen(false)}
|
||||
/>
|
||||
) : (
|
||||
<NoTemplateDialog
|
||||
fix={activeFix}
|
||||
onClose={() => setScriptPanelOpen(false)}
|
||||
onDecide={handleScriptDecision}
|
||||
busy={scriptDecisionBusy}
|
||||
/>
|
||||
)
|
||||
{scriptPanelOpen && activeFix && activeChatId && activeFix.script_template_id && (
|
||||
<TemplateMatchPanel
|
||||
fix={activeFix}
|
||||
sessionId={activeChatId}
|
||||
onClose={() => setScriptPanelOpen(false)}
|
||||
onMarkRun={handleMarkRun}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center gap-3 px-3 mt-1">
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user