diff --git a/CLAUDE.md b/CLAUDE.md index 21a34431..25af73c8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,7 @@ - **Design system:** [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) — THE source of truth for all design decisions - **Design aesthetic:** Flat, high-contrast dark theme (Sentry/PostHog-inspired). No glass morphism, no gradients on surfaces, no ambient effects. Light mode planned. -- **Accent color:** Ember orange (#f97316 / #ea580c). Used sparingly — ≤5% of the UI. Warning is yellow (#eab308), not amber, to stay distinct from accent. +- **Accent color:** Electric blue (#60a5fa dark / #2563eb light). Used sparingly — ≤5% of the UI. Warning is amber (#fbbf24), info is cyan (#67e8f9). - **Fonts:** IBM Plex Sans (`font-sans`, body), Bricolage Grotesque (`font-heading`, headings), JetBrains Mono (`font-mono`, code) — loaded via Google Fonts - **Logo:** 30px gradient square (ember orange) + "ResolutionFlow" in Bricolage Grotesque 700 - **Layout:** Icon rail sidebar (72px default) with hover flyout panels. Pinnable to full 260px sidebar. See [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) @@ -211,6 +211,10 @@ cd frontend && npm run build # Database migrations cd backend && alembic upgrade head alembic revision --autogenerate -m "Description" --rev-id=NNN # NNN = next sequential number +# IMPORTANT: Migrations use sequential 3-digit IDs (001, 002, ..., 068, 069). +# Check the latest: ls backend/alembic/versions/ | grep -E '^\d{3}_' | sort | tail -1 +# The revision ID and filename prefix MUST match (e.g., revision="068", file=068_description.py). +# down_revision MUST point to the previous sequential number. Never use hex hash IDs for new migrations. # Access PostgreSQL docker exec -it patherly_postgres psql -U postgres -d patherly @@ -338,7 +342,7 @@ gh run view --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi **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`. -**88. Charcoal palette — sidebar-darkest approach:** Sidebar `#10121a`, page `#1a1c23`, cards `#22252e`, borders `#2e3240`. This gives more contrast range than true-dark (`#0c0d10`). All colors via CSS variables in `index.css` `@theme` block. Accent is ember orange (#f97316), not cyan. +**88. Charcoal palette — sidebar-darkest approach:** Sidebar `#0e1016`, page `#16181f`, cards `#1e2028`, borders `#2a2e3a`. This gives more contrast range than true-dark. All colors via CSS variables in `index.css` `@theme` block. Accent is electric blue (#60a5fa), not orange or cyan. **92. `tsc -b` in Dockerfile is stricter than `npx tsc --noEmit`:** The production build (`tsc -b && vite build`) enforces `noUnusedLocals` and `noUnusedParameters` as hard errors. After any refactor that moves logic between components or removes features, trace every import and destructured prop to remove orphans. IDE warnings (yellow squiggles) flag these — check them before pushing. @@ -415,7 +419,7 @@ gh run view --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi - **New endpoint:** Create in `endpoints/` → add to `router.py` → schema in `schemas/` → tests → frontend API client - **New page:** Create in `pages/` → add route in `router.tsx` → nav link in `AppLayout.tsx` - **New public route (no auth):** Add at top level in `router.tsx` alongside `/login`, `/register` — NOT inside the `ProtectedRoute`/`AppLayout` children. -- **Schema change:** Update model → `alembic revision --autogenerate -m "desc"` → review → `alembic upgrade head` +- **Schema change:** Update model → `alembic revision --autogenerate -m "desc" --rev-id=NNN` (NNN = next sequential number, e.g., 068 → 069) → review → `alembic upgrade head` - **New frontend API module:** Types in `types/` → export from `types/index.ts` → client in `api/` → export from `api/index.ts` --- diff --git a/DESIGN-SYSTEM.md b/DESIGN-SYSTEM.md index e7808b01..04d0fe58 100644 --- a/DESIGN-SYSTEM.md +++ b/DESIGN-SYSTEM.md @@ -1,16 +1,16 @@ -# ResolutionFlow Design System v5 +# ResolutionFlow Design System v6 > **Status:** ACTIVE — This document is the single source of truth for all frontend design decisions. -> **Supersedes:** All previous design system docs including `DESIGN_SYSTEM_GUIDE.md`, `UI-DESIGN-SYSTEM.md`, `REBRAND-IMPLEMENTATION-GUIDE.md`, and any `COMPONENT_EXAMPLES.md` files. Also supersedes the v4 cyan accent system. -> **Last Updated:** March 24, 2026 +> **Supersedes:** All previous design system docs including v5 (ember orange accent), `DESIGN_SYSTEM_GUIDE.md`, `UI-DESIGN-SYSTEM.md`, `REBRAND-IMPLEMENTATION-GUIDE.md`, and any `COMPONENT_EXAMPLES.md` files. +> **Last Updated:** March 29, 2026 --- ## Design Philosophy -ResolutionFlow uses a **flat, high-contrast dark theme** inspired by Sentry and PostHog. The aesthetic is premium, clean, and minimal — no glass morphism, no backdrop blur, no ambient orbs, no gradient backgrounds on surfaces. The accent color (ember orange) appears in ≤5% of the UI. Warm accent on cold charcoal surfaces creates high contrast and distinctive personality — orange conveys urgency and action, fitting for a troubleshooting tool. Every design decision prioritizes **readability over decoration**. +ResolutionFlow uses a **flat, high-contrast dark theme** inspired by Sentry and PostHog. The aesthetic is premium, clean, and minimal — no glass morphism, no backdrop blur, no ambient orbs, no gradient backgrounds on surfaces. The accent color (electric blue) appears in ≤5% of the UI. Cool blue accent on deep charcoal surfaces creates high contrast and technical confidence — blue conveys trust, precision, and reliability, fitting for a troubleshooting tool that MSP engineers depend on during outages. Every design decision prioritizes **readability over decoration**. -**Light mode** is a planned addition (dark/light toggle). Design all components with CSS variables so theming is a variable swap, not a rewrite. +**Dual-mode design:** Dark mode is the default. Light mode is fully specified — all colors use CSS custom properties so theming is a variable swap, not a rewrite. Semantic colors shift to darker variants (Tailwind 400→600) in light mode to maintain WCAG AA contrast. --- @@ -18,16 +18,17 @@ ResolutionFlow uses a **flat, high-contrast dark theme** inspired by Sentry and All colors are defined as CSS custom properties in `index.css` inside the `@theme` block (Tailwind v4) or `:root` / `.dark` blocks (Tailwind v3). -### Dark Mode (Default) — Charcoal Palette +### Dark Mode (Default) — Deep Charcoal Palette ``` -Page background: #1a1c23 -Sidebar background: #10121a -Card background: #22252e -Card hover: #282b35 -Input background: #282b35 -Code background: #14161e -Elevated surface: #2e3140 +Page background: #16181f +Sidebar background: #0e1016 +Card background: #1e2028 +Card hover: #252830 +Input background: #252830 +Code background: #12141a +Elevated surface: #2a2d38 +Surface raised: #303442 Text primary: #e2e5eb Text heading: #f0f2f5 @@ -35,25 +36,26 @@ Text secondary: #848b9b Text muted: #4f5666 Text rail label: #e2e5eb -Border default: #2e3240 +Border default: #2a2e3a Border hover: #3d4252 -Accent (ember): #f97316 -Accent hover: #ea580c -Accent dim (10%): rgba(249,115,22,0.10) -Accent text: #fdba74 +Accent (blue): #60a5fa +Accent hover: #3b82f6 +Accent dim (10%): rgba(96,165,250,0.10) +Accent text: #93c5fd +Accent glow (15%): rgba(96,165,250,0.15) Success: #34d399 Success dim: rgba(52,211,153,0.10) -Warning: #eab308 -Warning dim: rgba(234,179,8,0.10) +Warning (amber): #fbbf24 +Warning dim: rgba(251,191,36,0.10) Danger: #f87171 Danger dim: rgba(248,113,113,0.10) +Info (cyan): #67e8f9 +Info dim: rgba(103,232,249,0.10) ``` -> **Note:** Warning shifted from amber (#fbbf24) to yellow (#eab308) to maintain clear separation from the orange accent. - -### Light Mode (Planned) +### Light Mode ``` Page background: #f3f4f7 @@ -63,6 +65,7 @@ Card hover: #f8f9fb Input background: #eef0f4 Code background: #f5f6f9 Elevated surface: #e8eaef +Surface raised: #dde0e7 Text primary: #1a1d24 Text heading: #0d0f13 @@ -72,9 +75,20 @@ Text muted: #8b92a1 Border default: #dde0e7 Border hover: #c5c9d3 -Accent: #ea580c -Accent dim: rgba(234,88,12,0.07) -Accent text: #c2410c +Accent: #2563eb +Accent hover: #1d4ed8 +Accent dim: rgba(37,99,235,0.07) +Accent text: #1d4ed8 +Accent glow: rgba(37,99,235,0.10) + +Success: #059669 +Success dim: rgba(5,150,105,0.07) +Warning: #d97706 +Warning dim: rgba(217,119,6,0.07) +Danger: #dc2626 +Danger dim: rgba(220,38,38,0.07) +Info: #0891b2 +Info dim: rgba(8,145,178,0.07) ``` ### What NOT To Use @@ -83,8 +97,7 @@ Accent text: #c2410c - No gradient backgrounds on cards or surfaces - No ambient orbs or floating glow elements - No `bg-white/[0.04]` opacity-based backgrounds -- No purple gradient accent (`#818cf8 → #a78bfa`) — this is deprecated -- No cyan accent (`#22d3ee` / `#06b6d4` / `#67e8f9`) — replaced by ember orange in v5 +- No ember orange accent (`#f97316` / `#ea580c` / `#fdba74`) — replaced by blue in v6 - No `text-gradient-brand` utility — replaced by solid `accent-text` color - No `glass-card`, `glass-stat`, `glass-card-glow` CSS utilities @@ -173,7 +186,7 @@ The default navigation is a narrow icon rail (72px) with: ``` Background: bg-card Border: 1px solid border-default -Border-left: 3px solid [varies by position - accent, success, warning, accent] +Border-left: 3px solid [varies by position - accent, success, warning, info] Border-radius: 8px Padding: 18px 16px ``` @@ -218,10 +231,11 @@ Border-radius: 20px (pill) | Type | Background | Text Color | |------|-----------|------------| -| Info/Accent | accent-dim | accent-text | +| Accent | accent-dim | accent-text | | Success | success-dim | success | | Warning | warning-dim | warning | | Danger | danger-dim | danger | +| Info | info-dim | info | ### Form Inputs @@ -239,14 +253,14 @@ Placeholder: text-muted **Primary:** ``` -Background: accent (#f97316) +Background: accent (#60a5fa dark / #2563eb light) Color: #fff Border: none Border-radius: 5px Padding: 9px 16px Font: 13px, 550 weight -Hover: accent-hover (#ea580c), box-shadow 0 2px 12px rgba(249,115,22,0.25), translateY(-1px) -Active: translateY(0), box-shadow 0 0 4px rgba(249,115,22,0.15) +Hover: accent-hover (#3b82f6 dark / #1d4ed8 light), box-shadow 0 2px 12px accent-glow, translateY(-1px) +Active: translateY(0), box-shadow 0 0 4px accent-glow ``` **Ghost:** @@ -260,7 +274,7 @@ Hover: bg-elevated, text-primary, border-hover ### Code Blocks ``` -Background: bg-code (#0e1017) +Background: bg-code (#12141a) Border: 1px solid border-default Border-radius: 8px Padding: 18px 20px @@ -275,7 +289,7 @@ Font: JetBrains Mono, 12px, line-height 1.7 | Keyword | #c792ea | | Function/Cmdlet | #82aaff | | String | #c3e88d | -| Variable | #89ddff | +| Variable | #93c5fd (accent-text) | | Parameter | #8c93a4 | | Number | #f78c6c | @@ -293,7 +307,7 @@ Color: accent-text ## Logo -- **Mark:** 30-32px square, border-radius 8px, `linear-gradient(135deg, #ea580c, #f97316)`, white lightning bolt SVG +- **Mark:** 30-32px square, border-radius 8px, `linear-gradient(135deg, #3b82f6, #60a5fa)`, white lightning bolt SVG - **Wordmark:** "ResolutionFlow" in Bricolage Grotesque, 16-17px, weight 700, text-heading color - **Combined:** Mark + wordmark horizontally, 10px gap @@ -329,21 +343,21 @@ Shadows communicate **interaction state**, not decoration. On dark backgrounds, **Resting state:** No shadows. Elements are flat with 1px borders. -**Elevation on dark backgrounds (the principle):** Instead of shadow = darker, elevation = lighter. A "raised" element gets a brighter surface color (`bg-elevated` / `#2e3140`) and optionally a very faint orange glow. This creates perceived depth through contrast. +**Elevation on dark backgrounds (the principle):** Instead of shadow = darker, elevation = lighter. A "raised" element gets a brighter surface color (`bg-elevated` / `#2a2d38` or `bg-raised` / `#303442`) and optionally a very faint blue glow. This creates perceived depth through contrast. **Hover state (buttons):** Lift effect with accent glow. -- Primary button hover: `0 2px 12px rgba(249,115,22,0.25)` + `translateY(-1px)` — orange glow +- Primary button hover: `0 2px 12px rgba(96,165,250,0.25)` + `translateY(-1px)` — blue glow - Ghost button hover: brighter border (`border-hover`) + `translateY(-1px)`, no shadow - Active/click: glow fades, element "presses down" to `translateY(0)` **Active/selected state (tabs, toggles):** Elevated surface + faint accent glow. -- Active tab: `bg-elevated` + `box-shadow: 0 1px 4px rgba(249,115,22,0.08)` — class: `tab-active-shadow` +- Active tab: `bg-elevated` + `box-shadow: 0 1px 4px rgba(96,165,250,0.08)` — class: `tab-active-shadow` **Card hover lift (optional):** For clickable cards. -- Hover: brighter border + `0 2px 8px rgba(249,115,22,0.06)` + `translateY(-2px)` — class: `card-lift` +- Hover: brighter border + `0 2px 8px rgba(96,165,250,0.06)` + `translateY(-2px)` — class: `card-lift` **Overlays:** Flyouts, dropdowns, modals get stronger shadows (they overlay lighter content). @@ -356,7 +370,7 @@ Shadows communicate **interaction state**, not decoration. On dark backgrounds, - No `rgba(0,0,0,...)` shadows on resting elements (invisible on dark bg) - No permanent decorative shadows - No heavy glow effects — accent glow should be barely perceptible (≤ 0.1 opacity) -- No cyan glow effects — all accent glows use orange rgba(249,115,22,...) +- No orange glow effects — all accent glows use blue rgba(96,165,250,...) --- @@ -407,7 +421,8 @@ These files contain outdated design information and should be ignored: - `REBRAND-IMPLEMENTATION-GUIDE.md` — Old purple rebrand from Patherly - `COMPONENT_EXAMPLES.md` — Old monochrome component patterns - Any file referencing `glass-card`, `glass-stat`, `bg-gradient-brand`, or `text-gradient-brand` -- Any code using cyan accent values (`#22d3ee`, `#06b6d4`, `#67e8f9`, `rgba(34,211,238,...)`) — migrate to ember orange +- Any code using ember orange accent values (`#f97316`, `#ea580c`, `#fdba74`, `rgba(249,115,22,...)`) — migrate to blue accent +- Any code using old cyan accent values (`#22d3ee`, `#06b6d4`) for accent purposes — cyan is now the info color only --- @@ -415,7 +430,11 @@ These files contain outdated design information and should be ignored: | Date | Decision | Rationale | |------|----------|-----------| -| 2026-03-24 | Accent color changed from cyan (#22d3ee) to ember orange (#f97316) | Cyan caused contrast issues, felt generic "tech SaaS". Orange provides warmth against cold charcoal, conveys urgency fitting for troubleshooting, and is distinctive in the MSP tool space. | -| 2026-03-24 | Warning color shifted from amber (#fbbf24) to yellow (#eab308) | Orange accent would clash with amber warning. Yellow provides clear semantic separation from the orange accent. | -| 2026-03-24 | Light mode accent set to #ea580c (orange-600) | Darker orange variant ensures proper contrast on white/light surfaces. | -| 2026-03-24 | Synced DESIGN-SYSTEM.md to actual charcoal palette | Doc was behind — still showed pre-charcoal values (#0c0d10 page, #14161d card). Updated to match index.css (#1a1c23 page, #22252e card). | +| 2026-03-24 | Accent color changed from cyan (#22d3ee) to ember orange (#f97316) | Cyan caused contrast issues, felt generic "tech SaaS". Orange provides warmth against cold charcoal. | +| 2026-03-24 | Warning color shifted from amber (#fbbf24) to yellow (#eab308) | Orange accent would clash with amber warning. | +| 2026-03-24 | Synced DESIGN-SYSTEM.md to actual charcoal palette | Doc was behind — updated to match index.css. | +| 2026-03-29 | Accent changed from ember orange (#f97316) to electric blue (#60a5fa/#2563eb) | Orange did not test well with users — read as "budget/cautionary." Blue conveys trust and technical confidence. Electric blue is brighter than the corporate blues used by NinjaOne/ConnectWise/Datto, maintaining differentiation. Works on both dark and light backgrounds. | +| 2026-03-29 | Info color set to cyan (#67e8f9/#0891b2) | Blue accent freed the info slot. Cyan is visually distinct from accent blue (cooler/lighter) and intuitive for informational contexts. | +| 2026-03-29 | Warning reclaimed as amber (#fbbf24/#d97706) | Yellow (#eab308) was only needed to avoid clashing with orange accent. With blue accent, amber is the natural warning color again. | +| 2026-03-29 | Surfaces deepened for better layer separation | Page: #1a1c23→#16181f, Card: #22252e→#1e2028, Sidebar: #10121a→#0e1016. Wider range gives 4+ clear visual depth layers instead of 2. Added surface-raised (#303442) for hover/active states. | +| 2026-03-29 | Full light mode semantic colors specified | All semantic colors get light-mode variants at Tailwind 600 level (dark uses 400). Ensures WCAG AA contrast on white/light surfaces. | diff --git a/backend/alembic/versions/068_add_pending_task_lane.py b/backend/alembic/versions/068_add_pending_task_lane.py new file mode 100644 index 00000000..a8da1977 --- /dev/null +++ b/backend/alembic/versions/068_add_pending_task_lane.py @@ -0,0 +1,30 @@ +"""add pending_task_lane to ai_sessions + +Revision ID: 068 +Revises: 067 +Create Date: 2026-03-27 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB + +revision = "068" +down_revision = "067" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "ai_sessions", + sa.Column( + "pending_task_lane", + JSONB, + nullable=True, + comment="Current task lane state: {questions: [...], actions: [...]}", + ), + ) + + +def downgrade() -> None: + op.drop_column("ai_sessions", "pending_task_lane") diff --git a/backend/alembic/versions/069_add_status_update_step_type.py b/backend/alembic/versions/069_add_status_update_step_type.py new file mode 100644 index 00000000..382cef9e --- /dev/null +++ b/backend/alembic/versions/069_add_status_update_step_type.py @@ -0,0 +1,31 @@ +"""add status_update to ai_session_steps step_type constraint + +Revision ID: 069 +Revises: 068 +Create Date: 2026-03-29 +""" +from alembic import op + + +revision = "069" +down_revision = "068" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.drop_constraint("ck_ai_session_steps_step_type", "ai_session_steps", type_="check") + op.create_check_constraint( + "ck_ai_session_steps_step_type", "ai_session_steps", + "step_type IN ('question', 'action', 'script_generation', 'verification', " + "'info_request', 'note', 'intake_analysis', 'fork', 'status_update')", + ) + + +def downgrade() -> None: + op.drop_constraint("ck_ai_session_steps_step_type", "ai_session_steps", type_="check") + op.create_check_constraint( + "ck_ai_session_steps_step_type", "ai_session_steps", + "step_type IN ('question', 'action', 'script_generation', 'verification', " + "'info_request', 'note', 'intake_analysis', 'fork')", + ) diff --git a/backend/app/api/endpoints/ai_sessions.py b/backend/app/api/endpoints/ai_sessions.py index 722f54b7..38ca0286 100644 --- a/backend/app/api/endpoints/ai_sessions.py +++ b/backend/app/api/endpoints/ai_sessions.py @@ -48,6 +48,7 @@ from app.schemas.ai_session import ( ChatSessionCreateResponse, ChatMessageRequest, ChatMessageResponse, + SaveTaskLaneRequest, ) from app.services import flowpilot_engine from app.services import unified_chat_service @@ -116,6 +117,9 @@ def _build_session_detail(session: AISession) -> AISessionDetail: resolved_at=session.resolved_at, steps=step_responses, conversation_messages=session.conversation_messages or [], + pending_task_lane=session.pending_task_lane, + is_branching=getattr(session, 'is_branching', False), + active_branch_id=str(session.active_branch_id) if getattr(session, 'active_branch_id', None) else None, ) @@ -416,7 +420,7 @@ async def resolve_session( db: Annotated[AsyncSession, Depends(get_db)], _: None = Depends(require_engineer_or_admin), ): - """Resolve a FlowPilot session and generate documentation.""" + """Resolve a session. Returns immediately; use /documentation/stream for ticket notes.""" try: result = await flowpilot_engine.resolve_session( session_id=session_id, @@ -429,16 +433,21 @@ async def resolve_session( except PermissionError as e: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) - # Generate resolution outputs (branching feature) - try: - from app.services.resolution_output_generator import ResolutionOutputGenerator - gen = ResolutionOutputGenerator(db) - await gen.generate_all(session_id) - except Exception: - logger.exception(f"Failed to generate resolution outputs for session {session_id}") - # Non-blocking — resolve still succeeds even if output generation fails - await db.commit() + + # Fire-and-forget: resolution outputs (don't block the response) + import asyncio + + async def _post_resolve_tasks(): + try: + from app.services.resolution_output_generator import ResolutionOutputGenerator + gen = ResolutionOutputGenerator(db) + await gen.generate_all(session_id) + except Exception: + logger.exception(f"Failed to generate resolution outputs for session {session_id}") + + asyncio.create_task(_post_resolve_tasks()) + return result @@ -497,6 +506,40 @@ async def pause_session( await db.commit() +# ── Save Task Lane ── + +@router.put("/{session_id}/task-lane", status_code=204) +@limiter.limit("30/minute") +async def save_task_lane( + request: Request, + session_id: UUID, + body: SaveTaskLaneRequest, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], + _: None = Depends(require_engineer_or_admin), +): + """Save the current task lane state including user's in-progress responses.""" + session = await db.get(AISession, session_id) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + if session.user_id != current_user.id: + raise HTTPException(status_code=403, detail="Not your session") + + payload = { + "questions": [q.model_dump() for q in body.questions], + "actions": [a.model_dump() for a in body.actions], + "responses": body.responses, + } + + # Guard against oversized payloads (max 256KB serialized) + import json + if len(json.dumps(payload)) > 256 * 1024: + raise HTTPException(status_code=413, detail="Task lane payload too large") + + session.pending_task_lane = payload + await db.commit() + + # ── Resume ── @router.post("/{session_id}/resume", status_code=204) @@ -886,6 +929,52 @@ async def get_documentation( raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e)) +@router.get("/{session_id}/documentation/stream") +@limiter.limit("20/minute") +async def stream_documentation( + request: Request, + session_id: UUID, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], +): + """Stream AI-generated ticket notes as Server-Sent Events.""" + from starlette.responses import StreamingResponse + + # Verify session ownership + result = await db.execute( + select(AISession).where(AISession.id == session_id) + ) + session = result.scalar_one_or_none() + if not session: + raise HTTPException(status_code=404, detail="Session not found") + if session.user_id != current_user.id: + raise HTTPException(status_code=403, detail="Not authorized") + + async def event_generator(): + try: + async for chunk in flowpilot_engine.stream_ticket_notes( + session_id=session_id, + user_id=current_user.id, + db=db, + ): + # SSE format: data: \n\n + yield f"data: {chunk}\n\n" + yield "data: [DONE]\n\n" + except Exception as e: + logger.exception("SSE stream error for session %s: %s", session_id, e) + yield f"data: [ERROR] {str(e)}\n\n" + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", # Disable nginx buffering + }, + ) + + # ── Status Update ── @router.post("/{session_id}/status-update", response_model=StatusUpdateResponse) diff --git a/backend/app/api/endpoints/script_builder.py b/backend/app/api/endpoints/script_builder.py index 1ac5851c..b328477c 100644 --- a/backend/app/api/endpoints/script_builder.py +++ b/backend/app/api/endpoints/script_builder.py @@ -179,6 +179,8 @@ async def save_to_library( share_with_team=data.share_with_team, user_id=current_user.id, team_id=current_user.team_id, + script_body=data.script_body, + parameters_schema=data.parameters_schema, ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) diff --git a/backend/app/core/ai_provider.py b/backend/app/core/ai_provider.py index 4e38cceb..84d4c33b 100644 --- a/backend/app/core/ai_provider.py +++ b/backend/app/core/ai_provider.py @@ -7,6 +7,7 @@ backends for JSON generation used by the AI Flow Builder. import logging from abc import ABC, abstractmethod +from collections.abc import AsyncIterator from app.core.config import settings @@ -54,6 +55,26 @@ class AIProvider(ABC): """ ... + async def generate_text_stream( + self, + system_prompt: str, + messages: list[dict[str, str]], + max_tokens: int = 4096, + ) -> "AsyncIterator[str]": + """Stream a text response token by token. + + Args: + system_prompt: System-level instruction for the model. + messages: List of message dicts with "role" and "content" keys. + max_tokens: Maximum output tokens. + + Yields: + Text chunks as they are generated. + """ + raise NotImplementedError("Streaming not supported for this provider") + # Make this an async generator to satisfy type checker + yield "" # pragma: no cover + class GeminiProvider(AIProvider): """Google Gemini provider using the google-genai SDK.""" @@ -165,6 +186,24 @@ class GeminiProvider(AIProvider): return text, input_tokens, output_tokens +# Singleton client cache — avoids creating new HTTP connections per call +_anthropic_clients: dict[str, "anthropic.AsyncAnthropic"] = {} + + +def _get_anthropic_client(api_key: str, timeout: int = 45) -> "anthropic.AsyncAnthropic": + """Return a cached AsyncAnthropic client, creating one if needed.""" + import anthropic + + cache_key = f"{api_key[:8]}:{timeout}" + if cache_key not in _anthropic_clients: + _anthropic_clients[cache_key] = anthropic.AsyncAnthropic( + api_key=api_key, + timeout=timeout, + max_retries=1, + ) + return _anthropic_clients[cache_key] + + class AnthropicProvider(AIProvider): """Anthropic Claude provider using the anthropic SDK.""" @@ -179,13 +218,7 @@ class AnthropicProvider(AIProvider): messages: list[dict[str, str]], max_tokens: int = 4096, ) -> tuple[str, int, int]: - import anthropic - - client = anthropic.AsyncAnthropic( - api_key=self._api_key, - timeout=self._timeout, - max_retries=1, - ) + client = _get_anthropic_client(self._api_key, self._timeout) response = await client.messages.create( model=self._model, @@ -209,6 +242,23 @@ class AnthropicProvider(AIProvider): # Anthropic doesn't differentiate between JSON and text mode return await self.generate_json(system_prompt, messages, max_tokens) + async def generate_text_stream( + self, + system_prompt: str, + messages: list[dict[str, str]], + max_tokens: int = 4096, + ) -> AsyncIterator[str]: + client = _get_anthropic_client(self._api_key, self._timeout) + + async with client.messages.stream( + model=self._model, + max_tokens=max_tokens, + system=system_prompt, + messages=messages, + ) as stream: + async for text in stream.text_stream: + yield text + def get_ai_provider(model: str | None = None) -> AIProvider: """Factory that returns the configured AI provider. diff --git a/backend/app/models/ai_session.py b/backend/app/models/ai_session.py index 0c10d9db..8bf1684a 100644 --- a/backend/app/models/ai_session.py +++ b/backend/app/models/ai_session.py @@ -209,6 +209,10 @@ class AISession(Base): JSONB, nullable=False, default=list, comment="Full LLM message history for context continuity", ) + pending_task_lane: Mapped[Optional[dict[str, Any]]] = mapped_column( + JSONB, nullable=True, + comment="Current task lane state: {questions: [...], actions: [...]}", + ) # ── Branching ── is_branching: Mapped[bool] = mapped_column( diff --git a/backend/app/models/ai_session_step.py b/backend/app/models/ai_session_step.py index ac08da72..1642632b 100644 --- a/backend/app/models/ai_session_step.py +++ b/backend/app/models/ai_session_step.py @@ -36,7 +36,7 @@ class AISessionStep(Base): __table_args__ = ( CheckConstraint( "step_type IN ('question', 'action', 'script_generation', 'verification', " - "'info_request', 'note', 'intake_analysis', 'fork')", + "'info_request', 'note', 'intake_analysis', 'fork', 'status_update')", name="ck_ai_session_steps_step_type", ), ) diff --git a/backend/app/schemas/ai_session.py b/backend/app/schemas/ai_session.py index 4037f38a..b66afbbf 100644 --- a/backend/app/schemas/ai_session.py +++ b/backend/app/schemas/ai_session.py @@ -136,7 +136,7 @@ class SessionCloseResponse(BaseModel): """Response after resolving or escalating.""" session_id: UUID status: str - documentation: SessionDocumentation + documentation: SessionDocumentation | None = None psa_push_status: str = "no_psa" # sent | pending_retry | no_psa | failed psa_push_error: str | None = None member_mapping_warning: str | None = None @@ -228,6 +228,7 @@ class AISessionDetail(AISessionSummary): ticket_data: dict[str, Any] | None = None steps: list[AISessionStepResponse] = [] conversation_messages: list[dict[str, Any]] = [] # Chat sessions store messages here + pending_task_lane: dict[str, Any] | None = None is_branching: bool = False active_branch_id: str | None = None @@ -286,6 +287,17 @@ class ChatMessageResponse(BaseModel): questions: list[QuestionItem] | None = None +class SaveTaskLaneRequest(BaseModel): + """Save the full task lane state (AI items + user responses).""" + questions: list[QuestionItem] = Field(default_factory=list, max_length=50) + actions: list[ActionItem] = Field(default_factory=list, max_length=50) + responses: list[dict[str, Any]] = Field( + default_factory=list, + max_length=100, + description="User's in-progress task responses with state/value", + ) + + class AISessionSearchResult(BaseModel): """Lightweight session result for Command Palette / autocomplete.""" id: UUID diff --git a/backend/app/schemas/script_builder.py b/backend/app/schemas/script_builder.py index 893d16be..1a7add15 100644 --- a/backend/app/schemas/script_builder.py +++ b/backend/app/schemas/script_builder.py @@ -82,3 +82,5 @@ class SaveToLibraryRequest(BaseModel): description: str | None = None category_id: UUID | None = None share_with_team: bool = False + script_body: str | None = None + parameters_schema: dict | None = None diff --git a/backend/app/services/flowpilot_engine.py b/backend/app/services/flowpilot_engine.py index 2f6f40ff..3a9a6f78 100644 --- a/backend/app/services/flowpilot_engine.py +++ b/backend/app/services/flowpilot_engine.py @@ -8,6 +8,7 @@ import json import logging import uuid from datetime import datetime, timezone +from collections.abc import AsyncIterator from typing import Any, Optional from uuid import UUID @@ -991,8 +992,7 @@ async def generate_status_update( "generated_content": raw_response.strip(), "client_name": client_name, }, - confidence_score=1.0, - confidence_tier="high", + confidence_at_step=1.0, ) db.add(step) session.step_count += 1 @@ -1011,6 +1011,104 @@ async def generate_status_update( ) +async def stream_ticket_notes( + session_id: UUID, + user_id: UUID, + db: AsyncSession, +) -> AsyncIterator[str]: + """Stream AI-generated structured ticket notes for a resolved session. + + Yields text chunks suitable for SSE streaming. + """ + session = await _load_session(session_id, user_id, db) + + # Build conversation summary from messages (chat sessions) + # or steps (guided sessions) + messages = session.conversation_messages or [] + if messages: + recent = messages[-20:] # Last 20 messages for richer context + convo_text = "\n".join( + f"{'Engineer' if m['role'] == 'user' else 'AI Assistant'}: {m['content'][:500]}" + for m in recent + if isinstance(m, dict) and "role" in m and "content" in m + ) + else: + # Fall back to steps for guided sessions + steps_summary = [] + for step in sorted(session.steps, key=lambda s: s.step_order): + content = step.content or {} + text = content.get("text", "") + response = step.free_text_input or step.selected_option or ("Skipped" if step.was_skipped else None) + entry = f"Step {step.step_order + 1}: {text}" + if response: + entry += f"\n Engineer response: {response}" + steps_summary.append(entry) + convo_text = "\n".join(steps_summary) if steps_summary else "No session data." + + # Calculate time spent + now = datetime.now(timezone.utc) + ref_time = session.resolved_at or now + delta = ref_time - session.created_at + total_minutes = int(delta.total_seconds() / 60) + time_display = f"{total_minutes} minutes" if total_minutes < 60 else f"{total_minutes // 60}h {total_minutes % 60}m" + + system_prompt = """You are generating internal ticket notes for an MSP engineer's PSA system. + +Generate EXACTLY these four markdown sections, in this order: + +## Problem Summary +Summarize what the engineer reported and the initial symptoms. 1-3 sentences. + +## Steps Taken +List the key diagnostic steps, commands run, checks performed, and findings. Use bullet points. + +## Resolution +What fixed the issue or what the final action was. Be specific and technical. + +## Next Steps +Any follow-up items, monitoring to watch, or preventive measures. Write "None" if not applicable. + +Rules: +- Be technical, concise, and factual +- Use markdown formatting (headers, bullet lists, bold for emphasis) +- Include specific technical details (commands, settings, error messages) where available +- Do NOT include greetings, sign-offs, or pleasantries +- Do NOT wrap output in code fences +- Output ONLY the four sections above, nothing else""" + + user_message_parts = [ + f"Session status: {session.status}", + f"Time spent: {time_display}", + f"Problem summary: {session.problem_summary or 'Not specified'}", + ] + if session.problem_domain: + user_message_parts.append(f"Problem domain: {session.problem_domain}") + if session.resolution_summary: + user_message_parts.append(f"Resolution notes: {session.resolution_summary}") + user_message_parts.append(f"\nSession conversation:\n{convo_text}") + + user_message = "\n".join(user_message_parts) + + provider = get_ai_provider(settings.get_model_for_action("quick_action")) + + # Use streaming if provider supports it (Anthropic), otherwise fall back + try: + async for chunk in provider.generate_text_stream( + system_prompt=system_prompt, + messages=[{"role": "user", "content": user_message}], + max_tokens=1500, + ): + yield chunk + except NotImplementedError: + # Fallback for non-streaming providers (Gemini) + text, _, _ = await provider.generate_text( + system_prompt=system_prompt, + messages=[{"role": "user", "content": user_message}], + max_tokens=1500, + ) + yield text + + def _build_status_update_prompt( audience: str, length: str, diff --git a/backend/app/services/script_builder_service.py b/backend/app/services/script_builder_service.py index 3fdeb3ea..b483f39e 100644 --- a/backend/app/services/script_builder_service.py +++ b/backend/app/services/script_builder_service.py @@ -323,6 +323,8 @@ async def save_to_library( share_with_team: bool, user_id: UUID, team_id: UUID | None, + script_body: str | None = None, + parameters_schema: dict | None = None, ) -> "ScriptTemplate": """Save the latest generated script to the Script Library as a ScriptTemplate.""" import uuid as uuid_mod @@ -361,8 +363,8 @@ async def save_to_library( name=name, slug=slug, description=description, - script_body=session.latest_script, - parameters_schema={"parameters": []}, + script_body=script_body or session.latest_script, + parameters_schema=parameters_schema or {"parameters": []}, default_values={}, validation_rules={}, tags=[session.language, "ai-generated"], diff --git a/backend/app/services/unified_chat_service.py b/backend/app/services/unified_chat_service.py index 494eb01f..44236e66 100644 --- a/backend/app/services/unified_chat_service.py +++ b/backend/app/services/unified_chat_service.py @@ -286,6 +286,15 @@ async def send_chat_message( except Exception: logger.exception("Failed to create fork within branch for session %s", session.id) + # Persist task lane state on session + if branch_questions_data or branch_actions_data: + session.pending_task_lane = { + "questions": branch_questions_data or [], + "actions": branch_actions_data or [], + } + else: + session.pending_task_lane = None + suggested_flows = extract_suggested_flows( await rag_search(query=message, account_id=account_id, db=db, limit=8) ) @@ -393,6 +402,15 @@ async def send_chat_message( logger.exception("Failed to create fork for session %s", session_id) # Fork failed but chat message still sent — don't break the response + # Persist task lane state on session + if questions_data or actions_data: + session.pending_task_lane = { + "questions": questions_data or [], + "actions": actions_data or [], + } + else: + session.pending_task_lane = None + suggested_flows = extract_suggested_flows(rag_results) return display_content, suggested_flows, session, fork_metadata, actions_data, questions_data diff --git a/docs/superpowers/plans/2026-03-28-tasklane-minimize-and-resolve-docs.md b/docs/superpowers/plans/2026-03-28-tasklane-minimize-and-resolve-docs.md new file mode 100644 index 00000000..42a7d66c --- /dev/null +++ b/docs/superpowers/plans/2026-03-28-tasklane-minimize-and-resolve-docs.md @@ -0,0 +1,1066 @@ +# Task Lane Minimize/Reopen + Resolve Documentation Streaming — 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:** Make the task lane collapsible (not destructive) with a reopen pill, and replace the blocking resolve flow with instant resolve + streamed ticket notes generation. + +**Architecture:** Feature 1 is a frontend-only change to TaskLane close behavior + a new pill button. Feature 2 splits the resolve endpoint into a fast status-only call and a new SSE streaming endpoint for AI-generated ticket notes, with prompt caching and singleton client reuse on the backend. + +**Tech Stack:** React, TypeScript, Lucide icons, FastAPI, Anthropic SDK (streaming + prompt caching), SSE via `StreamingResponse` + +--- + +### Task 1: Task Lane — Replace X with PanelRightClose Icon + +**Files:** +- Modify: `frontend/src/components/assistant/TaskLane.tsx:2,250-252` + +- [ ] **Step 1: Update the import to include PanelRightClose** + +Replace line 2 import: + +```tsx +import { + Copy, Check, SkipForward, Terminal, ChevronDown, ChevronUp, + Send, Clipboard, Loader2, PanelRightClose, MessageCircleQuestion, Eye, +} from 'lucide-react' +``` + +(Replace `X` with `PanelRightClose` in the import list.) + +- [ ] **Step 2: Replace the X icon in the close button** + +Replace lines 250-252: + +```tsx + +``` + +- [ ] **Step 3: Verify the icon renders correctly** + +Run the frontend dev server, open an assistant chat, trigger a task lane, and confirm the close button shows a panel-collapse icon instead of an X. + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src/components/assistant/TaskLane.tsx +git commit -m "feat: replace task lane X with PanelRightClose icon" +``` + +--- + +### Task 2: Task Lane — Stop Destroying State on Close + +**Files:** +- Modify: `frontend/src/pages/AssistantChatPage.tsx:797-800` + +- [ ] **Step 1: Remove clearTaskState from the onClose handler** + +Find the TaskLane onClose handler (around line 797-800): + +```tsx + onClose={() => { + setShowTaskLane(false) + if (activeChatId) clearTaskState(activeChatId) + }} +``` + +Replace with: + +```tsx + onClose={() => { + setShowTaskLane(false) + }} +``` + +This keeps task state in sessionStorage and backend `pending_task_lane` so it can be restored. + +- [ ] **Step 2: Verify state persists after closing** + +1. Open assistant chat, trigger task lane with questions/actions +2. Type partial answers into the task lane +3. Click the PanelRightClose button +4. Task lane disappears but answers should persist in sessionStorage + +Verify: `sessionStorage.getItem('rf-tasklane-meta')` still has `questions` and `actions` with the chatId. + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/pages/AssistantChatPage.tsx +git commit -m "feat: task lane close preserves state instead of clearing" +``` + +--- + +### Task 3: Task Lane — Add Reopen Pill to Input Toolbar + +**Files:** +- Modify: `frontend/src/pages/AssistantChatPage.tsx:1-3,738-756` + +- [ ] **Step 1: Add ListChecks to the Lucide import** + +Find the Lucide import at the top of `AssistantChatPage.tsx` (line 3): + +```tsx +import { Sparkles, Send, Loader2, Flag, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus } from 'lucide-react' +``` + +Add `ListChecks`: + +```tsx +import { Sparkles, Send, Loader2, Flag, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus, ListChecks } from 'lucide-react' +``` + +- [ ] **Step 2: Add the Tasks pill button to the input toolbar** + +Find the input toolbar's left button group (around line 738-756). After the Conclude button block (the `{messages.length >= 2 && (` block that ends around line 755), add the Tasks pill: + +```tsx + {!showTaskLane && (activeQuestions.length > 0 || activeActions.length > 0) && ( + + )} +``` + +- [ ] **Step 3: Verify the full collapse/reopen flow** + +1. Open assistant chat, trigger task lane +2. Click PanelRightClose — task lane collapses +3. Confirm "Tasks (N)" pill appears in the input toolbar +4. Click the pill — task lane reopens with previous state intact +5. Confirm pill disappears when task lane is open + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src/pages/AssistantChatPage.tsx +git commit -m "feat: add Tasks pill to reopen collapsed task lane" +``` + +--- + +### Task 4: Backend — Singleton AsyncAnthropic Client + +**Files:** +- Modify: `backend/app/core/ai_provider.py:168-210` + +- [ ] **Step 1: Add a module-level singleton for the Anthropic client** + +In `backend/app/core/ai_provider.py`, add a module-level cache dict before the `AnthropicProvider` class (before line 168): + +```python +# Singleton client cache — avoids creating new HTTP connections per call +_anthropic_clients: dict[str, "anthropic.AsyncAnthropic"] = {} + + +def _get_anthropic_client(api_key: str, timeout: int = 45) -> "anthropic.AsyncAnthropic": + """Return a cached AsyncAnthropic client, creating one if needed.""" + import anthropic + + cache_key = f"{api_key[:8]}:{timeout}" + if cache_key not in _anthropic_clients: + _anthropic_clients[cache_key] = anthropic.AsyncAnthropic( + api_key=api_key, + timeout=timeout, + max_retries=1, + ) + return _anthropic_clients[cache_key] +``` + +- [ ] **Step 2: Update AnthropicProvider to use the singleton** + +Replace the `generate_json` method's client creation (lines 182-188): + +```python + async def generate_json( + self, + system_prompt: str, + messages: list[dict[str, str]], + max_tokens: int = 4096, + ) -> tuple[str, int, int]: + client = _get_anthropic_client(self._api_key, self._timeout) + + response = await client.messages.create( + model=self._model, + max_tokens=max_tokens, + system=system_prompt, + messages=messages, + ) + + text = response.content[0].text + input_tokens = response.usage.input_tokens + output_tokens = response.usage.output_tokens + + return text, input_tokens, output_tokens +``` + +- [ ] **Step 3: Commit** + +```bash +git add backend/app/core/ai_provider.py +git commit -m "perf: singleton AsyncAnthropic client to avoid per-call connection setup" +``` + +--- + +### Task 5: Backend — Add generate_text_stream to AIProvider + +**Files:** +- Modify: `backend/app/core/ai_provider.py:16-55,168-210` + +- [ ] **Step 1: Add the abstract method to the AIProvider base class** + +After the `generate_text` abstract method (after line 55), add: + +```python + async def generate_text_stream( + self, + system_prompt: str, + messages: list[dict[str, str]], + max_tokens: int = 4096, + ) -> "AsyncIterator[str]": + """Stream a text response token by token. + + Args: + system_prompt: System-level instruction for the model. + messages: List of message dicts with "role" and "content" keys. + max_tokens: Maximum output tokens. + + Yields: + Text chunks as they are generated. + """ + raise NotImplementedError("Streaming not supported for this provider") + # Make this an async generator to satisfy type checker + yield "" # pragma: no cover +``` + +Also add the import at the top of the file (line 1 area): + +```python +from collections.abc import AsyncIterator +``` + +- [ ] **Step 2: Implement streaming in AnthropicProvider** + +Add the streaming method to `AnthropicProvider` after `generate_text` (after line 210): + +```python + async def generate_text_stream( + self, + system_prompt: str, + messages: list[dict[str, str]], + max_tokens: int = 4096, + ) -> AsyncIterator[str]: + client = _get_anthropic_client(self._api_key, self._timeout) + + async with client.messages.stream( + model=self._model, + max_tokens=max_tokens, + system=system_prompt, + messages=messages, + ) as stream: + async for text in stream.text_stream: + yield text +``` + +- [ ] **Step 3: Commit** + +```bash +git add backend/app/core/ai_provider.py +git commit -m "feat: add generate_text_stream to AnthropicProvider for SSE support" +``` + +--- + +### Task 6: Backend — Add Streaming Ticket Notes Generator + +**Files:** +- Modify: `backend/app/services/flowpilot_engine.py` (after `generate_status_update`, around line 1012) + +- [ ] **Step 1: Add the streaming ticket notes function** + +After the `generate_status_update` function (after line 1011), add: + +```python +async def stream_ticket_notes( + session_id: UUID, + user_id: UUID, + db: AsyncSession, +) -> AsyncIterator[str]: + """Stream AI-generated structured ticket notes for a resolved session. + + Yields text chunks suitable for SSE streaming. Uses prompt caching + on the system prompt for fast repeat calls. + """ + session = await _load_session(session_id, user_id, db) + + # Build conversation summary from messages (chat sessions) + # or steps (guided sessions) + messages = session.conversation_messages or [] + if messages: + recent = messages[-20:] # Last 20 messages for richer context + convo_text = "\n".join( + f"{'Engineer' if m['role'] == 'user' else 'AI Assistant'}: {m['content'][:500]}" + for m in recent + if isinstance(m, dict) and "role" in m and "content" in m + ) + else: + # Fall back to steps for guided sessions + steps_summary = [] + for step in sorted(session.steps, key=lambda s: s.step_order): + content = step.content or {} + text = content.get("text", "") + response = step.free_text_input or step.selected_option or ("Skipped" if step.was_skipped else None) + entry = f"Step {step.step_order + 1}: {text}" + if response: + entry += f"\n Engineer response: {response}" + steps_summary.append(entry) + convo_text = "\n".join(steps_summary) if steps_summary else "No session data." + + # Calculate time spent + now = datetime.now(timezone.utc) + ref_time = session.resolved_at or now + delta = ref_time - session.created_at + total_minutes = int(delta.total_seconds() / 60) + time_display = f"{total_minutes} minutes" if total_minutes < 60 else f"{total_minutes // 60}h {total_minutes % 60}m" + + system_prompt = """You are generating internal ticket notes for an MSP engineer's PSA system. + +Generate EXACTLY these four markdown sections, in this order: + +## Problem Summary +Summarize what the engineer reported and the initial symptoms. 1-3 sentences. + +## Steps Taken +List the key diagnostic steps, commands run, checks performed, and findings. Use bullet points. + +## Resolution +What fixed the issue or what the final action was. Be specific and technical. + +## Next Steps +Any follow-up items, monitoring to watch, or preventive measures. Write "None" if not applicable. + +Rules: +- Be technical, concise, and factual +- Use markdown formatting (headers, bullet lists, bold for emphasis) +- Include specific technical details (commands, settings, error messages) where available +- Do NOT include greetings, sign-offs, or pleasantries +- Do NOT wrap output in code fences +- Output ONLY the four sections above, nothing else""" + + user_message_parts = [ + f"Session status: {session.status}", + f"Time spent: {time_display}", + f"Problem summary: {session.problem_summary or 'Not specified'}", + ] + if session.problem_domain: + user_message_parts.append(f"Problem domain: {session.problem_domain}") + if session.resolution_summary: + user_message_parts.append(f"Resolution notes: {session.resolution_summary}") + user_message_parts.append(f"\nSession conversation:\n{convo_text}") + + user_message = "\n".join(user_message_parts) + + provider = get_ai_provider(settings.get_model_for_action("quick_action")) + + # Use streaming if provider supports it (Anthropic), otherwise fall back + try: + async for chunk in provider.generate_text_stream( + system_prompt=system_prompt, + messages=[{"role": "user", "content": user_message}], + max_tokens=1500, + ): + yield chunk + except NotImplementedError: + # Fallback for non-streaming providers (Gemini) + text, _, _ = await provider.generate_text( + system_prompt=system_prompt, + messages=[{"role": "user", "content": user_message}], + max_tokens=1500, + ) + yield text +``` + +Also add the import at the top of the file if not already present: + +```python +from collections.abc import AsyncIterator +``` + +- [ ] **Step 2: Commit** + +```bash +git add backend/app/services/flowpilot_engine.py +git commit -m "feat: add stream_ticket_notes generator for SSE doc streaming" +``` + +--- + +### Task 7: Backend — Add SSE Streaming Endpoint + +**Files:** +- Modify: `backend/app/api/endpoints/ai_sessions.py` (after `get_documentation`, around line 925) + +- [ ] **Step 1: Add the SSE streaming endpoint** + +After the `get_documentation` endpoint (after line 924), add: + +```python +@router.get("/{session_id}/documentation/stream") +@limiter.limit("20/minute") +async def stream_documentation( + request: Request, + session_id: UUID, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], +): + """Stream AI-generated ticket notes as Server-Sent Events.""" + from starlette.responses import StreamingResponse + + # Verify session ownership + result = await db.execute( + select(AISession).where(AISession.id == session_id) + ) + session = result.scalar_one_or_none() + if not session: + raise HTTPException(status_code=404, detail="Session not found") + if session.user_id != current_user.id: + raise HTTPException(status_code=403, detail="Not authorized") + + async def event_generator(): + try: + async for chunk in flowpilot_engine.stream_ticket_notes( + session_id=session_id, + user_id=current_user.id, + db=db, + ): + # SSE format: data: \n\n + yield f"data: {chunk}\n\n" + yield "data: [DONE]\n\n" + except Exception as e: + logger.exception("SSE stream error for session %s: %s", session_id, e) + yield f"data: [ERROR] {str(e)}\n\n" + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", # Disable nginx buffering + }, + ) +``` + +Also add the `AISession` import at the top if not already there (it should be — verify with the existing imports around line 30-55): + +```python +from app.models.ai_session import AISession +``` + +- [ ] **Step 2: Commit** + +```bash +git add backend/app/api/endpoints/ai_sessions.py +git commit -m "feat: add SSE endpoint for streaming ticket notes generation" +``` + +--- + +### Task 8: Backend — Make resolve_session Non-Blocking + +**Files:** +- Modify: `backend/app/api/endpoints/ai_sessions.py:413-446` +- Modify: `backend/app/schemas/ai_session.py:135-142` + +- [ ] **Step 1: Make documentation optional in SessionCloseResponse** + +In `backend/app/schemas/ai_session.py`, change line 139: + +```python +class SessionCloseResponse(BaseModel): + """Response after resolving or escalating.""" + session_id: UUID + status: str + documentation: SessionDocumentation | None = None + psa_push_status: str = "no_psa" # sent | pending_retry | no_psa | failed + psa_push_error: str | None = None + member_mapping_warning: str | None = None +``` + +(Change `documentation: SessionDocumentation` to `documentation: SessionDocumentation | None = None`) + +- [ ] **Step 2: Update the frontend type to match** + +In `frontend/src/types/ai-session.ts`, change line 128: + +```typescript +export interface SessionCloseResponse { + session_id: string + status: string + documentation: SessionDocumentation | null + psa_push_status: string + psa_push_error: string | null + member_mapping_warning: string | null +} +``` + +(Change `documentation: SessionDocumentation` to `documentation: SessionDocumentation | null`) + +- [ ] **Step 3: Update the resolve endpoint to not generate docs inline** + +The `resolve_session` endpoint (lines 413-446) currently calls `flowpilot_engine.resolve_session()` which generates `_generate_documentation(session)` inline. The resolve_session in flowpilot_engine already sets status + saves summary + returns docs — we just need the endpoint to stop depending on docs for its response. + +In `backend/app/services/flowpilot_engine.py`, the `resolve_session` function (line 497) already returns `SessionCloseResponse` with `documentation=documentation`. Since we made `documentation` optional, the existing flow still works but is no longer blocking on an LLM call (it never was — `_generate_documentation` is pure Python and fast). The slow part was `generate_session_embedding` (Voyage AI embedding call) and `_push_to_psa`. + +The actual fix: move the embedding generation to be fire-and-forget in the endpoint. In `backend/app/api/endpoints/ai_sessions.py`, replace the resolve_session endpoint (lines 413-446): + +```python +@router.post("/{session_id}/resolve", response_model=SessionCloseResponse) +@limiter.limit("15/minute") +async def resolve_session( + request: Request, + session_id: UUID, + data: ResolveSessionRequest, + current_user: Annotated[User, Depends(get_current_active_user)], + db: Annotated[AsyncSession, Depends(get_db)], + _: None = Depends(require_engineer_or_admin), +): + """Resolve a session. Returns immediately; use /documentation/stream for ticket notes.""" + try: + result = await flowpilot_engine.resolve_session( + session_id=session_id, + request=data, + user_id=current_user.id, + 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() + + # Fire-and-forget: resolution outputs + embedding (don't block the response) + import asyncio + + async def _post_resolve_tasks(): + try: + from app.services.resolution_output_generator import ResolutionOutputGenerator + gen = ResolutionOutputGenerator(db) + await gen.generate_all(session_id) + except Exception: + logger.exception(f"Failed to generate resolution outputs for session {session_id}") + + asyncio.create_task(_post_resolve_tasks()) + + return result +``` + +- [ ] **Step 4: Commit** + +```bash +git add backend/app/api/endpoints/ai_sessions.py backend/app/schemas/ai_session.py frontend/src/types/ai-session.ts +git commit -m "feat: make resolve endpoint non-blocking, documentation optional" +``` + +--- + +### Task 9: Frontend — Add streamDocumentation to API Client + +**Files:** +- Modify: `frontend/src/api/aiSessions.ts:95-100` + +- [ ] **Step 1: Add the streaming API method** + +After the `getDocumentation` method (around line 100), add: + +```typescript + async streamDocumentation( + sessionId: string, + onChunk: (text: string) => void, + onDone: () => void, + onError: (error: string) => void, + ): Promise { + const token = localStorage.getItem('access_token') + const baseUrl = import.meta.env.VITE_API_URL || '' + + try { + const response = await fetch( + `${baseUrl}/api/ai-sessions/${sessionId}/documentation/stream`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ) + + if (!response.ok) { + onError(`HTTP ${response.status}`) + return + } + + const reader = response.body?.getReader() + if (!reader) { + onError('No response body') + return + } + + const decoder = new TextDecoder() + let buffer = '' + + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() || '' + + for (const line of lines) { + if (line.startsWith('data: ')) { + const data = line.slice(6) + if (data === '[DONE]') { + onDone() + return + } + if (data.startsWith('[ERROR]')) { + onError(data.slice(8)) + return + } + onChunk(data) + } + } + } + // Stream ended without [DONE] + onDone() + } catch (err) { + onError(err instanceof Error ? err.message : 'Stream failed') + } + }, +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/src/api/aiSessions.ts +git commit -m "feat: add streamDocumentation SSE client for ticket notes" +``` + +--- + +### Task 10: Frontend — Rewrite ConcludeSessionModal for Two-Phase Flow + +**Files:** +- Modify: `frontend/src/components/assistant/ConcludeSessionModal.tsx` +- Modify: `frontend/src/pages/AssistantChatPage.tsx:396-414` + +- [ ] **Step 1: Update the ConcludeSessionModal props and state** + +The modal's `onConclude` prop currently returns `Promise` (the summary text). We need to change it to return `Promise<{ sessionId: string }>` so the modal can initiate streaming itself. + +Update the interface and imports at the top of `ConcludeSessionModal.tsx`: + +```tsx +import { useState, useEffect, useRef } from 'react' +import { + X, + CheckCircle2, + ArrowUpRight, + Pause, + Loader2, + Copy, + Check, + RefreshCw, + ClipboardList, + Sparkles, + AlertTriangle, +} from 'lucide-react' +import { cn } from '@/lib/utils' +import { MarkdownContent } from '@/components/ui/MarkdownContent' +import { aiSessionsApi } from '@/api/aiSessions' +import { toast } from '@/lib/toast' + +type ConclusionOutcome = 'resolved' | 'escalated' | 'paused' + +interface ConcludeSessionModalProps { + isOpen: boolean + onClose: () => void + onConclude: (outcome: ConclusionOutcome, notes: string) => Promise + onResumeNew: (summary: string) => void + chatTitle: string + sessionId: string | null +} +``` + +(Added `sessionId` prop, `useRef` import, `AlertTriangle` icon, `aiSessionsApi` import, `toast` import.) + +- [ ] **Step 2: Rewrite the component state and handleGenerate** + +Replace the state declarations and `handleGenerate` function (lines 66-106): + +```tsx +export function ConcludeSessionModal({ + isOpen, + onClose, + onConclude, + onResumeNew, + chatTitle, + sessionId, +}: ConcludeSessionModalProps) { + const [step, setStep] = useState('select-outcome') + const [outcome, setOutcome] = useState(null) + const [notes, setNotes] = useState('') + const [summary, setSummary] = useState('') + const [generating, setGenerating] = useState(false) + const [streaming, setStreaming] = useState(false) + const [streamError, setStreamError] = useState(null) + const [copied, setCopied] = useState(false) + const [error, setError] = useState(null) + const summaryRef = useRef('') + + // Reset state when modal opens + useEffect(() => { + if (isOpen) { + setStep('select-outcome') + setOutcome(null) + setNotes('') + setSummary('') + summaryRef.current = '' + setGenerating(false) + setStreaming(false) + setStreamError(null) + setCopied(false) + setError(null) + } + }, [isOpen]) + + const handleOutcomeSelect = (selected: ConclusionOutcome) => { + setOutcome(selected) + setStep('add-notes') + } + + const handleGenerate = async () => { + if (!outcome) return + setGenerating(true) + setError(null) + + try { + // Phase 1: Resolve/escalate/pause the session (fast) + await onConclude(outcome, notes) + + // Phase 2: Transition to summary step immediately + setStep('summary') + setGenerating(false) + + // For resolved sessions, stream ticket notes + if (outcome === 'resolved' && sessionId) { + setStreaming(true) + setStreamError(null) + summaryRef.current = '' + + aiSessionsApi.streamDocumentation( + sessionId, + (chunk) => { + summaryRef.current += chunk + setSummary(summaryRef.current) + }, + () => { + setStreaming(false) + }, + (err) => { + setStreaming(false) + setStreamError(err) + // Try non-streaming fallback + aiSessionsApi.getDocumentation(sessionId).then((doc) => { + const fallback = `## Problem Summary\n${doc.problem_summary}\n\n## Steps Taken\n${doc.diagnostic_steps.map(s => `- ${s.description}`).join('\n')}\n\n## Resolution\n${doc.resolution_summary || 'See conversation'}\n\n## Next Steps\nNone` + setSummary(fallback) + setStreamError(null) + }).catch(() => { + // Final fallback — just show what we have + if (!summaryRef.current) { + setSummary('Documentation generation failed. You can copy the conversation from the chat.') + } + }) + }, + ) + } else if (outcome === 'escalated') { + setSummary('Session escalated. Ticket notes will be generated when the session is resolved.') + } else { + setSummary('Session paused. Progress saved — you can resume anytime.') + } + } catch { + setError('Failed to conclude session. Please try again.') + setGenerating(false) + } + } +``` + +- [ ] **Step 3: Update the summary step rendering** + +Replace the summary step content (lines 298-325) with: + +```tsx + {/* Step 3: Summary */} + {step === 'summary' && ( +
+ {/* Outcome badge */} + {selectedOutcome && ( +
+ + {selectedOutcome.label} +
+ )} + + {/* Generated ticket notes */} +
+
+ + + Ticket Notes + + {streaming && ( + + )} +
+ + {/* Streaming content or skeleton */} + {summary ? ( +
+ +
+ ) : streaming ? ( +
+
+
+
+
+
+
+
+ ) : streamError ? ( +
+ + {streamError} +
+ ) : null} +
+
+ )} +``` + +- [ ] **Step 4: Update the footer for the summary step** + +Replace the summary step footer (lines 373-416) with: + +```tsx + {step === 'summary' && ( + <> +
+ {outcome === 'paused' && ( + + )} +
+
+ {summary && !streaming && ( + + )} + +
+ + )} +``` + +- [ ] **Step 5: Update handleConclude in AssistantChatPage** + +In `frontend/src/pages/AssistantChatPage.tsx`, update `handleConclude` (lines 396-414) to return immediately without waiting for documentation: + +```tsx + const handleConclude = async (outcome: ConclusionOutcome, _notes: string): Promise => { + if (!activeChatId) throw new Error('No active chat') + + if (outcome === 'resolved') { + await aiSessionsApi.resolveSession(activeChatId, { + resolution_summary: _notes || 'Resolved via assistant chat', + }) + return activeChatId + } else if (outcome === 'escalated') { + await aiSessionsApi.escalateSession(activeChatId, { + escalation_reason: _notes || 'Escalated from assistant chat', + }) + return activeChatId + } else { + await aiSessionsApi.pauseSession(activeChatId) + return activeChatId + } + } +``` + +- [ ] **Step 6: Pass sessionId to ConcludeSessionModal** + +Find where `ConcludeSessionModal` is rendered (around line 812-818) and add the `sessionId` prop: + +```tsx + setShowConclude(false)} + onConclude={handleConclude} + onResumeNew={handleResumeNew} + chatTitle={chats.find(c => c.id === activeChatId)?.title ?? 'Chat'} + sessionId={activeChatId} + /> +``` + +- [ ] **Step 7: Verify the full flow** + +1. Open assistant chat, have a conversation +2. Click Conclude → Resolved → add optional notes → Generate Summary +3. Modal should transition to summary step IMMEDIATELY +4. Skeleton loading should appear, then ticket notes stream in progressively +5. When streaming completes, "Copy to Clipboard" button appears +6. Click copy → toast "Ticket notes copied" → clipboard has the markdown + +- [ ] **Step 8: Commit** + +```bash +git add frontend/src/components/assistant/ConcludeSessionModal.tsx frontend/src/pages/AssistantChatPage.tsx +git commit -m "feat: two-phase resolve with streaming ticket notes generation" +``` + +--- + +### Task 11: Frontend Build Verification + +**Files:** None (verification only) + +- [ ] **Step 1: Run the frontend build** + +```bash +export NVM_DIR="$HOME/.nvm" && source "$NVM_DIR/nvm.sh" && nvm use 20 +cd frontend && npm run build +``` + +Expected: Build succeeds with no TypeScript errors. If OOM occurs (VPS memory constraint), run `npx tsc --noEmit` instead as a lighter check. + +- [ ] **Step 2: Fix any TypeScript errors** + +If errors appear, fix unused imports, missing props, or type mismatches. Common issues: +- `ConcludeSessionModal` now requires `sessionId` prop — verify all usages pass it +- `SessionCloseResponse.documentation` is now optional — verify `.documentation?.` optional chaining everywhere it's accessed +- Unused `X` import in TaskLane after replacing with `PanelRightClose` + +- [ ] **Step 3: Commit fixes if needed** + +```bash +git add -A +git commit -m "fix: resolve TypeScript errors from task lane and resolve docs changes" +``` + +--- + +### Task 12: Remove Debug Logging from AssistantChatPage + +**Files:** +- Modify: `frontend/src/pages/AssistantChatPage.tsx` + +- [ ] **Step 1: Remove the console.log statements added during debugging** + +Remove these debug lines: +- `console.log('[AssistantChat] Mount restore check — ...')` (around line 105) +- `console.log('[AssistantChat] Calling selectChat to restore:', ...)` (around line 107) +- `console.log('[AssistantChat] selectChat called with:', ...)` (around line 212) +- `console.log('[AssistantChat] getSession response — ...')` (around line 220) +- `console.error('[AssistantChat] Failed to load chat session:', err)` (around line 243) + +Keep the `catch (err)` form (not bare `catch`) but remove the `console.error` line. Revert to bare `catch`: + +```tsx + } catch { + setMessages([]) + } +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/src/pages/AssistantChatPage.tsx +git commit -m "chore: remove debug logging from AssistantChatPage" +``` + +--- + +### Task 13: Final Integration Test + +**Files:** None (manual testing) + +- [ ] **Step 1: Test task lane minimize/reopen flow** + +1. Start or continue a chat that has task lane items +2. Click PanelRightClose — panel collapses, "Tasks (N)" pill appears in toolbar +3. Click pill — panel reopens with previous state (including partial answers) +4. Navigate away from assistant page and back — task lane restores from backend +5. Close and reopen — state persists + +- [ ] **Step 2: Test resolve with streaming ticket notes** + +1. Have a multi-message conversation +2. Click Conclude → Resolved → optional notes → Generate Summary +3. Modal transitions instantly to summary step with skeleton +4. Ticket notes stream in with four sections: Problem Summary, Steps Taken, Resolution, Next Steps +5. Copy button appears after streaming completes +6. Click Copy → toast confirms → paste into text editor to verify markdown + +- [ ] **Step 3: Test fallback behavior** + +1. If streaming fails (e.g., AI key missing), verify fallback to non-streaming doc generation +2. If both fail, verify "Documentation generation failed" message appears + +- [ ] **Step 4: Push to PR** + +```bash +git push +``` diff --git a/docs/superpowers/plans/2026-03-29-color-migration-v6.md b/docs/superpowers/plans/2026-03-29-color-migration-v6.md new file mode 100644 index 00000000..ff2e4db8 --- /dev/null +++ b/docs/superpowers/plans/2026-03-29-color-migration-v6.md @@ -0,0 +1,461 @@ +# Color Migration v5→v6 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:** Migrate all color values in the frontend from design system v5 (ember orange accent, shallow surfaces, yellow warning) to v6 (electric blue accent, deeper charcoal surfaces, amber warning, cyan info). + +**Architecture:** Layer 1 updates CSS custom properties in `index.css` (cascades to ~80% of usage). Layer 2 does find-and-replace on hardcoded `rgba(249,115,22,...)` focus border patterns across ~40 component files. Layer 3 replaces `orange-*` Tailwind classes. Layer 4 updates hardcoded hex values in specific files (BrandLogo, EmptyStateIllustrations, landing page, etc.). Each layer commits independently. + +**Tech Stack:** React 19, Tailwind CSS v4, CSS custom properties + +**Spec:** `docs/superpowers/specs/2026-03-29-color-migration-v6-design.md` + +--- + +### Task 1: Update CSS Custom Properties in `index.css` + +This is the highest-impact change — updating the `@theme` block cascades to every component using Tailwind utility classes. + +**Files:** +- Modify: `frontend/src/index.css` + +- [ ] **Step 1: Update surface colors in the @theme block** + +In `frontend/src/index.css`, replace lines 11-18 (the surface color block) with: + +```css + /* ── Surface colors (Deep Charcoal — sidebar darkest) ─ */ + --color-bg-page: #16181f; + --color-bg-sidebar: #0e1016; + --color-bg-card: #1e2028; + --color-bg-card-hover: #252830; + --color-bg-input: #252830; + --color-bg-code: #12141a; + --color-bg-elevated: #2a2d38; + --color-bg-raised: #303442; +``` + +- [ ] **Step 2: Update border colors** + +Replace lines 28-29 (border colors) with: + +```css + --color-border-default: #2a2e3a; + --color-border-hover: #3d4252; +``` + +- [ ] **Step 3: Update accent colors** + +Replace lines 31-35 (accent block) with: + +```css + /* ── Accent (electric blue) ───────────────────────── */ + --color-accent: #60a5fa; + --color-accent-hover: #3b82f6; + --color-accent-dim: rgba(96,165,250,0.10); + --color-accent-text: #93c5fd; +``` + +- [ ] **Step 4: Update semantic colors (warning + add info)** + +Replace lines 37-43 (semantic block) with: + +```css + /* ── Semantic colors ───────────────────────────── */ + --color-success: #34d399; + --color-success-dim: rgba(52,211,153,0.10); + --color-warning: #fbbf24; + --color-warning-dim: rgba(251,191,36,0.10); + --color-danger: #f87171; + --color-danger-dim: rgba(248,113,113,0.10); + --color-info: #67e8f9; + --color-info-dim: rgba(103,232,249,0.10); +``` + +- [ ] **Step 5: Update Tailwind semantic mappings** + +Replace lines 46-64 (Tailwind mappings) with: + +```css + /* ── Tailwind semantic mappings ─────────────────── */ + --color-background: #16181f; + --color-foreground: #e2e5eb; + --color-card: #1e2028; + --color-card-foreground: #e2e5eb; + --color-popover: #1e2028; + --color-popover-foreground: #e2e5eb; + --color-primary: #60a5fa; + --color-primary-foreground: #ffffff; + --color-secondary: #2a2d38; + --color-secondary-foreground: #e2e5eb; + --color-muted: #2a2d38; + --color-muted-foreground: #848b9b; + --color-accent-tw: #2a2d38; + --color-accent-foreground: #e2e5eb; + --color-destructive: #f87171; + --color-destructive-foreground: #ffffff; + --color-border: #2a2e3a; + --color-input: #252830; + --color-ring: #60a5fa; +``` + +- [ ] **Step 6: Update orange rgba in utility classes** + +Replace the `btn-primary-v4` hover/active box-shadow values (lines 194-200): + +```css + &:hover { + filter: brightness(1.1); + box-shadow: 0 2px 10px rgba(96, 165, 250, 0.2); + transform: translateY(-1px); + } + &:active { + box-shadow: 0 0 4px rgba(96, 165, 250, 0.1); + transform: translateY(0); + } +``` + +Replace `tab-active-shadow` (line 229): + +```css + box-shadow: 0 1px 4px rgba(96, 165, 250, 0.08); +``` + +Replace `card-lift` hover box-shadow (line 237): + +```css + box-shadow: 0 2px 8px rgba(96, 165, 250, 0.06); +``` + +- [ ] **Step 7: Verify it compiles** + +Run: `cd /home/coder/resolutionflow/frontend && npx tsc --noEmit --pretty 2>&1 | head -10` + +Expected: No errors. + +- [ ] **Step 8: Commit** + +```bash +git add frontend/src/index.css +git commit -m "feat: migrate index.css to design system v6 color tokens + +Accent: orange (#f97316) → blue (#60a5fa) +Surfaces: deeper charcoal range (#16181f page, #1e2028 card) +Warning: yellow (#eab308) → amber (#fbbf24) +Info: new cyan (#67e8f9) token added +All Tailwind semantic mappings updated to match. + +Co-Authored-By: Claude Opus 4.6 (1M context) " +``` + +--- + +### Task 2: Replace hardcoded `rgba(249,115,22,...)` focus borders across all components + +~40 files use `focus:border-[rgba(249,115,22,0.3)]` or similar patterns for input focus states. This is a mechanical find-and-replace. + +**Files:** All files listed in the grep output for `rgba(249,115,22` — approximately 40 component and page files. + +- [ ] **Step 1: Replace all `rgba(249,115,22,` with `rgba(96,165,250,` across the codebase** + +Run this command to do the replacement: + +```bash +cd /home/coder/resolutionflow/frontend/src +grep -rl 'rgba(249,115,22' --include='*.tsx' --include='*.ts' --include='*.css' | while read f; do + sed -i 's/rgba(249,115,22/rgba(96,165,250/g' "$f" +done +``` + +This replaces ALL instances: `rgba(249,115,22,0.3)` → `rgba(96,165,250,0.3)`, `rgba(249,115,22,0.10)` → `rgba(96,165,250,0.10)`, etc. The opacity values stay the same. + +Note: `index.css` was already updated in Task 1 so this is a no-op for that file's `--color-accent-dim` line. + +- [ ] **Step 2: Also replace `rgba(249, 115, 22` (with spaces)** + +Some files may use spaced format: + +```bash +grep -rl 'rgba(249, 115, 22' --include='*.tsx' --include='*.ts' --include='*.css' | while read f; do + sed -i 's/rgba(249, 115, 22/rgba(96, 165, 250/g' "$f" +done +``` + +- [ ] **Step 3: Verify it compiles** + +Run: `cd /home/coder/resolutionflow/frontend && npx tsc --noEmit --pretty 2>&1 | head -10` + +Expected: No errors. + +- [ ] **Step 4: Commit** + +```bash +cd /home/coder/resolutionflow +git add -A frontend/src/ +git commit -m "feat: replace all hardcoded orange rgba with blue rgba + +Mechanical find-and-replace: rgba(249,115,22,...) → rgba(96,165,250,...) +across ~40 component and page files. Focus borders, hover glows, and +dim backgrounds all now use the blue accent color. + +Co-Authored-By: Claude Opus 4.6 (1M context) " +``` + +--- + +### Task 3: Replace `orange-*` Tailwind classes + +~21 files use `orange-400`, `orange-500`, `orange-600` Tailwind color classes. Replace with blue equivalents. + +**Files:** All files with `orange-[3456]00` class references. + +- [ ] **Step 1: Replace orange Tailwind classes with blue equivalents** + +The mapping: +- `orange-300` → `blue-300` +- `orange-400` → `blue-400` +- `orange-500` → `blue-500` +- `orange-600` → `blue-600` + +Run: + +```bash +cd /home/coder/resolutionflow/frontend/src +grep -rl 'orange-' --include='*.tsx' --include='*.ts' | while read f; do + sed -i 's/orange-300/blue-300/g; s/orange-400/blue-400/g; s/orange-500/blue-500/g; s/orange-600/blue-600/g' "$f" +done +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `cd /home/coder/resolutionflow/frontend && npx tsc --noEmit --pretty 2>&1 | head -10` + +Expected: No errors. + +- [ ] **Step 3: Commit** + +```bash +cd /home/coder/resolutionflow +git add -A frontend/src/ +git commit -m "feat: replace orange-* Tailwind classes with blue-* equivalents + +orange-400→blue-400, orange-500→blue-500, orange-600→blue-600 +across ~21 component files. + +Co-Authored-By: Claude Opus 4.6 (1M context) " +``` + +--- + +### Task 4: Update hardcoded hex values in specific files + +Several files have hardcoded `#f97316`, `#ea580c`, `#fdba74` hex values that don't go through CSS variables. + +**Files:** +- Modify: `frontend/src/components/common/BrandLogo.tsx` +- Modify: `frontend/src/components/common/EmptyStateIllustrations.tsx` +- Modify: `frontend/src/constants/categoryColors.ts` +- Modify: `frontend/src/styles/landing.css` +- Modify: `frontend/src/pages/LandingPage.tsx` +- Modify: `frontend/src/assets/brand/icon.svg` +- Modify: `frontend/src/assets/brand/logo-horizontal.svg` +- Modify: `frontend/src/assets/brand/logo-with-tagline.svg` +- Modify: Any remaining files from the grep output for `#f97316|#ea580c|#fdba74` + +- [ ] **Step 1: Update BrandLogo.tsx gradient** + +In `frontend/src/components/common/BrandLogo.tsx`, replace the gradient on line 22: + +```tsx + background: 'linear-gradient(135deg, #3b82f6, #60a5fa)', +``` + +Also update the JSDoc comment on line 9: + +```tsx + * Brand logo mark: gradient blue square with rounded corners +``` + +- [ ] **Step 2: Replace all hardcoded orange hex values across the codebase** + +Run: + +```bash +cd /home/coder/resolutionflow/frontend/src +# #f97316 → #60a5fa (accent) +grep -rl '#f97316' --include='*.tsx' --include='*.ts' --include='*.css' --include='*.svg' | while read f; do + sed -i 's/#f97316/#60a5fa/g' "$f" +done + +# #ea580c → #3b82f6 (accent-hover) +grep -rl '#ea580c' --include='*.tsx' --include='*.ts' --include='*.css' --include='*.svg' | while read f; do + sed -i 's/#ea580c/#3b82f6/g' "$f" +done + +# #fdba74 → #93c5fd (accent-text) +grep -rl '#fdba74' --include='*.tsx' --include='*.ts' --include='*.css' --include='*.svg' | while read f; do + sed -i 's/#fdba74/#93c5fd/g' "$f" +done +``` + +- [ ] **Step 3: Update warning yellow to amber** + +```bash +cd /home/coder/resolutionflow/frontend/src +# #eab308 → #fbbf24 +grep -rl '#eab308' --include='*.tsx' --include='*.ts' --include='*.css' | while read f; do + sed -i 's/#eab308/#fbbf24/g' "$f" +done + +# rgba(234,179,8 → rgba(251,191,36 +grep -rl 'rgba(234,179,8' --include='*.tsx' --include='*.ts' --include='*.css' | while read f; do + sed -i 's/rgba(234,179,8/rgba(251,191,36/g' "$f" +done +``` + +- [ ] **Step 4: Fix categoryColors.ts duplicates** + +After the sed replacements, `frontend/src/constants/categoryColors.ts` will have `#3b82f6` (from `#ea580c`) and `#60a5fa` (from `#f97316`) — but the array already had `#3b82f6` at position 0. Fix the file to have 10 unique colors: + +```typescript +export const CATEGORY_COLORS = [ + '#3b82f6', // blue + '#22c55e', // green + '#f59e0b', // amber + '#ef4444', // red + '#8b5cf6', // violet + '#0891b2', // cyan + '#ec4899', // pink + '#60a5fa', // sky blue + '#14b8a6', // teal + '#6366f1', // indigo +] as const +``` + +This replaces the old `#ea580c` (deep orange) slot with `#0891b2` (cyan) and keeps `#60a5fa` (sky blue) in the old `#f97316` slot. + +- [ ] **Step 5: Verify it compiles** + +Run: `cd /home/coder/resolutionflow/frontend && npx tsc --noEmit --pretty 2>&1 | head -10` + +Expected: No errors. + +- [ ] **Step 6: Commit** + +```bash +cd /home/coder/resolutionflow +git add -A frontend/src/ +git commit -m "feat: replace hardcoded orange hex values with blue equivalents + +BrandLogo gradient, EmptyStateIllustrations SVGs, categoryColors, +landing page, brand SVG assets, and all remaining files with +hardcoded #f97316/#ea580c/#fdba74 values. +Also migrates warning #eab308 → #fbbf24 (amber). + +Co-Authored-By: Claude Opus 4.6 (1M context) " +``` + +--- + +### Task 5: Update landing.css and LandingPage.tsx + +The landing page has its own CSS file and inline styles with orange references. + +**Files:** +- Modify: `frontend/src/styles/landing.css` +- Modify: `frontend/src/pages/LandingPage.tsx` + +- [ ] **Step 1: Read landing.css and fix any remaining orange references** + +Read `frontend/src/styles/landing.css` and replace any remaining: +- `#f97316` → `#60a5fa` +- `#ea580c` → `#3b82f6` +- `rgba(249,115,22,...)` → `rgba(96,165,250,...)` +- Any old surface colors (`#1a1c23` → `#16181f`, `#22252e` → `#1e2028`, etc.) + +These should already be handled by Task 2 and Task 4's sed commands, but verify and fix any that were missed (e.g., spaced hex values, different formatting). + +- [ ] **Step 2: Read LandingPage.tsx and fix any remaining orange references** + +Read `frontend/src/pages/LandingPage.tsx` and verify all orange references were replaced by the sed commands in Tasks 2 and 4. Fix any remaining manually. + +- [ ] **Step 3: Verify it compiles** + +Run: `cd /home/coder/resolutionflow/frontend && npx tsc --noEmit --pretty 2>&1 | head -10` + +Expected: No errors. + +- [ ] **Step 4: Commit (if changes were needed)** + +```bash +cd /home/coder/resolutionflow +git add frontend/src/styles/landing.css frontend/src/pages/LandingPage.tsx +git commit -m "fix: clean up remaining orange references in landing page + +Co-Authored-By: Claude Opus 4.6 (1M context) " +``` + +--- + +### Task 6: Update index.html and configuration files + +**Files:** +- Modify: `frontend/index.html` + +- [ ] **Step 1: Update meta theme-color** + +In `frontend/index.html`, line 17, replace: + +```html + +``` + +with: + +```html + +``` + +- [ ] **Step 2: Commit** + +```bash +cd /home/coder/resolutionflow +git add frontend/index.html +git commit -m "fix: update meta theme-color to new sidebar color (#0e1016) + +Co-Authored-By: Claude Opus 4.6 (1M context) " +``` + +--- + +### Task 7: Full build verification and sweep for stragglers + +**Files:** None (verification only) + +- [ ] **Step 1: Run full frontend build** + +Run: `cd /home/coder/resolutionflow/frontend && NODE_OPTIONS="--max-old-space-size=4096" npm run build 2>&1 | tail -20` + +Expected: Build succeeds with no errors. + +- [ ] **Step 2: Search for any remaining orange references** + +Run: + +```bash +cd /home/coder/resolutionflow/frontend/src +echo "=== Remaining #f97316 ===" && grep -r '#f97316' --include='*.tsx' --include='*.ts' --include='*.css' || echo "NONE" +echo "=== Remaining #ea580c ===" && grep -r '#ea580c' --include='*.tsx' --include='*.ts' --include='*.css' || echo "NONE" +echo "=== Remaining #fdba74 ===" && grep -r '#fdba74' --include='*.tsx' --include='*.ts' --include='*.css' || echo "NONE" +echo "=== Remaining rgba(249,115,22 ===" && grep -r 'rgba(249,115,22' --include='*.tsx' --include='*.ts' --include='*.css' || echo "NONE" +echo "=== Remaining orange- classes ===" && grep -r 'orange-[3456]00' --include='*.tsx' --include='*.ts' || echo "NONE" +echo "=== Remaining #eab308 ===" && grep -r '#eab308' --include='*.tsx' --include='*.ts' --include='*.css' || echo "NONE" +``` + +Expected: All say "NONE". If any remain, fix them and commit. + +- [ ] **Step 3: Verify git status is clean** + +Run: `git status` + +Expected: Working tree clean (all changes committed). diff --git a/docs/superpowers/plans/2026-03-29-parameterize-and-save.md b/docs/superpowers/plans/2026-03-29-parameterize-and-save.md new file mode 100644 index 00000000..23d36095 --- /dev/null +++ b/docs/superpowers/plans/2026-03-29-parameterize-and-save.md @@ -0,0 +1,937 @@ +# Parameterize & Save 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:** Fix AI-generated scripts saving to library without parameters by adding parameter detection, user review, and template rewriting to the save flow — plus a "New from Script" paste entry point on the library page. + +**Architecture:** A new `ParameterizeAndSavePanel` slide-in panel component handles two modes: `script` (pre-populated from AI builder) and `paste` (user pastes raw script). It auto-runs `detectParameterCandidates()`, renders `ParameterDetectorStepper` for review, rewrites the script body with `{{ key }}` placeholders, and sends enriched payload to the backend. Backend `save_to_library()` accepts the provided `script_body` + `parameters_schema` instead of hardcoding empty values. + +**Tech Stack:** React 19, TypeScript, Tailwind CSS v4, FastAPI, Pydantic v2, SQLAlchemy 2.0 + +**Spec:** `docs/superpowers/specs/2026-03-29-parameterize-and-save-design.md` + +--- + +### Task 1: Backend — Update `SaveToLibraryRequest` schema and `save_to_library()` service + +**Files:** +- Modify: `backend/app/schemas/script_builder.py:79-84` (SaveToLibraryRequest) +- Modify: `backend/app/services/script_builder_service.py:317-377` (save_to_library function) +- Modify: `backend/app/api/endpoints/script_builder.py:156-188` (save_to_library endpoint) + +- [ ] **Step 1: Update `SaveToLibraryRequest` schema** + +In `backend/app/schemas/script_builder.py`, add two optional fields to `SaveToLibraryRequest`: + +```python +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 + script_body: str | None = None + parameters_schema: dict | None = None +``` + +- [ ] **Step 2: Update `save_to_library()` service function signature and body** + +In `backend/app/services/script_builder_service.py`, add two new parameters to `save_to_library()` and use them in the `ScriptTemplate` constructor: + +```python +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, + script_body: str | None = None, + parameters_schema: dict | None = None, +) -> "ScriptTemplate": +``` + +And in the `ScriptTemplate(...)` constructor inside the same function, change the two hardcoded lines: + +```python + 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=script_body or session.latest_script, + parameters_schema=parameters_schema or {"parameters": []}, + default_values={}, + validation_rules={}, + tags=[session.language, "ai-generated"], + complexity="intermediate", + is_verified=False, + is_active=True, + version=1, + usage_count=0, + ) +``` + +- [ ] **Step 3: Update the endpoint to pass new fields through** + +In `backend/app/api/endpoints/script_builder.py`, update the `save_to_library` endpoint to pass the new fields to the service: + +```python + 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, + script_body=data.script_body, + parameters_schema=data.parameters_schema, + ) +``` + +- [ ] **Step 4: Verify backend still starts cleanly** + +Run: `cd /home/coder/resolutionflow/backend && source venv/bin/activate && python -c "from app.schemas.script_builder import SaveToLibraryRequest; print('OK')"` + +Expected: `OK` + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/schemas/script_builder.py backend/app/services/script_builder_service.py backend/app/api/endpoints/script_builder.py +git commit -m "feat: accept script_body and parameters_schema in save-to-library flow + +Previously save_to_library() hardcoded parameters_schema to empty and +always used session.latest_script. Now accepts optional overrides from +the frontend for parameterized script bodies. + +Co-Authored-By: Claude Opus 4.6 (1M context) " +``` + +--- + +### Task 2: Frontend — Update `SaveToLibraryRequest` type + +**Files:** +- Modify: `frontend/src/types/script-builder.ts:45-50` (SaveToLibraryRequest interface) + +- [ ] **Step 1: Add new fields to the TypeScript interface** + +In `frontend/src/types/script-builder.ts`, update `SaveToLibraryRequest`: + +```typescript +export interface SaveToLibraryRequest { + name: string + description?: string + category_id?: string + share_with_team?: boolean + script_body?: string + parameters_schema?: { parameters: ScriptParameter[] } +} +``` + +Add the import for `ScriptParameter` at the top of the file: + +```typescript +import type { ScriptParameter } from './scripts' +``` + +- [ ] **Step 2: Verify types compile** + +Run: `cd /home/coder/resolutionflow/frontend && npx tsc --noEmit --pretty 2>&1 | head -30` + +Expected: No errors related to `SaveToLibraryRequest` or `ScriptParameter`. + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/types/script-builder.ts +git commit -m "feat: add script_body and parameters_schema to SaveToLibraryRequest type + +Co-Authored-By: Claude Opus 4.6 (1M context) " +``` + +--- + +### Task 3: Frontend — Create `ParameterizeAndSavePanel` component + +**Files:** +- Create: `frontend/src/components/scripts/ParameterizeAndSavePanel.tsx` + +This is the core new component. It handles two modes (`script` and `paste`), auto-runs parameter detection, embeds the existing `ParameterDetectorStepper`, rewrites the script body with `{{ key }}` placeholders, collects metadata, and calls the save handler. + +- [ ] **Step 1: Create the component file** + +Create `frontend/src/components/scripts/ParameterizeAndSavePanel.tsx`: + +```tsx +import { useState, useEffect, useRef, useCallback } from 'react' +import { X, Loader2, FileCode, AlertCircle } from 'lucide-react' +import { cn } from '@/lib/utils' +import { scriptsApi } from '@/api' +import { detectParameterCandidates } from '@/lib/scriptParameterDetector' +import { ParameterDetectorStepper } from '@/components/script-editor/ParameterDetectorStepper' +import type { + ScriptCategoryResponse, + ScriptParameter, + ScriptParametersSchema, + ParameterCandidate, +} from '@/types' + +interface ParameterizeAndSavePanelProps { + /** Pre-populated script body (script mode). Undefined triggers paste mode. */ + scriptBody?: string + /** Script language. Undefined shows language picker in paste mode. */ + language?: string + /** Default name for the template. */ + defaultName?: string + /** Default description for the template. */ + defaultDescription?: string + /** Called with the final enriched payload when user saves. */ + onSave: (payload: { + name: string + description: string | undefined + category_id: string | undefined + share_with_team: boolean + script_body: string + parameters_schema: ScriptParametersSchema + }) => Promise + /** Called when the panel is closed without saving. */ + onClose: () => void +} + +const LANGUAGES = [ + { value: 'powershell', label: 'PowerShell' }, + { value: 'bash', label: 'Bash' }, + { value: 'python', label: 'Python' }, +] + +export function ParameterizeAndSavePanel({ + scriptBody, + language: initialLanguage, + defaultName = '', + defaultDescription = '', + onSave, + onClose, +}: ParameterizeAndSavePanelProps) { + // Mode: script (body provided) vs paste (user pastes) + const isPasteMode = scriptBody === undefined + + // Paste mode state + const [pastedScript, setPastedScript] = useState('') + const [selectedLanguage, setSelectedLanguage] = useState(initialLanguage || 'powershell') + const [scriptConfirmed, setScriptConfirmed] = useState(false) + + // Working state — the script body being rewritten as params are accepted + const effectiveScript = isPasteMode ? pastedScript : scriptBody! + const [workingScript, setWorkingScript] = useState(effectiveScript) + const [parameters, setParameters] = useState([]) + + // Detection state + const [candidates, setCandidates] = useState([]) + const [detectionRan, setDetectionRan] = useState(false) + const [showStepper, setShowStepper] = useState(false) + const [detectionSummary, setDetectionSummary] = useState(null) + + // Metadata state + const [name, setName] = useState(defaultName) + const [description, setDescription] = useState(defaultDescription) + const [categoryId, setCategoryId] = useState('') + const [shareWithTeam, setShareWithTeam] = useState(false) + const [categories, setCategories] = useState([]) + + // Save state + const [isSaving, setIsSaving] = useState(false) + const [error, setError] = useState(null) + + const panelRef = useRef(null) + + // Load categories on mount + useEffect(() => { + scriptsApi.getCategories().then(setCategories).catch(() => {}) + }, []) + + // Auto-run detection when script is ready (script mode: on mount, paste mode: after confirm) + const runDetection = useCallback((script: string) => { + const detected = detectParameterCandidates(script) + setCandidates(detected) + setDetectionRan(true) + if (detected.length > 0) { + setShowStepper(true) + } else { + setDetectionSummary('No parameters detected — script will be saved as-is. Parameter detection currently supports PowerShell only.') + } + }, []) + + // Script mode: run detection on mount + useEffect(() => { + if (!isPasteMode && effectiveScript) { + setWorkingScript(effectiveScript) + runDetection(effectiveScript) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // Paste mode: run detection after script is confirmed + useEffect(() => { + if (isPasteMode && scriptConfirmed && pastedScript) { + setWorkingScript(pastedScript) + runDetection(pastedScript) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [scriptConfirmed]) + + // Escape key closes panel + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [onClose]) + + const handleConfirmPaste = () => { + if (!pastedScript.trim()) return + setScriptConfirmed(true) + } + + const handleAcceptCandidate = ( + candidate: ParameterCandidate, + overrides: { + key: string + label: string + type: ScriptParameter['type'] + sensitive: boolean + required: boolean + defaultValue: string | boolean | number | null + } + ) => { + // Rewrite the script body — replace the default value with {{ key }} placeholder + let updatedScript = workingScript + if (candidate.source === 'param_block') { + const defaultMatch = candidate.matchedLine.match(/=\s*(.+?)(?:\s*,?\s*$)/) + if (defaultMatch) { + updatedScript = updatedScript.replace( + candidate.matchedLine, + candidate.matchedLine.replace(defaultMatch[1], `'{{${overrides.key}}}'`) + ) + } + } else { + const assignMatch = candidate.matchedLine.match(/=\s*(.+)$/) + if (assignMatch) { + updatedScript = updatedScript.replace( + candidate.matchedLine, + candidate.matchedLine.replace(assignMatch[1], `'{{${overrides.key}}}'`) + ) + } + } + setWorkingScript(updatedScript) + + // Add parameter to accumulated schema + const newParam: ScriptParameter = { + key: overrides.key, + label: overrides.label, + type: overrides.type, + required: overrides.required, + placeholder: null, + group: null, + order: parameters.length + 1, + help_text: null, + options: null, + default: overrides.defaultValue, + validation: null, + sensitive: overrides.sensitive, + } + setParameters(prev => [...prev, newParam]) + } + + const handleSkipCandidate = () => { + // Stepper advances internally — nothing to do here + } + + const handleDetectionFinish = (acceptedCount: number, totalCount: number) => { + setShowStepper(false) + setCandidates([]) + setDetectionSummary( + acceptedCount === 0 + ? 'No parameters were added. Script will be saved as-is.' + : `Added ${acceptedCount} of ${totalCount} detected parameter${totalCount !== 1 ? 's' : ''}.` + ) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!name.trim()) return + + setIsSaving(true) + setError(null) + + try { + await onSave({ + name: name.trim(), + description: description.trim() || undefined, + category_id: categoryId || undefined, + share_with_team: shareWithTeam, + script_body: workingScript, + parameters_schema: { parameters }, + }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to save script. Please try again.') + } finally { + setIsSaving(false) + } + } + + // Determine if save button should be enabled + const canSave = name.trim().length > 0 + && !isSaving + && !showStepper + && (isPasteMode ? scriptConfirmed : true) + + return ( + <> + {/* Scrim */} +
+ + {/* Panel */} +
+ {/* Header */} +
+
+ +

+ {isPasteMode ? 'Import Script to Library' : 'Save to Library'} +

+
+ +
+ + {/* Scrollable content */} +
+ + {/* Paste mode: script input area (before confirmation) */} + {isPasteMode && !scriptConfirmed && ( +
+

+ Paste Your Script +

+
+ {LANGUAGES.map((lang) => ( + + ))} +
+