diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ee18403..05fde67b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 44246772..c75453bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -356,6 +356,16 @@ gh run view --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) diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 069c808e..f3a1014d 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -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 diff --git a/backend/alembic/versions/062_add_script_builder_sessions_table.py b/backend/alembic/versions/062_add_script_builder_sessions_table.py new file mode 100644 index 00000000..ceb8f701 --- /dev/null +++ b/backend/alembic/versions/062_add_script_builder_sessions_table.py @@ -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') diff --git a/backend/alembic/versions/063_add_language_to_script_templates_and_ai_.py b/backend/alembic/versions/063_add_language_to_script_templates_and_ai_.py new file mode 100644 index 00000000..fef95de5 --- /dev/null +++ b/backend/alembic/versions/063_add_language_to_script_templates_and_ai_.py @@ -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') diff --git a/backend/alembic/versions/064_normalize_script_builder_messages.py b/backend/alembic/versions/064_normalize_script_builder_messages.py new file mode 100644 index 00000000..224d1d8c --- /dev/null +++ b/backend/alembic/versions/064_normalize_script_builder_messages.py @@ -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) diff --git a/backend/app/api/endpoints/ai_sessions.py b/backend/app/api/endpoints/ai_sessions.py index 7fb7d519..c04ff591 100644 --- a/backend/app/api/endpoints/ai_sessions.py +++ b/backend/app/api/endpoints/ai_sessions.py @@ -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]) diff --git a/backend/app/api/endpoints/script_builder.py b/backend/app/api/endpoints/script_builder.py new file mode 100644 index 00000000..1ac5851c --- /dev/null +++ b/backend/app/api/endpoints/script_builder.py @@ -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) diff --git a/backend/app/api/endpoints/scripts.py b/backend/app/api/endpoints/scripts.py index 182f837e..da8d8955 100644 --- a/backend/app/api/endpoints/scripts.py +++ b/backend/app/api/endpoints/scripts.py @@ -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() diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 884111fc..01465810 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -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) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 955543ef..7fdf7fb6 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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: diff --git a/backend/app/models/script_builder_session.py b/backend/app/models/script_builder_session.py new file mode 100644 index 00000000..f7075494 --- /dev/null +++ b/backend/app/models/script_builder_session.py @@ -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" + ) diff --git a/backend/app/models/script_template.py b/backend/app/models/script_template.py index 84ddfb19..d120d37c 100644 --- a/backend/app/models/script_template.py +++ b/backend/app/models/script_template.py @@ -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) diff --git a/backend/app/schemas/script_builder.py b/backend/app/schemas/script_builder.py new file mode 100644 index 00000000..893d16be --- /dev/null +++ b/backend/app/schemas/script_builder.py @@ -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 diff --git a/backend/app/schemas/script_template.py b/backend/app/schemas/script_template.py index de6ec106..fbaaa45d 100644 --- a/backend/app/schemas/script_template.py +++ b/backend/app/schemas/script_template.py @@ -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 diff --git a/backend/app/services/flowpilot_engine.py b/backend/app/services/flowpilot_engine.py index e97c36b9..6e553957 100644 --- a/backend/app/services/flowpilot_engine.py +++ b/backend/app/services/flowpilot_engine.py @@ -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, diff --git a/backend/app/services/script_builder_service.py b/backend/app/services/script_builder_service.py new file mode 100644 index 00000000..3fdeb3ea --- /dev/null +++ b/backend/app/services/script_builder_service.py @@ -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 diff --git a/backend/tests/test_script_builder.py b/backend/tests/test_script_builder.py new file mode 100644 index 00000000..3f12cfda --- /dev/null +++ b/backend/tests/test_script_builder.py @@ -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 diff --git a/docs/superpowers/plans/2026-03-21-flowpilot-message-bar-and-script-builder.md b/docs/superpowers/plans/2026-03-21-flowpilot-message-bar-and-script-builder.md new file mode 100644 index 00000000..891f240d --- /dev/null +++ b/docs/superpowers/plans/2026-03-21-flowpilot-message-bar-and-script-builder.md @@ -0,0 +1,1990 @@ +# FlowPilot Message Bar & AI Script Builder — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an always-visible message bar to FlowPilot sessions, build a standalone AI Script Builder with chat-style UX and fullscreen preview modal, reorganize the Script Library with My/Team tabs, and connect FlowPilot to the Script Builder. + +**Architecture:** Five phases executed sequentially. Phase 1 is frontend-only (message bar). Phases 2-3 add the Script Builder (new model, service, endpoints, chat page, preview modal, save flow). Phase 4 reorganizes the existing Script Library. Phase 5 wires FlowPilot to the Script Builder. + +**Tech Stack:** FastAPI, SQLAlchemy 2.0 (async), Alembic, Anthropic Claude API (Sonnet), React 19, TypeScript, Zustand, Tailwind CSS, react-syntax-highlighter, Lucide React + +**Spec:** `docs/superpowers/specs/2026-03-21-flowpilot-message-bar-and-script-builder-design.md` + +--- + +## File Map + +### Phase 1 — Message Bar + +| Action | File | +| ------ | ---- | +| Create | `frontend/src/components/flowpilot/FlowPilotMessageBar.tsx` | +| Modify | `frontend/src/components/flowpilot/FlowPilotSession.tsx` | +| Modify | `frontend/src/components/flowpilot/FlowPilotStepCard.tsx` | + +### Phase 2 — Script Builder Core (Backend) + +| Action | File | +| ------ | ---- | +| Create | `backend/app/models/script_builder_session.py` | +| Modify | `backend/alembic/env.py` (import new model) | +| Create | `backend/alembic/versions/_add_script_builder_sessions.py` (migration) | +| Create | `backend/app/schemas/script_builder.py` | +| Create | `backend/app/services/script_builder_service.py` | +| Create | `backend/app/api/endpoints/script_builder.py` | +| Modify | `backend/app/api/router.py` (register new router) | +| Modify | `backend/app/core/config.py` (add `script_build` action to `ACTION_MODEL_MAP`) | +| Create | `backend/tests/test_script_builder.py` | + +### Phase 2 — Script Builder Core (Frontend) + +| Action | File | +| ------ | ---- | +| Create | `frontend/src/types/script-builder.ts` | +| Modify | `frontend/src/types/index.ts` (export new types) | +| Create | `frontend/src/api/scriptBuilder.ts` | +| Modify | `frontend/src/api/index.ts` (export new API module) | +| Create | `frontend/src/pages/ScriptBuilderPage.tsx` | +| Create | `frontend/src/components/script-builder/ScriptBuilderChat.tsx` | +| Create | `frontend/src/components/script-builder/ScriptBuilderInput.tsx` | +| Create | `frontend/src/components/script-builder/ScriptCodeBlock.tsx` | +| Modify | `frontend/src/router.tsx` (add route) | +| Modify | `frontend/src/components/layout/Sidebar.tsx` (add nav item) | +| Modify | `frontend/src/components/layout/AppLayout.tsx` (add mobile nav item) | + +### Phase 3 — Preview Modal & Save Flow + +| Action | File | +| ------ | ---- | +| Create | `frontend/src/components/script-builder/ScriptPreviewModal.tsx` | +| Create | `frontend/src/components/script-builder/SaveToLibraryDialog.tsx` | +| Modify | `frontend/src/components/script-builder/ScriptCodeBlock.tsx` (wire modal + save) | +| Modify | `frontend/src/api/scriptBuilder.ts` (add save method) | + +### Phase 4 — Library Reorganization + +| Action | File | +| ------ | ---- | +| Create | `backend/alembic/versions/_add_language_and_ai_generated_category.py` (migration) | +| Modify | `backend/app/models/script_template.py` (add `language` column) | +| Modify | `backend/app/schemas/script_template.py` (add `language` field, `SaveToLibraryRequest`) | +| Modify | `backend/app/api/endpoints/scripts.py` (add `mine` filter) | +| Modify | `frontend/src/pages/ScriptLibraryPage.tsx` (tabs + Build button) | +| Modify | `frontend/src/types/scripts.ts` (add `language` field) | + +### Phase 5 — FlowPilot Integration + +| Action | File | +| ------ | ---- | +| Modify | `backend/app/services/flowpilot_engine.py` (prompt update for script detection) | +| Modify | `frontend/src/components/flowpilot/FlowPilotStepCard.tsx` (Script Builder action button) | + +--- + +## Phase 1: Always-Visible Message Bar + +### Task 1.1: Create FlowPilotMessageBar Component + +**Files:** + +- Create: `frontend/src/components/flowpilot/FlowPilotMessageBar.tsx` + +- [ ] **Step 1: Create the message bar component** + +```tsx +// frontend/src/components/flowpilot/FlowPilotMessageBar.tsx +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(null) + + const isDisabled = disabled || isProcessing + + const handleSubmit = useCallback(() => { + const trimmed = message.trim() + if (!trimmed || isDisabled) return + onRespond({ free_text_input: trimmed }) + setMessage('') + // Reset textarea height + 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) => { + setMessage(e.target.value) + // Auto-resize textarea + const textarea = e.target + textarea.style.height = 'auto' + textarea.style.height = `${Math.min(textarea.scrollHeight, 120)}px` + }, []) + + return ( +
+
+