Merge pull request #121 from resolutionflow/feat/task-lane-persistence
feat: task lane persistence + sidebar cleanup
This commit was merged in pull request #121.
This commit is contained in:
10
CLAUDE.md
10
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 <id> --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 <id> --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`
|
||||
|
||||
---
|
||||
|
||||
111
DESIGN-SYSTEM.md
111
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. |
|
||||
|
||||
30
backend/alembic/versions/068_add_pending_task_lane.py
Normal file
30
backend/alembic/versions/068_add_pending_task_lane.py
Normal file
@@ -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")
|
||||
31
backend/alembic/versions/069_add_status_update_step_type.py
Normal file
31
backend/alembic/versions/069_add_status_update_step_type.py
Normal file
@@ -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')",
|
||||
)
|
||||
@@ -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: <text>\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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
461
docs/superpowers/plans/2026-03-29-color-migration-v6.md
Normal file
461
docs/superpowers/plans/2026-03-29-color-migration-v6.md
Normal file
@@ -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) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 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) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 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) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 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) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 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) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 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
|
||||
<meta name="theme-color" content="#10121a" />
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```html
|
||||
<meta name="theme-color" content="#0e1016" />
|
||||
```
|
||||
|
||||
- [ ] **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) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 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).
|
||||
937
docs/superpowers/plans/2026-03-29-parameterize-and-save.md
Normal file
937
docs/superpowers/plans/2026-03-29-parameterize-and-save.md
Normal file
@@ -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) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 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) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 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<void>
|
||||
/** 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<ScriptParameter[]>([])
|
||||
|
||||
// Detection state
|
||||
const [candidates, setCandidates] = useState<ParameterCandidate[]>([])
|
||||
const [detectionRan, setDetectionRan] = useState(false)
|
||||
const [showStepper, setShowStepper] = useState(false)
|
||||
const [detectionSummary, setDetectionSummary] = useState<string | null>(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<ScriptCategoryResponse[]>([])
|
||||
|
||||
// Save state
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const panelRef = useRef<HTMLDivElement>(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 */}
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/50"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="fixed top-0 right-0 z-50 h-full w-[480px] max-w-full bg-page border-l border-default flex flex-col shadow-2xl"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-3.5 border-b border-default shrink-0">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<FileCode size={18} className="text-primary" />
|
||||
<h3 className="text-sm font-heading font-bold text-foreground">
|
||||
{isPasteMode ? 'Import Script to Library' : 'Save to Library'}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-elevated transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div className="flex-1 overflow-y-auto p-5 space-y-5">
|
||||
|
||||
{/* Paste mode: script input area (before confirmation) */}
|
||||
{isPasteMode && !scriptConfirmed && (
|
||||
<section className="space-y-3">
|
||||
<p className="font-sans text-xs uppercase tracking-widest text-muted-foreground">
|
||||
Paste Your Script
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
{LANGUAGES.map((lang) => (
|
||||
<button
|
||||
key={lang.value}
|
||||
type="button"
|
||||
onClick={() => setSelectedLanguage(lang.value)}
|
||||
className={cn(
|
||||
'px-3 py-1.5 rounded-md text-xs font-medium transition-all',
|
||||
selectedLanguage === lang.value
|
||||
? 'bg-primary text-white'
|
||||
: 'text-muted-foreground hover:text-foreground bg-elevated'
|
||||
)}
|
||||
>
|
||||
{lang.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
value={pastedScript}
|
||||
onChange={(e) => setPastedScript(e.target.value)}
|
||||
rows={12}
|
||||
className={cn(
|
||||
'w-full rounded-lg px-3 py-2 text-sm font-mono resize-none',
|
||||
'border border-default bg-card text-foreground placeholder:text-muted-foreground',
|
||||
'focus:outline-none focus:border-primary/30 transition-colors'
|
||||
)}
|
||||
placeholder="Paste your PowerShell, Bash, or Python script here..."
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirmPaste}
|
||||
disabled={!pastedScript.trim()}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-all',
|
||||
'bg-primary text-white hover:brightness-110 active:scale-[0.98]',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
Detect Parameters
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Script preview (visible after paste confirm, or always in script mode) */}
|
||||
{(!isPasteMode || scriptConfirmed) && (
|
||||
<section className="space-y-2">
|
||||
<p className="font-sans text-xs uppercase tracking-widest text-muted-foreground">
|
||||
Script Preview
|
||||
</p>
|
||||
<div className="rounded-lg bg-black/30 border border-default overflow-hidden max-h-48 overflow-y-auto">
|
||||
<pre className="p-3 text-xs font-mono text-foreground whitespace-pre-wrap break-all">
|
||||
{workingScript.split(/({{.*?}})/).map((part, i) =>
|
||||
/^{{.*}}$/.test(part)
|
||||
? <span key={i} className="text-amber-400 font-semibold">{part}</span>
|
||||
: <span key={i}>{part}</span>
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Parameter detection zone */}
|
||||
{detectionRan && !showStepper && detectionSummary && (
|
||||
<div className="flex items-start gap-2 rounded-lg bg-elevated p-3">
|
||||
<AlertCircle size={14} className="text-muted-foreground mt-0.5 shrink-0" />
|
||||
<p className="text-xs text-muted-foreground">{detectionSummary}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showStepper && candidates.length > 0 && (
|
||||
<section className="space-y-2">
|
||||
<p className="font-sans text-xs uppercase tracking-widest text-muted-foreground">
|
||||
Detected Parameters
|
||||
</p>
|
||||
<ParameterDetectorStepper
|
||||
candidates={candidates}
|
||||
existingKeys={parameters.map(p => p.key)}
|
||||
onAccept={handleAcceptCandidate}
|
||||
onSkip={handleSkipCandidate}
|
||||
onFinish={handleDetectionFinish}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Accepted parameters summary */}
|
||||
{parameters.length > 0 && !showStepper && (
|
||||
<section className="space-y-2">
|
||||
<p className="font-sans text-xs uppercase tracking-widest text-muted-foreground">
|
||||
Parameters ({parameters.length})
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{parameters.map((p) => (
|
||||
<div
|
||||
key={p.key}
|
||||
className="flex items-center justify-between rounded-lg bg-elevated px-3 py-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs font-mono text-amber-400">{`{{${p.key}}}`}</code>
|
||||
<span className="text-xs text-muted-foreground">{p.label}</span>
|
||||
</div>
|
||||
<span className="text-[0.625rem] text-muted-foreground uppercase tracking-wide">
|
||||
{p.type}{p.sensitive ? ' · sensitive' : ''}{p.required ? '' : ' · optional'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Metadata form — shown after detection is done (or immediately if no candidates) */}
|
||||
{detectionRan && !showStepper && (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<p className="font-sans text-xs uppercase tracking-widest text-muted-foreground">
|
||||
Template Details
|
||||
</p>
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="font-sans text-xs text-muted-foreground mb-1.5 block">
|
||||
Name <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
className={cn(
|
||||
'w-full rounded-lg px-3 py-2 text-sm',
|
||||
'border border-default bg-card text-foreground placeholder:text-muted-foreground',
|
||||
'focus:outline-none focus:border-primary/30 transition-colors'
|
||||
)}
|
||||
placeholder="Script name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="font-sans text-xs text-muted-foreground mb-1.5 block">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
className={cn(
|
||||
'w-full rounded-lg px-3 py-2 text-sm resize-none',
|
||||
'border border-default bg-card text-foreground placeholder:text-muted-foreground',
|
||||
'focus:outline-none focus:border-primary/30 transition-colors'
|
||||
)}
|
||||
placeholder="What does this script do?"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label className="font-sans text-xs text-muted-foreground mb-1.5 block">
|
||||
Category
|
||||
</label>
|
||||
<select
|
||||
value={categoryId}
|
||||
onChange={(e) => setCategoryId(e.target.value)}
|
||||
className={cn(
|
||||
'w-full rounded-lg px-3 py-2 text-sm',
|
||||
'border border-default bg-card text-foreground',
|
||||
'focus:outline-none focus:border-primary/30 transition-colors'
|
||||
)}
|
||||
>
|
||||
<option value="">No category</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>{cat.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Share with team */}
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={shareWithTeam}
|
||||
onChange={(e) => setShareWithTeam(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-border bg-card text-orange-500 focus:ring-orange-500/20"
|
||||
/>
|
||||
<span className="text-sm text-foreground">Share with team</span>
|
||||
</label>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<p className="text-xs text-rose-400">{error}</p>
|
||||
)}
|
||||
|
||||
{/* Save button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSave}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-semibold transition-all',
|
||||
'bg-primary text-white hover:brightness-110 active:scale-[0.98]',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{isSaving && <Loader2 size={14} className="animate-spin" />}
|
||||
{isSaving ? 'Saving...' : 'Save to Library'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify the component compiles**
|
||||
|
||||
Run: `cd /home/coder/resolutionflow/frontend && npx tsc --noEmit --pretty 2>&1 | head -30`
|
||||
|
||||
Expected: No errors in `ParameterizeAndSavePanel.tsx`.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/components/scripts/ParameterizeAndSavePanel.tsx
|
||||
git commit -m "feat: add ParameterizeAndSavePanel component
|
||||
|
||||
Slide-in panel for saving scripts to library with parameter detection,
|
||||
stepper review, template rewriting, and metadata collection. Supports
|
||||
both script mode (from AI builder) and paste mode (raw script import).
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Frontend — Wire `ParameterizeAndSavePanel` into `ScriptBuilderPage`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/pages/ScriptBuilderPage.tsx`
|
||||
|
||||
Replace the `SaveToLibraryDialog` import and usage with `ParameterizeAndSavePanel`.
|
||||
|
||||
- [ ] **Step 1: Update imports**
|
||||
|
||||
In `frontend/src/pages/ScriptBuilderPage.tsx`, replace the `SaveToLibraryDialog` import:
|
||||
|
||||
```typescript
|
||||
// REMOVE this line:
|
||||
import { SaveToLibraryDialog } from '@/components/script-builder/SaveToLibraryDialog'
|
||||
|
||||
// ADD this line:
|
||||
import { ParameterizeAndSavePanel } from '@/components/scripts/ParameterizeAndSavePanel'
|
||||
```
|
||||
|
||||
Also add `scriptBuilderApi` to the existing import if not already there (it is already imported on line 8):
|
||||
|
||||
```typescript
|
||||
import { scriptBuilderApi } from '@/api'
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the save handler function**
|
||||
|
||||
Inside the `ScriptBuilderPage` component, replace `handleSaved`:
|
||||
|
||||
```typescript
|
||||
const handleSaved = async (payload: {
|
||||
name: string
|
||||
description: string | undefined
|
||||
category_id: string | undefined
|
||||
share_with_team: boolean
|
||||
script_body: string
|
||||
parameters_schema: { parameters: import('@/types').ScriptParameter[] }
|
||||
}) => {
|
||||
if (!session) return
|
||||
await scriptBuilderApi.saveToLibrary(session.id, {
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
category_id: payload.category_id,
|
||||
share_with_team: payload.share_with_team,
|
||||
script_body: payload.script_body,
|
||||
parameters_schema: payload.parameters_schema,
|
||||
})
|
||||
setShowSaveDialog(false)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace the dialog JSX with the panel**
|
||||
|
||||
Replace the `SaveToLibraryDialog` JSX block (lines 194-200) with:
|
||||
|
||||
```tsx
|
||||
{/* Save panel */}
|
||||
{showSaveDialog && session && session.latest_script && (
|
||||
<ParameterizeAndSavePanel
|
||||
scriptBody={session.latest_script}
|
||||
language={session.language}
|
||||
defaultName={defaultSaveName}
|
||||
onSave={handleSaved}
|
||||
onClose={() => setShowSaveDialog(false)}
|
||||
/>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify it compiles**
|
||||
|
||||
Run: `cd /home/coder/resolutionflow/frontend && npx tsc --noEmit --pretty 2>&1 | head -30`
|
||||
|
||||
Expected: No type errors.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/pages/ScriptBuilderPage.tsx
|
||||
git commit -m "feat: wire ParameterizeAndSavePanel into ScriptBuilderPage
|
||||
|
||||
Replace SaveToLibraryDialog with the new panel that includes parameter
|
||||
detection, review, and template rewriting before saving to library.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Frontend — Add "New from Script" button to `ScriptLibraryPage`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/pages/ScriptLibraryPage.tsx`
|
||||
|
||||
- [ ] **Step 1: Add imports**
|
||||
|
||||
Add these imports to the top of `ScriptLibraryPage.tsx`:
|
||||
|
||||
```typescript
|
||||
import { FileUp } from 'lucide-react'
|
||||
import { ParameterizeAndSavePanel } from '@/components/scripts/ParameterizeAndSavePanel'
|
||||
import { scriptsApi } from '@/api'
|
||||
import type { ScriptParameter } from '@/types'
|
||||
```
|
||||
|
||||
Update the existing lucide import to include `FileUp` alongside `Terminal`, `Settings`, `Wand2`:
|
||||
|
||||
```typescript
|
||||
import { Terminal, Settings, Wand2, FileUp } from 'lucide-react'
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add state and handler for the import panel**
|
||||
|
||||
Inside `ScriptLibraryPage`, add state and a save handler:
|
||||
|
||||
```typescript
|
||||
const [showImportPanel, setShowImportPanel] = useState(false)
|
||||
|
||||
const handleImportSave = async (payload: {
|
||||
name: string
|
||||
description: string | undefined
|
||||
category_id: string | undefined
|
||||
share_with_team: boolean
|
||||
script_body: string
|
||||
parameters_schema: { parameters: ScriptParameter[] }
|
||||
}) => {
|
||||
// createTemplate requires category_id — if user didn't pick one,
|
||||
// fall back to the first available category
|
||||
const categoryId = payload.category_id || categories[0]?.id
|
||||
if (!categoryId) {
|
||||
throw new Error('No categories available. Please create a category first.')
|
||||
}
|
||||
await scriptsApi.createTemplate({
|
||||
category_id: categoryId,
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
script_body: payload.script_body,
|
||||
parameters_schema: payload.parameters_schema,
|
||||
})
|
||||
setShowImportPanel(false)
|
||||
// Reload templates to show the newly created one
|
||||
const filters = activeTab === 'mine' ? { mine: true } : { shared: true }
|
||||
loadTemplates(filters)
|
||||
}
|
||||
```
|
||||
|
||||
Note: `categories` needs to be read from the store. Add this line alongside the other store selectors:
|
||||
|
||||
```typescript
|
||||
const categories = useScriptGeneratorStore(s => s.categories)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add the "New from Script" button**
|
||||
|
||||
In the page header section, next to the "Manage Templates" link, add:
|
||||
|
||||
```tsx
|
||||
{isEngineer && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Link
|
||||
to="/scripts/manage"
|
||||
className="inline-flex items-center gap-1.5 text-xs text-primary bg-accent-dim hover:bg-primary/15 px-2.5 py-1 rounded-full transition-colors group"
|
||||
>
|
||||
<Settings size={12} className="group-hover:rotate-90 transition-transform duration-300" />
|
||||
Manage Templates
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowImportPanel(true)}
|
||||
className="inline-flex items-center gap-1.5 text-xs text-primary bg-accent-dim hover:bg-primary/15 px-2.5 py-1 rounded-full transition-colors"
|
||||
>
|
||||
<FileUp size={12} />
|
||||
New from Script
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
This replaces the existing `{isEngineer && (...)}` block that only had the Manage Templates link.
|
||||
|
||||
- [ ] **Step 4: Add the panel JSX**
|
||||
|
||||
At the end of the component return, before the closing `</div>`, add:
|
||||
|
||||
```tsx
|
||||
{/* Import script panel */}
|
||||
{showImportPanel && (
|
||||
<ParameterizeAndSavePanel
|
||||
onSave={handleImportSave}
|
||||
onClose={() => setShowImportPanel(false)}
|
||||
/>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify it compiles**
|
||||
|
||||
Run: `cd /home/coder/resolutionflow/frontend && npx tsc --noEmit --pretty 2>&1 | head -30`
|
||||
|
||||
Expected: No type errors.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/src/pages/ScriptLibraryPage.tsx
|
||||
git commit -m "feat: add 'New from Script' button to ScriptLibraryPage
|
||||
|
||||
Opens the ParameterizeAndSavePanel in paste mode, letting users import
|
||||
raw scripts with parameter detection and review before saving to library.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Frontend — Delete `SaveToLibraryDialog` and clean up imports
|
||||
|
||||
**Files:**
|
||||
- Delete: `frontend/src/components/script-builder/SaveToLibraryDialog.tsx`
|
||||
|
||||
- [ ] **Step 1: Verify no other files import `SaveToLibraryDialog`**
|
||||
|
||||
Run: `grep -r "SaveToLibraryDialog" frontend/src/ --include="*.ts" --include="*.tsx" | grep -v "node_modules"`
|
||||
|
||||
Expected: No results (ScriptBuilderPage was updated in Task 4).
|
||||
|
||||
- [ ] **Step 2: Delete the file**
|
||||
|
||||
```bash
|
||||
rm frontend/src/components/script-builder/SaveToLibraryDialog.tsx
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run full build check**
|
||||
|
||||
Run: `cd /home/coder/resolutionflow/frontend && npm run build 2>&1 | tail -20`
|
||||
|
||||
Expected: Build succeeds with no errors. This is the strictest check (`tsc -b` enforces `noUnusedLocals`).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add -u frontend/src/components/script-builder/SaveToLibraryDialog.tsx
|
||||
git commit -m "chore: delete SaveToLibraryDialog, replaced by ParameterizeAndSavePanel
|
||||
|
||||
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Full build verification and final commit
|
||||
|
||||
**Files:** None (verification only)
|
||||
|
||||
- [ ] **Step 1: Run frontend build**
|
||||
|
||||
Run: `cd /home/coder/resolutionflow/frontend && npm run build 2>&1 | tail -20`
|
||||
|
||||
Expected: Build succeeds. This catches any `noUnusedLocals`/`noUnusedParameters` errors the earlier `tsc --noEmit` might miss.
|
||||
|
||||
- [ ] **Step 2: Verify backend imports are clean**
|
||||
|
||||
Run: `cd /home/coder/resolutionflow/backend && source venv/bin/activate && python -c "from app.api.endpoints.script_builder import router; print('OK')"`
|
||||
|
||||
Expected: `OK`
|
||||
|
||||
- [ ] **Step 3: Verify git status is clean**
|
||||
|
||||
Run: `git status`
|
||||
|
||||
Expected: On branch `feat/task-lane-persistence`, working tree clean (all changes committed in Tasks 1-6).
|
||||
@@ -0,0 +1,158 @@
|
||||
# Task Lane Minimize/Reopen + Resolve Documentation Streaming
|
||||
|
||||
**Date:** 2026-03-28
|
||||
**Status:** Approved
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
Two UX issues in the Assistant Chat page:
|
||||
|
||||
1. **Task Lane close destroys state.** The X button calls `clearTaskState()` and hides the panel. There's no way to bring it back without getting a new AI response with markers. Engineers lose in-progress task responses.
|
||||
|
||||
2. **Conclude → Resolved hangs.** The resolve flow blocks on an LLM call to generate documentation. The modal shows "Generating..." for 2-5s+ with no progressive feedback. The generated output needs to be structured ticket notes engineers can copy into their PSA.
|
||||
|
||||
---
|
||||
|
||||
## Feature 1: Task Lane Minimize/Reopen
|
||||
|
||||
### Close Button Change
|
||||
|
||||
- Replace the X icon (`X` from Lucide) with `PanelRightClose` to signal "collapse" not "destroy."
|
||||
- On click: set `showTaskLane = false`. Do NOT call `clearTaskState(sessionId)`.
|
||||
- Task state (questions, actions, user responses) remains in sessionStorage and backend `pending_task_lane`.
|
||||
|
||||
### Reopen Pill on Input Toolbar
|
||||
|
||||
- New pill button in the chat input toolbar row, alongside Attach / Paste Logs / Conclude.
|
||||
- **Visibility condition:** `(activeQuestions.length > 0 || activeActions.length > 0) && !showTaskLane`
|
||||
- **Label:** `Tasks (N)` where N = `activeQuestions.length + activeActions.length`
|
||||
- **Icon:** `ListChecks` from Lucide
|
||||
- **Action:** `setShowTaskLane(true)`
|
||||
- **Style:** Same ghost button style as Attach/Paste Logs — muted text, hover highlight.
|
||||
|
||||
### Files Changed
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `frontend/src/components/assistant/TaskLane.tsx` | Replace `X` icon with `PanelRightClose` in header |
|
||||
| `frontend/src/pages/AssistantChatPage.tsx` | Remove `clearTaskState()` from onClose handler; add Tasks pill to input toolbar |
|
||||
|
||||
---
|
||||
|
||||
## Feature 2: Resolve with Streaming Documentation
|
||||
|
||||
### Two-Phase Resolve Flow
|
||||
|
||||
**Phase 1 — Instant resolve:**
|
||||
|
||||
1. User clicks Resolve in `ConcludeSessionModal`, enters optional notes, confirms.
|
||||
2. Frontend calls `POST /ai-sessions/{id}/resolve` with `{ resolution_summary }`.
|
||||
3. Backend sets session status to `resolved`, saves summary, returns immediately. No LLM call on this path.
|
||||
4. Modal transitions to the summary step instantly, showing a skeleton loading state for "Ticket Notes."
|
||||
|
||||
**Phase 2 — Streamed doc generation:**
|
||||
|
||||
1. Immediately after phase 1, frontend opens an SSE connection to `GET /ai-sessions/{id}/documentation/stream`.
|
||||
2. Backend streams the structured ticket notes as they generate, token by token.
|
||||
3. Frontend renders chunks progressively into the summary step using `MarkdownContent`.
|
||||
4. When stream completes, show a "Copy to Clipboard" button.
|
||||
|
||||
### Ticket Notes Format
|
||||
|
||||
The LLM generates four structured sections:
|
||||
|
||||
```markdown
|
||||
## Problem Summary
|
||||
[What the engineer reported / intake context]
|
||||
|
||||
## Steps Taken
|
||||
[Key diagnostic steps and findings from conversation]
|
||||
|
||||
## Resolution
|
||||
[What fixed it / final action taken]
|
||||
|
||||
## Next Steps
|
||||
[Follow-up items, if any — or "None"]
|
||||
```
|
||||
|
||||
### Fallback Behavior
|
||||
|
||||
- If SSE stream fails or times out (30s): fall back to non-streaming `GET /ai-sessions/{id}/documentation`.
|
||||
- If that also fails: show "Documentation unavailable" with a "Copy Conversation" button that formats the raw `conversation_messages` into a basic structured summary (no LLM, pure template).
|
||||
|
||||
### Copy to Clipboard
|
||||
|
||||
- Prominent button below the rendered ticket notes.
|
||||
- Copies the full markdown text.
|
||||
- Toast confirmation: "Ticket notes copied."
|
||||
|
||||
### AI Optimizations
|
||||
|
||||
**1. Streaming (SSE endpoint):**
|
||||
- New endpoint: `GET /ai-sessions/{id}/documentation/stream`
|
||||
- Returns `text/event-stream` via FastAPI `StreamingResponse`.
|
||||
- Uses Anthropic's `client.messages.stream()` for token-level streaming.
|
||||
- Time-to-first-byte drops from ~2-5s to ~200ms.
|
||||
|
||||
**2. Prompt caching:**
|
||||
- Apply `cache_control: {"type": "ephemeral"}` to the system prompt and conversation context prefix in the documentation generation call.
|
||||
- Pattern already exists in `assistant_chat_service.py` (`_call_anthropic_cached`).
|
||||
- Repeat calls for the same session (regenerate, different doc types) hit cache — up to 85% faster, 90% cheaper on input tokens.
|
||||
|
||||
**3. Client reuse:**
|
||||
- Singleton `AsyncAnthropic` client instead of creating a new one per call.
|
||||
- Eliminates connection setup overhead.
|
||||
|
||||
**Model tier:** Haiku (already configured as `quick_action` tier) — fastest model, appropriate for summarization.
|
||||
|
||||
### Backend Changes
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `backend/app/api/endpoints/ai_sessions.py` | Modify `resolve_session` to only set status + save summary (remove the blocking `get_session_documentation` call); add new SSE streaming endpoint for doc generation |
|
||||
| `backend/app/services/flowpilot_engine.py` | Add `stream_session_documentation()` generator function using `client.messages.stream()` |
|
||||
| `backend/app/core/ai_provider.py` | Add `generate_text_stream()` method returning async iterator; singleton client |
|
||||
| `backend/app/services/flowpilot_engine.py` | Add prompt caching to `_build_status_update_prompt` call path |
|
||||
|
||||
### Frontend Changes
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `frontend/src/components/assistant/ConcludeSessionModal.tsx` | Two-phase flow: instant resolve → streaming doc render with skeleton → copy button |
|
||||
| `frontend/src/api/aiSessions.ts` | Add `streamDocumentation(sessionId)` using fetch + ReadableStream |
|
||||
| `frontend/src/pages/AssistantChatPage.tsx` | Update `handleConclude` to not await documentation |
|
||||
|
||||
### ConcludeSessionModal Summary Step Layout
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Session Resolved │
|
||||
│ │
|
||||
│ ┌─ Ticket Notes ─────────────────┐ │
|
||||
│ │ ## Problem Summary │ │
|
||||
│ │ [streaming text...] │ │
|
||||
│ │ │ │
|
||||
│ │ ## Steps Taken │ │
|
||||
│ │ [streaming text...] │ │
|
||||
│ │ │ │
|
||||
│ │ ## Resolution │ │
|
||||
│ │ [streaming text...] │ │
|
||||
│ │ │ │
|
||||
│ │ ## Next Steps │ │
|
||||
│ │ [streaming text...] │ │
|
||||
│ └────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [ Copy to Clipboard ] [ Done ] │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Client-facing update generation (future feature, different audience prompt)
|
||||
- Direct PSA posting (requires ConnectWise integration to be wired to sessions)
|
||||
- Task lane drag-to-reorder
|
||||
- Task lane persistence across browser tabs (sessionStorage is per-tab by design)
|
||||
111
docs/superpowers/specs/2026-03-29-color-migration-v6-design.md
Normal file
111
docs/superpowers/specs/2026-03-29-color-migration-v6-design.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# Color Migration v5→v6 — Design Spec
|
||||
|
||||
> **Date:** 2026-03-29
|
||||
> **Status:** Approved
|
||||
> **Scope:** Migrate all color values from design system v5 (ember orange accent) to v6 (electric blue accent, cyan info, amber warning, deeper surfaces)
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
DESIGN-SYSTEM.md was updated to v6 with new color values, but the actual CSS variables and hardcoded color references in the codebase still use v5 values. The UI still renders with ember orange accent, old surface depths, yellow warnings, and no info color.
|
||||
|
||||
## Color Mapping
|
||||
|
||||
### Accent (orange → blue)
|
||||
|
||||
| Token | v5 (old) | v6 dark | v6 light |
|
||||
|-------|----------|---------|----------|
|
||||
| accent | #f97316 | #60a5fa | #2563eb |
|
||||
| accent-hover | #ea580c | #3b82f6 | #1d4ed8 |
|
||||
| accent-dim | rgba(249,115,22,0.10) | rgba(96,165,250,0.10) | rgba(37,99,235,0.07) |
|
||||
| accent-text | #fdba74 | #93c5fd | #1d4ed8 |
|
||||
| accent-glow | — | rgba(96,165,250,0.15) | rgba(37,99,235,0.10) |
|
||||
|
||||
### Surfaces (deeper charcoal)
|
||||
|
||||
| Token | v5 (old) | v6 |
|
||||
|-------|----------|-----|
|
||||
| page | #1a1c23 | #16181f |
|
||||
| sidebar | #10121a | #0e1016 |
|
||||
| card | #22252e | #1e2028 |
|
||||
| card-hover | #282b35 | #252830 |
|
||||
| input | #282b35 | #252830 |
|
||||
| code | #14161e | #12141a |
|
||||
| elevated | #2e3140 | #2a2d38 |
|
||||
| surface-raised (NEW) | — | #303442 |
|
||||
| border-default | #2e3240 | #2a2e3a |
|
||||
|
||||
### Warning (yellow → amber)
|
||||
|
||||
| Token | v5 (old) | v6 dark | v6 light |
|
||||
|-------|----------|---------|----------|
|
||||
| warning | #eab308 | #fbbf24 | #d97706 |
|
||||
| warning-dim | rgba(234,179,8,0.10) | rgba(251,191,36,0.10) | rgba(217,119,6,0.07) |
|
||||
|
||||
### Info (NEW — cyan)
|
||||
|
||||
| Token | v6 dark | v6 light |
|
||||
|-------|---------|----------|
|
||||
| info | #67e8f9 | #0891b2 |
|
||||
| info-dim | rgba(103,232,249,0.10) | rgba(8,145,178,0.07) |
|
||||
|
||||
### Semantic (light mode variants added)
|
||||
|
||||
| Token | v6 dark (unchanged) | v6 light (new) |
|
||||
|-------|---------------------|----------------|
|
||||
| success | #34d399 | #059669 |
|
||||
| success-dim | rgba(52,211,153,0.10) | rgba(5,150,105,0.07) |
|
||||
| danger | #f87171 | #dc2626 |
|
||||
| danger-dim | rgba(248,113,113,0.10) | rgba(220,38,38,0.07) |
|
||||
|
||||
## Migration Layers
|
||||
|
||||
### Layer 1: CSS Custom Properties (`index.css`)
|
||||
|
||||
The `@theme` block in `frontend/src/index.css` defines all design tokens. Updating these cascades to ~80% of the codebase automatically via Tailwind utility classes (`bg-accent`, `text-primary`, `border-default`, etc.).
|
||||
|
||||
**Changes:** Swap all color values per the mapping above. Add `info` and `info-dim` tokens. Add `surface-raised` token. Add light mode accent/semantic variants.
|
||||
|
||||
### Layer 2: Hardcoded Hex Values
|
||||
|
||||
Files that bypass CSS variables with inline hex colors:
|
||||
- `frontend/src/components/common/BrandLogo.tsx` — logo gradient (`#ea580c`, `#f97316`)
|
||||
- `frontend/src/pages/LandingPage.tsx` — inline styles with `#f97316`
|
||||
- `frontend/src/constants/categoryColors.ts` — color array with `#ea580c`, `#f97316`
|
||||
- `frontend/src/styles/landing.css` (if exists) — orange rgba values in shadows/glows
|
||||
- Any component with `rgba(249,115,22,...)` inline
|
||||
|
||||
### Layer 3: Tailwind Color Classes
|
||||
|
||||
~21 files using `orange-500`, `orange-600`, `orange-400` Tailwind classes. Replace with `blue-400`, `blue-500`, `blue-600` equivalents.
|
||||
|
||||
### Layer 4: Configuration
|
||||
|
||||
- `frontend/index.html` — `meta theme-color` value
|
||||
- Any PWA manifest colors
|
||||
|
||||
## File Changes
|
||||
|
||||
### Primary
|
||||
- `frontend/src/index.css` — CSS custom properties (the big one)
|
||||
- `frontend/index.html` — meta theme-color
|
||||
|
||||
### Hardcoded Colors
|
||||
- `frontend/src/components/common/BrandLogo.tsx` — logo gradient
|
||||
- `frontend/src/pages/LandingPage.tsx` — inline styles
|
||||
- `frontend/src/constants/categoryColors.ts` — color array
|
||||
- Any landing.css / component CSS files with hardcoded orange
|
||||
|
||||
### Tailwind Classes (grep for orange-400/500/600)
|
||||
- ~21 component files — mechanical find-and-replace
|
||||
|
||||
### Warning Yellow
|
||||
- Any files with hardcoded `#eab308` — replace with `#fbbf24`
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Typography changes (none in v6)
|
||||
- Layout/spacing changes (none in v6)
|
||||
- Component structure changes (none in v6)
|
||||
- Adding the light mode toggle UI (separate feature)
|
||||
@@ -0,0 +1,165 @@
|
||||
# Parameterize & Save — Script Library Integration
|
||||
|
||||
> **Date:** 2026-03-29
|
||||
> **Status:** Approved
|
||||
> **Scope:** Fix AI-generated scripts saving to library without parameters; add parameter detection/review to save flow; add "New from Script" paste entry point
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
When the AI Script Builder generates a script and the user saves it to the Script Library:
|
||||
|
||||
1. **`parameters_schema` is hardcoded to `{"parameters": []}`** in `save_to_library()` — no parameter detection runs
|
||||
2. **The script body uses raw PowerShell `param()` syntax**, not the `{{ key }}` template placeholders that `ScriptTemplateEngine.render()` expects
|
||||
|
||||
The result: saved templates have no parameters and can't be rendered with user-provided values. The template engine has nothing to substitute.
|
||||
|
||||
The frontend already has working parameter detection (`scriptParameterDetector.ts`) and a review UI (`ParameterDetectorStepper`), but they're only wired into `ScriptTemplateEditor` — not the save-to-library flow.
|
||||
|
||||
## Solution
|
||||
|
||||
A shared `ParameterizeAndSavePanel` component that:
|
||||
|
||||
1. Shows the script with live preview of `{{ }}` replacements
|
||||
2. Auto-runs parameter detection and lets the user review/accept/skip each candidate
|
||||
3. Collects minimal metadata (name, description, category, share toggle)
|
||||
4. Sends the rewritten script body + built parameters schema to the backend
|
||||
|
||||
Used in two entry points:
|
||||
- **AI Script Builder** — replaces the current `SaveToLibraryDialog`
|
||||
- **Script Library page** — "New from Script" button for pasting raw scripts
|
||||
|
||||
> **Note:** Parameter detection currently supports PowerShell only. Bash and Python scripts will show "No parameters detected" and save as-is. Detection for those languages is planned for a future iteration.
|
||||
|
||||
---
|
||||
|
||||
## Design
|
||||
|
||||
### `ParameterizeAndSavePanel` Component
|
||||
|
||||
A slide-in panel from the right, ~480px wide, semi-transparent scrim behind it. Close via X button or scrim click.
|
||||
|
||||
**Layout (top to bottom):**
|
||||
|
||||
1. **Script Preview** — read-only code block showing the script body. As parameters are accepted via the stepper, the preview updates live to show `{{ key }}` replacements highlighted in amber.
|
||||
|
||||
2. **Parameter Detection Zone** — auto-runs `detectParameterCandidates()` when the panel opens.
|
||||
- If candidates found: renders the existing `ParameterDetectorStepper` inline
|
||||
- If no candidates found: shows "No parameters detected — script will be saved as-is"
|
||||
|
||||
3. **Metadata Fields** — name (required), description, category dropdown, share-with-team toggle. Same fields as the current `SaveToLibraryDialog`.
|
||||
|
||||
4. **Save Button** — sends rewritten script body + parameters schema + metadata to the backend.
|
||||
|
||||
**Two modes controlled by props:**
|
||||
|
||||
- **`script` mode** (from AI builder): script body + language provided, skips straight to preview + detection
|
||||
- **`paste` mode** (from library page): shows a textarea + language picker at the top of the panel, above the preview area. Once pasted and confirmed, textarea collapses into the read-only preview and detection runs.
|
||||
|
||||
**State:** Local React state (not Zustand). Tracks:
|
||||
- `workingScript`: the in-progress script body, mutated as candidates are accepted
|
||||
- `parametersSchema`: accumulated `ScriptParameter[]` array
|
||||
- `metadata`: name, description, categoryId, shareWithTeam
|
||||
- `mode`: paste vs script (derived from props)
|
||||
|
||||
### Entry Point 1: AI Script Builder
|
||||
|
||||
`ScriptBuilderPage` currently opens `SaveToLibraryDialog`. Replace with `ParameterizeAndSavePanel` in `script` mode.
|
||||
|
||||
**Props:**
|
||||
- `scriptBody`: `session.latest_script`
|
||||
- `language`: `session.language`
|
||||
- `defaultName`: suggested filename minus extension
|
||||
- `onSave`: calls `scriptBuilderApi.saveToLibrary()` with enriched payload
|
||||
- `onClose`: closes the panel
|
||||
|
||||
### Entry Point 2: Script Library Page
|
||||
|
||||
Add a "New from Script" button on `ScriptLibraryPage` (near the "Manage Templates" link). Opens `ParameterizeAndSavePanel` in `paste` mode.
|
||||
|
||||
**Props:**
|
||||
- `scriptBody`: `undefined` (triggers paste mode)
|
||||
- `language`: `undefined` (user picks in the panel)
|
||||
- `onSave`: calls `scriptsApi.createTemplate()` directly
|
||||
- `onClose`: closes the panel
|
||||
|
||||
### Backend Changes
|
||||
|
||||
**`SaveToLibraryRequest` schema** (`backend/app/schemas/script_builder.py`):
|
||||
|
||||
Add two optional fields:
|
||||
```python
|
||||
script_body: str | None = None # Rewritten script with {{ }} placeholders
|
||||
parameters_schema: dict | None = None # Built parameter schema from frontend
|
||||
```
|
||||
|
||||
**`save_to_library()` service** (`backend/app/services/script_builder_service.py`):
|
||||
|
||||
Add `script_body: str | None = None` and `parameters_schema: dict | None = None` to the function signature. Use provided values instead of hardcoding:
|
||||
```python
|
||||
template = ScriptTemplate(
|
||||
...
|
||||
script_body=script_body or session.latest_script,
|
||||
parameters_schema=parameters_schema or {"parameters": []},
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
**`save_to_library` endpoint** (`backend/app/api/endpoints/script_builder.py`):
|
||||
|
||||
Pass `data.script_body` and `data.parameters_schema` from the request through to the service function.
|
||||
|
||||
**No new endpoints needed.** The library paste flow uses the existing `POST /scripts/templates` which already accepts `script_body` + `parameters_schema`.
|
||||
|
||||
### Script Rewriting Logic
|
||||
|
||||
The `ParameterizeAndSavePanel` reuses the same approach as `ScriptTemplateEditor.handleAcceptCandidate()`:
|
||||
|
||||
When a candidate is accepted:
|
||||
1. Find the matched line in the working script
|
||||
2. Replace the default value portion with `'{{ key }}'`
|
||||
3. Add the parameter to the accumulated schema
|
||||
|
||||
This happens in the panel's local state. The stepper emits accept/skip events; the panel handles the rewriting.
|
||||
|
||||
---
|
||||
|
||||
## File Changes
|
||||
|
||||
### New Files
|
||||
- `frontend/src/components/scripts/ParameterizeAndSavePanel.tsx` — shared panel component
|
||||
|
||||
### Modified Files
|
||||
- `frontend/src/pages/ScriptBuilderPage.tsx` — replace `SaveToLibraryDialog` with `ParameterizeAndSavePanel` (script mode)
|
||||
- `frontend/src/pages/ScriptLibraryPage.tsx` — add "New from Script" button, render `ParameterizeAndSavePanel` (paste mode)
|
||||
- `backend/app/schemas/script_builder.py` — add `script_body` and `parameters_schema` to `SaveToLibraryRequest`
|
||||
- `backend/app/services/script_builder_service.py` — use provided values in `save_to_library()`
|
||||
- `backend/app/api/endpoints/script_builder.py` — pass new fields through
|
||||
|
||||
### Deleted Files
|
||||
- `frontend/src/components/script-builder/SaveToLibraryDialog.tsx` — replaced entirely
|
||||
|
||||
### Unchanged (reused as-is)
|
||||
- `frontend/src/components/script-editor/ParameterDetectorStepper.tsx`
|
||||
- `frontend/src/lib/scriptParameterDetector.ts`
|
||||
- `frontend/src/components/script-editor/ScriptTemplateEditor.tsx`
|
||||
- `POST /scripts/templates` endpoint
|
||||
|
||||
---
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
**In scope:**
|
||||
- `ParameterizeAndSavePanel` component with script/paste modes
|
||||
- Parameter detection + stepper review in the save flow
|
||||
- Script body rewriting with `{{ }}` placeholders
|
||||
- Backend accepting enriched save payload
|
||||
- "New from Script" button on library page
|
||||
- PowerShell parameter detection only
|
||||
|
||||
**Out of scope:**
|
||||
- Bash/Python parameter detection (future iteration)
|
||||
- Changes to `ScriptTemplateEditor` or `ParameterSchemaBuilder`
|
||||
- Changes to `ScriptTemplateEngine` rendering logic
|
||||
- New API endpoints
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<!-- PWA Icons -->
|
||||
<link rel="apple-touch-icon" href="/icons/app-icon-gradient.svg" />
|
||||
<meta name="theme-color" content="#10121a" />
|
||||
<meta name="theme-color" content="#0e1016" />
|
||||
|
||||
<script>
|
||||
// Prevent flash of wrong theme on initial load
|
||||
|
||||
@@ -99,6 +99,69 @@ export const aiSessionsApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
async streamDocumentation(
|
||||
sessionId: string,
|
||||
onChunk: (text: string) => void,
|
||||
onDone: () => void,
|
||||
onError: (error: string) => void,
|
||||
): Promise<void> {
|
||||
const token = localStorage.getItem('access_token')
|
||||
const baseUrl = import.meta.env.VITE_API_URL || ''
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${baseUrl}/api/v1/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')
|
||||
}
|
||||
},
|
||||
|
||||
async rateSession(sessionId: string, data: { rating: number; feedback?: string }): Promise<void> {
|
||||
await apiClient.post(`/ai-sessions/${sessionId}/rate`, data)
|
||||
},
|
||||
@@ -110,6 +173,14 @@ export const aiSessionsApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
async saveTaskLane(sessionId: string, data: {
|
||||
questions: Array<{ text: string; context?: string }>;
|
||||
actions: Array<{ label: string; command?: string | null; description?: string }>;
|
||||
responses: Array<Record<string, unknown>>;
|
||||
}): Promise<void> {
|
||||
await apiClient.put(`/ai-sessions/${sessionId}/task-lane`, data)
|
||||
},
|
||||
|
||||
async pauseSession(sessionId: string): Promise<void> {
|
||||
await apiClient.post(`/ai-sessions/${sessionId}/pause`)
|
||||
},
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<svg viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="resolutionflow-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#ea580c"/>
|
||||
<stop offset="100%" stop-color="#f97316"/>
|
||||
<stop offset="0%" stop-color="#3b82f6"/>
|
||||
<stop offset="100%" stop-color="#60a5fa"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Input circles (choices) -->
|
||||
|
||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -1,8 +1,8 @@
|
||||
<svg viewBox="0 0 320 80" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="rf-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#ea580c"/>
|
||||
<stop offset="100%" stop-color="#f97316"/>
|
||||
<stop offset="0%" stop-color="#3b82f6"/>
|
||||
<stop offset="100%" stop-color="#60a5fa"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.8 KiB |
@@ -1,8 +1,8 @@
|
||||
<svg viewBox="0 0 320 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="rf-gradient-tag" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#ea580c"/>
|
||||
<stop offset="100%" stop-color="#f97316"/>
|
||||
<stop offset="0%" stop-color="#3b82f6"/>
|
||||
<stop offset="100%" stop-color="#60a5fa"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
@@ -294,7 +294,7 @@ export function NotificationSettings() {
|
||||
type="checkbox"
|
||||
checked={config.events_enabled[eventKey] ?? false}
|
||||
onChange={() => handleToggleEvent(config, eventKey)}
|
||||
className="h-3.5 w-3.5 rounded border-border bg-card text-primary focus:ring-primary/30 focus:ring-offset-0 cursor-pointer accent-[#ea580c]"
|
||||
className="h-3.5 w-3.5 rounded border-border bg-card text-primary focus:ring-primary/30 focus:ring-offset-0 cursor-pointer accent-[#3b82f6]"
|
||||
/>
|
||||
<span className="text-sm text-foreground">{eventLabel}</span>
|
||||
</label>
|
||||
|
||||
@@ -16,7 +16,7 @@ import type { FlowAnalyticsResponse, AnalyticsPeriod } from '@/types'
|
||||
const CHART_COLORS = {
|
||||
resolved: '#34d399',
|
||||
escalated: '#f87171',
|
||||
workaround: '#eab308',
|
||||
workaround: '#fbbf24',
|
||||
unresolved: '#94a3b8',
|
||||
}
|
||||
|
||||
|
||||
@@ -95,21 +95,21 @@ export default function PsaMetricsPanel({ data }: PsaMetricsPanelProps) {
|
||||
<Bar
|
||||
yAxisId="entries"
|
||||
dataKey="entries"
|
||||
fill="rgba(249,115,22,0.3)"
|
||||
fill="rgba(96,165,250,0.3)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
<Area
|
||||
yAxisId="hours"
|
||||
type="monotone"
|
||||
dataKey="hours"
|
||||
stroke="#f97316"
|
||||
stroke="#60a5fa"
|
||||
fill="url(#psaHoursGradient)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient id="psaHoursGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#f97316" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#f97316" stopOpacity={0} />
|
||||
<stop offset="5%" stopColor="#60a5fa" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#60a5fa" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</ComposedChart>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import {
|
||||
X,
|
||||
CheckCircle2,
|
||||
@@ -10,9 +10,14 @@ import {
|
||||
RefreshCw,
|
||||
ClipboardList,
|
||||
Sparkles,
|
||||
AlertTriangle,
|
||||
FileText,
|
||||
User,
|
||||
Mail,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { MarkdownContent } from '@/components/ui/MarkdownContent'
|
||||
import { aiSessionsApi } from '@/api/aiSessions'
|
||||
|
||||
type ConclusionOutcome = 'resolved' | 'escalated' | 'paused'
|
||||
|
||||
@@ -22,6 +27,7 @@ interface ConcludeSessionModalProps {
|
||||
onConclude: (outcome: ConclusionOutcome, notes: string) => Promise<string>
|
||||
onResumeNew: (summary: string) => void
|
||||
chatTitle: string
|
||||
sessionId: string | null
|
||||
}
|
||||
|
||||
const OUTCOMES: { value: ConclusionOutcome; label: string; description: string; icon: typeof CheckCircle2; color: string; bg: string; border: string }[] = [
|
||||
@@ -62,6 +68,7 @@ export function ConcludeSessionModal({
|
||||
onConclude,
|
||||
onResumeNew,
|
||||
chatTitle,
|
||||
sessionId,
|
||||
}: ConcludeSessionModalProps) {
|
||||
const [step, setStep] = useState<ModalStep>('select-outcome')
|
||||
const [outcome, setOutcome] = useState<ConclusionOutcome | null>(null)
|
||||
@@ -70,6 +77,10 @@ export function ConcludeSessionModal({
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [streaming, setStreaming] = useState(false)
|
||||
const [streamError, setStreamError] = useState<string | null>(null)
|
||||
const [generatingUpdate, setGeneratingUpdate] = useState(false)
|
||||
const summaryRef = useRef('')
|
||||
|
||||
// Reset state when modal opens
|
||||
useEffect(() => {
|
||||
@@ -81,6 +92,10 @@ export function ConcludeSessionModal({
|
||||
setGenerating(false)
|
||||
setCopied(false)
|
||||
setError(null)
|
||||
setStreaming(false)
|
||||
setStreamError(null)
|
||||
setGeneratingUpdate(false)
|
||||
summaryRef.current = ''
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
@@ -95,12 +110,52 @@ export function ConcludeSessionModal({
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const result = await onConclude(outcome, notes)
|
||||
setSummary(result)
|
||||
// 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)
|
||||
// Fallback: use status update API which works with conversation context
|
||||
aiSessionsApi.generateStatusUpdate(sessionId, {
|
||||
audience: 'ticket_notes',
|
||||
length: 'detailed',
|
||||
context: 'resolution',
|
||||
}).then((result) => {
|
||||
setSummary(result.content)
|
||||
setStreamError(null)
|
||||
}).catch(() => {
|
||||
if (!summaryRef.current) {
|
||||
setSummary('Documentation generation failed. You can copy the conversation from the chat.')
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
} else {
|
||||
// For paused/escalated: don't set summary yet — show status update options
|
||||
setSummary('')
|
||||
}
|
||||
} catch {
|
||||
setError('Failed to generate summary. Please try again.')
|
||||
} finally {
|
||||
setError('Failed to conclude session. Please try again.')
|
||||
setGenerating(false)
|
||||
}
|
||||
}
|
||||
@@ -128,6 +183,25 @@ export function ConcludeSessionModal({
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleGenerateStatusUpdate = async (audience: 'ticket_notes' | 'client_update' | 'email_draft') => {
|
||||
if (!sessionId) return
|
||||
setGeneratingUpdate(true)
|
||||
try {
|
||||
const context = outcome === 'escalated' ? 'escalation' : 'status'
|
||||
const result = await aiSessionsApi.generateStatusUpdate(sessionId, {
|
||||
audience,
|
||||
length: 'detailed',
|
||||
context,
|
||||
})
|
||||
setSummary(result.content)
|
||||
setCopied(false)
|
||||
} catch {
|
||||
setSummary('Failed to generate status update. You can copy the conversation from the chat.')
|
||||
} finally {
|
||||
setGeneratingUpdate(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const selectedOutcome = OUTCOMES.find(o => o.value === outcome)
|
||||
@@ -306,21 +380,115 @@ export function ConcludeSessionModal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Generated summary */}
|
||||
<div
|
||||
className="rounded-xl border p-5 bg-card"
|
||||
style={{ borderColor: 'var(--color-border-default)' }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="font-sans text-xs text-[0.625rem] uppercase tracking-widest text-muted-foreground flex items-center gap-1.5">
|
||||
<Sparkles size={10} className="text-primary" />
|
||||
Generated Ticket Notes
|
||||
</span>
|
||||
{/* Resolved: streamed ticket notes */}
|
||||
{outcome === 'resolved' && (
|
||||
<div
|
||||
className="rounded-xl border p-5 bg-card"
|
||||
style={{ borderColor: 'var(--color-border-default)' }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="font-sans text-xs text-[0.625rem] uppercase tracking-widest text-muted-foreground flex items-center gap-1.5">
|
||||
<Sparkles size={10} className="text-primary" />
|
||||
Ticket Notes
|
||||
</span>
|
||||
{streaming && (
|
||||
<Loader2 size={14} className="animate-spin text-primary" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{summary ? (
|
||||
<div className="prose-sm text-foreground">
|
||||
<MarkdownContent content={summary} className="text-[0.8125rem] leading-relaxed" />
|
||||
</div>
|
||||
) : streaming ? (
|
||||
<div className="space-y-3 animate-pulse">
|
||||
<div className="h-4 bg-elevated rounded w-1/3" />
|
||||
<div className="h-3 bg-elevated rounded w-full" />
|
||||
<div className="h-3 bg-elevated rounded w-5/6" />
|
||||
<div className="h-4 bg-elevated rounded w-1/4 mt-4" />
|
||||
<div className="h-3 bg-elevated rounded w-full" />
|
||||
<div className="h-3 bg-elevated rounded w-4/5" />
|
||||
</div>
|
||||
) : streamError ? (
|
||||
<div className="flex items-center gap-2 text-sm text-amber-400">
|
||||
<AlertTriangle size={14} />
|
||||
{streamError}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="prose-sm text-foreground">
|
||||
<MarkdownContent content={summary} className="text-[0.8125rem] leading-relaxed" />
|
||||
)}
|
||||
|
||||
{/* Paused/Escalated: status update options */}
|
||||
{(outcome === 'paused' || outcome === 'escalated') && !summary && !generatingUpdate && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{outcome === 'paused'
|
||||
? 'Session paused. Generate a status update to share progress.'
|
||||
: 'Session escalated. Generate an update to document the handoff.'}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={() => handleGenerateStatusUpdate('ticket_notes')}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-4 py-3 text-left transition-colors hover:bg-[var(--color-bg-elevated)]"
|
||||
style={{ border: '1px solid var(--color-border-default)' }}
|
||||
>
|
||||
<FileText size={18} className="text-muted-foreground shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Ticket Notes</p>
|
||||
<p className="text-xs text-muted-foreground">Technical, for your PSA</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleGenerateStatusUpdate('client_update')}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-4 py-3 text-left transition-colors hover:bg-[var(--color-bg-elevated)]"
|
||||
style={{ border: '1px solid var(--color-border-default)' }}
|
||||
>
|
||||
<User size={18} className="text-muted-foreground shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Client Update</p>
|
||||
<p className="text-xs text-muted-foreground">Professional, non-technical</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleGenerateStatusUpdate('email_draft')}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-4 py-3 text-left transition-colors hover:bg-[var(--color-bg-elevated)]"
|
||||
style={{ border: '1px solid var(--color-border-default)' }}
|
||||
>
|
||||
<Mail size={18} className="text-muted-foreground shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Email Draft</p>
|
||||
<p className="text-xs text-muted-foreground">Full email with subject line</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Paused/Escalated: generating spinner */}
|
||||
{(outcome === 'paused' || outcome === 'escalated') && generatingUpdate && (
|
||||
<div className="flex flex-col items-center justify-center py-8 gap-3">
|
||||
<Loader2 size={24} className="animate-spin text-blue-400" />
|
||||
<p className="text-sm text-muted-foreground">Generating status update...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Paused/Escalated: generated result */}
|
||||
{(outcome === 'paused' || outcome === 'escalated') && summary && !generatingUpdate && (
|
||||
<div
|
||||
className="rounded-xl border p-5 bg-card"
|
||||
style={{ borderColor: 'var(--color-border-default)' }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="font-sans text-xs text-[0.625rem] uppercase tracking-widest text-muted-foreground flex items-center gap-1.5">
|
||||
<Sparkles size={10} className="text-primary" />
|
||||
Status Update
|
||||
</span>
|
||||
</div>
|
||||
<div className="prose-sm text-foreground">
|
||||
<MarkdownContent content={summary} className="text-[0.8125rem] leading-relaxed" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -382,29 +550,39 @@ export function ConcludeSessionModal({
|
||||
Resume in New Chat
|
||||
</button>
|
||||
)}
|
||||
{(outcome === 'paused' || outcome === 'escalated') && summary && !generatingUpdate && (
|
||||
<button
|
||||
onClick={() => { setSummary(''); setCopied(false) }}
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium text-muted-foreground hover:text-foreground bg-input border border-border hover:border-border-hover transition-all"
|
||||
>
|
||||
Switch Format
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-semibold transition-all',
|
||||
copied
|
||||
? 'bg-emerald-400/15 text-emerald-400 border border-emerald-400/30'
|
||||
: 'bg-primary text-white hover:brightness-110 active:scale-[0.98]'
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check size={15} />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy size={15} />
|
||||
Copy to Clipboard
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{summary && !streaming && !generatingUpdate && (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-semibold transition-all',
|
||||
copied
|
||||
? 'bg-emerald-400/15 text-emerald-400 border border-emerald-400/30'
|
||||
: 'bg-primary text-white hover:brightness-110 active:scale-[0.98]'
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check size={15} />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy size={15} />
|
||||
Copy to Clipboard
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2.5 rounded-lg text-sm text-muted-foreground hover:text-foreground bg-input border border-border hover:border-border-hover transition-all"
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import {
|
||||
Copy, Check, SkipForward, Terminal, ChevronDown, ChevronUp,
|
||||
Send, Clipboard, Loader2, X, MessageCircleQuestion, Eye,
|
||||
Send, Clipboard, Loader2, PanelRightClose, MessageCircleQuestion, Eye,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { aiSessionsApi } from '@/api/aiSessions'
|
||||
import type { ActionItem, QuestionItem } from '@/types/ai-session'
|
||||
|
||||
// ── Types ──
|
||||
@@ -33,24 +34,52 @@ type TaskResponse = QuestionResponse | ActionResponse
|
||||
interface TaskLaneProps {
|
||||
questions: QuestionItem[]
|
||||
actions: ActionItem[]
|
||||
sessionId?: string | null
|
||||
onSubmit: (responses: TaskResponse[]) => void
|
||||
onClose: () => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
// ── Storage helpers ──
|
||||
|
||||
const TASK_LANE_STORAGE_KEY = 'rf-tasklane-state'
|
||||
|
||||
function saveTaskState(sessionId: string, tasks: TaskResponse[]) {
|
||||
try {
|
||||
sessionStorage.setItem(`${TASK_LANE_STORAGE_KEY}:${sessionId}`, JSON.stringify(tasks))
|
||||
} catch { /* quota exceeded — ignore */ }
|
||||
}
|
||||
|
||||
function loadTaskState(sessionId: string): TaskResponse[] | null {
|
||||
try {
|
||||
const stored = sessionStorage.getItem(`${TASK_LANE_STORAGE_KEY}:${sessionId}`)
|
||||
return stored ? JSON.parse(stored) : null
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
export function clearTaskState(sessionId: string) {
|
||||
try { sessionStorage.removeItem(`${TASK_LANE_STORAGE_KEY}:${sessionId}`) } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// ── Component ──
|
||||
|
||||
export function TaskLane({ questions, actions, onSubmit, onClose, loading }: TaskLaneProps) {
|
||||
const [tasks, setTasks] = useState<TaskResponse[]>(() => [
|
||||
...questions.map((q): QuestionResponse => ({
|
||||
type: 'question', text: q.text, context: q.context, state: 'pending', value: '',
|
||||
})),
|
||||
...actions.map((a): ActionResponse => ({
|
||||
type: 'action', label: a.label, command: a.command, description: a.description, state: 'pending', value: '',
|
||||
})),
|
||||
])
|
||||
export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loading }: TaskLaneProps) {
|
||||
const [tasks, setTasks] = useState<TaskResponse[]>(() => {
|
||||
// Try to restore saved state for this session (preserves user's in-progress answers)
|
||||
if (sessionId) {
|
||||
const saved = loadTaskState(sessionId)
|
||||
if (saved && saved.length > 0) return saved
|
||||
}
|
||||
return [
|
||||
...questions.map((q): QuestionResponse => ({
|
||||
type: 'question', text: q.text, context: q.context, state: 'pending', value: '',
|
||||
})),
|
||||
...actions.map((a): ActionResponse => ({
|
||||
type: 'action', label: a.label, command: a.command, description: a.description, state: 'pending', value: '',
|
||||
})),
|
||||
]
|
||||
})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [submitted, setSubmitted] = useState(false)
|
||||
const [showRunAll, setShowRunAll] = useState(false)
|
||||
const [showPreview, setShowPreview] = useState(false)
|
||||
|
||||
@@ -101,8 +130,33 @@ export function TaskLane({ questions, actions, onSubmit, onClose, loading }: Tas
|
||||
}
|
||||
}, [handleMouseMove, handleMouseUp])
|
||||
|
||||
// Reset when new tasks come in from AI response
|
||||
// Save task state to sessionStorage on every change + debounce to backend
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
useEffect(() => {
|
||||
if (!sessionId) return
|
||||
saveTaskState(sessionId, tasks)
|
||||
// Debounce save to backend (2s after last change)
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
aiSessionsApi.saveTaskLane(sessionId, {
|
||||
questions: questions.map(q => ({ text: q.text, context: q.context })),
|
||||
actions: actions.map(a => ({ label: a.label, command: a.command, description: a.description })),
|
||||
responses: tasks as unknown as Array<Record<string, unknown>>,
|
||||
}).catch(() => { /* silent — best-effort save */ })
|
||||
}, 2000)
|
||||
return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current) }
|
||||
}, [sessionId, tasks]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Reset when new tasks come in from AI response — but preserve saved state
|
||||
useEffect(() => {
|
||||
if (sessionId) {
|
||||
const saved = loadTaskState(sessionId)
|
||||
if (saved && saved.length > 0) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: syncs derived state from prop changes
|
||||
setTasks(saved)
|
||||
return
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: syncs derived state from prop changes
|
||||
setTasks([
|
||||
...questions.map((q): QuestionResponse => ({
|
||||
@@ -112,8 +166,7 @@ export function TaskLane({ questions, actions, onSubmit, onClose, loading }: Tas
|
||||
type: 'action', label: a.label, command: a.command, description: a.description, state: 'pending', value: '',
|
||||
})),
|
||||
])
|
||||
setSubmitted(false)
|
||||
}, [questions, actions])
|
||||
}, [questions, actions]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const updateTask = (idx: number, updates: Partial<TaskResponse>) => {
|
||||
setTasks(prev => prev.map((t, i) => i === idx ? { ...t, ...updates } as TaskResponse : t))
|
||||
@@ -164,12 +217,12 @@ export function TaskLane({ questions, actions, onSubmit, onClose, loading }: Tas
|
||||
const handleSubmit = () => {
|
||||
setSubmitting(true)
|
||||
onSubmit(tasks)
|
||||
setSubmitted(true)
|
||||
// Don't self-hide — parent controls visibility via showTaskLane.
|
||||
// The AI response will either send updated tasks (replacing these)
|
||||
// or send none (parent hides the lane).
|
||||
setSubmitting(false)
|
||||
}
|
||||
|
||||
if (submitted) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative bg-sidebar border-l border-default flex flex-col shrink-0 animate-in slide-in-from-right-4 duration-200"
|
||||
@@ -202,8 +255,8 @@ export function TaskLane({ questions, actions, onSubmit, onClose, loading }: Tas
|
||||
{allHandled ? '✓ Ready' : `${doneCount}/${totalCount}`}
|
||||
</span>
|
||||
</h3>
|
||||
<button onClick={onClose} className="text-muted-foreground hover:text-heading transition-colors p-1">
|
||||
<X size={16} />
|
||||
<button onClick={onClose} className="text-muted-foreground hover:text-heading transition-colors p-1" title="Collapse tasks">
|
||||
<PanelRightClose size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -214,7 +267,7 @@ export function TaskLane({ questions, actions, onSubmit, onClose, loading }: Tas
|
||||
{questionTasks.length > 0 && (
|
||||
<section>
|
||||
<div className="sticky top-0 z-10 bg-sidebar pb-2">
|
||||
<div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[1.2px] text-muted pl-0.5">
|
||||
<div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[1.2px] text-muted-foreground pl-0.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
|
||||
Questions
|
||||
{questionTasks.every(q => q.state === 'done' || q.state === 'skipped') && (
|
||||
@@ -305,7 +358,7 @@ export function TaskLane({ questions, actions, onSubmit, onClose, loading }: Tas
|
||||
{actionTasks.length > 0 && (
|
||||
<section>
|
||||
<div className="sticky top-0 z-10 bg-sidebar pb-2">
|
||||
<div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[1.2px] text-muted pl-0.5">
|
||||
<div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[1.2px] text-muted-foreground pl-0.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[#60a5fa]" />
|
||||
Diagnostic Checks
|
||||
{actionTasks.every(a => a.state === 'done' || a.state === 'skipped') && (
|
||||
|
||||
@@ -6,7 +6,7 @@ interface BrandLogoProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Brand logo mark: gradient orange square with rounded corners
|
||||
* Brand logo mark: gradient blue square with rounded corners
|
||||
* containing a white lightning bolt.
|
||||
*/
|
||||
export function BrandLogo({ size = 'sm', className }: BrandLogoProps) {
|
||||
@@ -19,7 +19,7 @@ export function BrandLogo({ size = 'sm', className }: BrandLogoProps) {
|
||||
width: dim,
|
||||
height: dim,
|
||||
borderRadius: size === 'sm' ? 8 : 14,
|
||||
background: 'linear-gradient(135deg, #ea580c, #f97316)',
|
||||
background: 'linear-gradient(135deg, #3b82f6, #60a5fa)',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* SVG illustrations for EmptyState components.
|
||||
* Each uses the brand orange palette (#ea580c / #f97316) at low opacity.
|
||||
* Each uses the brand orange palette (#3b82f6 / #60a5fa) at low opacity.
|
||||
* ViewBox: 80x60, simple line art style.
|
||||
*/
|
||||
|
||||
@@ -8,19 +8,19 @@ export function FlowIllustration() {
|
||||
return (
|
||||
<svg width="80" height="60" viewBox="0 0 80 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
{/* Root node */}
|
||||
<circle cx="40" cy="10" r="6" fill="rgba(249,115,22,0.15)" stroke="#ea580c" strokeWidth="1.5" />
|
||||
<circle cx="40" cy="10" r="6" fill="rgba(96,165,250,0.15)" stroke="#3b82f6" strokeWidth="1.5" />
|
||||
{/* Branches */}
|
||||
<line x1="40" y1="16" x2="20" y2="34" stroke="#ea580c" strokeWidth="1.5" />
|
||||
<line x1="40" y1="16" x2="60" y2="34" stroke="#ea580c" strokeWidth="1.5" />
|
||||
<line x1="40" y1="16" x2="20" y2="34" stroke="#3b82f6" strokeWidth="1.5" />
|
||||
<line x1="40" y1="16" x2="60" y2="34" stroke="#3b82f6" strokeWidth="1.5" />
|
||||
{/* Left child */}
|
||||
<circle cx="20" cy="38" r="5" fill="rgba(249,115,22,0.15)" stroke="#f97316" strokeWidth="1.5" />
|
||||
<circle cx="20" cy="38" r="5" fill="rgba(96,165,250,0.15)" stroke="#60a5fa" strokeWidth="1.5" />
|
||||
{/* Right child */}
|
||||
<circle cx="60" cy="38" r="5" fill="rgba(249,115,22,0.15)" stroke="#f97316" strokeWidth="1.5" />
|
||||
<circle cx="60" cy="38" r="5" fill="rgba(96,165,250,0.15)" stroke="#60a5fa" strokeWidth="1.5" />
|
||||
{/* Leaf branches */}
|
||||
<line x1="20" y1="43" x2="12" y2="52" stroke="#f97316" strokeWidth="1" />
|
||||
<line x1="20" y1="43" x2="28" y2="52" stroke="#f97316" strokeWidth="1" />
|
||||
<circle cx="12" cy="54" r="3" fill="rgba(249,115,22,0.15)" stroke="#f97316" strokeWidth="1" />
|
||||
<circle cx="28" cy="54" r="3" fill="rgba(249,115,22,0.15)" stroke="#f97316" strokeWidth="1" />
|
||||
<line x1="20" y1="43" x2="12" y2="52" stroke="#60a5fa" strokeWidth="1" />
|
||||
<line x1="20" y1="43" x2="28" y2="52" stroke="#60a5fa" strokeWidth="1" />
|
||||
<circle cx="12" cy="54" r="3" fill="rgba(96,165,250,0.15)" stroke="#60a5fa" strokeWidth="1" />
|
||||
<circle cx="28" cy="54" r="3" fill="rgba(96,165,250,0.15)" stroke="#60a5fa" strokeWidth="1" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -29,12 +29,12 @@ export function AnalyticsIllustration() {
|
||||
return (
|
||||
<svg width="80" height="60" viewBox="0 0 80 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
{/* Bars */}
|
||||
<rect x="12" y="38" width="10" height="16" rx="2" fill="rgba(249,115,22,0.15)" stroke="#ea580c" strokeWidth="1.5" />
|
||||
<rect x="26" y="28" width="10" height="26" rx="2" fill="rgba(249,115,22,0.15)" stroke="#f97316" strokeWidth="1.5" />
|
||||
<rect x="40" y="20" width="10" height="34" rx="2" fill="rgba(249,115,22,0.15)" stroke="#ea580c" strokeWidth="1.5" />
|
||||
<rect x="54" y="10" width="10" height="44" rx="2" fill="rgba(249,115,22,0.15)" stroke="#f97316" strokeWidth="1.5" />
|
||||
<rect x="12" y="38" width="10" height="16" rx="2" fill="rgba(96,165,250,0.15)" stroke="#3b82f6" strokeWidth="1.5" />
|
||||
<rect x="26" y="28" width="10" height="26" rx="2" fill="rgba(96,165,250,0.15)" stroke="#60a5fa" strokeWidth="1.5" />
|
||||
<rect x="40" y="20" width="10" height="34" rx="2" fill="rgba(96,165,250,0.15)" stroke="#3b82f6" strokeWidth="1.5" />
|
||||
<rect x="54" y="10" width="10" height="44" rx="2" fill="rgba(96,165,250,0.15)" stroke="#60a5fa" strokeWidth="1.5" />
|
||||
{/* Baseline */}
|
||||
<line x1="8" y1="56" x2="72" y2="56" stroke="#ea580c" strokeWidth="1" opacity="0.5" />
|
||||
<line x1="8" y1="56" x2="72" y2="56" stroke="#3b82f6" strokeWidth="1" opacity="0.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -43,17 +43,17 @@ export function SessionIllustration() {
|
||||
return (
|
||||
<svg width="80" height="60" viewBox="0 0 80 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
{/* Card 1 */}
|
||||
<rect x="12" y="8" width="56" height="12" rx="3" fill="rgba(249,115,22,0.15)" stroke="#ea580c" strokeWidth="1.5" />
|
||||
<circle cx="22" cy="14" r="2" fill="#ea580c" />
|
||||
<line x1="28" y1="14" x2="56" y2="14" stroke="#ea580c" strokeWidth="1" opacity="0.5" />
|
||||
<rect x="12" y="8" width="56" height="12" rx="3" fill="rgba(96,165,250,0.15)" stroke="#3b82f6" strokeWidth="1.5" />
|
||||
<circle cx="22" cy="14" r="2" fill="#3b82f6" />
|
||||
<line x1="28" y1="14" x2="56" y2="14" stroke="#3b82f6" strokeWidth="1" opacity="0.5" />
|
||||
{/* Card 2 */}
|
||||
<rect x="12" y="24" width="56" height="12" rx="3" fill="rgba(249,115,22,0.15)" stroke="#f97316" strokeWidth="1.5" />
|
||||
<circle cx="22" cy="30" r="2" fill="#f97316" />
|
||||
<line x1="28" y1="30" x2="52" y2="30" stroke="#f97316" strokeWidth="1" opacity="0.5" />
|
||||
<rect x="12" y="24" width="56" height="12" rx="3" fill="rgba(96,165,250,0.15)" stroke="#60a5fa" strokeWidth="1.5" />
|
||||
<circle cx="22" cy="30" r="2" fill="#60a5fa" />
|
||||
<line x1="28" y1="30" x2="52" y2="30" stroke="#60a5fa" strokeWidth="1" opacity="0.5" />
|
||||
{/* Card 3 */}
|
||||
<rect x="12" y="40" width="56" height="12" rx="3" fill="rgba(249,115,22,0.15)" stroke="#ea580c" strokeWidth="1.5" />
|
||||
<circle cx="22" cy="46" r="2" fill="#ea580c" />
|
||||
<line x1="28" y1="46" x2="48" y2="46" stroke="#ea580c" strokeWidth="1" opacity="0.5" />
|
||||
<rect x="12" y="40" width="56" height="12" rx="3" fill="rgba(96,165,250,0.15)" stroke="#3b82f6" strokeWidth="1.5" />
|
||||
<circle cx="22" cy="46" r="2" fill="#3b82f6" />
|
||||
<line x1="28" y1="46" x2="48" y2="46" stroke="#3b82f6" strokeWidth="1" opacity="0.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -62,19 +62,19 @@ export function IntegrationIllustration() {
|
||||
return (
|
||||
<svg width="80" height="60" viewBox="0 0 80 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
{/* Left box */}
|
||||
<rect x="6" y="18" width="22" height="24" rx="4" fill="rgba(249,115,22,0.15)" stroke="#ea580c" strokeWidth="1.5" />
|
||||
<line x1="12" y1="26" x2="22" y2="26" stroke="#ea580c" strokeWidth="1" opacity="0.6" />
|
||||
<line x1="12" y1="30" x2="20" y2="30" stroke="#ea580c" strokeWidth="1" opacity="0.4" />
|
||||
<rect x="6" y="18" width="22" height="24" rx="4" fill="rgba(96,165,250,0.15)" stroke="#3b82f6" strokeWidth="1.5" />
|
||||
<line x1="12" y1="26" x2="22" y2="26" stroke="#3b82f6" strokeWidth="1" opacity="0.6" />
|
||||
<line x1="12" y1="30" x2="20" y2="30" stroke="#3b82f6" strokeWidth="1" opacity="0.4" />
|
||||
{/* Right box */}
|
||||
<rect x="52" y="18" width="22" height="24" rx="4" fill="rgba(249,115,22,0.15)" stroke="#f97316" strokeWidth="1.5" />
|
||||
<line x1="58" y1="26" x2="68" y2="26" stroke="#f97316" strokeWidth="1" opacity="0.6" />
|
||||
<line x1="58" y1="30" x2="66" y2="30" stroke="#f97316" strokeWidth="1" opacity="0.4" />
|
||||
<rect x="52" y="18" width="22" height="24" rx="4" fill="rgba(96,165,250,0.15)" stroke="#60a5fa" strokeWidth="1.5" />
|
||||
<line x1="58" y1="26" x2="68" y2="26" stroke="#60a5fa" strokeWidth="1" opacity="0.6" />
|
||||
<line x1="58" y1="30" x2="66" y2="30" stroke="#60a5fa" strokeWidth="1" opacity="0.4" />
|
||||
{/* Dashed arrows */}
|
||||
<line x1="30" y1="26" x2="50" y2="26" stroke="#ea580c" strokeWidth="1.5" strokeDasharray="3 2" />
|
||||
<line x1="50" y1="34" x2="30" y2="34" stroke="#f97316" strokeWidth="1.5" strokeDasharray="3 2" />
|
||||
<line x1="30" y1="26" x2="50" y2="26" stroke="#3b82f6" strokeWidth="1.5" strokeDasharray="3 2" />
|
||||
<line x1="50" y1="34" x2="30" y2="34" stroke="#60a5fa" strokeWidth="1.5" strokeDasharray="3 2" />
|
||||
{/* Arrow tips */}
|
||||
<path d="M48 23 L52 26 L48 29" stroke="#ea580c" strokeWidth="1.5" fill="none" />
|
||||
<path d="M32 31 L28 34 L32 37" stroke="#f97316" strokeWidth="1.5" fill="none" />
|
||||
<path d="M48 23 L52 26 L48 29" stroke="#3b82f6" strokeWidth="1.5" fill="none" />
|
||||
<path d="M32 31 L28 34 L32 37" stroke="#60a5fa" strokeWidth="1.5" fill="none" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -83,14 +83,14 @@ export function StepLibraryIllustration() {
|
||||
return (
|
||||
<svg width="80" height="60" viewBox="0 0 80 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
{/* List items */}
|
||||
<circle cx="18" cy="14" r="3" fill="rgba(249,115,22,0.15)" stroke="#ea580c" strokeWidth="1.5" />
|
||||
<line x1="26" y1="14" x2="62" y2="14" stroke="#ea580c" strokeWidth="1.5" opacity="0.5" />
|
||||
<circle cx="18" cy="27" r="3" fill="rgba(249,115,22,0.15)" stroke="#f97316" strokeWidth="1.5" />
|
||||
<line x1="26" y1="27" x2="58" y2="27" stroke="#f97316" strokeWidth="1.5" opacity="0.5" />
|
||||
<circle cx="18" cy="40" r="3" fill="rgba(249,115,22,0.15)" stroke="#ea580c" strokeWidth="1.5" />
|
||||
<line x1="26" y1="40" x2="54" y2="40" stroke="#ea580c" strokeWidth="1.5" opacity="0.5" />
|
||||
<circle cx="18" cy="53" r="3" fill="rgba(249,115,22,0.15)" stroke="#f97316" strokeWidth="1.5" />
|
||||
<line x1="26" y1="53" x2="50" y2="53" stroke="#f97316" strokeWidth="1.5" opacity="0.5" />
|
||||
<circle cx="18" cy="14" r="3" fill="rgba(96,165,250,0.15)" stroke="#3b82f6" strokeWidth="1.5" />
|
||||
<line x1="26" y1="14" x2="62" y2="14" stroke="#3b82f6" strokeWidth="1.5" opacity="0.5" />
|
||||
<circle cx="18" cy="27" r="3" fill="rgba(96,165,250,0.15)" stroke="#60a5fa" strokeWidth="1.5" />
|
||||
<line x1="26" y1="27" x2="58" y2="27" stroke="#60a5fa" strokeWidth="1.5" opacity="0.5" />
|
||||
<circle cx="18" cy="40" r="3" fill="rgba(96,165,250,0.15)" stroke="#3b82f6" strokeWidth="1.5" />
|
||||
<line x1="26" y1="40" x2="54" y2="40" stroke="#3b82f6" strokeWidth="1.5" opacity="0.5" />
|
||||
<circle cx="18" cy="53" r="3" fill="rgba(96,165,250,0.15)" stroke="#60a5fa" strokeWidth="1.5" />
|
||||
<line x1="26" y1="53" x2="50" y2="53" stroke="#60a5fa" strokeWidth="1.5" opacity="0.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -99,16 +99,16 @@ export function ScriptIllustration() {
|
||||
return (
|
||||
<svg width="80" height="60" viewBox="0 0 80 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
{/* Terminal window */}
|
||||
<rect x="8" y="4" width="64" height="52" rx="4" fill="rgba(249,115,22,0.15)" stroke="#ea580c" strokeWidth="1.5" />
|
||||
<rect x="8" y="4" width="64" height="52" rx="4" fill="rgba(96,165,250,0.15)" stroke="#3b82f6" strokeWidth="1.5" />
|
||||
{/* Title bar */}
|
||||
<line x1="8" y1="14" x2="72" y2="14" stroke="#ea580c" strokeWidth="1" opacity="0.3" />
|
||||
<circle cx="16" cy="9" r="2" fill="#ea580c" opacity="0.4" />
|
||||
<circle cx="23" cy="9" r="2" fill="#f97316" opacity="0.4" />
|
||||
<line x1="8" y1="14" x2="72" y2="14" stroke="#3b82f6" strokeWidth="1" opacity="0.3" />
|
||||
<circle cx="16" cy="9" r="2" fill="#3b82f6" opacity="0.4" />
|
||||
<circle cx="23" cy="9" r="2" fill="#60a5fa" opacity="0.4" />
|
||||
{/* Code lines */}
|
||||
<line x1="16" y1="22" x2="40" y2="22" stroke="#ea580c" strokeWidth="1.5" opacity="0.6" />
|
||||
<line x1="20" y1="30" x2="52" y2="30" stroke="#f97316" strokeWidth="1.5" opacity="0.5" />
|
||||
<line x1="20" y1="38" x2="46" y2="38" stroke="#ea580c" strokeWidth="1.5" opacity="0.4" />
|
||||
<line x1="16" y1="46" x2="36" y2="46" stroke="#f97316" strokeWidth="1.5" opacity="0.5" />
|
||||
<line x1="16" y1="22" x2="40" y2="22" stroke="#3b82f6" strokeWidth="1.5" opacity="0.6" />
|
||||
<line x1="20" y1="30" x2="52" y2="30" stroke="#60a5fa" strokeWidth="1.5" opacity="0.5" />
|
||||
<line x1="20" y1="38" x2="46" y2="38" stroke="#3b82f6" strokeWidth="1.5" opacity="0.4" />
|
||||
<line x1="16" y1="46" x2="36" y2="46" stroke="#60a5fa" strokeWidth="1.5" opacity="0.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -117,14 +117,14 @@ export function ShareIllustration() {
|
||||
return (
|
||||
<svg width="80" height="60" viewBox="0 0 80 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
{/* Center node */}
|
||||
<circle cx="28" cy="30" r="8" fill="rgba(249,115,22,0.15)" stroke="#ea580c" strokeWidth="1.5" />
|
||||
<circle cx="28" cy="30" r="8" fill="rgba(96,165,250,0.15)" stroke="#3b82f6" strokeWidth="1.5" />
|
||||
{/* Top-right node */}
|
||||
<circle cx="58" cy="14" r="6" fill="rgba(249,115,22,0.15)" stroke="#f97316" strokeWidth="1.5" />
|
||||
<circle cx="58" cy="14" r="6" fill="rgba(96,165,250,0.15)" stroke="#60a5fa" strokeWidth="1.5" />
|
||||
{/* Bottom-right node */}
|
||||
<circle cx="58" cy="46" r="6" fill="rgba(249,115,22,0.15)" stroke="#f97316" strokeWidth="1.5" />
|
||||
<circle cx="58" cy="46" r="6" fill="rgba(96,165,250,0.15)" stroke="#60a5fa" strokeWidth="1.5" />
|
||||
{/* Connecting lines */}
|
||||
<line x1="35" y1="25" x2="52" y2="17" stroke="#ea580c" strokeWidth="1.5" />
|
||||
<line x1="35" y1="35" x2="52" y2="43" stroke="#ea580c" strokeWidth="1.5" />
|
||||
<line x1="35" y1="25" x2="52" y2="17" stroke="#3b82f6" strokeWidth="1.5" />
|
||||
<line x1="35" y1="35" x2="52" y2="43" stroke="#3b82f6" strokeWidth="1.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -234,7 +234,7 @@ export function RichTextInput({
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'w-full bg-card border border-border rounded-xl p-3 text-sm text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-[rgba(249,115,22,0.3)] focus:outline-none resize-none transition-colors',
|
||||
'focus:border-[rgba(96,165,250,0.3)] focus:outline-none resize-none transition-colors',
|
||||
isDragOver && 'border-primary/50 bg-primary/5',
|
||||
disabled && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
|
||||
@@ -22,7 +22,7 @@ export function FiltersBar({ filters, activeFilter, onFilterChange }: FiltersBar
|
||||
className={cn(
|
||||
'shrink-0 rounded-lg border px-3 py-1.5 text-[0.8125rem] font-medium transition-colors',
|
||||
activeFilter === f.id
|
||||
? 'border-[#f97316]/30 bg-accent-dim text-primary'
|
||||
? 'border-[#60a5fa]/30 bg-accent-dim text-primary'
|
||||
: 'border-border bg-card text-muted-foreground hover:border-border/80 hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -30,7 +30,7 @@ export function GreetingStatStrip() {
|
||||
|
||||
const stats: StatItem[] = [
|
||||
{ icon: CheckCircle, value: resolved, label: 'resolved today', color: '#34d399' },
|
||||
{ icon: Zap, value: active, label: 'active now', color: '#f97316' },
|
||||
{ icon: Zap, value: active, label: 'active now', color: '#60a5fa' },
|
||||
{ icon: Clock, value: avgMttr, label: 'avg MTTR', color: '#848b9b' },
|
||||
]
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ export function KnowledgeBaseCards() {
|
||||
const items = [
|
||||
{ label: 'Flows', value: flowCount, icon: Network, color: '#a78bfa', href: '/trees' },
|
||||
{ label: 'Scripts', value: '\u2014', icon: Code2, color: '#2dd4bf', href: '/scripts' },
|
||||
{ label: 'Pending Review', value: '\u2014', icon: ListChecks, color: '#eab308', href: '/review-queue' },
|
||||
{ label: 'Pending Review', value: '\u2014', icon: ListChecks, color: '#fbbf24', href: '/review-queue' },
|
||||
]
|
||||
|
||||
return (
|
||||
|
||||
@@ -45,7 +45,7 @@ export function PerformanceCards() {
|
||||
label: 'Avg Resolution',
|
||||
value: avgMttr > 0 ? `${avgMttr}m` : '\u2014',
|
||||
icon: Clock,
|
||||
iconColor: '#f97316',
|
||||
iconColor: '#60a5fa',
|
||||
href: '/analytics',
|
||||
},
|
||||
{
|
||||
@@ -59,7 +59,7 @@ export function PerformanceCards() {
|
||||
label: 'Session Time',
|
||||
value: totalMinutes > 0 ? `${totalMinutes}m` : '\u2014',
|
||||
icon: Timer,
|
||||
iconColor: '#eab308',
|
||||
iconColor: '#fbbf24',
|
||||
href: '/analytics',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -31,9 +31,9 @@ export function PreparedSessions() {
|
||||
<div className="card-flat p-5 fade-in" style={{ animationDelay: '200ms' }}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardList className="h-4 w-4 text-orange-400" />
|
||||
<ClipboardList className="h-4 w-4 text-blue-400" />
|
||||
<h3 className="font-heading text-sm font-semibold text-foreground">Prepared for You</h3>
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-orange-400/20 text-[0.625rem] font-bold text-orange-400">
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-blue-400/20 text-[0.625rem] font-bold text-blue-400">
|
||||
{sessions.length}
|
||||
</span>
|
||||
</div>
|
||||
@@ -52,7 +52,7 @@ export function PreparedSessions() {
|
||||
onClick={() => handleStart(session)}
|
||||
className={cn(
|
||||
'group flex w-full items-center justify-between gap-3 rounded-lg border border-border px-4 py-3',
|
||||
'text-left transition-all hover:border-orange-500/30 hover:bg-orange-500/5'
|
||||
'text-left transition-all hover:border-blue-500/30 hover:bg-blue-500/5'
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
|
||||
@@ -2,10 +2,10 @@ import { useNavigate } from 'react-router-dom'
|
||||
import { Plus, Play, BookOpen, UserPlus } from 'lucide-react'
|
||||
|
||||
const ACTIONS = [
|
||||
{ icon: Plus, label: 'New Flow', description: 'Create a new flow', href: '/trees/new', color: '#ea580c' },
|
||||
{ icon: Plus, label: 'New Flow', description: 'Create a new flow', href: '/trees/new', color: '#3b82f6' },
|
||||
{ icon: Play, label: 'Resume Session', description: 'Continue where you left off', href: '/sessions', color: '#34d399' },
|
||||
{ icon: BookOpen, label: 'Browse Solutions', description: 'Explore solutions library', href: '/step-library', color: '#eab308' },
|
||||
{ icon: UserPlus, label: 'Invite Team', description: 'Add team members', href: '/account', color: '#ea580c' },
|
||||
{ icon: BookOpen, label: 'Browse Solutions', description: 'Explore solutions library', href: '/step-library', color: '#fbbf24' },
|
||||
{ icon: UserPlus, label: 'Invite Team', description: 'Add team members', href: '/account', color: '#3b82f6' },
|
||||
] as const
|
||||
|
||||
export function QuickActions() {
|
||||
|
||||
@@ -16,9 +16,9 @@ interface RecentActivityProps {
|
||||
|
||||
const DEFAULT_ACTIVITIES: ActivityItem[] = [
|
||||
{ id: '1', icon: Play, iconColor: '#34d399', iconBg: 'rgba(52, 211, 153, 0.1)', description: 'Started VPN Connectivity Triage session', timestamp: '2 min ago' },
|
||||
{ id: '2', icon: CheckCircle, iconColor: '#ea580c', iconBg: 'rgba(249, 115, 22, 0.1)', description: 'Completed M365 License Provisioning', timestamp: '15 min ago' },
|
||||
{ id: '3', icon: Edit, iconColor: '#eab308', iconBg: 'rgba(234, 179, 8, 0.1)', description: 'Updated Printer Troubleshooting flow', timestamp: '1 hr ago' },
|
||||
{ id: '4', icon: GitBranch, iconColor: '#ea580c', iconBg: 'rgba(249, 115, 22, 0.1)', description: 'Created new DNS Resolution flow', timestamp: '3 hr ago' },
|
||||
{ id: '2', icon: CheckCircle, iconColor: '#3b82f6', iconBg: 'rgba(96, 165, 250, 0.1)', description: 'Completed M365 License Provisioning', timestamp: '15 min ago' },
|
||||
{ id: '3', icon: Edit, iconColor: '#fbbf24', iconBg: 'rgba(234, 179, 8, 0.1)', description: 'Updated Printer Troubleshooting flow', timestamp: '1 hr ago' },
|
||||
{ id: '4', icon: GitBranch, iconColor: '#3b82f6', iconBg: 'rgba(96, 165, 250, 0.1)', description: 'Created new DNS Resolution flow', timestamp: '3 hr ago' },
|
||||
{ id: '5', icon: FileText, iconColor: '#8891a0', iconBg: 'rgba(136, 145, 160, 0.1)', description: 'Exported session report #TK-4821', timestamp: 'Yesterday' },
|
||||
]
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ function timeAgo(dateStr: string): string {
|
||||
|
||||
const STATUS_CONFIG: Record<string, { icon: typeof CheckCircle; color: string }> = {
|
||||
resolved: { icon: CheckCircle, color: '#34d399' },
|
||||
escalated: { icon: AlertTriangle, color: '#eab308' },
|
||||
escalated: { icon: AlertTriangle, color: '#fbbf24' },
|
||||
abandoned: { icon: XCircle, color: '#8891a0' },
|
||||
}
|
||||
|
||||
|
||||
@@ -200,7 +200,7 @@ export function StartSessionInput() {
|
||||
<div className={cn(
|
||||
'relative rounded-2xl border bg-card transition-all',
|
||||
isDragOver ? 'border-primary/50 bg-primary/5' : 'border-border',
|
||||
'focus-within:border-[rgba(249,115,22,0.25)] focus-within:ring-1 focus-within:ring-[rgba(249,115,22,0.1)]'
|
||||
'focus-within:border-[rgba(96,165,250,0.25)] focus-within:ring-1 focus-within:ring-[rgba(96,165,250,0.1)]'
|
||||
)}>
|
||||
{/* Drag overlay */}
|
||||
{isDragOver && (
|
||||
@@ -278,7 +278,7 @@ export function StartSessionInput() {
|
||||
onChange={(e) => setLogContent(e.target.value)}
|
||||
placeholder="Paste event viewer logs, error messages, PowerShell output..."
|
||||
rows={4}
|
||||
className="w-full resize-none rounded-lg border border-border bg-background p-3 font-mono text-xs text-foreground placeholder:text-muted-foreground focus:border-[rgba(249,115,22,0.3)] focus:outline-none"
|
||||
className="w-full resize-none rounded-lg border border-border bg-background p-3 font-mono text-xs text-foreground placeholder:text-muted-foreground focus:border-[rgba(96,165,250,0.3)] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -346,7 +346,7 @@ export function StartSessionInput() {
|
||||
onClick={() => handleSuggestionClick(label)}
|
||||
className="group flex items-center gap-1.5 rounded-md border border-border bg-card px-3 py-1.5 text-xs text-muted-foreground transition-all hover:border-[var(--color-border-hover)] hover:bg-[var(--color-bg-elevated)] hover:text-foreground active:scale-[0.97]"
|
||||
>
|
||||
<Icon size={11} className="text-muted shrink-0 group-hover:text-[#f97316] transition-colors" />
|
||||
<Icon size={11} className="text-muted shrink-0 group-hover:text-[#60a5fa] transition-colors" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
|
||||
@@ -19,8 +19,8 @@ export function TeamSummary() {
|
||||
if (!isAccountOwner) return null
|
||||
|
||||
const items = [
|
||||
{ label: 'Escalations', value: escalationCount, icon: AlertTriangle, color: '#eab308', href: '/escalations' },
|
||||
{ label: 'Team Activity', value: '\u2014', icon: Activity, color: '#f97316', href: '/analytics' },
|
||||
{ label: 'Escalations', value: escalationCount, icon: AlertTriangle, color: '#fbbf24', href: '/escalations' },
|
||||
{ label: 'Team Activity', value: '\u2014', icon: Activity, color: '#60a5fa', href: '/analytics' },
|
||||
{ label: 'Members', value: '\u2014', icon: Users, color: '#a78bfa', href: '/account' },
|
||||
]
|
||||
|
||||
|
||||
@@ -53,10 +53,10 @@ export function WeeklyCalendar({ events = {} }: WeeklyCalendarProps) {
|
||||
<div
|
||||
className="px-2 py-2 text-center"
|
||||
style={{
|
||||
borderBottom: day.isToday ? '2px solid #ea580c' : '1px solid var(--color-border-default)',
|
||||
borderBottom: day.isToday ? '2px solid #3b82f6' : '1px solid var(--color-border-default)',
|
||||
}}
|
||||
>
|
||||
<span className={`font-sans text-xs text-[0.625rem] uppercase tracking-widest ${day.isToday ? 'text-orange-400' : 'text-muted-foreground'}`}>
|
||||
<span className={`font-sans text-xs text-[0.625rem] uppercase tracking-widest ${day.isToday ? 'text-blue-400' : 'text-muted-foreground'}`}>
|
||||
{day.label}
|
||||
</span>
|
||||
<div className={`text-sm font-heading font-bold ${day.isToday ? 'text-foreground' : 'text-muted-foreground'}`}>
|
||||
@@ -72,7 +72,7 @@ export function WeeklyCalendar({ events = {} }: WeeklyCalendarProps) {
|
||||
key={event.id}
|
||||
className="rounded-md px-2 py-1.5 text-[0.6875rem] cursor-pointer hover:bg-accent/30 transition-colors"
|
||||
style={{
|
||||
borderLeft: `3px solid ${event.type === 'maintenance' ? '#eab308' : '#ea580c'}`,
|
||||
borderLeft: `3px solid ${event.type === 'maintenance' ? '#fbbf24' : '#3b82f6'}`,
|
||||
background: 'rgba(255, 255, 255, 0.02)',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -108,7 +108,7 @@ export function FlowPilotActionBar({
|
||||
<button
|
||||
onClick={() => setShowStatusUpdate(true)}
|
||||
disabled={isProcessing}
|
||||
className="flex items-center justify-center gap-1.5 rounded-lg bg-orange-500/10 border border-orange-500/20 px-2.5 sm:px-4 py-2 min-h-[40px] sm:min-h-[44px] text-xs sm:text-sm font-medium text-orange-400 hover:bg-orange-500/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
|
||||
className="flex items-center justify-center gap-1.5 rounded-lg bg-blue-500/10 border border-blue-500/20 px-2.5 sm:px-4 py-2 min-h-[40px] sm:min-h-[44px] text-xs sm:text-sm font-medium text-blue-400 hover:bg-blue-500/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
|
||||
title="Share Update"
|
||||
>
|
||||
<FileText size={15} />
|
||||
@@ -152,7 +152,7 @@ export function FlowPilotActionBar({
|
||||
value={resolutionSummary}
|
||||
onChange={(e) => setResolutionSummary(e.target.value)}
|
||||
placeholder="What resolved the issue?"
|
||||
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(249,115,22,0.3)] focus:outline-none resize-none"
|
||||
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(96,165,250,0.3)] focus:outline-none resize-none"
|
||||
rows={4}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
@@ -166,7 +166,7 @@ export function FlowPilotIntake({ onSubmit, isLoading, defaultProblem }: FlowPil
|
||||
value={additionalContext}
|
||||
onChange={(e) => setAdditionalContext(e.target.value)}
|
||||
placeholder="Add extra context (optional) — e.g. 'User called back and said it's also affecting their second monitor'"
|
||||
className="mt-3 w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(249,115,22,0.3)] focus:outline-none resize-none"
|
||||
className="mt-3 w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(96,165,250,0.3)] focus:outline-none resize-none"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
@@ -229,7 +229,7 @@ export function FlowPilotIntake({ onSubmit, isLoading, defaultProblem }: FlowPil
|
||||
value={logContent}
|
||||
onChange={(e) => setLogContent(e.target.value)}
|
||||
placeholder="Paste log output, error messages, or Event Viewer entries here..."
|
||||
className="w-full rounded-lg border border-border bg-card px-4 py-3 font-mono text-xs text-foreground placeholder:text-muted-foreground focus:border-[rgba(249,115,22,0.3)] focus:outline-none resize-none"
|
||||
className="w-full rounded-lg border border-border bg-card px-4 py-3 font-mono text-xs text-foreground placeholder:text-muted-foreground focus:border-[rgba(96,165,250,0.3)] focus:outline-none resize-none"
|
||||
rows={6}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -194,7 +194,7 @@ export function FlowPilotMessageBar({ onRespond, disabled = false, isProcessing
|
||||
? 'border-border/50 opacity-50'
|
||||
: isDragOver
|
||||
? 'border-primary/50 bg-primary/5'
|
||||
: 'border-border focus-within:border-[rgba(249,115,22,0.3)] focus-within:ring-1 focus-within:ring-primary/20'
|
||||
: 'border-border focus-within:border-[rgba(96,165,250,0.3)] focus-within:ring-1 focus-within:ring-primary/20'
|
||||
)}
|
||||
style={{ background: 'var(--color-bg-card)' }}
|
||||
>
|
||||
@@ -275,7 +275,7 @@ export function FlowPilotMessageBar({ onRespond, disabled = false, isProcessing
|
||||
onChange={(e) => setLogContent(e.target.value)}
|
||||
placeholder="Paste event viewer logs, error messages, PowerShell output..."
|
||||
rows={3}
|
||||
className="w-full resize-none rounded-lg border border-border bg-background p-2 font-mono text-xs text-foreground placeholder:text-muted-foreground focus:border-[rgba(249,115,22,0.3)] focus:outline-none"
|
||||
className="w-full resize-none rounded-lg border border-border bg-background p-2 font-mono text-xs text-foreground placeholder:text-muted-foreground focus:border-[rgba(96,165,250,0.3)] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -29,7 +29,7 @@ export function FlowPilotOptions({ options, onSelect, disabled }: FlowPilotOptio
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'group relative rounded-xl border p-3 sm:p-4 text-left transition-all min-h-[44px]',
|
||||
'hover:border-[rgba(249,115,22,0.3)] hover:shadow-[0_0_20px_rgba(249,115,22,0.08)]',
|
||||
'hover:border-[rgba(96,165,250,0.3)] hover:shadow-[0_0_20px_rgba(96,165,250,0.08)]',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40',
|
||||
isSelected
|
||||
? 'border-primary/40 bg-accent-dim'
|
||||
|
||||
@@ -148,7 +148,7 @@ export function FlowPilotSession({
|
||||
<div className="mb-4">
|
||||
<button
|
||||
onClick={() => setShowShareCommunication(true)}
|
||||
className="flex items-center gap-2 rounded-lg bg-orange-500/10 border border-orange-500/20 px-4 py-2.5 text-sm font-medium text-orange-400 hover:bg-orange-500/20 transition-colors"
|
||||
className="flex items-center gap-2 rounded-lg bg-blue-500/10 border border-blue-500/20 px-4 py-2.5 text-sm font-medium text-blue-400 hover:bg-blue-500/20 transition-colors"
|
||||
>
|
||||
<FileText size={16} />
|
||||
{shareLabel}
|
||||
|
||||
@@ -96,7 +96,7 @@ export function InSessionScriptGenerator({
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => setParams(prev => ({ ...prev, [key]: e.target.value }))}
|
||||
className="w-full rounded-lg border border-border bg-card px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(249,115,22,0.3)] focus:outline-none"
|
||||
className="w-full rounded-lg border border-border bg-card px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(96,165,250,0.3)] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -187,7 +187,7 @@ export function ProposalDetail({ proposal, onReview }: ProposalDetailProps) {
|
||||
value={reviewNotes}
|
||||
onChange={(e) => setReviewNotes(e.target.value)}
|
||||
placeholder="Reviewer notes (optional)"
|
||||
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(249,115,22,0.3)] focus:outline-none"
|
||||
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(96,165,250,0.3)] focus:outline-none"
|
||||
/>
|
||||
|
||||
{/* Action buttons */}
|
||||
|
||||
@@ -145,7 +145,7 @@ export function SessionBriefing({
|
||||
value={freshContext}
|
||||
onChange={(e) => setFreshContext(e.target.value)}
|
||||
placeholder="What additional information do you have, or what would you like to investigate first?"
|
||||
className="w-full rounded-lg border border-border bg-card px-4 py-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(249,115,22,0.3)] focus:outline-none resize-none"
|
||||
className="w-full rounded-lg border border-border bg-card px-4 py-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(96,165,250,0.3)] focus:outline-none resize-none"
|
||||
rows={3}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
@@ -168,7 +168,7 @@ export function StatusUpdateModal({ open, onClose, onGenerate, context = 'status
|
||||
{/* Step 3: Generating */}
|
||||
{step === 'generating' && (
|
||||
<div className="flex flex-col items-center justify-center py-8 gap-3">
|
||||
<Loader2 size={24} className="animate-spin text-orange-400" />
|
||||
<Loader2 size={24} className="animate-spin text-blue-400" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Generating {audience === 'email_draft' ? 'email draft' : audience === 'client_update' ? 'client update' : 'ticket notes'}...
|
||||
</p>
|
||||
@@ -180,7 +180,7 @@ export function StatusUpdateModal({ open, onClose, onGenerate, context = 'status
|
||||
<div className="space-y-4">
|
||||
{/* Meta badges */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium bg-orange-500/10 text-orange-400 border border-orange-500/20">
|
||||
<span className="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium bg-blue-500/10 text-blue-400 border border-blue-500/20">
|
||||
{AUDIENCES.find(a => a.value === result.audience)?.label}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium bg-[rgba(255,255,255,0.06)] text-muted-foreground border border-[rgba(255,255,255,0.08)]">
|
||||
@@ -209,7 +209,7 @@ export function StatusUpdateModal({ open, onClose, onGenerate, context = 'status
|
||||
'flex items-center gap-2 rounded-lg px-4 py-2 min-h-[44px] text-sm font-medium transition-colors',
|
||||
copied
|
||||
? 'bg-emerald-500/20 border border-emerald-500/30 text-emerald-400'
|
||||
: 'bg-orange-500/10 border border-orange-500/20 text-orange-400 hover:bg-orange-500/20'
|
||||
: 'bg-blue-500/10 border border-blue-500/20 text-blue-400 hover:bg-blue-500/20'
|
||||
)}
|
||||
>
|
||||
{copied ? <Check size={16} /> : <Copy size={16} />}
|
||||
|
||||
@@ -355,7 +355,7 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) {
|
||||
<div key={group.type}>
|
||||
{/* Section label */}
|
||||
<div className="px-3 pt-3 pb-1">
|
||||
<span className="font-mono text-[0.6875rem] uppercase tracking-[0.12em] text-[#eab308]">
|
||||
<span className="font-mono text-[0.6875rem] uppercase tracking-[0.12em] text-[#fbbf24]">
|
||||
{group.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
LayoutGrid, Clock, AlertTriangle, GitBranch, Code2, Wand2,
|
||||
ListChecks, Download, BarChart3,
|
||||
Settings, Pin, PinOff,
|
||||
Plus, History, FileText,
|
||||
History, FileText,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||
@@ -288,7 +288,7 @@ export function Sidebar() {
|
||||
'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-[0.8125rem] font-medium transition-all duration-150',
|
||||
active
|
||||
? isParentDimmed
|
||||
? 'bg-[rgba(249,115,22,0.05)] text-foreground/70'
|
||||
? 'bg-[rgba(96,165,250,0.05)] text-foreground/70'
|
||||
: 'bg-accent-dim text-foreground'
|
||||
: 'text-muted-foreground hover:bg-input hover:text-foreground'
|
||||
)}
|
||||
@@ -363,17 +363,6 @@ export function Sidebar() {
|
||||
style={{ background: 'var(--color-bg-sidebar)', borderRight: '1px solid var(--color-border-default)' }}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
{/* New Session button */}
|
||||
<div className="px-3 pt-3 pb-1">
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center justify-center gap-2 w-full rounded-lg bg-amber-400/15 py-2.5 text-sm font-semibold text-amber-400 hover:bg-amber-400/25 transition-colors"
|
||||
>
|
||||
<Plus size={16} strokeWidth={2} />
|
||||
New Session
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Pinned sidebar content */}
|
||||
<div className="px-3 py-2 space-y-0.5">
|
||||
{sections.map((section, si) => (
|
||||
@@ -420,18 +409,6 @@ export function Sidebar() {
|
||||
style={{ background: 'var(--color-bg-sidebar)', borderRight: '1px solid var(--color-border-default)', width: '72px' }}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
{/* New Session button */}
|
||||
<div className="w-full px-2 pt-4 pb-2">
|
||||
<Link
|
||||
to="/"
|
||||
className="flex flex-col items-center justify-center w-full rounded-lg bg-amber-400/15 py-2.5 text-amber-400 hover:bg-amber-400/25 transition-colors"
|
||||
title="New Session"
|
||||
>
|
||||
<Plus size={20} strokeWidth={2} />
|
||||
<span className="mt-1 text-[0.5625rem] font-sans font-semibold">New</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Nav items */}
|
||||
<div className="flex flex-col items-center w-full px-1 space-y-1.5">
|
||||
{railGroups.map((item, i) => renderRailItem(item, `rail-${i}`))}
|
||||
@@ -473,7 +450,7 @@ export function Sidebar() {
|
||||
>
|
||||
{/* Drawer header */}
|
||||
<div className="px-3 mb-3">
|
||||
<h3 className="text-[0.6875rem] font-mono uppercase tracking-[0.12em] text-[#eab308]">
|
||||
<h3 className="text-[0.6875rem] font-mono uppercase tracking-[0.12em] text-[#fbbf24]">
|
||||
{activeFlyoutGroup.label}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -126,7 +126,7 @@ export function TopBar() {
|
||||
<button
|
||||
onClick={() => setUserMenuOpen(!userMenuOpen)}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-[10px] text-xs font-heading font-bold text-white hover:opacity-90 transition-opacity"
|
||||
style={{ background: 'linear-gradient(135deg, #ea580c, #f97316)' }}
|
||||
style={{ background: 'linear-gradient(135deg, #3b82f6, #60a5fa)' }}
|
||||
title={user?.name || user?.email || 'User'}
|
||||
>
|
||||
{initials}
|
||||
|
||||
@@ -12,8 +12,8 @@ const FOLDER_COLORS = [
|
||||
'#8b5cf6', // Violet
|
||||
'#ec4899', // Pink
|
||||
'#ef4444', // Red
|
||||
'#f97316', // Orange
|
||||
'#eab308', // Yellow
|
||||
'#60a5fa', // Orange
|
||||
'#fbbf24', // Yellow
|
||||
'#22c55e', // Green
|
||||
'#14b8a6', // Teal
|
||||
'#3b82f6', // Blue
|
||||
|
||||
@@ -145,7 +145,7 @@ export function TreeGridView({
|
||||
onClick={() => onPrepareSession(tree)}
|
||||
className={cn(
|
||||
'rounded-md border border-border p-2 text-muted-foreground',
|
||||
'hover:bg-orange-500/10 hover:text-orange-400 hover:border-orange-500/30'
|
||||
'hover:bg-blue-500/10 hover:text-blue-400 hover:border-blue-500/30'
|
||||
)}
|
||||
title="Prepare session for engineer"
|
||||
aria-label="Prepare session"
|
||||
|
||||
@@ -148,7 +148,7 @@ export function TreeListView({
|
||||
onClick={() => onPrepareSession(tree)}
|
||||
className={cn(
|
||||
'rounded-md border border-border p-1.5 text-muted-foreground',
|
||||
'hover:bg-orange-500/10 hover:text-orange-400 hover:border-orange-500/30'
|
||||
'hover:bg-blue-500/10 hover:text-blue-400 hover:border-blue-500/30'
|
||||
)}
|
||||
title="Prepare session for engineer"
|
||||
aria-label="Prepare session"
|
||||
|
||||
@@ -252,7 +252,7 @@ export function TreeTableView({
|
||||
onClick={() => onPrepareSession(tree)}
|
||||
className={cn(
|
||||
'rounded-md border border-border p-1.5 text-muted-foreground',
|
||||
'hover:bg-orange-500/10 hover:text-orange-400 hover:border-orange-500/30'
|
||||
'hover:bg-blue-500/10 hover:text-blue-400 hover:border-blue-500/30'
|
||||
)}
|
||||
title="Prepare session for engineer"
|
||||
aria-label="Prepare session"
|
||||
|
||||
@@ -59,10 +59,10 @@ export function InlineVariablePrompt({
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-sm font-medium transition-all',
|
||||
'border-orange-500/40 bg-orange-500/5 text-orange-400',
|
||||
'hover:border-orange-400/60 hover:bg-orange-500/10 hover:shadow-[0_0_12px_rgba(249,115,22,0.15)]',
|
||||
'border-blue-500/40 bg-blue-500/5 text-blue-400',
|
||||
'hover:border-blue-400/60 hover:bg-blue-500/10 hover:shadow-[0_0_12px_rgba(96,165,250,0.15)]',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
isRequired && 'ring-1 ring-orange-500/20'
|
||||
isRequired && 'ring-1 ring-blue-500/20'
|
||||
)}
|
||||
>
|
||||
<Variable className="h-3.5 w-3.5" />
|
||||
@@ -88,7 +88,7 @@ export function InlineVariablePrompt({
|
||||
onBlur={() => {
|
||||
if (!value) setIsEditing(false)
|
||||
}}
|
||||
className="rounded-md border border-orange-500/40 bg-orange-500/5 px-2.5 py-1 text-sm text-foreground shadow-[0_0_12px_rgba(249,115,22,0.15)] focus:border-orange-400 focus:outline-hidden focus:ring-1 focus:ring-orange-400/30"
|
||||
className="rounded-md border border-blue-500/40 bg-blue-500/5 px-2.5 py-1 text-sm text-foreground shadow-[0_0_12px_rgba(96,165,250,0.15)] focus:border-blue-400 focus:outline-hidden focus:ring-1 focus:ring-blue-400/30"
|
||||
>
|
||||
<option value="">{placeholder}</option>
|
||||
{options.map((opt) => (
|
||||
@@ -117,7 +117,7 @@ export function InlineVariablePrompt({
|
||||
onBlur={handleSubmit}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
className="w-48 rounded-md border border-orange-500/40 bg-orange-500/5 px-2.5 py-1 text-sm text-foreground shadow-[0_0_12px_rgba(249,115,22,0.15)] placeholder:text-muted-foreground focus:border-orange-400 focus:outline-hidden focus:ring-1 focus:ring-orange-400/30"
|
||||
className="w-48 rounded-md border border-blue-500/40 bg-blue-500/5 px-2.5 py-1 text-sm text-foreground shadow-[0_0_12px_rgba(96,165,250,0.15)] placeholder:text-muted-foreground focus:border-blue-400 focus:outline-hidden focus:ring-1 focus:ring-blue-400/30"
|
||||
/>
|
||||
{helpText && (
|
||||
<span className="absolute -bottom-5 left-0 text-[0.625rem] text-muted-foreground whitespace-nowrap">
|
||||
|
||||
@@ -94,7 +94,7 @@ export function PrepareSessionModal({
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-5 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-orange-400" />
|
||||
<FileText className="h-4 w-4 text-blue-400" />
|
||||
<h3 className="text-sm font-semibold text-foreground">Prepare Session</h3>
|
||||
</div>
|
||||
<button
|
||||
@@ -122,7 +122,7 @@ export function PrepareSessionModal({
|
||||
value={ticketNumber}
|
||||
onChange={(e) => setTicketNumber(e.target.value)}
|
||||
placeholder="e.g. TKT-12345"
|
||||
className="w-full rounded-md border border-border bg-card px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(249,115,22,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20"
|
||||
className="w-full rounded-md border border-border bg-card px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(96,165,250,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -132,7 +132,7 @@ export function PrepareSessionModal({
|
||||
value={clientName}
|
||||
onChange={(e) => setClientName(e.target.value)}
|
||||
placeholder="e.g. Acme Corp"
|
||||
className="w-full rounded-md border border-border bg-card px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(249,115,22,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20"
|
||||
className="w-full rounded-md border border-border bg-card px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(96,165,250,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -147,7 +147,7 @@ export function PrepareSessionModal({
|
||||
<select
|
||||
value={assignedToId}
|
||||
onChange={(e) => setAssignedToId(e.target.value)}
|
||||
className="w-full rounded-md border border-border bg-card px-3 py-1.5 text-sm text-foreground focus:border-[rgba(249,115,22,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20"
|
||||
className="w-full rounded-md border border-border bg-card px-3 py-1.5 text-sm text-foreground focus:border-[rgba(96,165,250,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20"
|
||||
>
|
||||
<option value="">Unassigned (visible to all)</option>
|
||||
{teamMembers.map(m => (
|
||||
@@ -178,7 +178,7 @@ export function PrepareSessionModal({
|
||||
<select
|
||||
value={values[field.variable_name] || ''}
|
||||
onChange={(e) => handleFieldChange(field.variable_name, e.target.value)}
|
||||
className="w-full rounded-md border border-border bg-card px-3 py-1.5 text-sm text-foreground focus:border-[rgba(249,115,22,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20"
|
||||
className="w-full rounded-md border border-border bg-card px-3 py-1.5 text-sm text-foreground focus:border-[rgba(96,165,250,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20"
|
||||
>
|
||||
<option value="">{field.placeholder || 'Select...'}</option>
|
||||
{field.options.map(opt => (
|
||||
@@ -191,7 +191,7 @@ export function PrepareSessionModal({
|
||||
onChange={(e) => handleFieldChange(field.variable_name, e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
rows={3}
|
||||
className="w-full rounded-md border border-border bg-card px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(249,115,22,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20"
|
||||
className="w-full rounded-md border border-border bg-card px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(96,165,250,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
@@ -199,7 +199,7 @@ export function PrepareSessionModal({
|
||||
value={values[field.variable_name] || ''}
|
||||
onChange={(e) => handleFieldChange(field.variable_name, e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
className="w-full rounded-md border border-border bg-card px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(249,115,22,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20"
|
||||
className="w-full rounded-md border border-border bg-card px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(96,165,250,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20"
|
||||
/>
|
||||
)}
|
||||
{field.help_text && (
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { X, Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { scriptBuilderApi, scriptsApi } from '@/api'
|
||||
import type { ScriptCategoryResponse } from '@/types'
|
||||
|
||||
interface SaveToLibraryDialogProps {
|
||||
sessionId: string
|
||||
defaultName: string
|
||||
defaultDescription?: string
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}
|
||||
|
||||
export function SaveToLibraryDialog({
|
||||
sessionId,
|
||||
defaultName,
|
||||
defaultDescription,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: SaveToLibraryDialogProps) {
|
||||
const [name, setName] = useState(defaultName)
|
||||
const [description, setDescription] = useState(defaultDescription || '')
|
||||
const [categoryId, setCategoryId] = useState('')
|
||||
const [shareWithTeam, setShareWithTeam] = useState(false)
|
||||
const [categories, setCategories] = useState<ScriptCategoryResponse[]>([])
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
scriptsApi.getCategories().then(setCategories).catch(() => {})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [onClose])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!name.trim()) return
|
||||
|
||||
setIsSaving(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await scriptBuilderApi.saveToLibrary(sessionId, {
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
category_id: categoryId || undefined,
|
||||
share_with_team: shareWithTeam,
|
||||
})
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save script. Please try again.')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
|
||||
>
|
||||
<div className="card-flat max-w-md w-full mx-4 rounded-xl overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-3.5 border-b border-[rgba(255,255,255,0.06)]">
|
||||
<h3 className="text-sm font-heading font-bold text-foreground">Save to Library</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="p-5 space-y-4">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="font-sans text-xs text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-1.5 block">
|
||||
Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
className={cn(
|
||||
"w-full rounded-lg px-3 py-2 text-sm",
|
||||
"border border-border bg-card text-foreground placeholder:text-muted-foreground",
|
||||
"focus:outline-none focus:border-[rgba(249,115,22,0.3)] transition-colors"
|
||||
)}
|
||||
placeholder="Script name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="font-sans text-xs text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-1.5 block">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
className={cn(
|
||||
"w-full rounded-lg px-3 py-2 text-sm resize-none",
|
||||
"border border-border bg-card text-foreground placeholder:text-muted-foreground",
|
||||
"focus:outline-none focus:border-[rgba(249,115,22,0.3)] transition-colors"
|
||||
)}
|
||||
placeholder="What does this script do?"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label className="font-sans text-xs text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-1.5 block">
|
||||
Category
|
||||
</label>
|
||||
<select
|
||||
value={categoryId}
|
||||
onChange={(e) => setCategoryId(e.target.value)}
|
||||
className={cn(
|
||||
"w-full rounded-lg px-3 py-2 text-sm",
|
||||
"border border-border bg-card text-foreground",
|
||||
"focus:outline-none focus:border-[rgba(249,115,22,0.3)] transition-colors"
|
||||
)}
|
||||
>
|
||||
<option value="">No category</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>{cat.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Share with team */}
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={shareWithTeam}
|
||||
onChange={(e) => setShareWithTeam(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-border bg-card text-orange-500 focus:ring-orange-500/20"
|
||||
/>
|
||||
<span className="text-sm text-foreground">Share with team</span>
|
||||
</label>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<p className="text-xs text-rose-400">{error}</p>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
"px-4 py-2 rounded-lg text-sm font-medium transition-colors",
|
||||
"bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground hover:border-[rgba(255,255,255,0.12)]"
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!name.trim() || isSaving}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-all",
|
||||
"bg-primary text-white hover:brightness-110 active:scale-[0.98]",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
{isSaving && <Loader2 size={14} className="animate-spin" />}
|
||||
{isSaving ? 'Saving...' : 'Save to Library'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -56,8 +56,8 @@ export function ScriptBuilderChat({
|
||||
)}
|
||||
>
|
||||
{msg.role === 'assistant' && (
|
||||
<div className="shrink-0 w-8 h-8 rounded-lg bg-[rgba(249,115,22,0.1)] flex items-center justify-center mt-0.5">
|
||||
<Bot size={16} className="text-orange-400" />
|
||||
<div className="shrink-0 w-8 h-8 rounded-lg bg-[rgba(96,165,250,0.1)] flex items-center justify-center mt-0.5">
|
||||
<Bot size={16} className="text-blue-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -65,7 +65,7 @@ export function ScriptBuilderChat({
|
||||
className={cn(
|
||||
"max-w-[85%] rounded-xl px-4 py-3 text-sm",
|
||||
msg.role === 'user'
|
||||
? "bg-[rgba(249,115,22,0.08)] border border-[rgba(249,115,22,0.15)] text-foreground"
|
||||
? "bg-[rgba(96,165,250,0.08)] border border-[rgba(96,165,250,0.15)] text-foreground"
|
||||
: "card-flat"
|
||||
)}
|
||||
>
|
||||
@@ -98,11 +98,11 @@ export function ScriptBuilderChat({
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex gap-3 justify-start">
|
||||
<div className="shrink-0 w-8 h-8 rounded-lg bg-[rgba(249,115,22,0.1)] flex items-center justify-center">
|
||||
<Bot size={16} className="text-orange-400" />
|
||||
<div className="shrink-0 w-8 h-8 rounded-lg bg-[rgba(96,165,250,0.1)] flex items-center justify-center">
|
||||
<Bot size={16} className="text-blue-400" />
|
||||
</div>
|
||||
<div className="card-flat rounded-xl px-4 py-3 text-sm flex items-center gap-2">
|
||||
<Loader2 size={14} className="animate-spin text-orange-400" />
|
||||
<Loader2 size={14} className="animate-spin text-blue-400" />
|
||||
<span className="text-muted-foreground">Generating script...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -56,7 +56,7 @@ export function ScriptBuilderInput({
|
||||
className={cn(
|
||||
"flex-1 resize-none rounded-xl px-4 py-2.5 text-sm",
|
||||
"bg-card border border-border text-foreground placeholder:text-muted-foreground",
|
||||
"focus:outline-none focus:border-[rgba(249,115,22,0.3)] transition-colors",
|
||||
"focus:outline-none focus:border-[rgba(96,165,250,0.3)] transition-colors",
|
||||
"disabled:opacity-50"
|
||||
)}
|
||||
style={{ maxHeight: 120 }}
|
||||
|
||||
@@ -55,7 +55,7 @@ export function ScriptCodeBlock({
|
||||
<div className="mt-3 rounded-lg border bg-[rgba(0,0,0,0.3)] border-[rgba(255,255,255,0.06)] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-[rgba(255,255,255,0.06)]">
|
||||
<span className="font-mono text-xs text-orange-400 truncate">
|
||||
<span className="font-mono text-xs text-blue-400 truncate">
|
||||
{filename || 'script'}
|
||||
</span>
|
||||
{lineCount != null && (
|
||||
|
||||
@@ -62,7 +62,7 @@ export function ScriptPreviewModal({
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-3.5 border-b border-[rgba(255,255,255,0.06)]">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<span className="font-mono text-sm text-orange-400 truncate">
|
||||
<span className="font-mono text-sm text-blue-400 truncate">
|
||||
{filename || 'script'}
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-[0.625rem] uppercase tracking-wider px-2 py-0.5 rounded-full bg-[rgba(255,255,255,0.06)] text-muted-foreground">
|
||||
|
||||
@@ -104,7 +104,7 @@ export function ParameterCard({
|
||||
value={param.type}
|
||||
onChange={e => update({ type: e.target.value as ScriptParameter['type'] })}
|
||||
disabled={disabled}
|
||||
className="w-full rounded-lg border border-border bg-card text-foreground px-3 py-2 text-sm focus:outline-none focus:border-[rgba(249,115,22,0.3)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="w-full rounded-lg border border-border bg-card text-foreground px-3 py-2 text-sm focus:outline-none focus:border-[rgba(96,165,250,0.3)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{PARAM_TYPES.map(t => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
|
||||
@@ -174,7 +174,7 @@ export function ParameterDetectorStepper({
|
||||
<select
|
||||
value={type}
|
||||
onChange={e => setType(e.target.value as ScriptParameter['type'])}
|
||||
className="w-full rounded-lg border border-border bg-card text-foreground px-3 py-2 text-sm focus:outline-none focus:border-[rgba(249,115,22,0.3)]"
|
||||
className="w-full rounded-lg border border-border bg-card text-foreground px-3 py-2 text-sm focus:outline-none focus:border-[rgba(96,165,250,0.3)]"
|
||||
>
|
||||
{PARAM_TYPES.map(t => (
|
||||
<option key={t.value} value={t.value}>{t.label}</option>
|
||||
|
||||
@@ -159,7 +159,7 @@ export function ParameterSchemaBuilder({ schema, onChange, disabled }: Props) {
|
||||
onChange={e => { setJsonText(e.target.value); setJsonError(null) }}
|
||||
disabled={disabled}
|
||||
spellCheck={false}
|
||||
className="w-full min-h-[300px] resize-y font-sans text-xs text-sm bg-card border border-border rounded-xl p-4 text-foreground focus:outline-none focus:border-[rgba(249,115,22,0.3)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="w-full min-h-[300px] resize-y font-sans text-xs text-sm bg-card border border-border rounded-xl p-4 text-foreground focus:outline-none focus:border-[rgba(96,165,250,0.3)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder='{ "parameters": [...] }'
|
||||
/>
|
||||
{jsonError && (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import Editor, { type BeforeMount } from '@monaco-editor/react'
|
||||
import { resolutionFlowTheme, THEME_ID } from '@/components/tree-editor/code-mode/resolutionFlowTheme'
|
||||
import { Spinner } from '@/components/common/Spinner'
|
||||
@@ -10,11 +10,22 @@ interface Props {
|
||||
}
|
||||
|
||||
export function ScriptBodyEditor({ value, onChange, disabled }: Props) {
|
||||
const lastValueRef = useRef(value)
|
||||
lastValueRef.current = value
|
||||
|
||||
const handleBeforeMount: BeforeMount = useCallback((monaco) => {
|
||||
// Register our dark theme if not already defined
|
||||
monaco.editor.defineTheme(THEME_ID, resolutionFlowTheme)
|
||||
}, [])
|
||||
|
||||
const handleChange = useCallback((v: string | undefined) => {
|
||||
const next = v ?? ''
|
||||
// Only propagate user-initiated edits, not echoes from external value prop changes
|
||||
if (next !== lastValueRef.current) {
|
||||
onChange(next)
|
||||
}
|
||||
}, [onChange])
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border overflow-hidden">
|
||||
<Editor
|
||||
@@ -22,7 +33,7 @@ export function ScriptBodyEditor({ value, onChange, disabled }: Props) {
|
||||
language="powershell"
|
||||
theme={THEME_ID}
|
||||
value={value}
|
||||
onChange={v => onChange(v ?? '')}
|
||||
onChange={handleChange}
|
||||
beforeMount={handleBeforeMount}
|
||||
loading={
|
||||
<div className="flex h-[300px] items-center justify-center bg-card">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { ArrowLeft, Loader2, Save, Scan, Trash2 } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/Input'
|
||||
import { Textarea } from '@/components/ui/Textarea'
|
||||
@@ -64,16 +64,19 @@ export function ScriptTemplateEditor({ templateId, onBack, onSaved }: Props) {
|
||||
const [detectedCandidates, setDetectedCandidates] = useState<ParameterCandidate[]>([])
|
||||
const [showStepper, setShowStepper] = useState(false)
|
||||
const [detectionSummary, setDetectionSummary] = useState<string | null>(null)
|
||||
const acceptingCandidateRef = useRef(false)
|
||||
|
||||
const { canShareScriptTemplate } = usePermissions()
|
||||
|
||||
// Dismiss stepper if user edits the script body during detection
|
||||
// Dismiss stepper if user manually edits the script body during detection
|
||||
// (but NOT when handleAcceptCandidate programmatically updates script_body)
|
||||
const scriptBodyRef = form.script_body
|
||||
useEffect(() => {
|
||||
if (showStepper) {
|
||||
if (showStepper && !acceptingCandidateRef.current) {
|
||||
setShowStepper(false)
|
||||
setDetectedCandidates([])
|
||||
}
|
||||
acceptingCandidateRef.current = false
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [scriptBodyRef])
|
||||
|
||||
@@ -263,6 +266,7 @@ export function ScriptTemplateEditor({ templateId, onBack, onSaved }: Props) {
|
||||
sensitive: overrides.sensitive,
|
||||
}
|
||||
|
||||
acceptingCandidateRef.current = true
|
||||
setForm(f => ({
|
||||
...f,
|
||||
script_body: updatedScript,
|
||||
@@ -356,7 +360,7 @@ export function ScriptTemplateEditor({ templateId, onBack, onSaved }: Props) {
|
||||
<select
|
||||
value={form.category_id}
|
||||
onChange={e => updateField('category_id', e.target.value)}
|
||||
className="w-full rounded-lg border border-border bg-card text-foreground px-3 py-2 text-sm focus:outline-none focus:border-[rgba(249,115,22,0.3)]"
|
||||
className="w-full rounded-lg border border-border bg-card text-foreground px-3 py-2 text-sm focus:outline-none focus:border-[rgba(96,165,250,0.3)]"
|
||||
>
|
||||
<option value="">Select category…</option>
|
||||
{categories.map(c => (
|
||||
@@ -369,7 +373,7 @@ export function ScriptTemplateEditor({ templateId, onBack, onSaved }: Props) {
|
||||
<select
|
||||
value={form.complexity}
|
||||
onChange={e => updateField('complexity', e.target.value as FormState['complexity'])}
|
||||
className="w-full rounded-lg border border-border bg-card text-foreground px-3 py-2 text-sm focus:outline-none focus:border-[rgba(249,115,22,0.3)]"
|
||||
className="w-full rounded-lg border border-border bg-card text-foreground px-3 py-2 text-sm focus:outline-none focus:border-[rgba(96,165,250,0.3)]"
|
||||
>
|
||||
<option value="beginner">Beginner</option>
|
||||
<option value="intermediate">Intermediate</option>
|
||||
|
||||
@@ -105,7 +105,7 @@ export function ScriptTemplateListView({ onEdit, onCreate }: Props) {
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
placeholder="Search templates…"
|
||||
className="w-full pl-8 pr-3 py-1.5 text-sm rounded-md border border-border bg-card text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-[rgba(249,115,22,0.3)] focus:ring-1 focus:ring-[rgba(249,115,22,0.2)]"
|
||||
className="w-full pl-8 pr-3 py-1.5 text-sm rounded-md border border-border bg-card text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-[rgba(96,165,250,0.3)] focus:ring-1 focus:ring-[rgba(96,165,250,0.2)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
472
frontend/src/components/scripts/ParameterizeAndSavePanel.tsx
Normal file
472
frontend/src/components/scripts/ParameterizeAndSavePanel.tsx
Normal file
@@ -0,0 +1,472 @@
|
||||
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<void>
|
||||
/** 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<ScriptParameter[]>([])
|
||||
|
||||
// Detection state
|
||||
const [candidates, setCandidates] = useState<ParameterCandidate[]>([])
|
||||
const [detectionRan, setDetectionRan] = useState(false)
|
||||
const [showStepper, setShowStepper] = useState(false)
|
||||
const [detectionSummary, setDetectionSummary] = useState<string | null>(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<ScriptCategoryResponse[]>([])
|
||||
|
||||
// Save state
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const panelRef = useRef<HTMLDivElement>(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 */}
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/50"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="fixed top-0 right-0 z-50 h-full w-[480px] max-w-full bg-page border-l border-default flex flex-col shadow-2xl"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-3.5 border-b border-default shrink-0">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<FileCode size={18} className="text-primary" />
|
||||
<h3 className="text-sm font-heading font-bold text-foreground">
|
||||
{isPasteMode ? 'Import Script to Library' : 'Save to Library'}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-elevated transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div className="flex-1 overflow-y-auto p-5 space-y-5">
|
||||
|
||||
{/* Paste mode: script input area (before confirmation) */}
|
||||
{isPasteMode && !scriptConfirmed && (
|
||||
<section className="space-y-3">
|
||||
<p className="font-sans text-xs uppercase tracking-widest text-muted-foreground">
|
||||
Paste Your Script
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
{LANGUAGES.map((lang) => (
|
||||
<button
|
||||
key={lang.value}
|
||||
type="button"
|
||||
onClick={() => setSelectedLanguage(lang.value)}
|
||||
className={cn(
|
||||
'px-3 py-1.5 rounded-md text-xs font-medium transition-all',
|
||||
selectedLanguage === lang.value
|
||||
? 'bg-primary text-white'
|
||||
: 'text-muted-foreground hover:text-foreground bg-elevated'
|
||||
)}
|
||||
>
|
||||
{lang.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
value={pastedScript}
|
||||
onChange={(e) => setPastedScript(e.target.value)}
|
||||
rows={12}
|
||||
className={cn(
|
||||
'w-full rounded-lg px-3 py-2 text-sm font-mono resize-none',
|
||||
'border border-default bg-card text-foreground placeholder:text-muted-foreground',
|
||||
'focus:outline-none focus:border-primary/30 transition-colors'
|
||||
)}
|
||||
placeholder="Paste your PowerShell, Bash, or Python script here..."
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirmPaste}
|
||||
disabled={!pastedScript.trim()}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-semibold transition-all',
|
||||
'bg-primary text-white hover:brightness-110 active:scale-[0.98]',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
Detect Parameters
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Script preview (visible after paste confirm, or always in script mode) */}
|
||||
{(!isPasteMode || scriptConfirmed) && (
|
||||
<section className="space-y-2">
|
||||
<p className="font-sans text-xs uppercase tracking-widest text-muted-foreground">
|
||||
Script Preview
|
||||
</p>
|
||||
<div className="rounded-lg bg-black/30 border border-default overflow-hidden max-h-48 overflow-y-auto">
|
||||
<pre className="p-3 text-xs font-mono text-foreground whitespace-pre-wrap break-all">
|
||||
{workingScript.split(/({{.*?}})/).map((part, i) =>
|
||||
/^{{.*}}$/.test(part)
|
||||
? <span key={i} className="text-amber-400 font-semibold">{part}</span>
|
||||
: <span key={i}>{part}</span>
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Parameter detection zone */}
|
||||
{detectionRan && !showStepper && detectionSummary && (
|
||||
<div className="flex items-start gap-2 rounded-lg bg-elevated p-3">
|
||||
<AlertCircle size={14} className="text-muted-foreground mt-0.5 shrink-0" />
|
||||
<p className="text-xs text-muted-foreground">{detectionSummary}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showStepper && candidates.length > 0 && (
|
||||
<section className="space-y-2">
|
||||
<p className="font-sans text-xs uppercase tracking-widest text-muted-foreground">
|
||||
Detected Parameters
|
||||
</p>
|
||||
<ParameterDetectorStepper
|
||||
candidates={candidates}
|
||||
existingKeys={parameters.map(p => p.key)}
|
||||
onAccept={handleAcceptCandidate}
|
||||
onSkip={handleSkipCandidate}
|
||||
onFinish={handleDetectionFinish}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Accepted parameters summary */}
|
||||
{parameters.length > 0 && !showStepper && (
|
||||
<section className="space-y-2">
|
||||
<p className="font-sans text-xs uppercase tracking-widest text-muted-foreground">
|
||||
Parameters ({parameters.length})
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{parameters.map((p) => (
|
||||
<div
|
||||
key={p.key}
|
||||
className="flex items-center justify-between rounded-lg bg-elevated px-3 py-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-xs font-mono text-amber-400">{`{{${p.key}}}`}</code>
|
||||
<span className="text-xs text-muted-foreground">{p.label}</span>
|
||||
</div>
|
||||
<span className="text-[0.625rem] text-muted-foreground uppercase tracking-wide">
|
||||
{p.type}{p.sensitive ? ' · sensitive' : ''}{p.required ? '' : ' · optional'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Metadata form — shown after detection is done (or immediately if no candidates) */}
|
||||
{detectionRan && !showStepper && (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<p className="font-sans text-xs uppercase tracking-widest text-muted-foreground">
|
||||
Template Details
|
||||
</p>
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="font-sans text-xs text-muted-foreground mb-1.5 block">
|
||||
Name <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
className={cn(
|
||||
'w-full rounded-lg px-3 py-2 text-sm',
|
||||
'border border-default bg-card text-foreground placeholder:text-muted-foreground',
|
||||
'focus:outline-none focus:border-primary/30 transition-colors'
|
||||
)}
|
||||
placeholder="Script name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="font-sans text-xs text-muted-foreground mb-1.5 block">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
className={cn(
|
||||
'w-full rounded-lg px-3 py-2 text-sm resize-none',
|
||||
'border border-default bg-card text-foreground placeholder:text-muted-foreground',
|
||||
'focus:outline-none focus:border-primary/30 transition-colors'
|
||||
)}
|
||||
placeholder="What does this script do?"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div>
|
||||
<label className="font-sans text-xs text-muted-foreground mb-1.5 block">
|
||||
Category
|
||||
</label>
|
||||
<select
|
||||
value={categoryId}
|
||||
onChange={(e) => setCategoryId(e.target.value)}
|
||||
className={cn(
|
||||
'w-full rounded-lg px-3 py-2 text-sm',
|
||||
'border border-default bg-card text-foreground',
|
||||
'focus:outline-none focus:border-primary/30 transition-colors'
|
||||
)}
|
||||
>
|
||||
<option value="">No category</option>
|
||||
{categories.map((cat) => (
|
||||
<option key={cat.id} value={cat.id}>{cat.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Share with team */}
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={shareWithTeam}
|
||||
onChange={(e) => setShareWithTeam(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-border bg-card text-blue-500 focus:ring-blue-500/20"
|
||||
/>
|
||||
<span className="text-sm text-foreground">Share with team</span>
|
||||
</label>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<p className="text-xs text-rose-400">{error}</p>
|
||||
)}
|
||||
|
||||
{/* Save button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSave}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg text-sm font-semibold transition-all',
|
||||
'bg-primary text-white hover:brightness-110 active:scale-[0.98]',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{isSaving && <Loader2 size={14} className="animate-spin" />}
|
||||
{isSaving ? 'Saving...' : 'Save to Library'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -93,7 +93,7 @@ export function ScriptParameterField({ param, value, error, disabled }: Props) {
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
className="w-full rounded-[10px] border border-border bg-card text-foreground px-3 py-2 text-sm focus:outline-none focus:border-[rgba(249,115,22,0.3)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="w-full rounded-[10px] border border-border bg-card text-foreground px-3 py-2 text-sm focus:outline-none focus:border-[rgba(96,165,250,0.3)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="">Select…</option>
|
||||
{(param.options ?? []).map(opt => (
|
||||
|
||||
@@ -166,7 +166,7 @@ export function AddSupportingDataModal({ isOpen, onClose, sessionId, onAdded }:
|
||||
className={cn(
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-[rgba(249,115,22,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20'
|
||||
'focus:border-[rgba(96,165,250,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
@@ -186,7 +186,7 @@ export function AddSupportingDataModal({ isOpen, onClose, sessionId, onAdded }:
|
||||
className={cn(
|
||||
'w-full resize-y rounded-md border border-border bg-card px-3 py-2 text-sm',
|
||||
'font-mono text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-[rgba(249,115,22,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20'
|
||||
'focus:border-[rgba(96,165,250,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -62,7 +62,7 @@ export function TicketContextPanel({ context, loading, error, onRefresh }: Ticke
|
||||
return (
|
||||
<div className="card-flat overflow-hidden rounded-lg">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 bg-[rgba(249,115,22,0.05)] px-3 py-2.5">
|
||||
<div className="flex items-center gap-2 bg-[rgba(96,165,250,0.05)] px-3 py-2.5">
|
||||
<Ticket className="h-3.5 w-3.5 text-primary" />
|
||||
<span className="flex-1 font-sans text-xs text-[0.625rem] uppercase tracking-[0.1em] text-primary">
|
||||
Ticket Context
|
||||
|
||||
@@ -36,7 +36,7 @@ export function TicketLinkIndicator({ session, hasConnection, onLinkClick, onUnl
|
||||
// Ticket linked
|
||||
return (
|
||||
<div className="card-flat inline-flex items-start gap-2.5 rounded-lg border border-border px-3 py-2">
|
||||
<Ticket className="mt-0.5 h-4 w-4 shrink-0 text-orange-400" />
|
||||
<Ticket className="mt-0.5 h-4 w-4 shrink-0 text-blue-400" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
CW #{session.psa_ticket_id}
|
||||
|
||||
@@ -259,7 +259,7 @@ export function UpdateTicketModal({ open, onClose, sessionId, onPosted }: Props)
|
||||
value={opt.value}
|
||||
checked={noteType === opt.value}
|
||||
onChange={() => setNoteType(opt.value)}
|
||||
className="mt-0.5 accent-orange-400"
|
||||
className="mt-0.5 accent-blue-400"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium text-foreground">{opt.label}</span>
|
||||
|
||||
@@ -154,7 +154,7 @@ export function BrandingSettings({ teamId }: BrandingSettingsProps) {
|
||||
className={cn(
|
||||
'mt-2 w-full max-w-md rounded-md border border-border bg-card px-3 py-2 text-sm',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-[rgba(249,115,22,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20'
|
||||
'focus:border-[rgba(96,165,250,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -69,7 +69,7 @@ export function SidebarStatsBar({ resolved, active, completedMinutes, activeSess
|
||||
<div className="flex-1 rounded-md bg-[rgba(255,255,255,0.02)] px-1 py-1.5 text-center">
|
||||
<div
|
||||
className="font-mono text-sm font-semibold leading-none"
|
||||
style={{ color: '#f97316' }}
|
||||
style={{ color: '#60a5fa' }}
|
||||
aria-label={`${active} active sessions`}
|
||||
>
|
||||
{active}
|
||||
|
||||
@@ -23,7 +23,7 @@ const NODE_TYPE_CONFIG: Record<Exclude<NodeType, 'answer'>, {
|
||||
label: 'Action',
|
||||
borderClass: 'border-l-4 border-l-yellow-500',
|
||||
badgeClass: 'bg-yellow-500/20 text-yellow-400',
|
||||
minimapColor: '#eab308',
|
||||
minimapColor: '#fbbf24',
|
||||
},
|
||||
solution: {
|
||||
icon: CheckCircle,
|
||||
|
||||
@@ -54,14 +54,14 @@ export function GlowEdgeDefs() {
|
||||
|
||||
{/* Downstream: accent brand */}
|
||||
<linearGradient id="edge-gradient-downstream" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stopColor="#ea580c" />
|
||||
<stop offset="100%" stopColor="#f97316" />
|
||||
<stop offset="0%" stopColor="#3b82f6" />
|
||||
<stop offset="100%" stopColor="#60a5fa" />
|
||||
</linearGradient>
|
||||
|
||||
{/* Upstream: amber */}
|
||||
<linearGradient id="edge-gradient-upstream" x1="0%" y1="100%" x2="0%" y2="0%">
|
||||
<stop offset="0%" stopColor="#f59e0b" />
|
||||
<stop offset="100%" stopColor="#eab308" />
|
||||
<stop offset="100%" stopColor="#fbbf24" />
|
||||
</linearGradient>
|
||||
|
||||
{/* Glow filters */}
|
||||
|
||||
@@ -4,9 +4,9 @@ export const CATEGORY_COLORS = [
|
||||
'#f59e0b', // amber
|
||||
'#ef4444', // red
|
||||
'#8b5cf6', // violet
|
||||
'#ea580c', // deep orange
|
||||
'#0891b2', // cyan
|
||||
'#ec4899', // pink
|
||||
'#f97316', // orange
|
||||
'#60a5fa', // sky blue
|
||||
'#14b8a6', // teal
|
||||
'#6366f1', // indigo
|
||||
] as const
|
||||
|
||||
@@ -27,8 +27,8 @@ export interface UseFlowPilotSession {
|
||||
// Actions
|
||||
startSession: (intake: AISessionCreateRequest) => Promise<void>
|
||||
respondToStep: (response: StepResponseRequest) => Promise<void>
|
||||
resolveSession: (data: ResolveSessionRequest) => Promise<SessionDocumentation>
|
||||
escalateSession: (data: EscalateSessionRequest) => Promise<SessionDocumentation>
|
||||
resolveSession: (data: ResolveSessionRequest) => Promise<SessionDocumentation | null>
|
||||
escalateSession: (data: EscalateSessionRequest) => Promise<SessionDocumentation | null>
|
||||
pauseSession: () => Promise<void>
|
||||
resumeOwnSession: () => Promise<void>
|
||||
abandonSession: () => Promise<void>
|
||||
@@ -92,6 +92,7 @@ export function useFlowPilotSession(): UseFlowPilotSession {
|
||||
ticket_data: null,
|
||||
steps: [firstStep],
|
||||
conversation_messages: [],
|
||||
pending_task_lane: null,
|
||||
is_branching: false,
|
||||
active_branch_id: null,
|
||||
})
|
||||
@@ -133,7 +134,7 @@ export function useFlowPilotSession(): UseFlowPilotSession {
|
||||
}
|
||||
}, [session])
|
||||
|
||||
const resolveSession = useCallback(async (data: ResolveSessionRequest): Promise<SessionDocumentation> => {
|
||||
const resolveSession = useCallback(async (data: ResolveSessionRequest): Promise<SessionDocumentation | null> => {
|
||||
if (!session) throw new Error('No active session')
|
||||
setIsProcessing(true)
|
||||
try {
|
||||
@@ -155,7 +156,7 @@ export function useFlowPilotSession(): UseFlowPilotSession {
|
||||
}
|
||||
}, [session])
|
||||
|
||||
const escalateSession = useCallback(async (data: EscalateSessionRequest): Promise<SessionDocumentation> => {
|
||||
const escalateSession = useCallback(async (data: EscalateSessionRequest): Promise<SessionDocumentation | null> => {
|
||||
if (!session) throw new Error('No active session')
|
||||
setIsProcessing(true)
|
||||
try {
|
||||
|
||||
@@ -8,14 +8,15 @@
|
||||
@import '@xyflow/react/dist/style.css';
|
||||
|
||||
@theme {
|
||||
/* ── Surface colors (Charcoal — sidebar darkest) ─ */
|
||||
--color-bg-page: #1a1c23;
|
||||
--color-bg-sidebar: #10121a;
|
||||
--color-bg-card: #22252e;
|
||||
--color-bg-card-hover: #282b35;
|
||||
--color-bg-input: #282b35;
|
||||
--color-bg-code: #14161e;
|
||||
--color-bg-elevated: #2e3140;
|
||||
/* ── 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;
|
||||
|
||||
/* ── Text colors ───────────────────────────────── */
|
||||
--color-text-heading: #f0f2f5;
|
||||
@@ -25,43 +26,45 @@
|
||||
--color-text-rail-label: #e2e5eb;
|
||||
|
||||
/* ── Border colors ─────────────────────────────── */
|
||||
--color-border-default: #2e3240;
|
||||
--color-border-default: #2a2e3a;
|
||||
--color-border-hover: #3d4252;
|
||||
|
||||
/* ── Accent (ember orange) ─────────────────────── */
|
||||
--color-accent: #f97316;
|
||||
--color-accent-hover: #ea580c;
|
||||
--color-accent-dim: rgba(249,115,22,0.10);
|
||||
--color-accent-text: #fdba74;
|
||||
/* ── Accent (electric blue) ───────────────────────── */
|
||||
--color-accent: #60a5fa;
|
||||
--color-accent-hover: #3b82f6;
|
||||
--color-accent-dim: rgba(96,165,250,0.10);
|
||||
--color-accent-text: #93c5fd;
|
||||
|
||||
/* ── Semantic colors ───────────────────────────── */
|
||||
--color-success: #34d399;
|
||||
--color-success-dim: rgba(52,211,153,0.10);
|
||||
--color-warning: #eab308;
|
||||
--color-warning-dim: rgba(234,179,8,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);
|
||||
|
||||
/* ── Tailwind semantic mappings ─────────────────── */
|
||||
--color-background: #1a1c23;
|
||||
--color-background: #16181f;
|
||||
--color-foreground: #e2e5eb;
|
||||
--color-card: #22252e;
|
||||
--color-card: #1e2028;
|
||||
--color-card-foreground: #e2e5eb;
|
||||
--color-popover: #22252e;
|
||||
--color-popover: #1e2028;
|
||||
--color-popover-foreground: #e2e5eb;
|
||||
--color-primary: #f97316;
|
||||
--color-primary: #60a5fa;
|
||||
--color-primary-foreground: #ffffff;
|
||||
--color-secondary: #2e3140;
|
||||
--color-secondary: #2a2d38;
|
||||
--color-secondary-foreground: #e2e5eb;
|
||||
--color-muted: #2e3140;
|
||||
--color-muted: #2a2d38;
|
||||
--color-muted-foreground: #848b9b;
|
||||
--color-accent-tw: #2e3140;
|
||||
--color-accent-tw: #2a2d38;
|
||||
--color-accent-foreground: #e2e5eb;
|
||||
--color-destructive: #f87171;
|
||||
--color-destructive-foreground: #ffffff;
|
||||
--color-border: #2e3240;
|
||||
--color-input: #282b35;
|
||||
--color-ring: #f97316;
|
||||
--color-border: #2a2e3a;
|
||||
--color-input: #252830;
|
||||
--color-ring: #60a5fa;
|
||||
|
||||
/* ── Radii ─────────────────────────────────────── */
|
||||
--radius-sm: 5px;
|
||||
@@ -191,11 +194,11 @@
|
||||
transition: filter 150ms ease, box-shadow 150ms ease, transform 150ms ease;
|
||||
&:hover {
|
||||
filter: brightness(1.1);
|
||||
box-shadow: 0 2px 10px rgba(249, 115, 22, 0.2);
|
||||
box-shadow: 0 2px 10px rgba(96, 165, 250, 0.2);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
&:active {
|
||||
box-shadow: 0 0 4px rgba(249, 115, 22, 0.1);
|
||||
box-shadow: 0 0 4px rgba(96, 165, 250, 0.1);
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@@ -226,7 +229,7 @@
|
||||
/* Tab/toggle group: active item gets elevated surface + faint accent glow */
|
||||
@utility tab-active-shadow {
|
||||
background: var(--color-bg-elevated);
|
||||
box-shadow: 0 1px 4px rgba(249, 115, 22, 0.08);
|
||||
box-shadow: 0 1px 4px rgba(96, 165, 250, 0.08);
|
||||
}
|
||||
|
||||
/* Card hover lift — brighter border + subtle accent glow */
|
||||
@@ -234,7 +237,7 @@
|
||||
transition: box-shadow 200ms ease, transform 200ms ease, border-color 200ms ease;
|
||||
&:hover {
|
||||
border-color: var(--color-border-hover);
|
||||
box-shadow: 0 2px 8px rgba(249, 115, 22, 0.06);
|
||||
box-shadow: 0 2px 8px rgba(96, 165, 250, 0.06);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
&:active {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||
import { Sparkles, Send, Loader2, Flag, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus } from 'lucide-react'
|
||||
import { Sparkles, Send, Loader2, Flag, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus, ListChecks, FileText } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { uploadsApi } from '@/api/uploads'
|
||||
import type { PendingUpload } from '@/types/upload'
|
||||
@@ -14,6 +14,7 @@ import { ChatSidebar, ChatSidebarCollapsedBar } from '@/components/assistant/Cha
|
||||
import { ChatMessage } from '@/components/assistant/ChatMessage'
|
||||
import { TaskLane } from '@/components/assistant/TaskLane'
|
||||
import { ConcludeSessionModal } from '@/components/assistant/ConcludeSessionModal'
|
||||
import { StatusUpdateModal } from '@/components/flowpilot/StatusUpdateModal'
|
||||
import type { ChatListItem, ConclusionOutcome } from '@/types/assistant-chat'
|
||||
import type { SuggestedFlow } from '@/types/copilot'
|
||||
|
||||
@@ -31,20 +32,42 @@ export default function AssistantChatPage() {
|
||||
const navigate = useNavigate()
|
||||
const { sessionId: urlSessionId } = useParams<{ sessionId?: string }>()
|
||||
const [chats, setChats] = useState<ChatListItem[]>([])
|
||||
const [activeChatId, setActiveChatId] = useState<string | null>(urlSessionId || null)
|
||||
const [activeChatId, setActiveChatId] = useState<string | null>(() => {
|
||||
if (urlSessionId) return urlSessionId
|
||||
try { return sessionStorage.getItem('rf-active-chat-id') } catch { return null }
|
||||
})
|
||||
const [messages, setMessages] = useState<MessageWithMeta[]>([])
|
||||
const [input, setInput] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showConclude, setShowConclude] = useState(false)
|
||||
const [showStatusUpdate, setShowStatusUpdate] = useState(false)
|
||||
const branching = useBranching()
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
|
||||
const [showLogs, setShowLogs] = useState(false)
|
||||
const [logContent, setLogContent] = useState('')
|
||||
const [pendingUploads, setPendingUploads] = useState<PendingUpload[]>([])
|
||||
const [isDragOver, setIsDragOver] = useState(false)
|
||||
const [activeQuestions, setActiveQuestions] = useState<QuestionItem[]>([])
|
||||
const [activeActions, setActiveActions] = useState<ActionItem[]>([])
|
||||
const [showTaskLane, setShowTaskLane] = useState(false)
|
||||
const [activeQuestions, setActiveQuestions] = useState<QuestionItem[]>(() => {
|
||||
try {
|
||||
const saved = sessionStorage.getItem('rf-tasklane-meta')
|
||||
if (saved) { const d = JSON.parse(saved); if (d.chatId === activeChatId) return d.questions || [] }
|
||||
} catch { /* ignore */ }
|
||||
return []
|
||||
})
|
||||
const [activeActions, setActiveActions] = useState<ActionItem[]>(() => {
|
||||
try {
|
||||
const saved = sessionStorage.getItem('rf-tasklane-meta')
|
||||
if (saved) { const d = JSON.parse(saved); if (d.chatId === activeChatId) return d.actions || [] }
|
||||
} catch { /* ignore */ }
|
||||
return []
|
||||
})
|
||||
const [showTaskLane, setShowTaskLane] = useState(() => {
|
||||
try {
|
||||
const saved = sessionStorage.getItem('rf-tasklane-meta')
|
||||
if (saved) { const d = JSON.parse(saved); return d.show === true && d.chatId === activeChatId }
|
||||
} catch { /* ignore */ }
|
||||
return false
|
||||
})
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(() =>
|
||||
localStorage.getItem('rf-chat-sidebar-collapsed') === 'true'
|
||||
)
|
||||
@@ -59,6 +82,14 @@ export default function AssistantChatPage() {
|
||||
const dragCounterRef = useRef(0)
|
||||
const prefillHandledRef = useRef(false)
|
||||
|
||||
// Persist active chat ID to sessionStorage
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (activeChatId) sessionStorage.setItem('rf-active-chat-id', activeChatId)
|
||||
else sessionStorage.removeItem('rf-active-chat-id')
|
||||
} catch { /* ignore */ }
|
||||
}, [activeChatId])
|
||||
|
||||
// Load chat list from ai_sessions
|
||||
useEffect(() => {
|
||||
loadChats()
|
||||
@@ -71,6 +102,13 @@ export default function AssistantChatPage() {
|
||||
}
|
||||
}, [urlSessionId]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Restore session from sessionStorage on mount (when URL has no session ID)
|
||||
useEffect(() => {
|
||||
if (!urlSessionId && activeChatId) {
|
||||
selectChat(activeChatId)
|
||||
}
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Handle prefill from command palette / dashboard handoff
|
||||
useEffect(() => {
|
||||
const state = location.state as { prefill?: string; uploadIds?: string[] } | null
|
||||
@@ -82,6 +120,11 @@ export default function AssistantChatPage() {
|
||||
navigate(location.pathname, { replace: true, state: {} })
|
||||
|
||||
const sendPrefill = async () => {
|
||||
// Clear stale task lane from previous session
|
||||
setShowTaskLane(false)
|
||||
setActiveQuestions([])
|
||||
setActiveActions([])
|
||||
|
||||
try {
|
||||
const session = await aiSessionsApi.createChatSession({
|
||||
intake_type: 'free_text',
|
||||
@@ -137,6 +180,18 @@ export default function AssistantChatPage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// Persist task lane metadata to sessionStorage
|
||||
useEffect(() => {
|
||||
try {
|
||||
sessionStorage.setItem('rf-tasklane-meta', JSON.stringify({
|
||||
show: showTaskLane,
|
||||
chatId: activeChatId,
|
||||
questions: activeQuestions,
|
||||
actions: activeActions,
|
||||
}))
|
||||
} catch { /* ignore */ }
|
||||
}, [showTaskLane, activeChatId, activeQuestions, activeActions])
|
||||
|
||||
// Auto-scroll
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
@@ -160,7 +215,7 @@ export default function AssistantChatPage() {
|
||||
|
||||
const selectChat = useCallback(async (chatId: string) => {
|
||||
setActiveChatId(chatId)
|
||||
// Clear TaskLane when switching chats
|
||||
// Clear TaskLane when switching chats — will restore from backend if available
|
||||
setShowTaskLane(false)
|
||||
setActiveQuestions([])
|
||||
setActiveActions([])
|
||||
@@ -172,6 +227,24 @@ export default function AssistantChatPage() {
|
||||
content: m.content,
|
||||
}))
|
||||
)
|
||||
// Restore task lane from persisted state
|
||||
if (detail.pending_task_lane) {
|
||||
const q = detail.pending_task_lane.questions || []
|
||||
const a = detail.pending_task_lane.actions || []
|
||||
if (q.length > 0 || a.length > 0) {
|
||||
// Pre-load user's saved responses into sessionStorage BEFORE setting props
|
||||
// so TaskLane can restore them on mount/prop-change
|
||||
const responses = (detail.pending_task_lane as Record<string, unknown>).responses as unknown[] | undefined
|
||||
if (responses && responses.length > 0) {
|
||||
try {
|
||||
sessionStorage.setItem(`rf-tasklane-state:${chatId}`, JSON.stringify(responses))
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
setActiveQuestions(q)
|
||||
setActiveActions(a)
|
||||
setShowTaskLane(true)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setMessages([])
|
||||
}
|
||||
@@ -288,11 +361,6 @@ export default function AssistantChatPage() {
|
||||
}
|
||||
const userMessage = parts.join('\n\n')
|
||||
|
||||
// Close the task lane
|
||||
setShowTaskLane(false)
|
||||
setActiveQuestions([])
|
||||
setActiveActions([])
|
||||
|
||||
setMessages(prev => [...prev, { role: 'user', content: userMessage }])
|
||||
setLoading(true)
|
||||
|
||||
@@ -305,13 +373,18 @@ export default function AssistantChatPage() {
|
||||
if (response.fork && activeChatId) {
|
||||
branching.loadBranches(activeChatId)
|
||||
}
|
||||
// Show task lane again if AI sends more tasks
|
||||
// Update task lane based on AI response
|
||||
const hasQuestions = response.questions && response.questions.length > 0
|
||||
const hasActions = response.actions && response.actions.length > 0
|
||||
if (hasQuestions || hasActions) {
|
||||
setActiveQuestions(response.questions || [])
|
||||
setActiveActions(response.actions || [])
|
||||
setShowTaskLane(true)
|
||||
} else {
|
||||
// AI sent no new tasks — clear the lane
|
||||
setShowTaskLane(false)
|
||||
setActiveQuestions([])
|
||||
setActiveActions([])
|
||||
}
|
||||
} catch {
|
||||
setMessages(prev => [
|
||||
@@ -326,21 +399,19 @@ export default function AssistantChatPage() {
|
||||
const handleConclude = async (outcome: ConclusionOutcome, _notes: string): Promise<string> => {
|
||||
if (!activeChatId) throw new Error('No active chat')
|
||||
|
||||
// Map conclusion outcomes to ai_sessions actions
|
||||
if (outcome === 'resolved') {
|
||||
const result = await aiSessionsApi.resolveSession(activeChatId, {
|
||||
await aiSessionsApi.resolveSession(activeChatId, {
|
||||
resolution_summary: _notes || 'Resolved via assistant chat',
|
||||
})
|
||||
return result.documentation?.problem_summary || 'Session resolved'
|
||||
return activeChatId
|
||||
} else if (outcome === 'escalated') {
|
||||
const result = await aiSessionsApi.escalateSession(activeChatId, {
|
||||
await aiSessionsApi.escalateSession(activeChatId, {
|
||||
escalation_reason: _notes || 'Escalated from assistant chat',
|
||||
})
|
||||
return result.documentation?.problem_summary || 'Session escalated'
|
||||
return activeChatId
|
||||
} else {
|
||||
// paused
|
||||
await aiSessionsApi.pauseSession(activeChatId)
|
||||
return 'Session paused'
|
||||
return activeChatId
|
||||
}
|
||||
}
|
||||
|
||||
@@ -593,7 +664,7 @@ export default function AssistantChatPage() {
|
||||
'relative rounded-xl border transition-all',
|
||||
loading ? 'border-border/50 opacity-50' :
|
||||
isDragOver ? 'border-primary/50 bg-primary/5' :
|
||||
'border-border focus-within:border-[rgba(249,115,22,0.3)] focus-within:ring-1 focus-within:ring-primary/20'
|
||||
'border-border focus-within:border-[rgba(96,165,250,0.3)] focus-within:ring-1 focus-within:ring-primary/20'
|
||||
)} style={{ background: 'var(--color-bg-card)' }}>
|
||||
{/* Drag overlay */}
|
||||
{isDragOver && (
|
||||
@@ -663,7 +734,7 @@ export default function AssistantChatPage() {
|
||||
onChange={(e) => setLogContent(e.target.value)}
|
||||
placeholder="Paste event viewer logs, error messages, PowerShell output..."
|
||||
rows={3}
|
||||
className="w-full resize-none rounded-lg border border-border bg-background p-2 font-mono text-xs text-foreground placeholder:text-muted-foreground focus:border-[rgba(249,115,22,0.3)] focus:outline-none"
|
||||
className="w-full resize-none rounded-lg border border-border bg-background p-2 font-mono text-xs text-foreground placeholder:text-muted-foreground focus:border-[rgba(96,165,250,0.3)] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -683,9 +754,26 @@ export default function AssistantChatPage() {
|
||||
</button>
|
||||
)}
|
||||
{messages.length >= 2 && (
|
||||
<button type="button" onClick={() => setShowConclude(true)} disabled={loading} className="flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-xs text-muted-foreground hover:text-amber-400 hover:bg-amber-400/10 transition-colors disabled:opacity-40" title="Conclude session">
|
||||
<Flag size={14} />
|
||||
<span className="hidden sm:inline">Conclude</span>
|
||||
<>
|
||||
<button type="button" onClick={() => setShowStatusUpdate(true)} disabled={loading} className="flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-xs text-muted-foreground hover:text-blue-400 hover:bg-blue-500/10 transition-colors disabled:opacity-40" title="Share status update">
|
||||
<FileText size={14} />
|
||||
<span className="hidden sm:inline">Update</span>
|
||||
</button>
|
||||
<button type="button" onClick={() => setShowConclude(true)} disabled={loading} className="flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-xs text-muted-foreground hover:text-amber-400 hover:bg-amber-400/10 transition-colors disabled:opacity-40" title="Conclude session">
|
||||
<Flag size={14} />
|
||||
<span className="hidden sm:inline">Conclude</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{!showTaskLane && (activeQuestions.length > 0 || activeActions.length > 0) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowTaskLane(true)}
|
||||
className="flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-xs text-accent-text hover:text-foreground hover:bg-accent-dim transition-colors"
|
||||
title="Show task panel"
|
||||
>
|
||||
<ListChecks size={14} />
|
||||
Tasks ({activeQuestions.length + activeActions.length})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -727,8 +815,11 @@ export default function AssistantChatPage() {
|
||||
<TaskLane
|
||||
questions={activeQuestions}
|
||||
actions={activeActions}
|
||||
sessionId={activeChatId}
|
||||
onSubmit={handleTaskSubmit}
|
||||
onClose={() => setShowTaskLane(false)}
|
||||
onClose={() => {
|
||||
setShowTaskLane(false)
|
||||
}}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
@@ -746,7 +837,20 @@ export default function AssistantChatPage() {
|
||||
onConclude={handleConclude}
|
||||
onResumeNew={handleResumeNew}
|
||||
chatTitle={chats.find(c => c.id === activeChatId)?.title ?? 'Chat'}
|
||||
sessionId={activeChatId}
|
||||
/>
|
||||
|
||||
{/* Status Update Modal */}
|
||||
{activeChatId && (
|
||||
<StatusUpdateModal
|
||||
open={showStatusUpdate}
|
||||
onClose={() => setShowStatusUpdate(false)}
|
||||
onGenerate={(audience, length, context) =>
|
||||
aiSessionsApi.generateStatusUpdate(activeChatId, { audience, length, context })
|
||||
}
|
||||
context="status"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -217,7 +217,7 @@ export default function FlowPilotAnalyticsPage() {
|
||||
icon={BarChart3}
|
||||
label="Total Sessions"
|
||||
value={dashboard.total_sessions}
|
||||
iconColor="#f97316"
|
||||
iconColor="#60a5fa"
|
||||
/>
|
||||
<MetricCard
|
||||
icon={CheckCircle2}
|
||||
@@ -235,7 +235,7 @@ export default function FlowPilotAnalyticsPage() {
|
||||
icon={Star}
|
||||
label="Avg Rating"
|
||||
value={dashboard.avg_rating ? `${dashboard.avg_rating.toFixed(1)}/5` : '—'}
|
||||
iconColor="#eab308"
|
||||
iconColor="#fbbf24"
|
||||
/>
|
||||
<MetricCard
|
||||
icon={Ticket}
|
||||
@@ -273,14 +273,14 @@ export default function FlowPilotAnalyticsPage() {
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="mttr_minutes"
|
||||
stroke="#f97316"
|
||||
stroke="#60a5fa"
|
||||
fill="url(#mttrGradient)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient id="mttrGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#f97316" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#f97316" stopOpacity={0} />
|
||||
<stop offset="5%" stopColor="#60a5fa" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#60a5fa" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</AreaChart>
|
||||
@@ -313,7 +313,7 @@ export default function FlowPilotAnalyticsPage() {
|
||||
labelStyle={{ color: '#f0f2f5' }}
|
||||
/>
|
||||
<Bar dataKey="resolved" name="Resolved" fill="#34d399" radius={[0, 4, 4, 0]} />
|
||||
<Bar dataKey="escalated" name="Escalated" fill="#eab308" radius={[0, 4, 4, 0]} />
|
||||
<Bar dataKey="escalated" name="Escalated" fill="#fbbf24" radius={[0, 4, 4, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
@@ -343,7 +343,7 @@ export default function FlowPilotAnalyticsPage() {
|
||||
label="Exploring"
|
||||
count={conf.exploring_sessions}
|
||||
rate={conf.exploring_resolution_rate}
|
||||
color="#eab308"
|
||||
color="#fbbf24"
|
||||
total={conf.guided_sessions + conf.exploring_sessions + conf.discovery_sessions}
|
||||
/>
|
||||
<ConfidenceTierRow
|
||||
|
||||
@@ -207,7 +207,7 @@ export default function FlowPilotSessionPage() {
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => blocker.reset()}
|
||||
className="flex-1 rounded-lg bg-gradient-to-r from-orange-500 to-orange-400 px-4 py-2.5 text-sm font-semibold text-white hover:brightness-110 active:scale-[0.98] transition-all"
|
||||
className="flex-1 rounded-lg bg-gradient-to-r from-blue-500 to-blue-400 px-4 py-2.5 text-sm font-semibold text-white hover:brightness-110 active:scale-[0.98] transition-all"
|
||||
>
|
||||
Stay in Session
|
||||
</button>
|
||||
@@ -263,7 +263,7 @@ export default function FlowPilotSessionPage() {
|
||||
<button
|
||||
onClick={() => setShowStatusUpdate(true)}
|
||||
disabled={fp.isProcessing}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-orange-500/10 border border-orange-500/20 px-3 py-1.5 text-xs font-medium text-orange-400 hover:bg-orange-500/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
|
||||
className="flex items-center gap-1.5 rounded-lg bg-blue-500/10 border border-blue-500/20 px-3 py-1.5 text-xs font-medium text-blue-400 hover:bg-blue-500/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
|
||||
title="Share Update"
|
||||
>
|
||||
<FileText size={13} />
|
||||
@@ -333,7 +333,7 @@ export default function FlowPilotSessionPage() {
|
||||
{fp.allSteps.length >= 2 && (
|
||||
<button
|
||||
onClick={() => { setShowOverflow(false); setShowStatusUpdate(true) }}
|
||||
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-orange-400 hover:bg-orange-500/10 transition-colors"
|
||||
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-blue-400 hover:bg-blue-500/10 transition-colors"
|
||||
>
|
||||
<FileText size={14} />
|
||||
Share Update
|
||||
@@ -414,7 +414,7 @@ export default function FlowPilotSessionPage() {
|
||||
value={resolutionSummary}
|
||||
onChange={(e) => setResolutionSummary(e.target.value)}
|
||||
placeholder="What resolved the issue?"
|
||||
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(249,115,22,0.3)] focus:outline-none resize-none"
|
||||
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(96,165,250,0.3)] focus:outline-none resize-none"
|
||||
rows={4}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
@@ -194,7 +194,7 @@ export default function LandingPage() {
|
||||
<div className="landing-preview-body">
|
||||
<div className="landing-preview-sidebar">
|
||||
<div className="landing-preview-sidebar-item active">
|
||||
<div className="dot" style={{ background: '#f97316' }} />
|
||||
<div className="dot" style={{ background: '#60a5fa' }} />
|
||||
FlowPilot
|
||||
</div>
|
||||
<div className="landing-preview-sidebar-item">
|
||||
@@ -221,15 +221,15 @@ export default function LandingPage() {
|
||||
<span className="text">User can't access shared drive after password reset</span>
|
||||
</div>
|
||||
<div className="landing-mock-chat-line">
|
||||
<span className="label" style={{ color: '#f97316' }}>FlowPilot:</span>
|
||||
<span className="label" style={{ color: '#60a5fa' }}>FlowPilot:</span>
|
||||
<span className="text">This is likely a cached credential issue. Let's check a few things:</span>
|
||||
</div>
|
||||
<div className="landing-mock-chat-line">
|
||||
<span className="label" style={{ color: '#f97316' }}>FlowPilot:</span>
|
||||
<span className="label" style={{ color: '#60a5fa' }}>FlowPilot:</span>
|
||||
<span className="text">1. Run <code>klist purge</code> to clear Kerberos tickets</span>
|
||||
</div>
|
||||
<div className="landing-mock-chat-line">
|
||||
<span className="label" style={{ color: '#f97316' }}>FlowPilot:</span>
|
||||
<span className="label" style={{ color: '#60a5fa' }}>FlowPilot:</span>
|
||||
<span className="text">2. Open Credential Manager → remove saved entries for the share</span>
|
||||
</div>
|
||||
<div className="landing-mock-chat-line doc">
|
||||
|
||||
@@ -20,7 +20,7 @@ import type { PersonalAnalyticsResponse, AnalyticsPeriod } from '@/types'
|
||||
const OUTCOME_COLORS: Record<string, string> = {
|
||||
resolved: '#34d399',
|
||||
escalated: '#f87171',
|
||||
workaround: '#eab308',
|
||||
workaround: '#fbbf24',
|
||||
unresolved: '#94a3b8',
|
||||
}
|
||||
|
||||
|
||||
@@ -872,7 +872,7 @@ export function ProceduralNavigationPage() {
|
||||
key={field.variable_name}
|
||||
className={cn(
|
||||
'rounded-lg border px-3 py-2.5',
|
||||
isFilled ? 'border-border bg-accent' : 'border-orange-500/20 bg-orange-500/5'
|
||||
isFilled ? 'border-border bg-accent' : 'border-blue-500/20 bg-blue-500/5'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
@@ -880,7 +880,7 @@ export function ProceduralNavigationPage() {
|
||||
{isFilled ? (
|
||||
<Check className="h-3.5 w-3.5 shrink-0 text-emerald-400" />
|
||||
) : (
|
||||
<AlertCircle className="h-3.5 w-3.5 shrink-0 text-orange-400" />
|
||||
<AlertCircle className="h-3.5 w-3.5 shrink-0 text-blue-400" />
|
||||
)}
|
||||
<span className="text-xs font-medium text-muted-foreground truncate">
|
||||
{field.label}
|
||||
@@ -903,7 +903,7 @@ export function ProceduralNavigationPage() {
|
||||
value={editingVarValue}
|
||||
onChange={(e) => setEditingVarValue(e.target.value)}
|
||||
autoFocus
|
||||
className="w-full rounded-md border border-orange-500/30 bg-card px-2.5 py-1.5 text-sm text-foreground focus:border-orange-400 focus:outline-hidden focus:ring-1 focus:ring-orange-400/30"
|
||||
className="w-full rounded-md border border-blue-500/30 bg-card px-2.5 py-1.5 text-sm text-foreground focus:border-blue-400 focus:outline-hidden focus:ring-1 focus:ring-blue-400/30"
|
||||
>
|
||||
<option value="">{field.placeholder || 'Select...'}</option>
|
||||
{field.options.map((opt) => (
|
||||
@@ -917,7 +917,7 @@ export function ProceduralNavigationPage() {
|
||||
autoFocus
|
||||
rows={3}
|
||||
placeholder={field.placeholder}
|
||||
className="w-full rounded-md border border-orange-500/30 bg-card px-2.5 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-orange-400 focus:outline-hidden focus:ring-1 focus:ring-orange-400/30"
|
||||
className="w-full rounded-md border border-blue-500/30 bg-card px-2.5 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-blue-400 focus:outline-hidden focus:ring-1 focus:ring-blue-400/30"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
@@ -927,7 +927,7 @@ export function ProceduralNavigationPage() {
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') saveEditingVar() }}
|
||||
autoFocus
|
||||
placeholder={field.placeholder}
|
||||
className="w-full rounded-md border border-orange-500/30 bg-card px-2.5 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-orange-400 focus:outline-hidden focus:ring-1 focus:ring-orange-400/30"
|
||||
className="w-full rounded-md border border-blue-500/30 bg-card px-2.5 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-blue-400 focus:outline-hidden focus:ring-1 focus:ring-blue-400/30"
|
||||
/>
|
||||
)}
|
||||
{field.help_text && (
|
||||
|
||||
@@ -161,7 +161,7 @@ export default function PublicTemplatesPage() {
|
||||
<div
|
||||
className="fixed top-[-20%] right-[-10%] w-[600px] h-[600px] rounded-full pointer-events-none"
|
||||
style={{
|
||||
background: 'radial-gradient(circle, rgba(249,115,22,0.05) 0%, transparent 70%)',
|
||||
background: 'radial-gradient(circle, rgba(96,165,250,0.05) 0%, transparent 70%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -211,7 +211,7 @@ export default function PublicTemplatesPage() {
|
||||
placeholder="Search templates..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
className="w-full pl-12 pr-4 py-3.5 rounded-2xl text-sm bg-card border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-[rgba(249,115,22,0.3)] transition-colors"
|
||||
className="w-full pl-12 pr-4 py-3.5 rounded-2xl text-sm bg-card border border-border text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-[rgba(96,165,250,0.3)] transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { scriptBuilderApi } from '@/api'
|
||||
import { ScriptBuilderChat } from '@/components/script-builder/ScriptBuilderChat'
|
||||
import { ScriptBuilderInput } from '@/components/script-builder/ScriptBuilderInput'
|
||||
import { ScriptPreviewModal } from '@/components/script-builder/ScriptPreviewModal'
|
||||
import { SaveToLibraryDialog } from '@/components/script-builder/SaveToLibraryDialog'
|
||||
import { ParameterizeAndSavePanel } from '@/components/scripts/ParameterizeAndSavePanel'
|
||||
import type { ScriptBuilderSessionDetail, ScriptBuilderMessage } from '@/types'
|
||||
|
||||
const LANGUAGES = [
|
||||
@@ -112,7 +112,23 @@ export default function ScriptBuilderPage() {
|
||||
setShowSaveDialog(true)
|
||||
}
|
||||
|
||||
const handleSaved = () => {
|
||||
const handleSaved = async (payload: {
|
||||
name: string
|
||||
description: string | undefined
|
||||
category_id: string | undefined
|
||||
share_with_team: boolean
|
||||
script_body: string
|
||||
parameters_schema: { parameters: import('@/types').ScriptParameter[] }
|
||||
}) => {
|
||||
if (!session) return
|
||||
await scriptBuilderApi.saveToLibrary(session.id, {
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
category_id: payload.category_id,
|
||||
share_with_team: payload.share_with_team,
|
||||
script_body: payload.script_body,
|
||||
parameters_schema: payload.parameters_schema,
|
||||
})
|
||||
setShowSaveDialog(false)
|
||||
}
|
||||
|
||||
@@ -190,13 +206,14 @@ export default function ScriptBuilderPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Save dialog */}
|
||||
{showSaveDialog && session && (
|
||||
<SaveToLibraryDialog
|
||||
sessionId={session.id}
|
||||
{/* Save panel */}
|
||||
{showSaveDialog && session && session.latest_script && (
|
||||
<ParameterizeAndSavePanel
|
||||
scriptBody={session.latest_script}
|
||||
language={session.language}
|
||||
defaultName={defaultSaveName}
|
||||
onSave={handleSaved}
|
||||
onClose={() => setShowSaveDialog(false)}
|
||||
onSaved={handleSaved}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Terminal, Settings, Wand2 } from 'lucide-react'
|
||||
import { Terminal, Settings, Wand2, FileUp } from 'lucide-react'
|
||||
import { useScriptGeneratorStore } from '@/store/scriptGeneratorStore'
|
||||
import { usePermissions } from '@/hooks/usePermissions'
|
||||
import { ScriptFilterBar } from '@/components/scripts/ScriptFilterBar'
|
||||
import { ScriptTemplateList } from '@/components/scripts/ScriptTemplateList'
|
||||
import { ScriptConfigurePane } from '@/components/scripts/ScriptConfigurePane'
|
||||
import { ScriptPreview } from '@/components/scripts/ScriptPreview'
|
||||
import { ParameterizeAndSavePanel } from '@/components/scripts/ParameterizeAndSavePanel'
|
||||
import { scriptsApi } from '@/api'
|
||||
import type { ScriptParameter } from '@/types'
|
||||
|
||||
type LibraryTab = 'mine' | 'team'
|
||||
|
||||
@@ -23,8 +26,35 @@ export default function ScriptLibraryPage() {
|
||||
const clearOutput = useScriptGeneratorStore(s => s.clearOutput)
|
||||
const selectedTemplate = useScriptGeneratorStore(s => s.selectedTemplate)
|
||||
|
||||
const categories = useScriptGeneratorStore(s => s.categories)
|
||||
|
||||
const { isEngineer } = usePermissions()
|
||||
const canGenerate = isEngineer
|
||||
const [showImportPanel, setShowImportPanel] = useState(false)
|
||||
|
||||
const handleImportSave = async (payload: {
|
||||
name: string
|
||||
description: string | undefined
|
||||
category_id: string | undefined
|
||||
share_with_team: boolean
|
||||
script_body: string
|
||||
parameters_schema: { parameters: ScriptParameter[] }
|
||||
}) => {
|
||||
const categoryId = payload.category_id || categories[0]?.id
|
||||
if (!categoryId) {
|
||||
throw new Error('No categories available. Please create a category first.')
|
||||
}
|
||||
await scriptsApi.createTemplate({
|
||||
category_id: categoryId,
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
script_body: payload.script_body,
|
||||
parameters_schema: payload.parameters_schema,
|
||||
})
|
||||
setShowImportPanel(false)
|
||||
const filters = activeTab === 'mine' ? { mine: true } : { shared: true }
|
||||
loadTemplates(filters)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadCategories().then(() => {
|
||||
@@ -70,13 +100,23 @@ export default function ScriptLibraryPage() {
|
||||
Browse PowerShell templates, fill in parameters, and generate ready-to-run scripts.
|
||||
</p>
|
||||
{isEngineer && (
|
||||
<Link
|
||||
to="/scripts/manage"
|
||||
className="inline-flex items-center gap-1.5 text-xs text-primary bg-accent-dim hover:bg-primary/15 px-2.5 py-1 rounded-full transition-colors mt-2 group"
|
||||
>
|
||||
<Settings size={12} className="group-hover:rotate-90 transition-transform duration-300" />
|
||||
Manage Templates
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Link
|
||||
to="/scripts/manage"
|
||||
className="inline-flex items-center gap-1.5 text-xs text-primary bg-accent-dim hover:bg-primary/15 px-2.5 py-1 rounded-full transition-colors group"
|
||||
>
|
||||
<Settings size={12} className="group-hover:rotate-90 transition-transform duration-300" />
|
||||
Manage Templates
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowImportPanel(true)}
|
||||
className="inline-flex items-center gap-1.5 text-xs text-primary bg-accent-dim hover:bg-primary/15 px-2.5 py-1 rounded-full transition-colors"
|
||||
>
|
||||
<FileUp size={12} />
|
||||
New from Script
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
@@ -138,6 +178,14 @@ export default function ScriptLibraryPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Import script panel */}
|
||||
{showImportPanel && (
|
||||
<ParameterizeAndSavePanel
|
||||
onSave={handleImportSave}
|
||||
onClose={() => setShowImportPanel(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -329,7 +329,7 @@ export function SessionHistoryPage() {
|
||||
value={aiSearchInput}
|
||||
onChange={(e) => setAiSearchInput(e.target.value)}
|
||||
placeholder="Search sessions..."
|
||||
className="w-full rounded-lg border border-border bg-card pl-8 pr-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(249,115,22,0.3)] focus:outline-none"
|
||||
className="w-full rounded-lg border border-border bg-card pl-8 pr-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(96,165,250,0.3)] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -356,7 +356,7 @@ export function SessionHistoryPage() {
|
||||
value={aiFilters.problem_domain}
|
||||
onChange={(e) => setAiFilters((f) => ({ ...f, problem_domain: e.target.value }))}
|
||||
title="Filter by problem domain"
|
||||
className="rounded-lg border border-border bg-card px-3 py-1.5 text-sm text-foreground focus:border-[rgba(249,115,22,0.3)] focus:outline-none [&>option]:bg-card [&>option]:text-foreground"
|
||||
className="rounded-lg border border-border bg-card px-3 py-1.5 text-sm text-foreground focus:border-[rgba(96,165,250,0.3)] focus:outline-none [&>option]:bg-card [&>option]:text-foreground"
|
||||
>
|
||||
<option value="">All domains</option>
|
||||
<option value="Active Directory">Active Directory</option>
|
||||
@@ -396,7 +396,7 @@ export function SessionHistoryPage() {
|
||||
value={aiFilters.date_from}
|
||||
onChange={(e) => setAiFilters((f) => ({ ...f, date_from: e.target.value }))}
|
||||
title="From date"
|
||||
className="rounded-lg border border-border bg-card px-3 py-1.5 text-sm text-foreground focus:border-[rgba(249,115,22,0.3)] focus:outline-none [color-scheme:dark]"
|
||||
className="rounded-lg border border-border bg-card px-3 py-1.5 text-sm text-foreground focus:border-[rgba(96,165,250,0.3)] focus:outline-none [color-scheme:dark]"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">to</span>
|
||||
<input
|
||||
@@ -404,7 +404,7 @@ export function SessionHistoryPage() {
|
||||
value={aiFilters.date_to}
|
||||
onChange={(e) => setAiFilters((f) => ({ ...f, date_to: e.target.value }))}
|
||||
title="To date"
|
||||
className="rounded-lg border border-border bg-card px-3 py-1.5 text-sm text-foreground focus:border-[rgba(249,115,22,0.3)] focus:outline-none [color-scheme:dark]"
|
||||
className="rounded-lg border border-border bg-card px-3 py-1.5 text-sm text-foreground focus:border-[rgba(96,165,250,0.3)] focus:outline-none [color-scheme:dark]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -564,7 +564,7 @@ export function SessionHistoryPage() {
|
||||
session.outcome === 'escalated' && 'bg-blue-500/20 text-blue-300',
|
||||
session.outcome === 'unresolved' && 'bg-rose-500/20 text-rose-300',
|
||||
session.outcome === 'cancelled' && 'bg-zinc-500/20 text-zinc-300',
|
||||
session.outcome === 'resolved_externally' && 'bg-orange-500/20 text-orange-300',
|
||||
session.outcome === 'resolved_externally' && 'bg-blue-500/20 text-blue-300',
|
||||
!session.outcome && 'bg-accent text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
@@ -647,7 +647,7 @@ export function SessionHistoryPage() {
|
||||
value={closeOutcome}
|
||||
onChange={(e) => setCloseOutcome(e.target.value as SessionOutcome)}
|
||||
title="Session outcome"
|
||||
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground focus:border-[rgba(249,115,22,0.3)] focus:outline-none mb-3"
|
||||
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground focus:border-[rgba(96,165,250,0.3)] focus:outline-none mb-3"
|
||||
>
|
||||
<option value="">Select outcome...</option>
|
||||
<option value="resolved">Resolved</option>
|
||||
@@ -664,7 +664,7 @@ export function SessionHistoryPage() {
|
||||
onChange={(e) => setCloseNotes(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Add closure notes..."
|
||||
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(249,115,22,0.3)] focus:outline-none resize-none mb-3"
|
||||
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(96,165,250,0.3)] focus:outline-none resize-none mb-3"
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
|
||||
@@ -279,7 +279,7 @@ export default function SurveyPage() {
|
||||
</div>
|
||||
<div className="relative z-10 mx-auto max-w-[680px] px-4 sm:px-5">
|
||||
<div className="text-center pt-20 sm:pt-32 animate-fade-in-up">
|
||||
<div className="w-16 h-16 mx-auto mb-5 rounded-full flex items-center justify-center" style={{ background: 'rgba(249, 115, 22, 0.10)' }}>
|
||||
<div className="w-16 h-16 mx-auto mb-5 rounded-full flex items-center justify-center" style={{ background: 'rgba(96, 165, 250, 0.10)' }}>
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" strokeWidth="2.5"><path d="M20 6L9 17l-5-5"/></svg>
|
||||
</div>
|
||||
<h2 className="font-heading text-2xl font-bold mb-2.5">Already Submitted</h2>
|
||||
@@ -329,7 +329,7 @@ export default function SurveyPage() {
|
||||
{/* Hero — visible only on first slide */}
|
||||
{currentSlide === 0 && !isComplete && (
|
||||
<div className="text-center pt-10 pb-8 sm:pt-[72px] sm:pb-10 animate-fade-in-up">
|
||||
<div className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[10px] sm:text-[11px] font-semibold font-sans text-xs uppercase tracking-widest mb-4 sm:mb-5" style={{ background: 'rgba(249, 115, 22, 0.10)', border: '1px solid rgba(249, 115, 22, 0.15)', color: 'var(--color-primary)' }}>
|
||||
<div className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-[10px] sm:text-[11px] font-semibold font-sans text-xs uppercase tracking-widest mb-4 sm:mb-5" style={{ background: 'rgba(96, 165, 250, 0.10)', border: '1px solid rgba(96, 165, 250, 0.15)', color: 'var(--color-primary)' }}>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
|
||||
FlowPilot Research
|
||||
</div>
|
||||
@@ -507,7 +507,7 @@ export default function SurveyPage() {
|
||||
|
||||
function ScenarioBox({ scenario }: { scenario: { title: string; symptom: string; details: string } }) {
|
||||
return (
|
||||
<div className="rounded-lg p-3.5 px-4 sm:p-4 sm:px-5 mb-4 text-[13px]" style={{ background: 'rgba(249, 115, 22, 0.04)', border: '1px solid color-mix(in srgb, var(--color-primary) 12%, transparent)' }}>
|
||||
<div className="rounded-lg p-3.5 px-4 sm:p-4 sm:px-5 mb-4 text-[13px]" style={{ background: 'rgba(96, 165, 250, 0.04)', border: '1px solid color-mix(in srgb, var(--color-primary) 12%, transparent)' }}>
|
||||
<div className="font-sans text-xs text-[10px] uppercase tracking-widest mb-2 font-semibold" style={{ color: 'var(--color-primary)' }}>{scenario.title}</div>
|
||||
<div className="sm:flex gap-2 mb-1">
|
||||
<span className="text-muted-foreground font-medium whitespace-nowrap">Symptom:</span>
|
||||
@@ -537,7 +537,7 @@ function QuestionCard({ question: q, answer, setAnswer }: { question: SurveyQues
|
||||
onClick={() => setAnswer(q.id, opt)}
|
||||
className="flex items-start gap-3 px-3.5 py-3 sm:px-4 rounded-[9px] text-left text-[13px] sm:text-sm transition-all duration-150 select-none"
|
||||
style={{
|
||||
background: answer === opt ? 'rgba(249, 115, 22, 0.10)' : 'rgba(16, 17, 20, 0.6)',
|
||||
background: answer === opt ? 'rgba(96, 165, 250, 0.10)' : 'rgba(16, 17, 20, 0.6)',
|
||||
border: `1px solid ${answer === opt ? 'var(--color-primary)' : 'var(--color-border-default)'}`,
|
||||
color: answer === opt ? 'var(--color-foreground)' : 'var(--color-muted-foreground)',
|
||||
}}
|
||||
@@ -564,7 +564,7 @@ function QuestionCard({ question: q, answer, setAnswer }: { question: SurveyQues
|
||||
}}
|
||||
className="flex items-start gap-3 px-3.5 py-3 sm:px-4 rounded-[9px] text-left text-[13px] sm:text-sm transition-all duration-150 select-none"
|
||||
style={{
|
||||
background: selected ? 'rgba(249, 115, 22, 0.10)' : 'rgba(16, 17, 20, 0.6)',
|
||||
background: selected ? 'rgba(96, 165, 250, 0.10)' : 'rgba(16, 17, 20, 0.6)',
|
||||
border: `1px solid ${selected ? 'var(--color-primary)' : 'var(--color-border-default)'}`,
|
||||
color: selected ? 'var(--color-foreground)' : 'var(--color-muted-foreground)',
|
||||
}}
|
||||
@@ -593,7 +593,7 @@ function QuestionCard({ question: q, answer, setAnswer }: { question: SurveyQues
|
||||
background: 'rgba(16, 17, 20, 0.6)',
|
||||
border: '1px solid var(--color-border-default)',
|
||||
}}
|
||||
onFocus={e => { e.currentTarget.style.borderColor = 'var(--color-primary)'; e.currentTarget.style.boxShadow = '0 0 0 3px rgba(249, 115, 22, 0.10)' }}
|
||||
onFocus={e => { e.currentTarget.style.borderColor = 'var(--color-primary)'; e.currentTarget.style.boxShadow = '0 0 0 3px rgba(96, 165, 250, 0.10)' }}
|
||||
onBlur={e => { e.currentTarget.style.borderColor = 'var(--color-border-default)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||
/>
|
||||
)}
|
||||
@@ -717,7 +717,7 @@ function DragRank({ items, onChange }: { items: string[]; onChange: (items: stri
|
||||
onTouchStart={() => handleTouchStart(idx)}
|
||||
className="flex items-center gap-2.5 sm:gap-3 px-3 py-3 sm:px-4 sm:py-2.5 rounded-[9px] text-[13px] sm:text-sm transition-all duration-150 select-none"
|
||||
style={{
|
||||
background: overIdx === idx ? 'rgba(249, 115, 22, 0.10)' : 'rgba(16, 17, 20, 0.6)',
|
||||
background: overIdx === idx ? 'rgba(96, 165, 250, 0.10)' : 'rgba(16, 17, 20, 0.6)',
|
||||
border: `1px solid ${overIdx === idx || draggingIdx === idx ? 'var(--color-primary)' : 'var(--color-border-default)'}`,
|
||||
opacity: draggingIdx === idx ? 0.5 : 1,
|
||||
cursor: 'grab',
|
||||
|
||||
@@ -21,7 +21,7 @@ import type { TeamAnalyticsResponse, AnalyticsPeriod } from '@/types'
|
||||
const CHART_COLORS = {
|
||||
resolved: '#34d399',
|
||||
escalated: '#f87171',
|
||||
workaround: '#eab308',
|
||||
workaround: '#fbbf24',
|
||||
unresolved: '#94a3b8',
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ interface AccountBranding {
|
||||
company_name: string | null
|
||||
}
|
||||
|
||||
const DEFAULT_COLOR = '#ea580c'
|
||||
const DEFAULT_COLOR = '#3b82f6'
|
||||
|
||||
export function BrandingSettingsPage() {
|
||||
const [branding, setBranding] = useState<AccountBranding | null>(null)
|
||||
@@ -137,7 +137,7 @@ export function BrandingSettingsPage() {
|
||||
className={cn(
|
||||
'mt-1 w-full rounded-lg border border-border bg-card px-3 py-2 text-sm',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-[rgba(249,115,22,0.3)] focus:outline-none focus:ring-1 focus:ring-primary/20'
|
||||
'focus:border-[rgba(96,165,250,0.3)] focus:outline-none focus:ring-1 focus:ring-primary/20'
|
||||
)}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
@@ -163,7 +163,7 @@ export function BrandingSettingsPage() {
|
||||
className={cn(
|
||||
'mt-1 w-full rounded-lg border border-border bg-card px-3 py-2 text-sm',
|
||||
'text-foreground placeholder:text-muted-foreground font-mono',
|
||||
'focus:border-[rgba(249,115,22,0.3)] focus:outline-none focus:ring-1 focus:ring-primary/20'
|
||||
'focus:border-[rgba(96,165,250,0.3)] focus:outline-none focus:ring-1 focus:ring-primary/20'
|
||||
)}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
@@ -207,12 +207,12 @@ export function BrandingSettingsPage() {
|
||||
const val = e.target.value
|
||||
setPrimaryColor(val)
|
||||
}}
|
||||
placeholder="#ea580c"
|
||||
placeholder="#3b82f6"
|
||||
maxLength={7}
|
||||
className={cn(
|
||||
'w-32 rounded-lg border border-border bg-card px-3 py-2 text-sm',
|
||||
'text-foreground placeholder:text-muted-foreground font-mono',
|
||||
'focus:border-[rgba(249,115,22,0.3)] focus:outline-none focus:ring-1 focus:ring-primary/20'
|
||||
'focus:border-[rgba(96,165,250,0.3)] focus:outline-none focus:ring-1 focus:ring-primary/20'
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
@@ -224,7 +224,7 @@ export function BrandingSettingsPage() {
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Hex color code for the primary accent color (e.g. #ea580c).
|
||||
Hex color code for the primary accent color (e.g. #3b82f6).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -813,7 +813,7 @@ function MemberMappingTab({ connection }: { connection: PsaConnectionResponse |
|
||||
onChange={(e) => handleMemberChange(user.user_id, e.target.value)}
|
||||
className={cn(
|
||||
'w-full rounded-lg border bg-card px-3 py-1.5 text-sm text-foreground',
|
||||
'border-border focus:border-[rgba(249,115,22,0.3)] focus:outline-none',
|
||||
'border-border focus:border-[rgba(96,165,250,0.3)] focus:outline-none',
|
||||
!currentMapping && 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
@@ -1084,7 +1084,7 @@ function SettingSelect({
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
className="w-full max-w-xs rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground focus:border-[rgba(249,115,22,0.3)] focus:outline-none disabled:opacity-50"
|
||||
className="w-full max-w-xs rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground focus:border-[rgba(96,165,250,0.3)] focus:outline-none disabled:opacity-50"
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user