Merge pull request #118 from patherly/feat/flowpilot-message-bar-and-script-builder

feat: FlowPilot message bar + AI Script Builder + Library reorg
This commit was merged in pull request #118.
This commit is contained in:
chihlasm
2026-03-21 22:07:27 -04:00
committed by GitHub
48 changed files with 5628 additions and 187 deletions

View File

@@ -11,6 +11,30 @@ All notable changes to ResolutionFlow are documented here.
---
## [0.10.0] - 2026-03-21
### Added
- **AI Script Builder** — chat-style page (`/script-builder`) for generating PowerShell, Bash, and Python scripts from natural language descriptions, with fullscreen preview modal and save-to-library flow
- **FlowPilot message bar** — always-visible chat input at bottom of guided sessions, replacing hidden "None of these" escape hatch
- **FlowPilot → Script Builder handoff** — FlowPilot detects custom script needs and offers "Open Script Builder" button with context pre-filled via sessionStorage
- **Script Library reorganization** — "My Scripts" / "Team Scripts" tabs, prominent "Build a New Script" button, `language` column on templates
- **Session close/abandon** — "Close" button in FlowPilot action bar sets status to `abandoned` and redirects to Active Sessions
- **Unified session history** — merged Flow Sessions and AI Sessions into single view on Active Sessions page
### Changed
- FlowPilot now asks user preference (GUI walkthrough vs script) before suggesting scripted solutions
- Script Builder messages normalized into separate `script_builder_messages` table (from JSONB array)
- Step card action types use typed content helpers instead of unsafe `as string` casts
- Message bar width expanded and repositioned above action bar
- Date range filters use end-of-day timestamps to include same-day items
### Fixed
- Missing `useNavigate` import causing Railway build failure
- FlowPilot message bar hidden behind fixed action bar
- Date filter excluding items created on the selected end date
---
## [0.9.0] - 2026-03-21
### Added

View File

@@ -356,6 +356,16 @@ gh run view <id> --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi
**82. `bun` requires PATH setup on devserver01:** `export BUN_INSTALL="$HOME/.bun" && export PATH="$BUN_INSTALL/bin:$PATH"`. The gstack browse binary and Playwright need this. Chromium system deps: `libatk1.0-0 libatk-bridge2.0-0 libcups2 libxkbcommon0 libatspi2.0-0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 libasound2`.
**83. FlowPilot ActionBar is `position: fixed; bottom: 0`:** Any UI element placed in normal document flow below the session content will be hidden behind it. New fixed-position elements (like the message bar) must use `bottom: 68px` (action bar height) and the same `left: var(--sidebar-w)` pattern. The conversation column uses `pb-32` for clearance.
**84. AI session `abandoned` status is fully wired:** `POST /ai-sessions/{id}/abandon` sets status to `abandoned` with optional `reason` param. Frontend: `aiSessionsApi.abandonSession()`, `useFlowPilotSession().abandonSession()`, "Close" button in `FlowPilotActionBar`. Redirects to `/sessions` after closing.
**85. Date range filter end dates must use end-of-day:** `toDate.toISOString()` sends midnight (start of day), excluding items created later that day. Always set `toDate.setHours(23, 59, 59, 999)` before sending. For string-based date inputs (AI sessions), append `T23:59:59.999Z`. See `SessionHistoryPage.tsx`.
**86. Script Builder system:** AI-powered script generation at `/script-builder`. Chat-style interface generates PowerShell/Bash/Python scripts from natural language. Backend: `ScriptBuilderSession` model, `script_builder_service.py`, endpoints at `/scripts/builder/`. Frontend: `ScriptBuilderPage`, `ScriptCodeBlock`, `ScriptPreviewModal`, `SaveToLibraryDialog`. FlowPilot can hand off to Script Builder via `action_type: "open_script_builder"` with `sessionStorage` context passing.
**87. FlowPilot must ask GUI vs script preference:** When a task can be done via GUI or script (e.g., creating AD users), FlowPilot must ask the engineer which approach they prefer BEFORE suggesting either. Never assume the user wants a script. See `FLOWPILOT_SYSTEM_PROMPT` rules in `flowpilot_engine.py`.
---
## RBAC & Permissions
@@ -439,6 +449,38 @@ When a feature, fix, or significant piece of work is finished and merged/committ
---
## gstack (Browser & Workflow Skills)
**Web browsing:** Always use the `/browse` skill from gstack for all web browsing needs. Never use `mcp__claude-in-chrome__*` tools.
**Available skills:**
| Skill | Purpose |
|-------|---------|
| `/office-hours` | Brainstorm new ideas (YC-style office hours) |
| `/plan-ceo-review` | CEO/founder-mode plan review (scope, ambition) |
| `/plan-eng-review` | Engineering plan review (architecture, edge cases) |
| `/plan-design-review` | Design plan review (UI/UX critique) |
| `/design-consultation` | Create a design system / DESIGN.md |
| `/review` | Pre-landing PR code review |
| `/ship` | Ship workflow (tests, review, PR creation) |
| `/browse` | Headless browser for QA testing and site dogfooding |
| `/qa` | Systematic QA testing + auto-fix bugs found |
| `/qa-only` | QA report only (no fixes) |
| `/design-review` | Visual QA — find and fix design inconsistencies |
| `/setup-browser-cookies` | Import cookies from real browser for authenticated testing |
| `/retro` | Weekly engineering retrospective |
| `/investigate` | Systematic debugging with root cause analysis |
| `/document-release` | Post-ship documentation updates |
| `/codex` | Second opinion via OpenAI Codex CLI |
| `/careful` | Safety guardrails for destructive commands |
| `/freeze` | Restrict edits to a specific directory |
| `/guard` | Full safety mode (careful + freeze) |
| `/unfreeze` | Remove edit restrictions |
| `/gstack-upgrade` | Upgrade gstack to latest version |
---
## Deployment (Railway)
- **Production:** `resolutionflow.com` (frontend), `api.resolutionflow.com` (backend)

View File

@@ -24,6 +24,7 @@ from app.models.ai_session import AISession # noqa: F401
from app.models.ai_session_step import AISessionStep # noqa: F401
from app.models.psa_post_log import PsaPostLog # noqa: F401
from app.models.psa_member_mapping import PsaMemberMapping # noqa: F401
from app.models.script_builder_session import ScriptBuilderSession, ScriptBuilderMessage # noqa: F401
from app.core.config import settings
# this is the Alembic Config object

View File

@@ -0,0 +1,45 @@
"""add_script_builder_sessions_table
Revision ID: 062
Revises: f0aad74ea51b
Create Date: 2026-03-21 20:54:41.621748
"""
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 = '062'
down_revision: Union[str, None] = 'f0aad74ea51b'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table('script_builder_sessions',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('user_id', sa.UUID(), nullable=False),
sa.Column('team_id', sa.UUID(), nullable=True),
sa.Column('language', sa.String(length=30), nullable=False, comment='Script language: powershell, bash, python'),
sa.Column('title', sa.String(length=200), nullable=True, comment='Auto-generated from first AI response'),
sa.Column('messages', postgresql.JSONB(astext_type=sa.Text()), nullable=False, comment='Array of {role, content, script?, script_filename?, timestamp}'),
sa.Column('latest_script', sa.Text(), nullable=True, comment='Most recent generated script for quick access'),
sa.Column('latest_script_filename', sa.String(length=200), nullable=True, comment='Filename of the latest generated script'),
sa.Column('message_count', sa.Integer(), nullable=False),
sa.Column('ai_session_id', sa.UUID(), nullable=True, comment='Link to FlowPilot session if launched from there'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['ai_session_id'], ['ai_sessions.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['team_id'], ['teams.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_script_builder_sessions_user_id'), 'script_builder_sessions', ['user_id'], unique=False)
def downgrade() -> None:
op.drop_index(op.f('ix_script_builder_sessions_user_id'), table_name='script_builder_sessions')
op.drop_table('script_builder_sessions')

View File

@@ -0,0 +1,55 @@
"""add_language_to_script_templates_and_ai_generated_category
Revision ID: 063
Revises: 062
Create Date: 2026-03-21 21:13:32.239533
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '063'
down_revision: Union[str, None] = '062'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add language column to script_templates
op.add_column(
'script_templates',
sa.Column(
'language',
sa.String(length=30),
nullable=True,
comment='Script language: powershell, bash, python',
),
)
# Seed "AI Generated" category
op.execute(
sa.text("""
INSERT INTO script_categories (id, name, slug, description, icon, sort_order, is_active, created_at, updated_at)
VALUES (
'a0000000-0000-0000-0000-000000000001'::uuid,
'AI Generated',
'ai-generated',
'Scripts generated by the AI Script Builder',
'sparkles',
100,
true,
NOW(),
NOW()
)
ON CONFLICT (slug) DO NOTHING
""")
)
def downgrade() -> None:
op.execute(sa.text("DELETE FROM script_categories WHERE slug = 'ai-generated'"))
op.drop_column('script_templates', 'language')

View File

@@ -0,0 +1,128 @@
"""normalize script builder messages
Revision ID: 064
Revises: 063
Create Date: 2026-03-21 22:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID, JSONB
# revision identifiers, used by Alembic.
revision: str = '064'
down_revision: Union[str, None] = '063'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 1. Create the new script_builder_messages table
op.create_table(
'script_builder_messages',
sa.Column('id', UUID(as_uuid=True), primary_key=True),
sa.Column('session_id', UUID(as_uuid=True), sa.ForeignKey('script_builder_sessions.id', ondelete='CASCADE'), nullable=False, index=True),
sa.Column('role', sa.String(20), nullable=False, comment='user or assistant'),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('script', sa.Text(), nullable=True, comment='Extracted script from AI response'),
sa.Column('script_filename', sa.String(200), nullable=True),
sa.Column('line_count', sa.Integer(), nullable=True),
sa.Column('input_tokens', sa.Integer(), nullable=True),
sa.Column('output_tokens', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
# 2. Migrate existing JSONB messages to the new table (if any exist)
conn = op.get_bind()
sessions = conn.execute(
sa.text("SELECT id, messages FROM script_builder_sessions WHERE messages IS NOT NULL AND messages != '[]'::jsonb")
).fetchall()
for session_row in sessions:
session_id = session_row[0]
messages = session_row[1]
if not messages:
continue
for msg in messages:
conn.execute(
sa.text("""
INSERT INTO script_builder_messages (id, session_id, role, content, script, script_filename, line_count, input_tokens, output_tokens, created_at)
VALUES (gen_random_uuid(), :session_id, :role, :content, :script, :script_filename, :line_count, :input_tokens, :output_tokens, COALESCE(:timestamp::timestamptz, NOW()))
"""),
{
"session_id": session_id,
"role": msg.get("role", "user"),
"content": msg.get("content", ""),
"script": msg.get("script"),
"script_filename": msg.get("script_filename"),
"line_count": msg.get("line_count"),
"input_tokens": msg.get("input_tokens"),
"output_tokens": msg.get("output_tokens"),
"timestamp": msg.get("timestamp"),
},
)
# 3. Drop the old columns
op.drop_column('script_builder_sessions', 'messages')
op.drop_column('script_builder_sessions', 'message_count')
def downgrade() -> None:
# 1. Re-add the JSONB columns
op.add_column('script_builder_sessions', sa.Column('messages', JSONB, nullable=False, server_default='[]'))
op.add_column('script_builder_sessions', sa.Column('message_count', sa.Integer(), nullable=False, server_default='0'))
# 2. Migrate data back from the messages table to JSONB
conn = op.get_bind()
sessions = conn.execute(
sa.text("SELECT DISTINCT session_id FROM script_builder_messages")
).fetchall()
for (session_id,) in sessions:
messages = conn.execute(
sa.text("""
SELECT role, content, script, script_filename, line_count, input_tokens, output_tokens, created_at
FROM script_builder_messages
WHERE session_id = :session_id
ORDER BY created_at ASC
"""),
{"session_id": session_id},
).fetchall()
json_messages = []
user_count = 0
for msg in messages:
entry = {
"role": msg[0],
"content": msg[1],
"timestamp": msg[7].isoformat() if msg[7] else None,
}
if msg[2]: # script
entry["script"] = msg[2]
if msg[3]: # script_filename
entry["script_filename"] = msg[3]
if msg[4] is not None: # line_count
entry["line_count"] = msg[4]
if msg[5] is not None: # input_tokens
entry["input_tokens"] = msg[5]
if msg[6] is not None: # output_tokens
entry["output_tokens"] = msg[6]
json_messages.append(entry)
if msg[0] == "user":
user_count += 1
import json
conn.execute(
sa.text("UPDATE script_builder_sessions SET messages = :messages::jsonb, message_count = :count WHERE id = :id"),
{"messages": json.dumps(json_messages), "count": user_count, "id": session_id},
)
# 3. Drop the messages table
op.drop_table('script_builder_messages')
# 4. Remove server defaults added for downgrade
op.alter_column('script_builder_sessions', 'messages', server_default=None)
op.alter_column('script_builder_sessions', 'message_count', server_default=None)

View File

@@ -396,6 +396,34 @@ async def resume_session(
await db.commit()
# ── Abandon / Close ──
@router.post("/{session_id}/abandon", status_code=204)
@limiter.limit("15/minute")
async def abandon_session(
request: Request,
session_id: UUID,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
_: None = Depends(require_engineer_or_admin),
reason: str | None = None,
):
"""Close a session without resolving or escalating."""
try:
await flowpilot_engine.abandon_session(
session_id=session_id,
user_id=current_user.id,
reason=reason,
db=db,
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except PermissionError as e:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
await db.commit()
# ── Escalation Queue ──
@router.get("/escalation-queue", response_model=list[AISessionSummary])

View File

@@ -0,0 +1,188 @@
"""Script Builder API endpoints."""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request
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.user import User
from app.models.script_builder_session import ScriptBuilderSession
from app.schemas.script_builder import (
ScriptBuilderCreateRequest,
ScriptBuilderMessageRequest,
ScriptBuilderMessageResponse,
ScriptBuilderMessageSchema,
ScriptBuilderSessionDetail,
ScriptBuilderSessionSummary,
SaveToLibraryRequest,
)
from app.schemas.script_template import ScriptTemplateDetail
from app.services import script_builder_service
router = APIRouter(prefix="/scripts/builder", tags=["script-builder"])
MAX_SESSIONS_PER_USER = 5
def _session_to_detail(session: ScriptBuilderSession) -> ScriptBuilderSessionDetail:
"""Convert a session ORM object (with message_records loaded) to detail schema."""
messages = [
ScriptBuilderMessageSchema.model_validate(m)
for m in session.message_records
]
return ScriptBuilderSessionDetail(
id=session.id,
language=session.language,
title=session.title,
messages=messages,
latest_script=session.latest_script,
latest_script_filename=session.latest_script_filename,
message_count=len([m for m in messages if m.role == "user"]),
ai_session_id=session.ai_session_id,
created_at=session.created_at,
updated_at=session.updated_at,
)
def _session_to_summary(session: ScriptBuilderSession) -> ScriptBuilderSessionSummary:
"""Convert a session ORM object to summary schema (no messages needed)."""
return ScriptBuilderSessionSummary(
id=session.id,
language=session.language,
title=session.title,
message_count=0, # Summary doesn't eagerly load messages
latest_script_filename=session.latest_script_filename,
created_at=session.created_at,
updated_at=session.updated_at,
)
@router.post("/sessions", response_model=ScriptBuilderSessionDetail, status_code=201)
async def create_session(
data: ScriptBuilderCreateRequest,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> ScriptBuilderSessionDetail:
"""Start a new Script Builder session."""
# Enforce max concurrent sessions
count = await script_builder_service.count_user_sessions(db, current_user.id)
if count >= MAX_SESSIONS_PER_USER:
raise HTTPException(
status_code=400,
detail=f"Maximum of {MAX_SESSIONS_PER_USER} builder sessions allowed. Delete an old session first.",
)
session = await script_builder_service.create_session(
db=db,
user_id=current_user.id,
team_id=current_user.team_id,
language=data.language,
)
await db.commit()
# Re-fetch with message_records loaded
session = await script_builder_service.get_session(db, session.id, current_user.id)
return _session_to_detail(session)
@router.get("/sessions", response_model=list[ScriptBuilderSessionSummary])
async def list_sessions(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
limit: int = 20,
offset: int = 0,
) -> list[ScriptBuilderSessionSummary]:
"""List user's recent builder sessions (lightweight, no messages)."""
sessions = await script_builder_service.list_sessions(
db=db, user_id=current_user.id, limit=limit, offset=offset
)
return [_session_to_summary(s) for s in sessions]
@router.get("/sessions/{session_id}", response_model=ScriptBuilderSessionDetail)
async def get_session(
session_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> ScriptBuilderSessionDetail:
"""Get full session detail with message history."""
session = await script_builder_service.get_session(db, session_id, current_user.id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
return _session_to_detail(session)
@router.post(
"/sessions/{session_id}/messages",
response_model=ScriptBuilderMessageResponse,
)
@limiter.limit("10/minute")
async def send_message(
request: Request,
session_id: UUID,
data: ScriptBuilderMessageRequest,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> ScriptBuilderMessageResponse:
"""Send a message and get AI-generated script response."""
session = await script_builder_service.get_session(db, session_id, current_user.id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
try:
response = await script_builder_service.send_message(db, session, data.content)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
await db.commit()
return response
@router.delete("/sessions/{session_id}", status_code=204)
async def delete_session(
session_id: UUID,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> None:
"""Delete a builder session."""
deleted = await script_builder_service.delete_session(db, session_id, current_user.id)
if not deleted:
raise HTTPException(status_code=404, detail="Session not found")
await db.commit()
@router.post(
"/sessions/{session_id}/save",
response_model=ScriptTemplateDetail,
status_code=201,
)
async def save_to_library(
session_id: UUID,
data: SaveToLibraryRequest,
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(get_current_active_user)],
) -> ScriptTemplateDetail:
"""Save the latest generated script to the Script Library."""
session = await script_builder_service.get_session(db, session_id, current_user.id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
try:
template = await script_builder_service.save_to_library(
db=db,
session=session,
name=data.name,
description=data.description,
category_id=data.category_id,
share_with_team=data.share_with_team,
user_id=current_user.id,
team_id=current_user.team_id,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
await db.commit()
await db.refresh(template)
return ScriptTemplateDetail.model_validate(template)

View File

@@ -76,6 +76,8 @@ async def list_templates(
search: Optional[str] = Query(None),
tags: Optional[str] = Query(None, description="Comma-separated tags"),
managed: Optional[bool] = Query(None, description="If true, return only templates this user can edit"),
mine: bool = Query(False, description="If true, return only templates created by the current user"),
shared: bool = Query(False, description="If true, return only templates shared with the user's team"),
) -> list[ScriptTemplateListItem]:
query = (
select(ScriptTemplate)
@@ -116,6 +118,12 @@ async def list_templates(
# engineers see only their own
query = query.where(ScriptTemplate.created_by == current_user.id)
if mine:
query = query.where(ScriptTemplate.created_by == current_user.id)
if shared:
query = query.where(ScriptTemplate.team_id == current_user.team_id)
result = await db.execute(query.order_by(ScriptTemplate.name))
templates = result.scalars().all()

View File

@@ -28,6 +28,7 @@ from app.api.endpoints import notifications
from app.api.endpoints import public_templates
from app.api.endpoints import admin_gallery
from app.api.endpoints import uploads
from app.api.endpoints import script_builder
api_router = APIRouter()
@@ -81,3 +82,4 @@ api_router.include_router(notifications.router)
api_router.include_router(public_templates.router)
api_router.include_router(admin_gallery.router)
api_router.include_router(uploads.router)
api_router.include_router(script_builder.router)

View File

@@ -104,6 +104,7 @@ class Settings(BaseSettings):
"open_chat": "standard",
"variable_inference": "fast",
"kb_convert": "standard",
"script_build": "standard",
}
def get_model_for_action(self, action_type: str) -> str:

View File

@@ -0,0 +1,111 @@
"""Script Builder session and message models.
Tracks AI-powered script generation conversations.
"""
import uuid
from datetime import datetime, timezone
from typing import Optional, TYPE_CHECKING
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
if TYPE_CHECKING:
from app.models.user import User
class ScriptBuilderSession(Base):
"""A conversation session in the AI Script Builder."""
__tablename__ = "script_builder_sessions"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
team_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("teams.id", ondelete="SET NULL"),
nullable=True,
)
language: Mapped[str] = mapped_column(
String(30), nullable=False, default="powershell",
comment="Script language: powershell, bash, python",
)
title: Mapped[Optional[str]] = mapped_column(
String(200), nullable=True,
comment="Auto-generated from first AI response",
)
latest_script: Mapped[Optional[str]] = mapped_column(
Text, nullable=True,
comment="Most recent generated script for quick access",
)
latest_script_filename: Mapped[Optional[str]] = mapped_column(
String(200), nullable=True,
comment="Filename of the latest generated script",
)
ai_session_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True),
ForeignKey("ai_sessions.id", ondelete="SET NULL"),
nullable=True,
comment="Link to FlowPilot session if launched from there",
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
# Relationships
user: Mapped["User"] = relationship("User")
message_records: Mapped[list["ScriptBuilderMessage"]] = relationship(
"ScriptBuilderMessage",
back_populates="session",
order_by="ScriptBuilderMessage.created_at",
cascade="all, delete-orphan",
)
class ScriptBuilderMessage(Base):
"""A single message in a Script Builder conversation."""
__tablename__ = "script_builder_messages"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
session_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("script_builder_sessions.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
role: Mapped[str] = mapped_column(
String(20), nullable=False, comment="user or assistant"
)
content: Mapped[str] = mapped_column(Text, nullable=False)
script: Mapped[Optional[str]] = mapped_column(
Text, nullable=True, comment="Extracted script from AI response"
)
script_filename: Mapped[Optional[str]] = mapped_column(
String(200), nullable=True
)
line_count: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
input_tokens: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
output_tokens: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
# Relationship
session: Mapped["ScriptBuilderSession"] = relationship(
"ScriptBuilderSession", back_populates="message_records"
)

View File

@@ -52,6 +52,10 @@ class ScriptTemplate(Base):
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
use_case: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
script_body: Mapped[str] = mapped_column(Text, nullable=False)
language: Mapped[Optional[str]] = mapped_column(
String(30), nullable=True, default="powershell",
comment="Script language: powershell, bash, python",
)
parameters_schema: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
default_values: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
validation_rules: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)

View File

@@ -0,0 +1,84 @@
"""Pydantic schemas for the AI Script Builder."""
from datetime import datetime
from typing import Optional
from uuid import UUID
from pydantic import BaseModel, Field
class ScriptBuilderCreateRequest(BaseModel):
"""Request to start a new builder session."""
language: str = Field(
default="powershell",
pattern=r"^(powershell|bash|python)$",
description="Script language",
)
class ScriptBuilderMessageRequest(BaseModel):
"""User message in a builder session."""
content: str = Field(min_length=1, max_length=5000)
class ScriptBuilderMessageSchema(BaseModel):
"""A single message in a builder session."""
id: UUID
role: str
content: str
script: str | None = None
script_filename: str | None = None
line_count: int | None = None
input_tokens: int | None = None
output_tokens: int | None = None
created_at: datetime
model_config = {"from_attributes": True}
class ScriptBuilderMessageResponse(BaseModel):
"""AI response to a builder message."""
role: str = "assistant"
content: str
script: str | None = None
script_filename: str | None = None
line_count: int | None = None
timestamp: datetime
model_config = {"from_attributes": True}
class ScriptBuilderSessionSummary(BaseModel):
"""Lightweight session for list views (no messages)."""
id: UUID
language: str
title: str | None = None
message_count: int = 0
latest_script_filename: str | None = None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class ScriptBuilderSessionDetail(BaseModel):
"""Full session with message history."""
id: UUID
language: str
title: str | None = None
messages: list[ScriptBuilderMessageSchema] = []
latest_script: str | None = None
latest_script_filename: str | None = None
message_count: int = 0
ai_session_id: UUID | None = None
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class SaveToLibraryRequest(BaseModel):
"""Request to save a generated script to the Script Library."""
name: str = Field(min_length=1, max_length=200)
description: str | None = None
category_id: UUID | None = None
share_with_team: bool = False

View File

@@ -62,6 +62,7 @@ class ScriptTemplateCreate(BaseModel):
estimated_runtime: Optional[str] = None
requires_elevation: bool = False
requires_modules: list[str] = Field(default_factory=list)
language: str | None = None
class ScriptTemplateUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=200)
@@ -93,6 +94,7 @@ class ScriptTemplateListItem(BaseModel):
requires_modules: list[str]
is_verified: bool
usage_count: int
language: str | None = None
class Config:
from_attributes = True

View File

@@ -48,7 +48,7 @@ Your response MUST be a valid JSON object with one of these shapes:
{"type": "question", "content": "Brief description", "reasoning": "Internal why", "context_message": "Shown to engineer", "options": [{"label": "Human text", "value": "machine_value", "followup_hint": "or null"}], "allow_free_text": true, "allow_skip": true, "confidence": 0.65}
2. Suggested action:
{"type": "action", "content": "What to do", "reasoning": "Internal why", "context_message": "Here's what to try", "action_type": "instruction | script_generation | verification | info_request", "expected_outcome": "What success looks like", "confidence": 0.78}
{"type": "action", "content": "What to do", "reasoning": "Internal why", "context_message": "Here's what to try", "action_type": "instruction | script_generation | verification | info_request | open_script_builder", "expected_outcome": "What success looks like", "confidence": 0.78}
3. Resolution suggestion:
{"type": "resolution_suggestion", "content": "Summary of what we did", "reasoning": "Internal why", "resolution_summary": "Issue was caused by X, fixed by Y", "confidence": 0.92, "follow_up_recommendations": ["Monitor for 24 hours"]}\
@@ -75,7 +75,9 @@ Every response must have a "type" field: "question", "action", or "resolution_su
- Always include relevant context in context_message — explain WHY you're asking
- confidence is a float 0.0-1.0 reflecting how certain you are about the diagnosis path
- When multiple symptoms point to one root cause with >90% confidence, suggest resolution
- If you detect the engineer needs a PowerShell script, suggest a script_generation action
- When a task can be accomplished via script OR through a GUI (e.g., Active Directory Users & Computers, Microsoft 365 Admin Center), ALWAYS ask the engineer which approach they prefer BEFORE suggesting either. Present options like "Would you like me to guide you through the GUI, or would you prefer a script to automate this?" Never assume the engineer wants a script.
- Only suggest a script_generation action AFTER the engineer has confirmed they want a script-based approach
- When the engineer wants a custom script that doesn't match an existing template, suggest opening the Script Builder. Use action_type "open_script_builder" with a "script_prompt" field containing a clear description of what the script should do, and a "script_language" field (powershell, bash, or python).
- Never suggest restarting or rebooting as a first step — diagnose first
- Be specific: "Check Event Viewer > System > source NTFS" not "check the logs"
@@ -791,6 +793,29 @@ async def resume_session(
await db.flush()
async def abandon_session(
session_id: UUID,
user_id: UUID,
reason: Optional[str],
db: AsyncSession,
) -> None:
"""Close a session without resolving or escalating.
Used when the engineer no longer needs help, figured it out on their own,
or the session is no longer relevant.
"""
session = await _load_session(session_id, user_id, db)
if session.status not in ("active", "paused"):
raise ValueError(f"Cannot close session in status: {session.status}")
session.status = "abandoned"
session.resolved_at = datetime.now(timezone.utc)
if reason:
session.resolution_notes = reason
await db.flush()
async def rate_session(
session_id: UUID,
rating: int,

View File

@@ -0,0 +1,377 @@
"""AI Script Builder service — generates scripts from natural language descriptions."""
import logging
import re
from datetime import datetime, timezone
from typing import Optional
from uuid import UUID
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.ai_provider import get_ai_provider
from app.core.config import settings
from app.models.script_builder_session import ScriptBuilderSession, ScriptBuilderMessage
from app.schemas.script_builder import (
ScriptBuilderMessageResponse,
ScriptBuilderSessionDetail,
ScriptBuilderSessionSummary,
)
logger = logging.getLogger(__name__)
MAX_MESSAGES_PER_SESSION = 30
LANGUAGE_PROMPTS = {
"powershell": """\
You are an expert PowerShell script writer for MSP (Managed Service Provider) environments.
## Script Standards
- Use Advanced Functions with CmdletBinding and param() blocks
- Include comment-based help (.SYNOPSIS, .DESCRIPTION, .PARAMETER, .EXAMPLE)
- Use try/catch/finally for error handling
- Use Write-Verbose for diagnostic output, Write-Error for failures
- Support pipeline input where appropriate
- Use approved PowerShell verbs (Get-, Set-, New-, Remove-, etc.)
- Import required modules at the top (e.g., Import-Module ActiveDirectory)
- Use [Parameter(Mandatory=$true)] for required params
- Default to UTF-8 output for exports
## Security
- Never hardcode credentials — use Get-Credential or SecureString params
- Use -WhatIf and -Confirm support via SupportsShouldProcess
- Validate input with ValidateSet, ValidatePattern, ValidateRange
""",
"bash": """\
You are an expert Bash script writer for Linux/macOS system administration.
## Script Standards
- Start with #!/bin/bash
- Use set -euo pipefail for safety
- Parse arguments with getopts or positional parameters
- Include a usage() function for --help
- Use functions for logical grouping
- Quote all variable expansions ("$var" not $var)
- Use [[ ]] for conditionals (not [ ])
- Add comments explaining non-obvious logic
- Use lowercase_with_underscores for variable names
- Exit with meaningful exit codes (0=success, 1=general error, 2=usage error)
## Security
- Never store passwords in scripts — use environment variables or prompts
- Validate all user inputs
- Use mktemp for temporary files
""",
"python": """\
You are an expert Python script writer for IT automation and system administration.
## Script Standards
- Use Python 3.10+ syntax
- Add type hints to all function signatures
- Use argparse for CLI argument parsing
- Include if __name__ == "__main__": guard
- Use logging module (not print) for diagnostic output
- Use docstrings for functions and modules
- Use pathlib.Path instead of os.path
- Handle exceptions with specific exception types
- Use f-strings for string formatting
- Follow PEP 8 naming conventions
## Security
- Never hardcode secrets — use environment variables or config files
- Validate and sanitize all user inputs
- Use subprocess.run() with shell=False (never shell=True with user input)
""",
}
SYSTEM_PROMPT_TEMPLATE = """\
{language_prompt}
## Response Format
Respond conversationally. When generating a script:
1. Briefly explain what the script does and any assumptions
2. Include the complete script in a single fenced code block with the language tag
3. Suggest a filename (e.g., `Get-LinkedGPOs.ps1`)
When the user asks for modifications, generate the COMPLETE updated script (not a diff).
## Context
The user is an MSP engineer using ResolutionFlow. They need scripts for managing client infrastructure.
Keep scripts practical, production-ready, and well-documented.\
"""
def _extract_script_from_response(content: str, language: str) -> tuple[str | None, str | None]:
"""Extract code block and filename from AI response.
Returns (script, filename) tuple.
"""
# Map language to code fence tags
lang_tags = {
"powershell": ["powershell", "ps1", "pwsh"],
"bash": ["bash", "sh", "shell"],
"python": ["python", "py", "python3"],
}
tags = lang_tags.get(language, [language])
# Try each language-specific tag, then fall back to generic fence
script = None
for tag in tags:
pattern = rf"```{tag}\s*\n(.*?)```"
match = re.search(pattern, content, re.DOTALL | re.IGNORECASE)
if match:
script = match.group(1).strip()
break
else:
# Try generic code fence
pattern = r"```\s*\n(.*?)```"
match = re.search(pattern, content, re.DOTALL)
script = match.group(1).strip() if match else None
# Extract filename suggestion
filename = None
ext_map = {"powershell": ".ps1", "bash": ".sh", "python": ".py"}
ext = ext_map.get(language, ".txt")
filename_pattern = rf"`([A-Za-z0-9_\-]+{re.escape(ext)})`"
fname_match = re.search(filename_pattern, content)
if fname_match:
filename = fname_match.group(1)
return script, filename
async def create_session(
db: AsyncSession,
user_id: UUID,
team_id: UUID | None,
language: str,
initial_prompt: str | None = None,
) -> ScriptBuilderSession:
"""Create a new Script Builder session."""
session = ScriptBuilderSession(
user_id=user_id,
team_id=team_id,
language=language,
)
db.add(session)
await db.flush()
# If initial prompt provided (e.g., from FlowPilot), send first message
if initial_prompt:
await send_message(db, session, initial_prompt)
return session
async def send_message(
db: AsyncSession,
session: ScriptBuilderSession,
user_content: str,
) -> ScriptBuilderMessageResponse:
"""Send a user message and get AI response with generated script."""
# Count existing messages for the session
msg_count_result = await db.execute(
select(func.count(ScriptBuilderMessage.id)).where(
ScriptBuilderMessage.session_id == session.id,
ScriptBuilderMessage.role == "user",
)
)
user_msg_count = msg_count_result.scalar_one()
if user_msg_count >= MAX_MESSAGES_PER_SESSION:
raise ValueError(f"Session has reached the maximum of {MAX_MESSAGES_PER_SESSION} messages.")
now = datetime.now(timezone.utc)
# Create user message record
user_msg = ScriptBuilderMessage(
session_id=session.id,
role="user",
content=user_content,
created_at=now,
)
db.add(user_msg)
await db.flush()
# Build system prompt
language_prompt = LANGUAGE_PROMPTS.get(session.language, LANGUAGE_PROMPTS["powershell"])
system_prompt = SYSTEM_PROMPT_TEMPLATE.format(language_prompt=language_prompt)
# Build conversation for AI — get last 20 messages for context window
recent_result = await db.execute(
select(ScriptBuilderMessage)
.where(ScriptBuilderMessage.session_id == session.id)
.order_by(ScriptBuilderMessage.created_at.desc())
.limit(20)
)
recent_msgs = list(reversed(recent_result.scalars().all()))
ai_messages = [{"role": m.role, "content": m.content} for m in recent_msgs]
# Call AI
model = settings.get_model_for_action("script_build")
provider = get_ai_provider(model=model)
ai_text, input_tokens, output_tokens = await provider.generate_text(
system_prompt=system_prompt,
messages=ai_messages,
max_tokens=8192,
)
# Extract script from response
script, filename = _extract_script_from_response(ai_text, session.language)
line_count = len(script.splitlines()) if script else None
# Create assistant message record
assistant_msg = ScriptBuilderMessage(
session_id=session.id,
role="assistant",
content=ai_text,
script=script,
script_filename=filename,
line_count=line_count,
input_tokens=input_tokens,
output_tokens=output_tokens,
created_at=datetime.now(timezone.utc),
)
db.add(assistant_msg)
# Update session denormalized fields
if script:
session.latest_script = script
session.latest_script_filename = filename
if not session.title:
# Auto-generate title from first user message (truncate)
first_user = user_content[:100]
session.title = first_user if len(user_content) <= 100 else first_user + "..."
session.updated_at = datetime.now(timezone.utc)
await db.flush()
return ScriptBuilderMessageResponse(
role="assistant",
content=ai_text,
script=script,
script_filename=filename,
line_count=line_count,
timestamp=datetime.now(timezone.utc),
)
async def get_session(
db: AsyncSession,
session_id: UUID,
user_id: UUID,
) -> ScriptBuilderSession | None:
"""Get a session by ID, ensuring the user owns it."""
result = await db.execute(
select(ScriptBuilderSession)
.options(selectinload(ScriptBuilderSession.message_records))
.where(
ScriptBuilderSession.id == session_id,
ScriptBuilderSession.user_id == user_id,
)
)
return result.scalar_one_or_none()
async def list_sessions(
db: AsyncSession,
user_id: UUID,
limit: int = 20,
offset: int = 0,
) -> list[ScriptBuilderSession]:
"""List user's builder sessions ordered by updated_at desc."""
result = await db.execute(
select(ScriptBuilderSession)
.where(ScriptBuilderSession.user_id == user_id)
.order_by(ScriptBuilderSession.updated_at.desc())
.limit(limit)
.offset(offset)
)
return list(result.scalars().all())
async def delete_session(
db: AsyncSession,
session_id: UUID,
user_id: UUID,
) -> bool:
"""Delete a builder session. Returns True if deleted."""
session = await get_session(db, session_id, user_id)
if not session:
return False
await db.delete(session)
await db.flush()
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
)
)
return result.scalar_one()
async def save_to_library(
db: AsyncSession,
session: ScriptBuilderSession,
name: str,
description: str | None,
category_id: UUID | None,
share_with_team: bool,
user_id: UUID,
team_id: UUID | None,
) -> "ScriptTemplate":
"""Save the latest generated script to the Script Library as a ScriptTemplate."""
import uuid as uuid_mod
from app.models.script_template import ScriptTemplate, ScriptCategory
if not session.latest_script:
raise ValueError("No script has been generated in this session yet")
# Resolve category: use provided, or find "AI Generated" default
resolved_category_id = category_id
if not resolved_category_id:
result = await db.execute(
select(ScriptCategory.id).where(ScriptCategory.slug == "ai-generated")
)
default_cat = result.scalar_one_or_none()
if not default_cat:
raise ValueError("Default 'AI Generated' category not found. Run migrations.")
resolved_category_id = default_cat
# Generate unique slug
base_slug = name.lower().replace(" ", "-").replace("_", "-")[:80]
base_slug = re.sub(r"[^a-z0-9\-]", "", base_slug)
slug = base_slug
# Check uniqueness
existing = await db.execute(
select(ScriptTemplate.id).where(ScriptTemplate.slug == slug)
)
if existing.scalar_one_or_none():
slug = f"{base_slug}-{uuid_mod.uuid4().hex[:6]}"
template = ScriptTemplate(
id=uuid_mod.uuid4(),
category_id=resolved_category_id,
created_by=user_id,
team_id=team_id if share_with_team else None,
name=name,
slug=slug,
description=description,
script_body=session.latest_script,
parameters_schema={"parameters": []},
default_values={},
validation_rules={},
tags=[session.language, "ai-generated"],
complexity="intermediate",
is_verified=False,
is_active=True,
version=1,
usage_count=0,
)
db.add(template)
await db.flush()
return template

View File

@@ -0,0 +1,666 @@
# backend/tests/test_script_builder.py
"""Tests for the Script Builder API endpoints."""
import pytest
import uuid as uuid_mod
from unittest.mock import AsyncMock, patch, PropertyMock
import sqlalchemy as sa
class TestScriptBuilderSessions:
"""Test Script Builder session CRUD."""
@pytest.mark.asyncio
async def test_create_session(self, client, auth_headers):
"""Creating a builder session returns a valid session."""
resp = await client.post(
"/api/v1/scripts/builder/sessions",
json={"language": "powershell"},
headers=auth_headers,
)
assert resp.status_code == 201
data = resp.json()
assert data["language"] == "powershell"
assert data["messages"] == []
assert data["message_count"] == 0
assert data["title"] is None
@pytest.mark.asyncio
async def test_create_session_invalid_language(self, client, auth_headers):
"""Invalid language is rejected."""
resp = await client.post(
"/api/v1/scripts/builder/sessions",
json={"language": "cobol"},
headers=auth_headers,
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_create_session_bash(self, client, auth_headers):
"""Bash language is accepted."""
resp = await client.post(
"/api/v1/scripts/builder/sessions",
json={"language": "bash"},
headers=auth_headers,
)
assert resp.status_code == 201
assert resp.json()["language"] == "bash"
@pytest.mark.asyncio
async def test_list_sessions_empty(self, client, auth_headers):
"""Listing sessions when none exist returns empty list."""
resp = await client.get(
"/api/v1/scripts/builder/sessions",
headers=auth_headers,
)
assert resp.status_code == 200
assert resp.json() == []
@pytest.mark.asyncio
async def test_list_sessions_returns_summaries(self, client, auth_headers):
"""Listed sessions are lightweight (no messages field)."""
# Create a session first
await client.post(
"/api/v1/scripts/builder/sessions",
json={"language": "powershell"},
headers=auth_headers,
)
resp = await client.get(
"/api/v1/scripts/builder/sessions",
headers=auth_headers,
)
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
assert "messages" not in data[0]
assert "language" in data[0]
assert "title" in data[0]
@pytest.mark.asyncio
async def test_get_session_detail(self, client, auth_headers):
"""Getting a session by ID returns full detail with messages."""
create_resp = await client.post(
"/api/v1/scripts/builder/sessions",
json={"language": "python"},
headers=auth_headers,
)
session_id = create_resp.json()["id"]
resp = await client.get(
f"/api/v1/scripts/builder/sessions/{session_id}",
headers=auth_headers,
)
assert resp.status_code == 200
data = resp.json()
assert data["id"] == session_id
assert "messages" in data
assert data["language"] == "python"
@pytest.mark.asyncio
async def test_get_session_not_found(self, client, auth_headers):
"""Getting a nonexistent session returns 404."""
fake_id = str(uuid_mod.uuid4())
resp = await client.get(
f"/api/v1/scripts/builder/sessions/{fake_id}",
headers=auth_headers,
)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_delete_session(self, client, auth_headers):
"""Deleting a session removes it."""
create_resp = await client.post(
"/api/v1/scripts/builder/sessions",
json={"language": "powershell"},
headers=auth_headers,
)
session_id = create_resp.json()["id"]
resp = await client.delete(
f"/api/v1/scripts/builder/sessions/{session_id}",
headers=auth_headers,
)
assert resp.status_code == 204
# Verify it's gone
resp = await client.get(
f"/api/v1/scripts/builder/sessions/{session_id}",
headers=auth_headers,
)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_delete_session_not_found(self, client, auth_headers):
"""Deleting a nonexistent session returns 404."""
fake_id = str(uuid_mod.uuid4())
resp = await client.delete(
f"/api/v1/scripts/builder/sessions/{fake_id}",
headers=auth_headers,
)
assert resp.status_code == 404
class TestScriptBuilderMessages:
"""Test sending messages (requires AI mock)."""
@pytest.fixture
def _enable_ai(self):
"""Mock AI as enabled for tests without API key."""
with patch.object(
type(__import__("app.core.config", fromlist=["settings"]).settings),
"ai_enabled",
new_callable=PropertyMock,
return_value=True,
):
yield
@pytest.fixture
def _mock_ai_response(self):
"""Mock the AI provider to return a script response."""
mock_response = (
'Here is your script:\n\n```powershell\nGet-Process | Format-Table\n```\n\nSaved as `Get-Processes.ps1`.',
100, # input_tokens
200, # output_tokens
)
with patch("app.services.script_builder_service.get_ai_provider") as mock:
provider = AsyncMock()
provider.generate_text = AsyncMock(return_value=mock_response)
mock.return_value = provider
yield
@pytest.mark.asyncio
async def test_send_message(self, client, auth_headers, _enable_ai, _mock_ai_response):
"""Sending a message returns AI response with script."""
# Create session
create_resp = await client.post(
"/api/v1/scripts/builder/sessions",
json={"language": "powershell"},
headers=auth_headers,
)
session_id = create_resp.json()["id"]
# Send message
resp = await client.post(
f"/api/v1/scripts/builder/sessions/{session_id}/messages",
json={"content": "List all running processes"},
headers=auth_headers,
)
assert resp.status_code == 200
data = resp.json()
assert data["role"] == "assistant"
assert data["script"] is not None
assert "Get-Process" in data["script"]
assert data["script_filename"] == "Get-Processes.ps1"
assert data["line_count"] == 1
@pytest.mark.asyncio
async def test_send_message_updates_session(self, client, auth_headers, _enable_ai, _mock_ai_response):
"""Sending a message updates session state."""
create_resp = await client.post(
"/api/v1/scripts/builder/sessions",
json={"language": "powershell"},
headers=auth_headers,
)
session_id = create_resp.json()["id"]
await client.post(
f"/api/v1/scripts/builder/sessions/{session_id}/messages",
json={"content": "List processes"},
headers=auth_headers,
)
# Check session was updated
resp = await client.get(
f"/api/v1/scripts/builder/sessions/{session_id}",
headers=auth_headers,
)
data = resp.json()
assert data["message_count"] == 1
assert data["latest_script"] is not None
assert len(data["messages"]) == 2 # user + assistant
assert data["title"] is not None
class TestScriptBuilderSaveToLibrary:
"""Test saving generated scripts to the library."""
@pytest.fixture
def _enable_ai(self):
with patch.object(
type(__import__("app.core.config", fromlist=["settings"]).settings),
"ai_enabled",
new_callable=PropertyMock,
return_value=True,
):
yield
@pytest.fixture
def _mock_ai_response(self):
mock_response = (
'Here is your script:\n\n```powershell\nGet-Process | Format-Table\n```\n\nSaved as `Get-Processes.ps1`.',
100, 200,
)
with patch("app.services.script_builder_service.get_ai_provider") as mock:
provider = AsyncMock()
provider.generate_text = AsyncMock(return_value=mock_response)
mock.return_value = provider
yield
@pytest.fixture
async def _seed_ai_category(self, test_db):
"""Seed the AI Generated category for save tests."""
await test_db.execute(
sa.text("""
INSERT INTO script_categories (id, name, slug, description, icon, sort_order, is_active, created_at, updated_at)
VALUES (
'a0000000-0000-0000-0000-000000000001'::uuid,
'AI Generated', 'ai-generated', 'Scripts from AI', 'sparkles', 100, true, NOW(), NOW()
)
ON CONFLICT (slug) DO NOTHING
""")
)
await test_db.commit()
@pytest.mark.asyncio
async def test_save_to_library_no_script(self, client, auth_headers):
"""Cannot save if no script has been generated."""
create_resp = await client.post(
"/api/v1/scripts/builder/sessions",
json={"language": "powershell"},
headers=auth_headers,
)
session_id = create_resp.json()["id"]
resp = await client.post(
f"/api/v1/scripts/builder/sessions/{session_id}/save",
json={"name": "Test Script"},
headers=auth_headers,
)
assert resp.status_code == 400
assert "No script" in resp.json()["detail"]
@pytest.mark.asyncio
async def test_save_to_library_success(
self, client, auth_headers, _enable_ai, _mock_ai_response, _seed_ai_category
):
"""Saving a generated script creates a ScriptTemplate."""
# Create session and generate a script
create_resp = await client.post(
"/api/v1/scripts/builder/sessions",
json={"language": "powershell"},
headers=auth_headers,
)
session_id = create_resp.json()["id"]
await client.post(
f"/api/v1/scripts/builder/sessions/{session_id}/messages",
json={"content": "List processes"},
headers=auth_headers,
)
# Save to library
resp = await client.post(
f"/api/v1/scripts/builder/sessions/{session_id}/save",
json={"name": "My Process Script", "share_with_team": False},
headers=auth_headers,
)
assert resp.status_code == 201
data = resp.json()
assert data["name"] == "My Process Script"
assert "ai-generated" in data["tags"]
class TestScriptBuilderMaxSessions:
"""Test the per-user session limit (MAX_SESSIONS_PER_USER = 5)."""
@pytest.mark.asyncio
async def test_max_sessions_limit(self, client, auth_headers):
"""Creating a 6th session should return 400."""
# Create 5 sessions (the maximum)
for i in range(5):
resp = await client.post(
"/api/v1/scripts/builder/sessions",
json={"language": "powershell"},
headers=auth_headers,
)
assert resp.status_code == 201, f"Session {i+1} should succeed"
# 6th session should be rejected
resp = await client.post(
"/api/v1/scripts/builder/sessions",
json={"language": "powershell"},
headers=auth_headers,
)
assert resp.status_code == 400
assert "Maximum" in resp.json()["detail"]
assert "5" in resp.json()["detail"]
@pytest.mark.asyncio
async def test_max_sessions_after_delete_allows_new(self, client, auth_headers):
"""After deleting a session, creating a new one should succeed."""
session_ids = []
for _ in range(5):
resp = await client.post(
"/api/v1/scripts/builder/sessions",
json={"language": "powershell"},
headers=auth_headers,
)
session_ids.append(resp.json()["id"])
# Delete one
await client.delete(
f"/api/v1/scripts/builder/sessions/{session_ids[0]}",
headers=auth_headers,
)
# Now creating a new session should work
resp = await client.post(
"/api/v1/scripts/builder/sessions",
json={"language": "bash"},
headers=auth_headers,
)
assert resp.status_code == 201
class TestScriptBuilderMaxMessages:
"""Test the per-session message limit (MAX_MESSAGES_PER_SESSION = 30)."""
@pytest.fixture
def _enable_ai(self):
with patch.object(
type(__import__("app.core.config", fromlist=["settings"]).settings),
"ai_enabled",
new_callable=PropertyMock,
return_value=True,
):
yield
@pytest.fixture
def _mock_ai_response(self):
mock_response = (
'Here is your script:\n\n```powershell\nGet-Process\n```\n\nSaved as `Get-Processes.ps1`.',
100, 200,
)
with patch("app.services.script_builder_service.get_ai_provider") as mock:
provider = AsyncMock()
provider.generate_text = AsyncMock(return_value=mock_response)
mock.return_value = provider
yield
@pytest.mark.asyncio
async def test_max_messages_limit(
self, client, auth_headers, test_db, _enable_ai, _mock_ai_response
):
"""Sending a message when session has 30 user messages should return 400."""
# Create a session
create_resp = await client.post(
"/api/v1/scripts/builder/sessions",
json={"language": "powershell"},
headers=auth_headers,
)
session_id = create_resp.json()["id"]
# Insert 30 user message rows directly into the DB to reach the limit
for i in range(30):
await test_db.execute(
sa.text("""
INSERT INTO script_builder_messages (id, session_id, role, content, created_at)
VALUES (:id, :sid, 'user', :content, NOW())
"""),
{
"id": str(uuid_mod.uuid4()),
"sid": session_id,
"content": f"message {i+1}",
},
)
await test_db.commit()
# Now sending a message should fail with 400
resp = await client.post(
f"/api/v1/scripts/builder/sessions/{session_id}/messages",
json={"content": "One more message"},
headers=auth_headers,
)
assert resp.status_code == 400
assert "maximum" in resp.json()["detail"].lower()
class TestScriptBuilderSlugCollision:
"""Test that saving a script with a colliding slug generates a unique slug."""
@pytest.fixture
def _enable_ai(self):
with patch.object(
type(__import__("app.core.config", fromlist=["settings"]).settings),
"ai_enabled",
new_callable=PropertyMock,
return_value=True,
):
yield
@pytest.fixture
def _mock_ai_response(self):
mock_response = (
'Here is your script:\n\n```powershell\nGet-Process | Format-Table\n```\n\nSaved as `Get-Processes.ps1`.',
100, 200,
)
with patch("app.services.script_builder_service.get_ai_provider") as mock:
provider = AsyncMock()
provider.generate_text = AsyncMock(return_value=mock_response)
mock.return_value = provider
yield
@pytest.fixture
async def _seed_ai_category(self, test_db):
await test_db.execute(
sa.text("""
INSERT INTO script_categories (id, name, slug, description, icon, sort_order, is_active, created_at, updated_at)
VALUES (
'a0000000-0000-0000-0000-000000000001'::uuid,
'AI Generated', 'ai-generated', 'Scripts from AI', 'sparkles', 100, true, NOW(), NOW()
)
ON CONFLICT (slug) DO NOTHING
""")
)
await test_db.commit()
@pytest.mark.asyncio
async def test_slug_collision_appends_suffix(
self, client, auth_headers, test_db, _enable_ai, _mock_ai_response, _seed_ai_category
):
"""When a slug already exists, the saved template gets a UUID-suffixed slug."""
# Pre-create a template with slug "test-script" to cause collision
user_resp = await client.get("/api/v1/auth/me", headers=auth_headers)
user_id = user_resp.json()["id"]
await test_db.execute(
sa.text("""
INSERT INTO script_templates
(id, category_id, created_by, name, slug, script_body,
parameters_schema, default_values, validation_rules, tags,
complexity, is_active, version, usage_count, created_at, updated_at)
VALUES
(:id, 'a0000000-0000-0000-0000-000000000001'::uuid, :uid,
'Test Script', 'test-script', 'echo hello',
'{"parameters": []}', '{}', '{}', '["powershell"]',
'beginner', true, 1, 0, NOW(), NOW())
"""),
{"id": str(uuid_mod.uuid4()), "uid": user_id},
)
await test_db.commit()
# Create a builder session and generate a script
create_resp = await client.post(
"/api/v1/scripts/builder/sessions",
json={"language": "powershell"},
headers=auth_headers,
)
session_id = create_resp.json()["id"]
await client.post(
f"/api/v1/scripts/builder/sessions/{session_id}/messages",
json={"content": "List processes"},
headers=auth_headers,
)
# Save with a name that would generate slug "test-script"
resp = await client.post(
f"/api/v1/scripts/builder/sessions/{session_id}/save",
json={"name": "Test Script"},
headers=auth_headers,
)
assert resp.status_code == 201
data = resp.json()
# The slug should start with "test-script-" (base + UUID suffix)
assert data["slug"].startswith("test-script-")
assert data["slug"] != "test-script"
class TestScriptTemplateFilters:
"""Test mine/shared query filters on GET /scripts/templates."""
@pytest.fixture
async def _seed_category(self, test_db):
"""Seed a script category for template creation."""
await test_db.execute(
sa.text("""
INSERT INTO script_categories (id, name, slug, description, icon, sort_order, is_active, created_at, updated_at)
VALUES (
'b0000000-0000-0000-0000-000000000001'::uuid,
'General', 'general', 'General scripts', 'code', 0, true, NOW(), NOW()
)
ON CONFLICT (slug) DO NOTHING
""")
)
await test_db.commit()
@pytest.fixture
async def second_user_headers(self, client, test_db):
"""Create a second user on the same team as the test user and return their auth headers."""
# Register second user
user_data = {
"email": "second@example.com",
"password": "TestPassword123!",
"name": "Second User",
}
resp = await client.post("/api/v1/auth/register", json=user_data)
assert resp.status_code in (200, 201)
# Login to get headers
login_resp = await client.post(
"/api/v1/auth/login/json",
json={"email": user_data["email"], "password": user_data["password"]},
)
assert login_resp.status_code == 200
token = login_resp.json()["access_token"]
return {"Authorization": f"Bearer {token}"}
@pytest.mark.asyncio
async def test_mine_filter(
self, client, auth_headers, test_db, test_user, second_user_headers, _seed_category
):
"""mine=true returns only templates created by the current user."""
user_resp = await client.get("/api/v1/auth/me", headers=auth_headers)
user_id = user_resp.json()["id"]
second_resp = await client.get("/api/v1/auth/me", headers=second_user_headers)
second_user_id = second_resp.json()["id"]
cat_id = "b0000000-0000-0000-0000-000000000001"
# Create template owned by test user
await test_db.execute(
sa.text("""
INSERT INTO script_templates
(id, category_id, created_by, team_id, name, slug, script_body,
parameters_schema, default_values, validation_rules, tags,
complexity, is_active, version, usage_count, created_at, updated_at)
VALUES
(:id, :cat, :uid, NULL,
'My Script', 'my-script', 'echo mine',
'{"parameters": []}', '{}', '{}', '[]',
'beginner', true, 1, 0, NOW(), NOW())
"""),
{"id": str(uuid_mod.uuid4()), "cat": cat_id, "uid": user_id},
)
# Create template owned by second user (no team_id, so visible to all)
await test_db.execute(
sa.text("""
INSERT INTO script_templates
(id, category_id, created_by, team_id, name, slug, script_body,
parameters_schema, default_values, validation_rules, tags,
complexity, is_active, version, usage_count, created_at, updated_at)
VALUES
(:id, :cat, :uid, NULL,
'Other Script', 'other-script', 'echo other',
'{"parameters": []}', '{}', '{}', '[]',
'beginner', true, 1, 0, NOW(), NOW())
"""),
{"id": str(uuid_mod.uuid4()), "cat": cat_id, "uid": second_user_id},
)
await test_db.commit()
# mine=true should only return the test user's template
resp = await client.get(
"/api/v1/scripts/templates?mine=true",
headers=auth_headers,
)
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
assert data[0]["name"] == "My Script"
@pytest.mark.asyncio
async def test_shared_filter(
self, client, auth_headers, test_db, test_user, _seed_category
):
"""shared=true returns only templates shared with the user's team."""
user_resp = await client.get("/api/v1/auth/me", headers=auth_headers)
user_id = user_resp.json()["id"]
team_id = user_resp.json().get("team_id")
cat_id = "b0000000-0000-0000-0000-000000000001"
# Template shared with user's team
await test_db.execute(
sa.text("""
INSERT INTO script_templates
(id, category_id, created_by, team_id, name, slug, script_body,
parameters_schema, default_values, validation_rules, tags,
complexity, is_active, version, usage_count, created_at, updated_at)
VALUES
(:id, :cat, :uid, :tid,
'Team Script', 'team-script', 'echo team',
'{"parameters": []}', '{}', '{}', '[]',
'beginner', true, 1, 0, NOW(), NOW())
"""),
{"id": str(uuid_mod.uuid4()), "cat": cat_id, "uid": user_id, "tid": team_id},
)
# Template NOT shared (no team_id)
await test_db.execute(
sa.text("""
INSERT INTO script_templates
(id, category_id, created_by, team_id, name, slug, script_body,
parameters_schema, default_values, validation_rules, tags,
complexity, is_active, version, usage_count, created_at, updated_at)
VALUES
(:id, :cat, :uid, NULL,
'Personal Script', 'personal-script', 'echo personal',
'{"parameters": []}', '{}', '{}', '[]',
'beginner', true, 1, 0, NOW(), NOW())
"""),
{"id": str(uuid_mod.uuid4()), "cat": cat_id, "uid": user_id},
)
await test_db.commit()
# shared=true should only return the team-shared template
resp = await client.get(
"/api/v1/scripts/templates?shared=true",
headers=auth_headers,
)
assert resp.status_code == 200
data = resp.json()
names = [t["name"] for t in data]
assert "Team Script" in names
assert "Personal Script" not in names

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,339 @@
# FlowPilot Message Bar & AI Script Builder
> **Date:** 2026-03-21
> **Status:** Approved
> **Scope:** Three interconnected features — FlowPilot always-visible message bar, standalone AI Script Builder, and Script Library reorganization
---
## Problem
1. **FlowPilot sessions lack a visible text input.** When FlowPilot asks the user to provide detailed information (e.g., AD user details for script building), the only way to type a response is a tiny "None of these — let me describe" link that expands into a textarea. Users don't notice it and feel stuck.
2. **No AI-powered script generation from natural language.** The existing script system is template-based — users pick a pre-built template and fill in parameters. There's no way to say "build me a script that does X" and get a custom script generated.
3. **Script Library lacks personal/team separation.** Scripts are either personal or team-shared via a toggle, but the UI doesn't clearly separate "My Scripts" from "Team Scripts." There's also no prominent entry point to create new scripts.
---
## Feature 1: Always-Visible Message Bar (FlowPilot Sessions)
### What Changes
Replace the hidden "None of these — let me describe" escape hatch with a persistent chat-style input bar pinned to the bottom of the FlowPilot session view.
### Current Flow
- Option buttons render inside the step card
- A small "None of these — let me describe" text link appears below options (controlled by `allow_free_text` flag)
- Clicking the link reveals a `RichTextInput` textarea with Submit/Cancel buttons
- If the user doesn't notice the link, they have no way to respond with free text
### New Flow
- Option buttons still render inside the step card (unchanged)
- A persistent message input bar is pinned to the bottom of the session view, **outside** step cards
- Always visible, always ready — styled like Claude Desktop's input bar
- Typing and submitting sends a `free_text_input` response to the current step (same backend flow, no backend changes needed)
- The "None of these — let me describe" link is removed entirely from `FlowPilotStepCard`
- The message bar is disabled when: FlowPilot is processing, the session is completed/resolved/escalated, or no current step exists
### Component Changes
| File | Change |
|------|--------|
| `FlowPilotStepCard.tsx` | Remove the free text escape hatch block (the `{!isResolutionSuggestion && step.allow_free_text && ...}` conditional and its children) |
| `FlowPilotSession.tsx` | Add `FlowPilotMessageBar` component, pinned to bottom of session area, above the Resolve/Escalate action bar |
| New: `FlowPilotMessageBar.tsx` | Persistent input bar using existing `RichTextInput`. Rounded input with placeholder "Type a message...", send button. Calls `onRespond({ free_text_input })` |
### Visual Design
- Rounded input field with `bg-card border-border` styling, consistent with the app's glass design system
- Send button with `bg-gradient-brand` (cyan gradient)
- Placeholder text: "Type a message..."
- Pinned to bottom of session content area, above Resolve/Escalate bar
- Disabled state (dimmed, no interaction) when processing or session is closed
### Step Targeting
The message bar always submits to the current step at the moment of submission (same behavior as the existing free text mechanism). If the step changes between when the user starts typing and when they click Send, the submission targets the new current step. This is acceptable because FlowPilot won't advance steps without a user response.
### Respecting `allow_free_text: false`
Certain step types explicitly set `allow_free_text: false` — specifically resolution suggestions and escalation briefings, where the AI expects a structured response (accept/reject). The message bar must respect this flag: when `allow_free_text` is `false` on the current step, the message bar is **disabled** (visually dimmed, input blocked) rather than hidden. This preserves visibility while preventing engineers from bypassing structured response constraints.
### Backend Changes
None. The `free_text_input` field in `StepResponseRequest` already handles this. The `allow_free_text` flag on steps continues to control whether the message bar is enabled/disabled for a given step.
---
## Feature 2: AI Script Builder (Standalone)
### Overview
A new standalone page accessible from the sidebar that lets users describe what they need in natural language, and an AI generates a custom script. Users can iterate on the script through multi-turn conversation, then save it to the Script Library.
### User Experience
1. User clicks "Script Builder" in the sidebar nav
2. Selects a language (PowerShell, Bash, Python) via pill-style selector at the top
3. Types a description of what they need in a chat-style input: *"I need a script that pulls all GPOs linked to a domain, showing name, link location, and enforcement status"*
4. AI responds in the conversation with an explanation and a collapsed code preview (first few lines + line count)
5. Inline action buttons on each code block: **"View Full Script"**, **"Copy"**, **"Save to Library"**
6. **"View Full Script"** opens a fullscreen modal overlay with the complete syntax-highlighted script, Copy/Save buttons in the header, line count and metadata in the footer, and a "Close & Return to Chat" button
7. User can type follow-up messages to refine: *"Add CSV export"*, *"Handle multiple domains"* — AI responds with an updated script
8. **"Save to Library"** opens a dialog for: name, description (optional), category, and whether to share with team
### Sidebar Navigation
- **Label:** "Script Builder"
- **Section:** "Knowledge" group (alongside Flows, Step Library, Scripts)
- **Icon:** TBD (a Lucide icon that conveys "build/generate" — e.g., `Wand2`, `Sparkles`, or `Terminal`)
- **Mobile nav:** Also add to `mobileNavItems` array in `AppLayout.tsx`
### Frontend Architecture
| Component | Purpose |
|-----------|---------|
| `ScriptBuilderPage.tsx` | New page at `/script-builder`. Chat-style layout with language selector |
| `ScriptBuilderChat.tsx` | Chat message list — user messages (right-aligned, cyan tint) and AI responses (left-aligned, glass card) |
| `ScriptBuilderInput.tsx` | Chat input bar at bottom with send button |
| `ScriptCodeBlock.tsx` | Collapsed code preview in AI messages with View/Copy/Save buttons |
| `ScriptPreviewModal.tsx` | Fullscreen modal overlay for viewing complete script with syntax highlighting |
| `SaveToLibraryDialog.tsx` | Dialog for naming and categorizing a script before saving |
| Route in `router.tsx` | `/script-builder` inside `ProtectedRoute`/`AppLayout` children |
### Backend Architecture
#### New Service: `script_builder_service.py`
Location: `backend/app/services/script_builder_service.py`
Responsibilities:
- Manages AI conversation for script generation
- Language-specific system prompts with syntax knowledge, best practices, error handling patterns, security considerations
- Multi-turn conversation support — maintains message history per session
- Extracts generated script from AI response (parses code fences)
- On save: auto-detects parameters from the generated script to populate `parameters_schema` (reuses existing `ParameterCandidate` analysis pattern)
Uses the existing `AnthropicProvider` with `standard` model tier (Sonnet) via `settings.get_model_for_action()`.
#### Syntax Highlighting
Use `react-syntax-highlighter` with the `oneDark` theme (already a common choice in React ecosystems, lightweight). Supports PowerShell, Bash, and Python out of the box. Used for both the inline collapsed code preview (first 5 lines shown) and the fullscreen modal.
#### Inline Code Preview
The collapsed code block in AI chat messages shows:
- Filename (auto-generated by AI, e.g., `Get-LinkedGPOs.ps1`)
- First 5 lines of the script with syntax highlighting
- Total line count (e.g., "42 lines")
- Clicking anywhere on the code block opens the fullscreen modal (same as "View Full Script" button)
#### New Model: `ScriptBuilderSession`
Location: `backend/app/models/script_builder_session.py`
| Column | Type | Description |
|--------|------|-------------|
| `id` | UUID | Primary key |
| `user_id` | UUID FK | Creator |
| `team_id` | UUID FK (nullable) | Team context |
| `language` | String(30) | Selected language (powershell, bash, python) |
| `title` | String(200, nullable) | Auto-generated from first message |
| `messages` | JSONB | Array of `{role, content, timestamp}` |
| `latest_script` | Text (nullable) | Most recent generated script (for quick access) |
| `ai_session_id` | UUID FK → `ai_sessions.id` (nullable) | Link to FlowPilot session if launched from there |
| `created_at` | DateTime(tz) | |
| `updated_at` | DateTime(tz) | |
#### New Endpoints: `/scripts/builder/`
| Method | Endpoint | Purpose |
|--------|----------|---------|
| `POST` | `/scripts/builder/sessions` | Start a new builder session. Body: `{ language }`. Returns session with ID |
| `POST` | `/scripts/builder/sessions/{id}/messages` | Send user message, get AI response with generated script. Body: `{ content }`. Returns `{ message, script, script_filename, line_count }` |
| `GET` | `/scripts/builder/sessions` | List user's recent builder sessions (paginated). Returns lightweight schema **without** `messages` array — only `id`, `title`, `language`, `created_at`, `updated_at` |
| `GET` | `/scripts/builder/sessions/{id}` | Get full session with message history (for resuming). Returns complete `messages` JSONB |
| `POST` | `/scripts/builder/sessions/{id}/save` | Save latest script to library. Body: `SaveToLibraryRequest { name, description?, category_id?, share_with_team? }`. Service layer pulls `script_body` from session's `latest_script` and combines with user-provided metadata to create `ScriptTemplate`. Uses a dedicated `SaveToLibraryRequest` schema (not `ScriptTemplateCreate`) |
| `DELETE` | `/scripts/builder/sessions/{id}` | Delete a builder session. Owner only. |
#### Language-Specific System Prompts
Each language gets a tailored system prompt injected into the AI conversation:
- **PowerShell:** Advanced function patterns, `param()` blocks, `CmdletBinding`, module imports, error handling with `try/catch/finally`, `Write-Verbose`/`Write-Error`, pipeline support, `.SYNOPSIS`/`.DESCRIPTION` comment-based help
- **Bash:** Shebang lines, `set -euo pipefail`, argument parsing with `getopts` or positional params, error handling, shellcheck-clean output, POSIX compatibility notes
- **Python:** Type hints, argparse for CLI scripts, `if __name__ == "__main__"` guard, logging module, docstrings, virtual environment considerations
### Frontend API Client
New module: `frontend/src/api/scriptBuilder.ts`
```typescript
export const scriptBuilderApi = {
createSession(language: string): Promise<ScriptBuilderSession>,
sendMessage(sessionId: string, content: string): Promise<ScriptBuilderMessage>,
listSessions(params?: PaginationParams): Promise<PaginatedResponse<ScriptBuilderSession>>,
getSession(sessionId: string): Promise<ScriptBuilderSessionDetail>,
saveToLibrary(sessionId: string, data: SaveToLibraryRequest): Promise<ScriptTemplateDetail>,
}
```
### FlowPilot Integration
When FlowPilot detects the user needs a custom script (no matching template):
1. FlowPilot responds with a step: *"It sounds like you need a custom script for this. I can help you build one."*
2. The step includes an action button: **"Open Script Builder"**
3. Clicking stores context in `sessionStorage` (`{ from_session, prompt, language }`) and opens `/script-builder?from=flowpilot` in a **new browser tab**. The Script Builder reads and clears `sessionStorage` on mount. This avoids URL length limits for long prompts.
4. The `ScriptBuilderSession` stores the `ai_session_id` for traceability
5. The FlowPilot session continues independently
Existing template-based flow (`InSessionScriptGenerator`) remains unchanged for when FlowPilot matches an existing template.
---
## Feature 3: Script Library Reorganization
### Tab Structure
Two tabs at the top of the Script Library page (`/scripts`):
- **My Scripts** — scripts where `created_by == current_user`. Includes both AI-generated saves and manually created templates.
- **Team Scripts** — scripts where the template is shared to the team (`team_id == current_team`, shared by any team member).
### "Build a New Script" Button
A prominent action button at the top of the library page (next to the tab bar or in the header area). Styled with `bg-gradient-brand` (primary CTA). Clicking routes to `/script-builder`.
### Data Model Changes
The existing `ScriptTemplate` model needs two additions:
- **`language` column** (String(30), nullable, default `'powershell'`): Stores the script language. Without this, saved scripts lose their language metadata and can't be filtered by language in the library. Added via migration.
- **Default "AI Generated" category**: The existing `category_id` column is `NOT NULL` with a `RESTRICT` FK. Rather than making it nullable (which would break existing queries), create a default "AI Generated" category via migration seed data. The save dialog auto-selects this category when no other is chosen.
Existing fields used for library filtering:
- `created_by` (user ID) — used for "My Scripts" filter
- `team_id` (team ID, nullable) — used for "Team Scripts" filter
- Team sharing toggle already exists
The frontend filtering changes:
- **My Scripts tab:** `GET /scripts/templates?mine=true` (new query param, filters by `created_by`)
- **Team Scripts tab:** `GET /scripts/templates?shared=true` (existing behavior, filters by `team_id`)
### Library Backend Changes
Add `mine` query parameter to `GET /scripts/templates` endpoint. When `mine=true`, filter by `created_by == current_user.id` regardless of team sharing status.
### Save to Library Dialog
When triggered from the Script Builder's "Save to Library" button:
| Field | Type | Required | Notes |
|-------|------|----------|-------|
| Name | Text input | Yes | Auto-suggested from AI-generated filename |
| Description | Textarea | No | Auto-suggested from AI's explanation |
| Category | Select dropdown | No | Defaults to "AI Generated" category if not selected |
| Share with team | Toggle | No | Default off. Shares to user's team |
On save, the backend:
1. Creates a `ScriptTemplate` with the generated script as `script_body` (pulled from session's `latest_script`)
2. Sets `language` from the builder session's language
3. Auto-detects parameters from the script to populate `parameters_schema`
4. Sets `created_by` to current user
5. Sets `category_id` to selected category or default "AI Generated" category
6. If "Share with team" is on, sets `team_id` to user's team
---
## Implementation Phases
### Phase 1: Always-Visible Message Bar
- Remove free text escape hatch from `FlowPilotStepCard`
- Create `FlowPilotMessageBar` component
- Add to `FlowPilotSession` layout
- No backend changes
### Phase 2: Script Builder Core
- New `ScriptBuilderSession` model + migration (includes `language` column on `ScriptTemplate` + "AI Generated" seed category)
- `script_builder_service.py` with language-specific prompts
- API endpoints for sessions and messages
- Frontend: `ScriptBuilderPage`, chat components, code block with syntax highlighting via `react-syntax-highlighter`
- Sidebar nav entry (desktop + mobile)
- Route setup
### Phase 3: Script Preview Modal & Save Flow
- `ScriptPreviewModal` fullscreen overlay
- `SaveToLibraryDialog` with auto-parameter detection
- Backend save endpoint (creates `ScriptTemplate` via `SaveToLibraryRequest` schema)
### Phase 4: Library Reorganization
- My Scripts / Team Scripts tabs
- "Build a New Script" button routing to Script Builder
- Backend `mine` filter on templates endpoint
### Phase 5: FlowPilot Integration
- FlowPilot prompt updates to detect custom script needs
- "Open Script Builder" action button in step cards
- Context pre-fill via `sessionStorage`
- `ai_session_id` linking
---
## Operational Constraints
### Rate Limiting & Token Budget
- **Max messages per session:** 30 (same safety limit as FlowPilot sessions)
- **Max concurrent builder sessions per user:** 5 active sessions
- **Rate limit:** 10 messages per minute per user (uses existing `@limiter.limit()` decorator, disabled when `DEBUG=True`)
- **Context window management:** When conversation history exceeds ~80% of the model's context window, the service truncates older messages (keeping the system prompt and last 10 exchanges). The `latest_script` field ensures the most recent script is always accessible even if early messages are truncated.
### Session Lifecycle
- Builder sessions have no explicit expiration — they persist until the user deletes them
- The list endpoint returns sessions ordered by `updated_at` descending
- A "New Conversation" button on the Script Builder page starts a fresh session
- Recent sessions are accessible via a collapsible sidebar/drawer on the Script Builder page (not a separate page)
- Session title is auto-generated after the first AI response (AI summarizes the intent)
## Error Handling
### AI Failures
| Scenario | Handling |
| ---- | ---- |
| AI response has no code block | Display the AI's text response normally. Show a subtle hint: "No script was generated. Try being more specific about what you need." |
| AI request times out (120s) | Show error toast: "Script generation timed out. Please try again." Keep conversation intact so user can retry. |
| AI returns malformed/unparseable response | Log the raw response for debugging. Show generic error: "Something went wrong generating your script. Please try again." |
| Context window exceeded | Auto-truncate older messages and retry once. If still fails, prompt user to start a new session. |
| `AnthropicProvider` rate limited (429) | Show error with retry-after hint: "AI service is busy. Please wait a moment and try again." |
### Save Failures
| Scenario | Handling |
| ---- | ---- |
| No `latest_script` on session | Disable "Save to Library" button. Show tooltip: "Generate a script first." |
| `category_id` constraint violation | Should not occur (default "AI Generated" category). If it does, return 400 with clear message. |
| Duplicate template name | Return 409 conflict. Dialog shows inline error: "A script with this name already exists." |
---
## Out of Scope
- Script execution/testing within ResolutionFlow (scripts are generated and copied, not run)
- Script version history (save creates a new template, no versioning on iterations)
- Marketplace/community script sharing beyond team level
- Languages beyond PowerShell, Bash, Python (extensible later)
- Script Builder conversation branching (linear conversation only)

View File

@@ -18,6 +18,7 @@
"@sentry/vite-plugin": "^5.1.1",
"@stripe/stripe-js": "^8.7.0",
"@tailwindcss/vite": "^4.2.1",
"@types/react-syntax-highlighter": "^15.5.13",
"@xyflow/react": "^12.10.0",
"axios": "^1.13.4",
"class-variance-authority": "^0.7.1",
@@ -33,6 +34,7 @@
"react-helmet-async": "^3.0.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.13.0",
"react-syntax-highlighter": "^16.1.1",
"recharts": "^3.7.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
@@ -362,7 +364,6 @@
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.9.0"
@@ -2991,6 +2992,12 @@
"undici-types": "~7.16.0"
}
},
"node_modules/@types/prismjs": {
"version": "1.26.6",
"resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz",
"integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==",
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.2.10",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz",
@@ -3010,6 +3017,15 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/react-syntax-highlighter": {
"version": "15.5.13",
"resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz",
"integrity": "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==",
"license": "MIT",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -4837,6 +4853,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/fault": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz",
"integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==",
"license": "MIT",
"dependencies": {
"format": "^0.2.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -4946,6 +4975,14 @@
"node": ">= 6"
}
},
"node_modules/format": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz",
"integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==",
"engines": {
"node": ">=0.4.x"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -5171,6 +5208,19 @@
"node": ">= 0.4"
}
},
"node_modules/hast-util-parse-selector": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz",
"integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-to-jsx-runtime": {
"version": "2.3.6",
"resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
@@ -5211,6 +5261,23 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/hastscript": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz",
"integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"comma-separated-tokens": "^2.0.0",
"hast-util-parse-selector": "^4.0.0",
"property-information": "^7.0.0",
"space-separated-tokens": "^2.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hermes-estree": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
@@ -5228,6 +5295,21 @@
"hermes-estree": "0.25.1"
}
},
"node_modules/highlight.js": {
"version": "10.7.3",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz",
"integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==",
"license": "BSD-3-Clause",
"engines": {
"node": "*"
}
},
"node_modules/highlightjs-vue": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz",
"integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==",
"license": "CC0-1.0"
},
"node_modules/html-encoding-sniffer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
@@ -5998,6 +6080,20 @@
"loose-envify": "cli.js"
}
},
"node_modules/lowlight": {
"version": "1.20.0",
"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz",
"integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==",
"license": "MIT",
"dependencies": {
"fault": "^1.0.0",
"highlight.js": "~10.7.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -7196,6 +7292,15 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/prismjs": {
"version": "1.30.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/progress": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
@@ -7428,6 +7533,26 @@
"react-dom": ">=18"
}
},
"node_modules/react-syntax-highlighter": {
"version": "16.1.1",
"resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz",
"integrity": "sha512-PjVawBGy80C6YbC5DDZJeUjBmC7skaoEUdvfFQediQHgCL7aKyVHe57SaJGfQsloGDac+gCpTfRdtxzWWKmCXA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4",
"highlight.js": "^10.4.1",
"highlightjs-vue": "^1.0.0",
"lowlight": "^1.17.0",
"prismjs": "^1.30.0",
"refractor": "^5.0.0"
},
"engines": {
"node": ">= 16.20.2"
},
"peerDependencies": {
"react": ">= 0.14.0"
}
},
"node_modules/recharts": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz",
@@ -7497,6 +7622,22 @@
"redux": "^5.0.0"
}
},
"node_modules/refractor": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz",
"integrity": "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/prismjs": "^1.0.0",
"hastscript": "^9.0.0",
"parse-entities": "^4.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/remark-parse": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",

View File

@@ -31,6 +31,7 @@
"@sentry/vite-plugin": "^5.1.1",
"@stripe/stripe-js": "^8.7.0",
"@tailwindcss/vite": "^4.2.1",
"@types/react-syntax-highlighter": "^15.5.13",
"@xyflow/react": "^12.10.0",
"axios": "^1.13.4",
"class-variance-authority": "^0.7.1",
@@ -46,6 +47,7 @@
"react-helmet-async": "^3.0.0",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.13.0",
"react-syntax-highlighter": "^16.1.1",
"recharts": "^3.7.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
@@ -62,8 +64,8 @@
"@types/node": "^24.10.9",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitest/coverage-v8": "^4.0.18",
"@vitejs/plugin-react": "^5.1.1",
"@vitest/coverage-v8": "^4.0.18",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",

View File

@@ -96,6 +96,12 @@ export const aiSessionsApi = {
await apiClient.post(`/ai-sessions/${sessionId}/resume`)
},
async abandonSession(sessionId: string, reason?: string): Promise<void> {
await apiClient.post(`/ai-sessions/${sessionId}/abandon`, null, {
params: reason ? { reason } : undefined,
})
},
async pickupSession(sessionId: string, data: PickupSessionRequest): Promise<StepResponseResponse> {
const response = await apiClient.post<StepResponseResponse>(
`/ai-sessions/${sessionId}/pickup`,

View File

@@ -30,3 +30,4 @@ export { flowpilotAnalyticsApi } from './flowpilotAnalytics'
export { notificationsApi } from './notifications'
export { publicTemplatesApi } from './publicTemplates'
export { uploadsApi, default as uploadsApiDefault } from './uploads'
export { scriptBuilderApi } from './scriptBuilder'

View File

@@ -0,0 +1,47 @@
import { apiClient } from './client'
import type {
ScriptBuilderSessionSummary,
ScriptBuilderSessionDetail,
ScriptBuilderMessageResponse,
SaveToLibraryRequest,
} from '@/types'
import type { ScriptTemplateDetail } from '@/types'
export const scriptBuilderApi = {
async createSession(language: string): Promise<ScriptBuilderSessionDetail> {
const { data } = await apiClient.post('/scripts/builder/sessions', { language })
return data
},
async listSessions(limit = 20, offset = 0): Promise<ScriptBuilderSessionSummary[]> {
const { data } = await apiClient.get('/scripts/builder/sessions', {
params: { limit, offset },
})
return data
},
async getSession(sessionId: string): Promise<ScriptBuilderSessionDetail> {
const { data } = await apiClient.get(`/scripts/builder/sessions/${sessionId}`)
return data
},
async sendMessage(sessionId: string, content: string): Promise<ScriptBuilderMessageResponse> {
const { data } = await apiClient.post(
`/scripts/builder/sessions/${sessionId}/messages`,
{ content }
)
return data
},
async deleteSession(sessionId: string): Promise<void> {
await apiClient.delete(`/scripts/builder/sessions/${sessionId}`)
},
async saveToLibrary(sessionId: string, req: SaveToLibraryRequest): Promise<ScriptTemplateDetail> {
const { data } = await apiClient.post(
`/scripts/builder/sessions/${sessionId}/save`,
req
)
return data
},
}

View File

@@ -20,6 +20,8 @@ export const scriptsApi = {
category_slug?: string
search?: string
tags?: string // Phase 3: comma-separated tag filter
mine?: boolean
shared?: boolean
}): Promise<ScriptTemplateListItem[]> {
const response = await apiClient.get<ScriptTemplateListItem[]>('/scripts/templates', { params })
return response.data

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'
import { CheckCircle2, ArrowUpRight, Pause } from 'lucide-react'
import { CheckCircle2, ArrowUpRight, Pause, X } from 'lucide-react'
import { EscalateModal } from './EscalateModal'
import type { ResolveSessionRequest, EscalateSessionRequest, SessionDocumentation } from '@/types/ai-session'
@@ -12,6 +12,7 @@ interface FlowPilotActionBarProps {
onResolve: (data: ResolveSessionRequest) => Promise<SessionDocumentation>
onEscalate: (data: EscalateSessionRequest) => Promise<SessionDocumentation>
onPause?: () => Promise<void>
onAbandon?: () => Promise<void>
}
export function FlowPilotActionBar({
@@ -23,9 +24,11 @@ export function FlowPilotActionBar({
onResolve,
onEscalate,
onPause,
onAbandon,
}: FlowPilotActionBarProps) {
const [showResolve, setShowResolve] = useState(false)
const [showEscalate, setShowEscalate] = useState(false)
const [showAbandon, setShowAbandon] = useState(false)
const [resolutionSummary, setResolutionSummary] = useState('')
const [submitting, setSubmitting] = useState(false)
@@ -51,6 +54,18 @@ export function FlowPilotActionBar({
}
}
const handleAbandon = async () => {
if (onAbandon) {
setSubmitting(true)
try {
await onAbandon()
setShowAbandon(false)
} finally {
setSubmitting(false)
}
}
}
return (
<>
{/* Bottom bar — fixed to viewport bottom, works regardless of height chain */}
@@ -76,16 +91,28 @@ export function FlowPilotActionBar({
Escalate
</button>
</div>
{onPause && (
<button
onClick={handlePause}
disabled={isProcessing || submitting}
className="flex items-center justify-center gap-2 rounded-lg bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] px-4 py-2 min-h-[44px] text-sm font-medium text-muted-foreground hover:text-foreground hover:border-[rgba(255,255,255,0.12)] disabled:opacity-40 disabled:pointer-events-none transition-colors sm:ml-auto"
>
<Pause size={16} />
Pause
</button>
)}
<div className="flex gap-2 sm:ml-auto">
{onPause && (
<button
onClick={handlePause}
disabled={isProcessing || submitting}
className="flex items-center justify-center gap-2 rounded-lg bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] px-4 py-2 min-h-[44px] text-sm font-medium text-muted-foreground hover:text-foreground hover:border-[rgba(255,255,255,0.12)] disabled:opacity-40 disabled:pointer-events-none transition-colors"
>
<Pause size={16} />
Pause
</button>
)}
{onAbandon && (
<button
onClick={() => setShowAbandon(true)}
disabled={isProcessing || submitting}
className="flex items-center justify-center gap-2 rounded-lg bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] px-4 py-2 min-h-[44px] text-sm font-medium text-muted-foreground hover:text-foreground hover:border-[rgba(255,255,255,0.12)] disabled:opacity-40 disabled:pointer-events-none transition-colors"
>
<X size={16} />
Close
</button>
)}
</div>
</div>
{/* Resolve modal */}
@@ -121,6 +148,33 @@ export function FlowPilotActionBar({
</div>
)}
{/* Close/Abandon confirmation */}
{showAbandon && (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="glass-card-static w-full max-w-full sm:max-w-lg mx-0 sm:mx-4 p-4 sm:p-6 rounded-t-2xl sm:rounded-2xl">
<h3 className="font-heading text-lg font-semibold text-foreground mb-1">Close Session</h3>
<p className="text-sm text-muted-foreground mb-4">
Are you sure you want to close this session? The session history will be kept but it won't count as resolved.
</p>
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button
onClick={() => setShowAbandon(false)}
className="rounded-lg px-4 py-2 min-h-[44px] text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Cancel
</button>
<button
onClick={handleAbandon}
disabled={submitting}
className="rounded-lg bg-rose-500/20 border border-rose-500/30 px-4 py-2 min-h-[44px] text-sm font-medium text-rose-400 hover:bg-rose-500/30 disabled:opacity-50 transition-colors"
>
{submitting ? 'Closing...' : 'Close Session'}
</button>
</div>
</div>
</div>
)}
{/* Escalate modal */}
<EscalateModal
open={showEscalate}

View File

@@ -0,0 +1,82 @@
import { useState, useRef, useCallback } from 'react'
import { Send } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { StepResponseRequest } from '@/types/ai-session'
interface FlowPilotMessageBarProps {
onRespond: (response: StepResponseRequest) => void
disabled?: boolean
isProcessing?: boolean
}
export function FlowPilotMessageBar({ onRespond, disabled = false, isProcessing = false }: FlowPilotMessageBarProps) {
const [message, setMessage] = useState('')
const textareaRef = useRef<HTMLTextAreaElement>(null)
const isDisabled = disabled || isProcessing
const handleSubmit = useCallback(() => {
const trimmed = message.trim()
if (!trimmed || isDisabled) return
onRespond({ free_text_input: trimmed })
setMessage('')
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
}
}, [message, isDisabled, onRespond])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSubmit()
}
}, [handleSubmit])
const handleInput = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
setMessage(e.target.value)
const textarea = e.target
textarea.style.height = 'auto'
textarea.style.height = `${Math.min(textarea.scrollHeight, 120)}px`
}, [])
return (
<div
className="fixed bottom-[68px] right-0 lg:right-72 z-40 px-4 sm:px-6 lg:px-8 pb-2"
style={{ left: 'var(--sidebar-w, 0px)' }}
>
<div
className={cn(
'flex items-end gap-2 rounded-xl border p-3 transition-colors',
isDisabled
? 'border-border/50 opacity-50'
: 'border-border focus-within:border-[rgba(6,182,212,0.3)]'
)}
style={{ background: 'rgba(16, 17, 20, 0.95)', backdropFilter: 'blur(16px)' }}
>
<textarea
ref={textareaRef}
value={message}
onChange={handleInput}
onKeyDown={handleKeyDown}
placeholder={isProcessing ? 'FlowPilot is thinking...' : 'Type a message...'}
disabled={isDisabled}
rows={1}
className="flex-1 resize-none bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none disabled:cursor-not-allowed py-1.5 px-2"
/>
<button
onClick={handleSubmit}
disabled={isDisabled || !message.trim()}
aria-label="Send message"
className={cn(
'flex h-9 w-9 shrink-0 items-center justify-center rounded-lg transition-all',
message.trim() && !isDisabled
? 'bg-gradient-brand text-[#101114] hover:opacity-90 active:scale-[0.97]'
: 'bg-[rgba(255,255,255,0.04)] text-muted-foreground cursor-not-allowed'
)}
>
<Send size={16} />
</button>
</div>
</div>
)
}

View File

@@ -11,6 +11,7 @@ import type {
import { ConfidenceIndicator } from './ConfidenceIndicator'
import { FlowPilotStepCard } from './FlowPilotStepCard'
import { FlowPilotActionBar } from './FlowPilotActionBar'
import { FlowPilotMessageBar } from './FlowPilotMessageBar'
import { SessionDocView } from './SessionDocView'
import { SessionTicketCard } from './SessionTicketCard'
import { SimilarSessions } from './SimilarSessions'
@@ -35,6 +36,7 @@ interface FlowPilotSessionProps {
onEscalate: (data: EscalateSessionRequest) => Promise<SessionDocumentation>
onPause?: () => Promise<void>
onResume?: () => Promise<void>
onAbandon?: () => Promise<void>
onRate: (rating: number) => void
onReloadSession?: () => Promise<void>
}
@@ -55,6 +57,7 @@ export function FlowPilotSession({
onEscalate,
onPause,
onResume,
onAbandon,
onRate,
onReloadSession,
}: FlowPilotSessionProps) {
@@ -193,8 +196,8 @@ export function FlowPilotSession({
{/* Main content area: conversation + sidebar */}
<div className="flex flex-1 min-h-0">
{/* Conversation column — pb-20 provides clearance for the fixed action bar */}
<div ref={scrollRef} className="flex-1 overflow-y-auto p-3 pb-20 sm:p-4 sm:pb-20 lg:p-6 lg:pb-20">
{/* Conversation column — pb-32 provides clearance for the fixed message bar + action bar */}
<div ref={scrollRef} className="flex-1 overflow-y-auto p-3 pb-32 sm:p-4 sm:pb-32 lg:p-6 lg:pb-32">
<div className="mx-auto max-w-2xl space-y-3">
{allSteps.map((step) => (
<FlowPilotStepCard
@@ -300,6 +303,15 @@ export function FlowPilotSession({
</div>
</div>
{/* Message bar */}
{session.status === 'active' && (
<FlowPilotMessageBar
onRespond={onRespond}
isProcessing={isProcessing}
disabled={currentStep?.allow_free_text === false}
/>
)}
{/* Action bar */}
{session.status === 'active' && (
<FlowPilotActionBar
@@ -311,6 +323,7 @@ export function FlowPilotSession({
onResolve={onResolve}
onEscalate={onEscalate}
onPause={onPause}
onAbandon={onAbandon}
/>
)}

View File

@@ -2,9 +2,8 @@ import { useState } from 'react'
import { MessageSquare, Zap, CheckCircle2, SkipForward, ChevronDown, ChevronUp } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { AISessionStepResponse, StepResponseRequest } from '@/types/ai-session'
import type { FileUploadResponse } from '@/types/upload'
import { isScriptGenerationAction, isScriptBuilderAction, getActionType } from '@/types/ai-session'
import { MarkdownContent } from '@/components/ui/MarkdownContent'
import { RichTextInput } from '@/components/common/RichTextInput'
import { FlowPilotOptions } from './FlowPilotOptions'
import { InSessionScriptGenerator } from './InSessionScriptGenerator'
@@ -27,10 +26,7 @@ const STEP_TYPE_ICONS = {
} as const
export function FlowPilotStepCard({ step, isCurrentStep, isProcessing, sessionId, onRespond }: FlowPilotStepCardProps) {
const [freeText, setFreeText] = useState('')
const [showFreeText, setShowFreeText] = useState(false)
const [isCollapsed, setIsCollapsed] = useState(!isCurrentStep)
const [_freeTextUploads, setFreeTextUploads] = useState<FileUploadResponse[]>([])
const content = step.content as Record<string, unknown>
const stepText = (content.text as string) || ''
@@ -42,13 +38,6 @@ export function FlowPilotStepCard({ step, isCurrentStep, isProcessing, sessionId
onRespond({ selected_option: value })
}
const handleFreeTextSubmit = () => {
if (!freeText.trim()) return
onRespond({ free_text_input: freeText.trim() })
setFreeText('')
setShowFreeText(false)
}
const handleSkip = () => {
onRespond({ was_skipped: true })
}
@@ -169,18 +158,36 @@ export function FlowPilotStepCard({ step, isCurrentStep, isProcessing, sessionId
)}
{/* In-session script generator */}
{!isResolutionSuggestion && (content.action_type as string) === 'script_generation' && sessionId && (
{!isResolutionSuggestion && isScriptGenerationAction(content) && sessionId && (
<InSessionScriptGenerator
templateId={(content.template_id as string) || ''}
preFilledParams={(content.pre_filled_params as Record<string, string>) || {}}
instructions={(content.instructions as string) || stepText}
templateId={content.template_id || ''}
preFilledParams={content.pre_filled_params || {}}
instructions={content.instructions || stepText}
sessionId={sessionId}
onRespond={onRespond}
/>
)}
{/* Script Builder handoff */}
{!isResolutionSuggestion && step.step_type === 'action' && isScriptBuilderAction(content) && (
<button
onClick={() => {
sessionStorage.setItem('scriptBuilderContext', JSON.stringify({
from_session: sessionId,
prompt: content.script_prompt || '',
language: content.script_language || 'powershell',
}))
window.open('/script-builder?from=flowpilot', '_blank')
onRespond({ action_result: { success: true, details: 'Opened Script Builder' } })
}}
className="flex-1 min-h-[44px] rounded-lg bg-gradient-brand px-4 py-2.5 text-sm font-semibold text-[#101114] hover:opacity-90 active:scale-[0.97] transition-all"
>
Open Script Builder
</button>
)}
{/* Action step buttons */}
{!isResolutionSuggestion && step.step_type === 'action' && (content.action_type as string) !== 'script_generation' && (
{!isResolutionSuggestion && step.step_type === 'action' && getActionType(content) !== 'script_generation' && getActionType(content) !== 'open_script_builder' && (
<div className="flex flex-col gap-2 sm:flex-row">
<button
onClick={() => handleActionComplete(true)}
@@ -197,46 +204,6 @@ export function FlowPilotStepCard({ step, isCurrentStep, isProcessing, sessionId
</div>
)}
{/* Free text escape hatch */}
{!isResolutionSuggestion && step.allow_free_text && (
<>
{!showFreeText ? (
<button
onClick={() => setShowFreeText(true)}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
None of these let me describe
</button>
) : (
<div className="space-y-2">
<RichTextInput
value={freeText}
onChange={setFreeText}
onFilesChange={setFreeTextUploads}
sessionId={sessionId}
placeholder="Describe what you're seeing..."
rows={3}
/>
<div className="flex gap-2">
<button
onClick={handleFreeTextSubmit}
disabled={!freeText.trim()}
className="rounded-lg bg-gradient-brand px-4 py-1.5 text-sm font-semibold text-[#101114] hover:opacity-90 disabled:opacity-50 transition-opacity"
>
Submit
</button>
<button
onClick={() => { setShowFreeText(false); setFreeText('') }}
className="rounded-lg px-4 py-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Cancel
</button>
</div>
</div>
)}
</>
)}
{/* Skip option */}
{!isResolutionSuggestion && step.allow_skip && (
<button

View File

@@ -1,6 +1,6 @@
import { useEffect, useState, useCallback } from 'react'
import { useLocation, useNavigate, Link } from 'react-router-dom'
import { Menu, X, LayoutGrid, Clock, Network, AlertTriangle, Code2, BarChart3, Settings, LogOut, Shield, Library } from 'lucide-react'
import { Menu, X, LayoutGrid, Clock, Network, AlertTriangle, Code2, Wand2, BarChart3, Settings, LogOut, Shield, Library } from 'lucide-react'
import { useAuthStore } from '@/store/authStore'
import { usePermissions } from '@/hooks/usePermissions'
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
@@ -57,6 +57,7 @@ export function AppLayout() {
{ path: '/trees', label: 'Flows', icon: Network },
{ path: '/step-library', label: 'Step Library', icon: Library },
{ path: '/scripts', label: 'Scripts', icon: Code2 },
{ path: '/script-builder', label: 'Script Builder', icon: Wand2 },
{ path: '/analytics', label: 'Analytics', icon: BarChart3 },
{ path: '/account', label: 'Account', icon: Settings },
]

View File

@@ -3,7 +3,7 @@ import { useLocation } from 'react-router-dom'
import {
LayoutGrid, Network, Clock, FileOutput, BarChart3, TrendingUp,
Settings, PanelLeftClose, PanelLeftOpen, MessageSquareText, ListChecks,
BookOpen, Code2, Library, AlertTriangle,
BookOpen, Code2, Library, AlertTriangle, Wand2,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
@@ -19,6 +19,7 @@ const NAV_COLORS = {
exports: '#60a5fa', // blue-400
stepLib: '#fb923c', // orange-400
scripts: '#2dd4bf', // teal-400
scriptBuilder: '#e879f9', // fuchsia-400
analytics: '#38bdf8', // sky-400
guides: '#a3e635', // lime-400
feedback: '#818cf8', // indigo-400
@@ -83,6 +84,7 @@ export function Sidebar() {
<NavItem href="/trees" icon={Network} label="Flows" matchPaths={['/trees', '/flows', '/my-trees']} iconColor={NAV_COLORS.flows} collapsed />
<NavItem href="/step-library" icon={Library} label="Step Library" iconColor={NAV_COLORS.stepLib} collapsed />
<NavItem href="/scripts" icon={Code2} label="Scripts" iconColor={NAV_COLORS.scripts} collapsed />
<NavItem href="/script-builder" icon={Wand2} label="Script Builder" iconColor={NAV_COLORS.scriptBuilder} collapsed />
<NavItem href="/review-queue" icon={ListChecks} label="Review Queue" iconColor="#fbbf24" collapsed />
<NavItem href="/shares" icon={FileOutput} label="Exports" iconColor={NAV_COLORS.exports} collapsed />
<NavItem href="/analytics" icon={BarChart3} label="Analytics" iconColor={NAV_COLORS.analytics} collapsed />
@@ -124,6 +126,7 @@ export function Sidebar() {
/>
<NavItem href="/step-library" icon={Library} label="Step Library" iconColor={NAV_COLORS.stepLib} />
<NavItem href="/scripts" icon={Code2} label="Scripts" iconColor={NAV_COLORS.scripts} />
<NavItem href="/script-builder" icon={Wand2} label="Script Builder" iconColor={NAV_COLORS.scriptBuilder} />
<NavItem href="/review-queue" icon={ListChecks} label="Review Queue" iconColor="#fbbf24" />
{/* Insights */}

View File

@@ -0,0 +1,186 @@
import { useState, useEffect } from 'react'
import { X, Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { scriptBuilderApi, scriptsApi } from '@/api'
import type { ScriptCategoryResponse } from '@/types'
interface SaveToLibraryDialogProps {
sessionId: string
defaultName: string
defaultDescription?: string
onClose: () => void
onSaved: () => void
}
export function SaveToLibraryDialog({
sessionId,
defaultName,
defaultDescription,
onClose,
onSaved,
}: SaveToLibraryDialogProps) {
const [name, setName] = useState(defaultName)
const [description, setDescription] = useState(defaultDescription || '')
const [categoryId, setCategoryId] = useState('')
const [shareWithTeam, setShareWithTeam] = useState(false)
const [categories, setCategories] = useState<ScriptCategoryResponse[]>([])
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
scriptsApi.getCategories().then(setCategories).catch(() => {})
}, [])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [onClose])
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) return
setIsSaving(true)
setError(null)
try {
await scriptBuilderApi.saveToLibrary(sessionId, {
name: name.trim(),
description: description.trim() || undefined,
category_id: categoryId || undefined,
share_with_team: shareWithTeam,
})
onSaved()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save script. Please try again.')
} finally {
setIsSaving(false)
}
}
return (
<div
className="fixed inset-0 z-50 bg-black/80 backdrop-blur-sm flex items-center justify-center"
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<div className="glass-card-static max-w-md w-full mx-4 rounded-xl overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-5 py-3.5 border-b border-[rgba(255,255,255,0.06)]">
<h3 className="text-sm font-heading font-bold text-foreground">Save to Library</h3>
<button
onClick={onClose}
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
>
<X size={18} />
</button>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="p-5 space-y-4">
{/* Name */}
<div>
<label className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-1.5 block">
Name *
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
className={cn(
"w-full rounded-[10px] px-3 py-2 text-sm",
"border border-border bg-card text-foreground placeholder:text-muted-foreground",
"focus:outline-none focus:border-[rgba(6,182,212,0.3)] transition-colors"
)}
placeholder="Script name"
/>
</div>
{/* Description */}
<div>
<label className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-1.5 block">
Description
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
className={cn(
"w-full rounded-[10px] px-3 py-2 text-sm resize-none",
"border border-border bg-card text-foreground placeholder:text-muted-foreground",
"focus:outline-none focus:border-[rgba(6,182,212,0.3)] transition-colors"
)}
placeholder="What does this script do?"
/>
</div>
{/* Category */}
<div>
<label className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-1.5 block">
Category
</label>
<select
value={categoryId}
onChange={(e) => setCategoryId(e.target.value)}
className={cn(
"w-full rounded-[10px] px-3 py-2 text-sm",
"border border-border bg-card text-foreground",
"focus:outline-none focus:border-[rgba(6,182,212,0.3)] transition-colors"
)}
>
<option value="">No category</option>
{categories.map((cat) => (
<option key={cat.id} value={cat.id}>{cat.name}</option>
))}
</select>
</div>
{/* Share with team */}
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={shareWithTeam}
onChange={(e) => setShareWithTeam(e.target.checked)}
className="w-4 h-4 rounded border-border bg-card text-cyan-500 focus:ring-cyan-500/20"
/>
<span className="text-sm text-foreground">Share with team</span>
</label>
{/* Error */}
{error && (
<p className="text-xs text-rose-400">{error}</p>
)}
{/* Actions */}
<div className="flex items-center justify-end gap-2 pt-2">
<button
type="button"
onClick={onClose}
className={cn(
"px-4 py-2 rounded-[10px] text-sm font-medium transition-colors",
"bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground hover:border-[rgba(255,255,255,0.12)]"
)}
>
Cancel
</button>
<button
type="submit"
disabled={!name.trim() || isSaving}
className={cn(
"flex items-center gap-2 px-4 py-2 rounded-[10px] text-sm font-semibold transition-all",
"bg-gradient-brand text-[#101114] hover:opacity-90 active:scale-[0.97]",
"disabled:opacity-50 disabled:cursor-not-allowed"
)}
>
{isSaving && <Loader2 size={14} className="animate-spin" />}
{isSaving ? 'Saving...' : 'Save to Library'}
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,114 @@
import { useEffect, useRef } from 'react'
import { Bot, User, Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { MarkdownContent } from '@/components/ui/MarkdownContent'
import { ScriptCodeBlock } from './ScriptCodeBlock'
import type { ScriptBuilderMessage } from '@/types'
interface ScriptBuilderChatProps {
messages: ScriptBuilderMessage[]
language: string
onViewScript: (script: string, filename: string | null) => void
onSaveScript: () => void
isLoading: boolean
}
export function ScriptBuilderChat({
messages,
language,
onViewScript,
onSaveScript,
isLoading,
}: ScriptBuilderChatProps) {
const bottomRef = useRef<HTMLDivElement>(null)
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages.length, isLoading])
if (messages.length === 0 && !isLoading) {
return (
<div className="flex-1 flex items-center justify-center p-8">
<div className="text-center max-w-md">
<div className="w-14 h-14 rounded-2xl bg-gradient-brand flex items-center justify-center mx-auto mb-4">
<Bot size={28} className="text-[#101114]" />
</div>
<h2 className="text-lg font-heading font-bold text-foreground mb-2">
Script Builder
</h2>
<p className="text-sm text-muted-foreground leading-relaxed">
Describe the script you need and AI will generate it for you. You can iterate on the script,
preview it, and save it to your library.
</p>
</div>
</div>
)
}
return (
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((msg, idx) => (
<div
key={idx}
className={cn(
"flex gap-3",
msg.role === 'user' ? "justify-end" : "justify-start"
)}
>
{msg.role === 'assistant' && (
<div className="shrink-0 w-8 h-8 rounded-lg bg-[rgba(6,182,212,0.1)] flex items-center justify-center mt-0.5">
<Bot size={16} className="text-cyan-400" />
</div>
)}
<div
className={cn(
"max-w-[85%] rounded-xl px-4 py-3 text-sm",
msg.role === 'user'
? "bg-[rgba(6,182,212,0.08)] border border-[rgba(6,182,212,0.15)] text-foreground"
: "glass-card-static"
)}
>
{msg.role === 'assistant' ? (
<>
<MarkdownContent content={msg.content} />
{msg.script && (
<ScriptCodeBlock
script={msg.script}
filename={msg.script_filename ?? null}
lineCount={msg.line_count ?? null}
language={language}
onViewFull={() => onViewScript(msg.script!, msg.script_filename ?? null)}
onSave={onSaveScript}
/>
)}
</>
) : (
<p className="whitespace-pre-wrap">{msg.content}</p>
)}
</div>
{msg.role === 'user' && (
<div className="shrink-0 w-8 h-8 rounded-lg bg-[rgba(255,255,255,0.06)] flex items-center justify-center mt-0.5">
<User size={16} className="text-muted-foreground" />
</div>
)}
</div>
))}
{isLoading && (
<div className="flex gap-3 justify-start">
<div className="shrink-0 w-8 h-8 rounded-lg bg-[rgba(6,182,212,0.1)] flex items-center justify-center">
<Bot size={16} className="text-cyan-400" />
</div>
<div className="glass-card-static rounded-xl px-4 py-3 text-sm flex items-center gap-2">
<Loader2 size={14} className="animate-spin text-cyan-400" />
<span className="text-muted-foreground">Generating script...</span>
</div>
</div>
)}
<div ref={bottomRef} />
</div>
)
}

View File

@@ -0,0 +1,78 @@
import { useState, useRef, useCallback, useEffect } from 'react'
import { Send } from 'lucide-react'
import { cn } from '@/lib/utils'
interface ScriptBuilderInputProps {
onSend: (content: string) => void
disabled: boolean
placeholder?: string
}
export function ScriptBuilderInput({
onSend,
disabled,
placeholder = 'Describe the script you need...',
}: ScriptBuilderInputProps) {
const [value, setValue] = useState('')
const textareaRef = useRef<HTMLTextAreaElement>(null)
const adjustHeight = useCallback(() => {
const textarea = textareaRef.current
if (!textarea) return
textarea.style.height = 'auto'
textarea.style.height = `${Math.min(textarea.scrollHeight, 120)}px`
}, [])
useEffect(() => {
adjustHeight()
}, [value, adjustHeight])
const handleSend = () => {
const trimmed = value.trim()
if (!trimmed || disabled) return
onSend(trimmed)
setValue('')
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
const canSend = value.trim().length > 0 && !disabled
return (
<div className="flex items-end gap-2 p-3 border-t" style={{ borderColor: 'var(--glass-border)' }}>
<textarea
ref={textareaRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
rows={1}
className={cn(
"flex-1 resize-none rounded-xl px-4 py-2.5 text-sm",
"bg-card border border-border text-foreground placeholder:text-muted-foreground",
"focus:outline-none focus:border-[rgba(6,182,212,0.3)] transition-colors",
"disabled:opacity-50"
)}
style={{ maxHeight: 120 }}
/>
<button
onClick={handleSend}
disabled={!canSend}
className={cn(
"shrink-0 flex items-center justify-center w-10 h-10 rounded-xl transition-all",
canSend
? "bg-gradient-brand text-[#101114] hover:opacity-90 active:scale-[0.97]"
: "bg-[rgba(255,255,255,0.04)] text-[#5a6170] cursor-not-allowed"
)}
>
<Send size={18} />
</button>
</div>
)
}

View File

@@ -0,0 +1,129 @@
import { useState } from 'react'
import { Light as SyntaxHighlighter } from 'react-syntax-highlighter'
import powershell from 'react-syntax-highlighter/dist/esm/languages/hljs/powershell'
import bash from 'react-syntax-highlighter/dist/esm/languages/hljs/bash'
import python from 'react-syntax-highlighter/dist/esm/languages/hljs/python'
import atomOneDark from 'react-syntax-highlighter/dist/esm/styles/hljs/atom-one-dark'
import { Eye, Copy, Check, BookmarkPlus } from 'lucide-react'
import { cn } from '@/lib/utils'
SyntaxHighlighter.registerLanguage('powershell', powershell)
SyntaxHighlighter.registerLanguage('bash', bash)
SyntaxHighlighter.registerLanguage('python', python)
const LANGUAGE_MAP: Record<string, string> = {
powershell: 'powershell',
bash: 'bash',
python: 'python',
}
interface ScriptCodeBlockProps {
script: string
filename: string | null
lineCount: number | null
language: string
onViewFull: () => void
onSave: () => void
}
export function ScriptCodeBlock({
script,
filename,
lineCount,
language,
onViewFull,
onSave,
}: ScriptCodeBlockProps) {
const [copied, setCopied] = useState(false)
const lines = script.split('\n')
const previewLines = lines.slice(0, 5).join('\n')
const remainingLines = lines.length - 5
const hlLanguage = LANGUAGE_MAP[language] || 'powershell'
const handleCopy = async (e: React.MouseEvent) => {
e.stopPropagation()
try {
await navigator.clipboard.writeText(script)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
// clipboard not available
}
}
return (
<div className="mt-3 rounded-lg border bg-[rgba(0,0,0,0.3)] border-[rgba(255,255,255,0.06)] overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-[rgba(255,255,255,0.06)]">
<span className="font-label text-xs text-cyan-400 truncate">
{filename || 'script'}
</span>
{lineCount != null && (
<span className="font-label text-[0.625rem] text-muted-foreground ml-2 shrink-0">
{lineCount} lines
</span>
)}
</div>
{/* Code preview — clickable */}
<button
onClick={onViewFull}
className="block w-full text-left cursor-pointer hover:bg-[rgba(255,255,255,0.02)] transition-colors"
>
<SyntaxHighlighter
language={hlLanguage}
style={atomOneDark}
customStyle={{
background: 'transparent',
padding: '12px',
margin: 0,
fontSize: '0.8125rem',
lineHeight: '1.5',
}}
wrapLongLines
>
{previewLines}
</SyntaxHighlighter>
{remainingLines > 0 && (
<div className="px-3 pb-2 font-label text-[0.625rem] text-[#5a6170]">
{"··· "}{remainingLines} more line{remainingLines !== 1 ? 's' : ''}
</div>
)}
</button>
{/* Action buttons */}
<div className="flex items-center gap-2 px-3 py-2 border-t border-[rgba(255,255,255,0.06)]">
<button
onClick={onViewFull}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-[10px] text-xs font-semibold transition-all",
"bg-gradient-brand text-[#101114] hover:opacity-90 active:scale-[0.97]"
)}
>
<Eye size={14} />
View Full Script
</button>
<button
onClick={handleCopy}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-[10px] text-xs font-medium transition-colors",
"bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground hover:border-[rgba(255,255,255,0.12)]"
)}
>
{copied ? <Check size={14} className="text-emerald-400" /> : <Copy size={14} />}
{copied ? 'Copied' : 'Copy'}
</button>
<button
onClick={(e) => { e.stopPropagation(); onSave() }}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-[10px] text-xs font-medium transition-colors",
"bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 hover:bg-emerald-500/15"
)}
>
<BookmarkPlus size={14} />
Save to Library
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,145 @@
import { useEffect, useState } from 'react'
import { Light as SyntaxHighlighter } from 'react-syntax-highlighter'
import atomOneDark from 'react-syntax-highlighter/dist/esm/styles/hljs/atom-one-dark'
import { X, Copy, Check, BookmarkPlus } from 'lucide-react'
import { cn } from '@/lib/utils'
const LANGUAGE_MAP: Record<string, string> = {
powershell: 'powershell',
bash: 'bash',
python: 'python',
}
const LANGUAGE_LABELS: Record<string, string> = {
powershell: 'PowerShell',
bash: 'Bash',
python: 'Python',
}
interface ScriptPreviewModalProps {
script: string
filename: string | null
language: string
onClose: () => void
onSave: () => void
}
export function ScriptPreviewModal({
script,
filename,
language,
onClose,
onSave,
}: ScriptPreviewModalProps) {
const [copied, setCopied] = useState(false)
const hlLanguage = LANGUAGE_MAP[language] || 'powershell'
const lineCount = script.split('\n').length
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [onClose])
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(script)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
// clipboard not available
}
}
return (
<div
className="fixed inset-0 z-50 bg-black/80 backdrop-blur-sm flex items-center justify-center"
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<div className="bg-[#18191f] rounded-xl border border-[rgba(255,255,255,0.08)] max-w-[900px] w-full mx-4 max-h-[85vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-5 py-3.5 border-b border-[rgba(255,255,255,0.06)]">
<div className="flex items-center gap-3 min-w-0">
<span className="font-label text-sm text-cyan-400 truncate">
{filename || 'script'}
</span>
<span className="shrink-0 font-label text-[0.625rem] uppercase tracking-wider px-2 py-0.5 rounded-full bg-[rgba(255,255,255,0.06)] text-muted-foreground">
{LANGUAGE_LABELS[language] || language}
</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleCopy}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-[10px] text-xs font-medium transition-colors",
"bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground hover:border-[rgba(255,255,255,0.12)]"
)}
>
{copied ? <Check size={14} className="text-emerald-400" /> : <Copy size={14} />}
{copied ? 'Copied' : 'Copy'}
</button>
<button
onClick={onSave}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-[10px] text-xs font-medium transition-colors",
"bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 hover:bg-emerald-500/15"
)}
>
<BookmarkPlus size={14} />
Save to Library
</button>
<button
onClick={onClose}
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
>
<X size={18} />
</button>
</div>
</div>
{/* Code body */}
<div className="flex-1 overflow-auto min-h-0">
<SyntaxHighlighter
language={hlLanguage}
style={atomOneDark}
showLineNumbers
customStyle={{
background: 'transparent',
padding: '16px',
margin: 0,
fontSize: '0.8125rem',
lineHeight: '1.6',
}}
lineNumberStyle={{
color: '#5a6170',
minWidth: '2.5em',
paddingRight: '1em',
userSelect: 'none',
}}
wrapLongLines
>
{script}
</SyntaxHighlighter>
</div>
{/* Footer */}
<div className="flex items-center justify-between px-5 py-3 border-t border-[rgba(255,255,255,0.06)]">
<span className="font-label text-[0.625rem] text-muted-foreground">
{lineCount} line{lineCount !== 1 ? 's' : ''}
</span>
<button
onClick={onClose}
className={cn(
"px-4 py-1.5 rounded-[10px] text-xs font-medium transition-colors",
"bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground hover:border-[rgba(255,255,255,0.12)]"
)}
>
Close & Return to Chat
</button>
</div>
</div>
</div>
)
}

View File

@@ -29,6 +29,7 @@ export interface UseFlowPilotSession {
escalateSession: (data: EscalateSessionRequest) => Promise<SessionDocumentation>
pauseSession: () => Promise<void>
resumeOwnSession: () => Promise<void>
abandonSession: () => Promise<void>
rateSession: (rating: number, feedback?: string) => Promise<void>
loadSession: (sessionId: string) => Promise<void>
@@ -190,6 +191,18 @@ export function useFlowPilotSession(): UseFlowPilotSession {
}
}, [session])
const abandonSession = useCallback(async () => {
if (!session) return
try {
await aiSessionsApi.abandonSession(session.id)
setSession(prev => prev ? { ...prev, status: 'abandoned' } : null)
setCurrentStep(null)
toast.success('Session closed')
} catch {
toast.error('Failed to close session')
}
}, [session])
const rateSession = useCallback(async (rating: number, feedback?: string) => {
if (!session) return
try {
@@ -245,6 +258,7 @@ export function useFlowPilotSession(): UseFlowPilotSession {
escalateSession,
pauseSession,
resumeOwnSession,
abandonSession,
rateSession,
loadSession,
isActive,

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'
import { useParams, useSearchParams, useLocation, useBlocker } from 'react-router-dom'
import { useParams, useSearchParams, useLocation, useBlocker, useNavigate } from 'react-router-dom'
import { Sparkles, Loader2, AlertTriangle } from 'lucide-react'
import { useFlowPilotSession } from '@/hooks/useFlowPilotSession'
import { FlowPilotIntake, FlowPilotSession, SessionBriefing } from '@/components/flowpilot'
@@ -9,6 +9,7 @@ import { toast } from '@/lib/toast'
export default function FlowPilotSessionPage() {
const { sessionId } = useParams<{ sessionId?: string }>()
const [searchParams, setSearchParams] = useSearchParams()
const navigate = useNavigate()
const location = useLocation()
const prefill = (location.state as { prefill?: string } | null)?.prefill || ''
const isPickup = searchParams.get('pickup') === 'true'
@@ -230,6 +231,10 @@ export default function FlowPilotSessionPage() {
onEscalate={fp.escalateSession}
onPause={fp.pauseSession}
onResume={fp.resumeOwnSession}
onAbandon={async () => {
await fp.abandonSession()
navigate('/sessions')
}}
onRate={fp.rateSession}
onReloadSession={() => fp.loadSession(fp.session!.id)}
/>

View File

@@ -0,0 +1,204 @@
import { useState, useEffect } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Terminal } from 'lucide-react'
import { cn } from '@/lib/utils'
import { scriptBuilderApi } from '@/api'
import { ScriptBuilderChat } from '@/components/script-builder/ScriptBuilderChat'
import { ScriptBuilderInput } from '@/components/script-builder/ScriptBuilderInput'
import { ScriptPreviewModal } from '@/components/script-builder/ScriptPreviewModal'
import { SaveToLibraryDialog } from '@/components/script-builder/SaveToLibraryDialog'
import type { ScriptBuilderSessionDetail, ScriptBuilderMessage } from '@/types'
const LANGUAGES = [
{ value: 'powershell', label: 'PowerShell' },
{ value: 'bash', label: 'Bash' },
{ value: 'python', label: 'Python' },
] as const
export default function ScriptBuilderPage() {
const [searchParams] = useSearchParams()
const [session, setSession] = useState<ScriptBuilderSessionDetail | null>(null)
const [messages, setMessages] = useState<ScriptBuilderMessage[]>([])
const [language, setLanguage] = useState('powershell')
const [isLoading, setIsLoading] = useState(false)
const [previewScript, setPreviewScript] = useState<{ script: string; filename: string | null } | null>(null)
const [showSaveDialog, setShowSaveDialog] = useState(false)
const [handoffProcessed, setHandoffProcessed] = useState(false)
const hasMessages = messages.length > 0
// Handle FlowPilot handoff on mount
useEffect(() => {
if (handoffProcessed) return
setHandoffProcessed(true)
const contextRaw = sessionStorage.getItem('scriptBuilderContext')
if (!contextRaw) return
try {
const context = JSON.parse(contextRaw) as {
from_session?: string
prompt?: string
language?: string
}
sessionStorage.removeItem('scriptBuilderContext')
if (context.language) {
setLanguage(context.language)
}
if (context.prompt) {
// Auto-send the prompt
handleSend(context.prompt, context.language || 'powershell')
}
} catch {
sessionStorage.removeItem('scriptBuilderContext')
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Suppress unused searchParams warning — used to detect ?from=flowpilot context
void searchParams
const handleSend = async (content: string, langOverride?: string) => {
const effectiveLanguage = langOverride || language
// Optimistically add user message
const userMessage: ScriptBuilderMessage = {
role: 'user',
content,
created_at: new Date().toISOString(),
}
setMessages((prev) => [...prev, userMessage])
setIsLoading(true)
try {
// Create session if needed
let currentSession = session
if (!currentSession) {
currentSession = await scriptBuilderApi.createSession(effectiveLanguage)
setSession(currentSession)
}
// Send message
const response = await scriptBuilderApi.sendMessage(currentSession.id, content)
const assistantMessage: ScriptBuilderMessage = {
role: 'assistant',
content: response.content,
script: response.script,
script_filename: response.script_filename,
line_count: response.line_count,
created_at: response.timestamp,
}
setMessages((prev) => [...prev, assistantMessage])
} catch (err) {
// Add error message
const errorMessage: ScriptBuilderMessage = {
role: 'assistant',
content: `An error occurred: ${err instanceof Error ? err.message : 'Failed to generate response. Please try again.'}`,
created_at: new Date().toISOString(),
}
setMessages((prev) => [...prev, errorMessage])
} finally {
setIsLoading(false)
}
}
const handleViewScript = (script: string, filename: string | null) => {
setPreviewScript({ script, filename })
}
const handleSaveScript = () => {
setShowSaveDialog(true)
}
const handleSaved = () => {
setShowSaveDialog(false)
}
// Derive default name from session title or filename
const defaultSaveName = session?.title
|| session?.latest_script_filename
|| 'Untitled Script'
return (
<div className="flex flex-col h-full min-h-0">
{/* Header with language selector */}
<div className="shrink-0 px-6 py-4 border-b" style={{ borderColor: 'var(--glass-border)' }}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="flex items-center justify-center w-9 h-9 rounded-xl bg-[rgba(232,121,249,0.1)]">
<Terminal size={18} className="text-fuchsia-400" />
</span>
<div>
<h1 className="text-base font-heading font-bold text-foreground">Script Builder</h1>
<p className="text-xs text-muted-foreground">Describe what you need, AI generates the script</p>
</div>
</div>
{/* Language pills */}
<div className="flex items-center gap-1 p-0.5 rounded-lg bg-[rgba(255,255,255,0.03)] border border-[rgba(255,255,255,0.06)]">
{LANGUAGES.map((lang) => (
<button
key={lang.value}
onClick={() => !hasMessages && setLanguage(lang.value)}
disabled={hasMessages}
className={cn(
"px-3 py-1.5 rounded-md text-xs font-label font-medium transition-all",
language === lang.value
? "bg-gradient-brand text-[#101114]"
: hasMessages
? "text-[#5a6170] cursor-not-allowed"
: "text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.04)]"
)}
>
{lang.label}
</button>
))}
</div>
</div>
</div>
{/* Chat area */}
<ScriptBuilderChat
messages={messages}
language={language}
onViewScript={handleViewScript}
onSaveScript={handleSaveScript}
isLoading={isLoading}
/>
{/* Input */}
<div className="shrink-0">
<ScriptBuilderInput
onSend={(content) => handleSend(content)}
disabled={isLoading}
/>
</div>
{/* Preview modal */}
{previewScript && (
<ScriptPreviewModal
script={previewScript.script}
filename={previewScript.filename}
language={language}
onClose={() => setPreviewScript(null)}
onSave={() => {
setPreviewScript(null)
setShowSaveDialog(true)
}}
/>
)}
{/* Save dialog */}
{showSaveDialog && session && (
<SaveToLibraryDialog
sessionId={session.id}
defaultName={defaultSaveName}
onClose={() => setShowSaveDialog(false)}
onSaved={handleSaved}
/>
)}
</div>
)
}

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { Terminal, Settings } from 'lucide-react'
import { Terminal, Settings, Wand2 } from 'lucide-react'
import { useScriptGeneratorStore } from '@/store/scriptGeneratorStore'
import { usePermissions } from '@/hooks/usePermissions'
import { ScriptFilterBar } from '@/components/scripts/ScriptFilterBar'
@@ -8,10 +8,13 @@ import { ScriptTemplateList } from '@/components/scripts/ScriptTemplateList'
import { ScriptConfigurePane } from '@/components/scripts/ScriptConfigurePane'
import { ScriptPreview } from '@/components/scripts/ScriptPreview'
type LibraryTab = 'mine' | 'team'
export default function ScriptLibraryPage() {
const [paneMode, setPaneMode] = useState<'browse' | 'configure'>('browse')
// inputValue owned here so it survives Configure ↔ Browse transitions
const [inputValue, setInputValue] = useState('')
const [activeTab, setActiveTab] = useState<LibraryTab>('mine')
const loadCategories = useScriptGeneratorStore(s => s.loadCategories)
const loadTemplates = useScriptGeneratorStore(s => s.loadTemplates)
@@ -24,8 +27,18 @@ export default function ScriptLibraryPage() {
const canGenerate = isEngineer
useEffect(() => {
loadCategories().then(() => loadTemplates())
}, [loadCategories, loadTemplates])
loadCategories().then(() => {
const filters = activeTab === 'mine' ? { mine: true } : { shared: true }
loadTemplates(filters)
})
}, [loadCategories, loadTemplates, activeTab])
const onTabChange = (tab: LibraryTab) => {
setActiveTab(tab)
setInputValue('')
setSearch('')
setPaneMode('browse')
}
const onClearSearch = () => {
setInputValue('')
@@ -42,23 +55,55 @@ export default function ScriptLibraryPage() {
setPaneMode('browse')
}
const tabClass = (tab: LibraryTab) =>
tab === activeTab
? 'border-b-2 border-primary text-foreground'
: 'text-muted-foreground hover:text-foreground'
return (
<div className="flex flex-col gap-4 p-6 h-full">
{/* Page header */}
<div>
<h1 className="text-2xl font-heading font-bold text-foreground">Script Library</h1>
<p className="text-sm text-muted-foreground mt-1">
Browse PowerShell templates, fill in parameters, and generate ready-to-run scripts.
</p>
{isEngineer && (
<Link
to="/scripts/manage"
className="inline-flex items-center gap-1.5 text-xs text-primary bg-primary/10 hover:bg-primary/15 px-2.5 py-1 rounded-full transition-colors mt-2 group"
>
<Settings size={12} className="group-hover:rotate-90 transition-transform duration-300" />
Manage Templates
</Link>
)}
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-heading font-bold text-foreground">Script Library</h1>
<p className="text-sm text-muted-foreground mt-1">
Browse PowerShell templates, fill in parameters, and generate ready-to-run scripts.
</p>
{isEngineer && (
<Link
to="/scripts/manage"
className="inline-flex items-center gap-1.5 text-xs text-primary bg-primary/10 hover:bg-primary/15 px-2.5 py-1 rounded-full transition-colors mt-2 group"
>
<Settings size={12} className="group-hover:rotate-90 transition-transform duration-300" />
Manage Templates
</Link>
)}
</div>
<Link
to="/script-builder"
className="inline-flex items-center gap-2 bg-gradient-brand text-[#101114] font-semibold rounded-[10px] px-4 py-2 shadow-lg shadow-primary/20 hover:opacity-90 active:scale-[0.97] transition-all"
>
<Wand2 size={16} />
Build a New Script
</Link>
</div>
{/* Tab bar */}
<div className="flex gap-6 border-b border-border">
<button
type="button"
onClick={() => onTabChange('mine')}
className={`pb-2 text-sm font-medium transition-colors ${tabClass('mine')}`}
>
My Scripts
</button>
<button
type="button"
onClick={() => onTabChange('team')}
className={`pb-2 text-sm font-medium transition-colors ${tabClass('team')}`}
>
Team Scripts
</button>
</div>
{/* Two-column layout */}

View File

@@ -21,8 +21,6 @@ export function SessionHistoryPage() {
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
// Top-level tab: flow sessions vs AI sessions
const [sessionType, setSessionType] = useState<'flow' | 'ai'>('flow')
const [aiSessions, setAiSessions] = useState<AISessionSummary[]>([])
const [aiLoading, setAiLoading] = useState(false)
const [aiSearchInput, setAiSearchInput] = useState('')
@@ -122,13 +120,16 @@ export function SessionHistoryPage() {
if (filters.dateRange?.from) {
const fromDate = filters.dateRange.from
const toDate = filters.dateRange.to || filters.dateRange.from
// Set end-of-day on the "to" date so sessions created that day are included
const toDateEnd = new Date(toDate)
toDateEnd.setHours(23, 59, 59, 999)
if (filters.dateType === 'started') {
params.started_after = fromDate.toISOString()
params.started_before = toDate.toISOString()
params.started_before = toDateEnd.toISOString()
} else {
params.completed_after = fromDate.toISOString()
params.completed_before = toDate.toISOString()
params.completed_before = toDateEnd.toISOString()
}
}
@@ -166,9 +167,8 @@ export function SessionHistoryPage() {
setSearchParams(params, { replace: true })
}, [filters, setSearchParams])
// Load AI sessions when tab is active or filters change
// Load AI sessions always
useEffect(() => {
if (sessionType !== 'ai') return
let cancelled = false
const loadAiSessions = async () => {
setAiLoading(true)
@@ -179,7 +179,7 @@ export function SessionHistoryPage() {
problem_domain: aiFilters.problem_domain || undefined,
confidence_tier: aiFilters.confidence_tier || undefined,
date_from: aiFilters.date_from || undefined,
date_to: aiFilters.date_to || undefined,
date_to: aiFilters.date_to ? `${aiFilters.date_to}T23:59:59.999Z` : undefined,
})
if (!cancelled) setAiSessions(data)
} catch {
@@ -190,7 +190,7 @@ export function SessionHistoryPage() {
}
loadAiSessions()
return () => { cancelled = true }
}, [sessionType, aiFilters])
}, [aiFilters])
const handleFilterChange = (newFilters: SessionFilterState) => {
setFilters(newFilters)
@@ -267,66 +267,55 @@ export function SessionHistoryPage() {
return labels[outcome] ?? outcome
}
const hasAiFiltersActive = !!(aiSearchInput || aiFilters.q || aiFilters.problem_domain || aiFilters.confidence_tier || aiFilters.date_from || aiFilters.date_to)
const hasFlowFiltersActive = !!(filters.ticketNumber || filters.clientName || filters.treeName || filters.dateRange?.from)
// Determine section visibility
const showAiSection = aiLoading || aiSessions.length > 0 || hasAiFiltersActive
const showFlowSection = isLoading || sessions.length > 0 || hasFlowFiltersActive
const showCombinedEmpty = !showAiSection && !showFlowSection
return (
<div className="overflow-y-auto h-full">
<PageMeta title="Session History" />
<PageMeta title="Sessions" />
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
<div className="mb-8">
<h1 className="text-2xl font-heading font-bold text-foreground sm:text-3xl">Session History</h1>
<h1 className="text-2xl font-heading font-bold text-foreground sm:text-3xl">Sessions</h1>
<p className="mt-2 text-muted-foreground">
Search and filter your troubleshooting sessions
View and manage all your sessions
</p>
</div>
{/* Session type toggle */}
<div className="mb-4 flex gap-1 rounded-lg bg-card/50 p-1 w-fit border border-border">
<button
onClick={() => setSessionType('flow')}
className={cn(
'rounded-md px-4 py-1.5 text-sm font-medium transition-colors',
sessionType === 'flow'
? 'bg-primary/10 text-foreground'
: 'text-muted-foreground hover:text-foreground'
)}
>
Flow Sessions
</button>
<button
onClick={() => setSessionType('ai')}
className={cn(
'rounded-md px-4 py-1.5 text-sm font-medium transition-colors',
sessionType === 'ai'
? 'bg-primary/10 text-foreground'
: 'text-muted-foreground hover:text-foreground'
)}
>
AI Sessions
</button>
</div>
{/* Filter Tabs (flow sessions only) */}
{sessionType === 'flow' && (
<div className="mb-6 flex gap-2 border-b border-border">
{(['active', 'prepared', 'completed', 'all'] as const).map((tab) => (
<button
key={tab}
onClick={() => setFilter(tab)}
className={cn(
'px-4 py-2 text-sm font-medium transition-colors',
filter === tab
? 'border-b-2 border-primary text-foreground'
: 'text-muted-foreground hover:text-foreground'
)}
>
{tab.charAt(0).toUpperCase() + tab.slice(1)}
</button>
))}
</div>
{showCombinedEmpty && (
<EmptyState
illustration={<SessionIllustration />}
title="No sessions yet"
description="Start a flow or FlowPilot session to begin. All your sessions will appear here."
action={
<div className="flex gap-3">
<Link
to="/trees"
className="inline-flex items-center gap-2 rounded-[10px] bg-gradient-brand px-5 py-2.5 text-sm font-semibold text-[#101114] shadow-lg shadow-primary/20 hover:opacity-90 active:scale-[0.97] transition-all"
>
Start a Flow
</Link>
<Link
to="/pilot"
className="inline-flex items-center gap-2 rounded-[10px] border border-border bg-[rgba(255,255,255,0.04)] px-5 py-2.5 text-sm font-semibold text-foreground hover:border-[rgba(255,255,255,0.12)] transition-all"
>
Start AI Session
</Link>
</div>
}
learnMoreLink="/guides/sessions"
/>
)}
{/* AI Sessions view */}
{sessionType === 'ai' && (
{/* FlowPilot Sessions Section */}
{showAiSection && (
<>
<h2 className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-3">FlowPilot Sessions</h2>
{/* AI Session Filter Bar */}
<div className="glass-card-static p-3 mb-4">
<div className="flex flex-wrap gap-3 items-center">
@@ -400,7 +389,7 @@ export function SessionHistoryPage() {
</div>
{/* Clear filters */}
{(aiSearchInput || aiFilters.q || aiFilters.problem_domain || aiFilters.confidence_tier || aiFilters.date_from || aiFilters.date_to) && (
{hasAiFiltersActive && (
<button
onClick={() => {
setAiSearchInput('')
@@ -419,36 +408,21 @@ export function SessionHistoryPage() {
<Spinner />
</div>
) : aiSessions.length === 0 ? (
(aiSearchInput || aiFilters.q || aiFilters.problem_domain || aiFilters.confidence_tier || aiFilters.date_from || aiFilters.date_to) ? (
<EmptyState
title="No sessions match your filters"
description="Try adjusting your search or filters."
action={
<button
onClick={() => {
setAiSearchInput('')
setAiFilters({ q: '', problem_domain: '', confidence_tier: '', date_from: '', date_to: '' })
}}
className="text-foreground hover:underline text-sm"
>
Clear all filters
</button>
}
/>
) : (
<EmptyState
title="No AI sessions yet"
description="Start a FlowPilot session to get AI-guided troubleshooting. Sessions will appear here."
action={
<Link
to="/pilot"
className="inline-flex items-center gap-2 rounded-[10px] bg-gradient-brand px-5 py-2.5 text-sm font-semibold text-[#101114] shadow-lg shadow-primary/20 hover:opacity-90 active:scale-[0.97] transition-all"
>
Start AI Session
</Link>
}
/>
)
<EmptyState
title="No sessions match your filters"
description="Try adjusting your search or filters."
action={
<button
onClick={() => {
setAiSearchInput('')
setAiFilters({ q: '', problem_domain: '', confidence_tier: '', date_from: '', date_to: '' })
}}
className="text-foreground hover:underline text-sm"
>
Clear all filters
</button>
}
/>
) : (
<div className="space-y-2">
{aiSessions.map((s) => (
@@ -456,12 +430,37 @@ export function SessionHistoryPage() {
))}
</div>
)}
{/* Divider between sections */}
{showFlowSection && (
<div className="my-8 border-t border-border" />
)}
</>
)}
{/* Flow Sessions Content */}
{sessionType === 'flow' && (
{/* Flow Sessions Section */}
{showFlowSection && (
<>
<h2 className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-3">Flow Sessions</h2>
{/* Filter Tabs */}
<div className="mb-6 flex gap-2 border-b border-border">
{(['active', 'prepared', 'completed', 'all'] as const).map((tab) => (
<button
key={tab}
onClick={() => setFilter(tab)}
className={cn(
'px-4 py-2 text-sm font-medium transition-colors',
filter === tab
? 'border-b-2 border-primary text-foreground'
: 'text-muted-foreground hover:text-foreground'
)}
>
{tab.charAt(0).toUpperCase() + tab.slice(1)}
</button>
))}
</div>
<div className="mb-6">
<SessionFilters
filters={filters}

View File

@@ -52,6 +52,7 @@ const FlowPilotSessionPage = lazy(() => import('@/pages/FlowPilotSessionPage'))
const EscalationQueuePage = lazy(() => import('@/pages/EscalationQueuePage'))
const ReviewQueuePage = lazy(() => import('@/pages/ReviewQueuePage'))
const FlowPilotAnalyticsPage = lazy(() => import('@/pages/FlowPilotAnalyticsPage'))
const ScriptBuilderPage = lazy(() => import('@/pages/ScriptBuilderPage'))
const KBAcceleratorPage = lazy(() => import('@/pages/KBAcceleratorPage'))
const GuidesHubPage = lazy(() => import('@/pages/GuidesHubPage'))
const GuideDetailPage = lazy(() => import('@/pages/GuideDetailPage'))
@@ -190,6 +191,7 @@ export const router = sentryCreateBrowserRouter([
{ path: 'step-library', element: page(StepLibraryPage) },
{ path: 'scripts', element: page(ScriptLibraryPage) },
{ path: 'scripts/manage', element: page(ScriptManagePage) },
{ path: 'script-builder', element: page(ScriptBuilderPage) },
{ path: 'kb-accelerator', element: page(KBAcceleratorPage) },
{ path: 'assistant', element: page(AssistantChatPage) },
{ path: 'flow-assist', element: page(FlowAssistPage) },

View File

@@ -30,7 +30,7 @@ interface ScriptGeneratorState {
// Actions
loadCategories: () => Promise<void>
loadTemplates: () => Promise<void>
loadTemplates: (filters?: { mine?: boolean; shared?: boolean }) => Promise<void>
selectTemplate: (id: string) => Promise<void>
setCategory: (id: string | null) => void
setSearch: (query: string) => void
@@ -67,14 +67,16 @@ export const useScriptGeneratorStore = create<ScriptGeneratorState>()((set, get)
}
},
loadTemplates: async () => {
loadTemplates: async (filters) => {
set({ isLoadingTemplates: true })
try {
const { activeCategoryId, categories, searchQuery } = get()
const category = categories.find(c => c.id === activeCategoryId)
const params: { category_slug?: string; search?: string } = {}
const params: { category_slug?: string; search?: string; mine?: boolean; shared?: boolean } = {}
if (category) params.category_slug = category.slug
if (searchQuery) params.search = searchQuery
if (filters?.mine) params.mine = true
if (filters?.shared) params.shared = true
const templates = await scriptsApi.getTemplates(params)
set({ templates, isLoadingTemplates: false })
} catch {

View File

@@ -41,6 +41,33 @@ export interface AISessionStepResponse {
confidence_score: number
}
// ── Step content type guards ──
export interface ScriptGenerationContent {
action_type: 'script_generation'
template_id?: string
pre_filled_params?: Record<string, string>
instructions?: string
}
export interface ScriptBuilderContent {
action_type: 'open_script_builder'
script_prompt?: string
script_language?: string
}
export function isScriptGenerationAction(content: Record<string, unknown>): content is Record<string, unknown> & ScriptGenerationContent {
return content.action_type === 'script_generation'
}
export function isScriptBuilderAction(content: Record<string, unknown>): content is Record<string, unknown> & ScriptBuilderContent {
return content.action_type === 'open_script_builder'
}
export function getActionType(content: Record<string, unknown>): string | undefined {
return typeof content.action_type === 'string' ? content.action_type : undefined
}
export interface StepResponseRequest {
selected_option?: string | null
free_text_input?: string | null

View File

@@ -93,6 +93,7 @@ export type {
} from './kbAccelerator'
export * from './scripts'
export * from './script-builder'
export * from './integrations'
export * from './notification'
export type * from './public-templates'

View File

@@ -0,0 +1,50 @@
export interface ScriptBuilderSessionSummary {
id: string
language: string
title: string | null
message_count: number
latest_script_filename: string | null
created_at: string
updated_at: string
}
export interface ScriptBuilderSessionDetail {
id: string
language: string
title: string | null
messages: ScriptBuilderMessage[]
latest_script: string | null
latest_script_filename: string | null
message_count: number
ai_session_id: string | null
created_at: string
updated_at: string
}
export interface ScriptBuilderMessage {
id?: string
role: 'user' | 'assistant'
content: string
script?: string | null
script_filename?: string | null
line_count?: number | null
input_tokens?: number | null
output_tokens?: number | null
created_at: string
}
export interface ScriptBuilderMessageResponse {
role: 'assistant'
content: string
script: string | null
script_filename: string | null
line_count: number | null
timestamp: string
}
export interface SaveToLibraryRequest {
name: string
description?: string
category_id?: string
share_with_team?: boolean
}

View File

@@ -23,6 +23,7 @@ export interface ScriptTemplateListItem {
requires_modules: string[]
is_verified: boolean
usage_count: number
language?: string | null
}
export interface ScriptParameterOption {