refactor: resolve merge conflicts — combine main improvements with token normalization

- .gitignore: keep both graphify-out/ entries and main's .gitnexus entry
- ScriptCodeBlock/ScriptPreviewModal: take main's border-border and text-accent-text
  for filename labels; use neutral ghost style for Save button in ScriptCodeBlock;
  use bg-accent (normalized from bg-primary) for Save button in ScriptPreviewModal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Michael Chihlas
2026-04-06 20:23:36 -04:00
51 changed files with 4039 additions and 2656 deletions

1
.gitignore vendored
View File

@@ -233,6 +233,7 @@ package.json
package-lock.json package-lock.json
.worktrees/ .worktrees/
.gstack/ .gstack/
.gitnexus
# graphify knowledge graph outputs # graphify knowledge graph outputs
graphify-out/ graphify-out/

View File

@@ -8,6 +8,44 @@ All notable changes to ResolutionFlow are documented here.
- Tree Templates + Import/Export marketplace (#66) - Tree Templates + Import/Export marketplace (#66)
- Recurring Issue Detection — client-specific pattern alerts (#60) - Recurring Issue Detection — client-specific pattern alerts (#60)
- Step Feedback Flag — "This Step is Wrong" reporting (#58) - Step Feedback Flag — "This Step is Wrong" reporting (#58)
- **Script Library default view** — "All Scripts" tab now displays all accessible scripts (team + library)
- **Session documentation overhaul** — reformatted PSA resolution/escalation notes with cleaner headers, inline engineer responses, decimal hour display (0.25 hrs), follow-up recommendations, and improved "What We Know" section from evidence items
- **Client communication improvements** — new `request_info` audience type for client-facing information requests, improved status update and email draft prompts with per-context guidance
### Changed
- **Edit Procedure page** — layout overhaul and color system refinements for better visual hierarchy
- **Flows sidebar navigation** — collapsed to reduce visual noise; session recovery removed from library view
- **Account settings page** — audit fixes for improved consistency and usability
- **PSA documentation formatting** — removed duplicate timing blocks and AI confidence sections; added client-facing communication context guidance
- **Status update generation** — fixed option label lookup to use human-readable labels instead of machine values
### Fixed
- Dark text rendering on blue accent step-number badges across all flow types
- Script Library tab ownership filter now preserved across category and search changes
- Race conditions in script builder session creation and slug generation
- Stale async results in Assistant Chat (selectChat) no longer clobber new session task lane
- Sentry DSN hardcoded fallback removed — now uses environment variable only
- Option label resolution in status update context generation
---
## [0.11.0] - 2026-03-30
### Changed
- **Landing page redesign** — replaced AI-template layout with bold hero, live chat animation, scroll-driven reveals, and FAQ section; self-contained `--lp-*` palette; electric blue accent throughout
- **Dashboard design critique** — eliminated section redundancy, differentiated card types across PerformanceCards, KnowledgeBaseCards, and TeamSummary; reduced visual noise
- **Session History** — redesigned as tabbed view (AI Sessions / Flow Sessions) with Load More pagination and domain filter chips; AI sessions now support lazy-loaded flow sessions with URL param routing to correct tab
- **Escalation Queue** — improved urgency signaling with time-based styling
- **Assistant page** — TaskLane UX improvements (confirmed-delete, restorable skipped tasks, progress counter); ChatSidebar delete confirmation flow fixed (no accidental chat switch while confirming)
- **Script Library/Builder** — design critique fixes; suggestion chips now correctly respect disabled state during generation
- **Create Flow dropdown** — simplified to two options (Troubleshooting / Procedural); removed AI generate flow and maintenance flow per pilot scope
- **Tag badges and buttons** — fixed unreadable text caused by `bg-accent` with dark foreground; tags now use elevated background with border
### Fixed
- Restored removed icon imports in MyTreesPage; added default export to SessionHistoryPage
- Fixed ternary closing brackets in KnowledgeBaseCards and TeamSummary
- Fixed `loadMoreAiSessions` race condition — stale pages from prior filter queries no longer mix with fresh results
- Fixed `--lp-btn` using `var(--color-accent)` in `landing.css` (violates lesson 104); now hardcoded to `#60a5fa`
--- ---

162
CLAUDE.md
View File

@@ -1,6 +1,6 @@
# CLAUDE.md - Patherly / ResolutionFlow Project Context # CLAUDE.md - Patherly / ResolutionFlow Project Context
> **Last Updated:** March 27, 2026 > **Last Updated:** April 6, 2026
--- ---
@@ -16,7 +16,8 @@
| Context | Name Used | | Context | Name Used |
|---------|-----------| |---------|-----------|
| Repository / directory / database / Docker | `patherly` / `patherly_postgres` | | Repository / directory / database | `patherly` (internal name) |
| Docker containers | `resolutionflow_postgres`, `resolutionflow_frontend`, `resolutionflow_backend` |
| Backend, frontend UI, production URLs | **ResolutionFlow** | | Backend, frontend UI, production URLs | **ResolutionFlow** |
- **Design system:** [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) — THE source of truth for all design decisions - **Design system:** [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) — THE source of truth for all design decisions
@@ -44,7 +45,7 @@
- **Phase:** Go-to-Market Validation (Pre-PMF) - **Phase:** Go-to-Market Validation (Pre-PMF)
- **Backend:** Complete (55+ API endpoints, 100+ integration tests) - **Backend:** Complete (55+ API endpoints, 100+ integration tests)
- **Frontend:** Core features complete, Tree Editor functional - **Frontend:** Core features complete, Tree Editor functional
- **Database:** PostgreSQL with Docker, 98 migrations - **Database:** PostgreSQL with Docker, 101 migrations
- **Detailed status:** [CURRENT-STATE.md](CURRENT-STATE.md) - **Detailed status:** [CURRENT-STATE.md](CURRENT-STATE.md)
### What's In Progress ### What's In Progress
@@ -96,7 +97,7 @@ patherly/
│ │ ├── services/knowledge_flywheel.py # AI session analysis → flow proposals │ │ ├── services/knowledge_flywheel.py # AI session analysis → flow proposals
│ │ ├── services/knowledge_flywheel_scheduler.py # APScheduler job for batch analysis │ │ ├── services/knowledge_flywheel_scheduler.py # APScheduler job for batch analysis
│ │ └── services/knowledge_gap_service.py # Weak options & escalation signal detection │ │ └── services/knowledge_gap_service.py # Weak options & escalation signal detection
│ ├── alembic/ # Database migrations (001-029+) │ ├── alembic/ # Database migrations (001-070 sequential, then hash IDs)
│ ├── scripts/ # seed_data.py, seed_trees.py │ ├── scripts/ # seed_data.py, seed_trees.py
│ └── tests/ # pytest integration tests │ └── tests/ # pytest integration tests
├── frontend/ ├── frontend/
@@ -188,8 +189,8 @@ Official ConnectWise developer guides live in `docs/connectwise/best-practices/`
## Development Commands ## Development Commands
```powershell ```powershell
# Start PostgreSQL # Start PostgreSQL (run from VPS SSH — docker not available inside code-server, see Lesson 103)
docker start patherly_postgres docker start resolutionflow_postgres
# Backend (from backend/) # Backend (from backend/)
source venv/bin/activate # Linux/Mac source venv/bin/activate # Linux/Mac
@@ -203,21 +204,19 @@ npm run dev
pytest --override-ini="addopts=" pytest --override-ini="addopts="
# First time only: create test database # First time only: create test database
docker exec -it patherly_postgres psql -U postgres -c "CREATE DATABASE patherly_test;" docker exec -it resolutionflow_postgres psql -U postgres -c "CREATE DATABASE resolutionflow_test;"
# Frontend build (IMPORTANT: stricter than tsc --noEmit — always use as final check) # Frontend build (IMPORTANT: stricter than tsc --noEmit — always use as final check)
cd frontend && npm run build cd frontend && npm run build
# Database migrations # Database migrations
cd backend && alembic upgrade head cd backend && alembic upgrade head
alembic revision --autogenerate -m "Description" --rev-id=NNN # NNN = next sequential number alembic revision --autogenerate -m "Description"
# IMPORTANT: Migrations use sequential 3-digit IDs (001, 002, ..., 068, 069). # Sequential 3-digit IDs (001070) were used historically. New migrations use Alembic's default hex hash IDs.
# Check the latest: ls backend/alembic/versions/ | grep -E '^\d{3}_' | sort | tail -1 # Do NOT pass --rev-id — let Alembic generate the hash automatically.
# 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 # Access PostgreSQL (run from VPS SSH — docker not available inside code-server, see Lesson 103)
docker exec -it patherly_postgres psql -U postgres -d patherly docker exec -it resolutionflow_postgres psql -U postgres -d resolutionflow
# Seed data # Seed data
cd backend && pip install httpx && python -m scripts.seed_trees cd backend && pip install httpx && python -m scripts.seed_trees
@@ -292,7 +291,7 @@ gh run view <id> --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi
**62. Playwright strict mode — scope selectors to avoid ambiguity:** Step titles appear in both the sidebar checklist and main content heading. Use `getByRole('heading', { name })` for the main content, or scope with `page.locator('.animate-scale-in')` for command palette items. `getByText()` frequently matches multiple elements due to the sidebar + main content layout. **62. Playwright strict mode — scope selectors to avoid ambiguity:** Step titles appear in both the sidebar checklist and main content heading. Use `getByRole('heading', { name })` for the main content, or scope with `page.locator('.animate-scale-in')` for command palette items. `getByText()` frequently matches multiple elements due to the sidebar + main content layout.
**63. Node 20 required for frontend builds:** Vite 7+ requires Node 20.19+. The system Node may be v18; use nvm: `export NVM_DIR="$HOME/.nvm" && source "$NVM_DIR/nvm.sh" && nvm use 20`. For direct binary access without nvm sourcing: `PATH="/home/michaelchihlas/.nvm/versions/node/v20.19.0/bin:$PATH"`. **63. Node 20 required for frontend builds:** Vite 7+ requires Node 20.19+. The system Node may be v18; use nvm: `export NVM_DIR="$HOME/.nvm" && source "$NVM_DIR/nvm.sh" && nvm use 20`. For direct binary access without nvm sourcing: `PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH"`.
**64. PostHog product analytics:** Initialized via `PostHogProvider` in `main.tsx` with explicit `posthog.init()` + `client` prop pattern. Event helpers in `lib/analytics.ts` — use `analytics.eventName(props)` to track. `identifyUser()` called in `authStore.fetchUser()`, `resetAnalytics()` on logout. Env vars: `VITE_PUBLIC_POSTHOG_KEY`, `VITE_PUBLIC_POSTHOG_HOST`. Autocapture enabled. **64. PostHog product analytics:** Initialized via `PostHogProvider` in `main.tsx` with explicit `posthog.init()` + `client` prop pattern. Event helpers in `lib/analytics.ts` — use `analytics.eventName(props)` to track. `identifyUser()` called in `authStore.fetchUser()`, `resetAnalytics()` on logout. Env vars: `VITE_PUBLIC_POSTHOG_KEY`, `VITE_PUBLIC_POSTHOG_HOST`. Autocapture enabled.
@@ -332,7 +331,7 @@ gh run view <id> --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi
**82. `bun` requires PATH setup on devserver01:** `export BUN_INSTALL="$HOME/.bun" && export PATH="$BUN_INSTALL/bin:$PATH"`. The gstack browse binary and Playwright need this. Chromium system deps: `libatk1.0-0 libatk-bridge2.0-0 libcups2 libxkbcommon0 libatspi2.0-0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 libasound2`. **82. `bun` requires PATH setup on devserver01:** `export BUN_INSTALL="$HOME/.bun" && export PATH="$BUN_INSTALL/bin:$PATH"`. The gstack browse binary and Playwright need this. Chromium system deps: `libatk1.0-0 libatk-bridge2.0-0 libcups2 libxkbcommon0 libatspi2.0-0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 libasound2`.
**83. FlowPilot ActionBar is `position: fixed; bottom: 0`:** Any UI element placed in normal document flow below the session content will be hidden behind it. New fixed-position elements (like the message bar) must use `bottom: 68px` (action bar height) and the same `left: var(--sidebar-w)` pattern. The conversation column uses `pb-32` for clearance. **83. ~~FlowPilot ActionBar fixed bottom~~ (Superseded by Lesson 93):** Actions moved to the page header. `FlowPilotActionBar` component exists but is no longer used in the main session flow. The only fixed-bottom element is the message input.
**84. AI session `abandoned` status is fully wired:** `POST /ai-sessions/{id}/abandon` sets status to `abandoned` with optional `reason` param. Frontend: `aiSessionsApi.abandonSession()`, `useFlowPilotSession().abandonSession()`, "Close" button in `FlowPilotActionBar`. Redirects to `/sessions` after closing. **84. AI session `abandoned` status is fully wired:** `POST /ai-sessions/{id}/abandon` sets status to `abandoned` with optional `reason` param. Frontend: `aiSessionsApi.abandonSession()`, `useFlowPilotSession().abandonSession()`, "Close" button in `FlowPilotActionBar`. Redirects to `/sessions` after closing.
@@ -344,6 +343,7 @@ gh run view <id> --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi
**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. **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.
*(Lessons 8991 were retracted.)*
**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. **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.
@@ -353,7 +353,7 @@ gh run view <id> --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi
**95. Image upload → AI vision pipeline:** Paste/attach images → upload to Railway S3 bucket via `uploadsApi.upload()` → send `upload_ids` with chat message → backend fetches from S3 via `storage_service.download_file()` → resized via `storage_service.resize_image_for_vision()` (Pillow, 1568px max, PNG→JPEG) → base64-encoded → sent as Claude multimodal content blocks. Max 3 images/message. Images are NOT stored in conversation history (text-only). Vision helpers live in `storage_service.py`. **95. Image upload → AI vision pipeline:** Paste/attach images → upload to Railway S3 bucket via `uploadsApi.upload()` → send `upload_ids` with chat message → backend fetches from S3 via `storage_service.download_file()` → resized via `storage_service.resize_image_for_vision()` (Pillow, 1568px max, PNG→JPEG) → base64-encoded → sent as Claude multimodal content blocks. Max 3 images/message. Images are NOT stored in conversation history (text-only). Vision helpers live in `storage_service.py`.
**96. `bg-accent` is ember orange — never use for code/kbd elements:** In Tailwind v4, `bg-accent` maps to `--color-accent: #f97316`. Use `bg-code` for code blocks, `bg-white/[0.12] border border-white/[0.06]` for inline code/badges, `bg-white/[0.08]` for kbd shortcuts. Orange is reserved for interactive elements only (buttons, active nav, links). **96. `bg-accent` is electric blue — never use for code/kbd elements:** In Tailwind v4, `bg-accent` maps to `--color-accent: #60a5fa` (dark) / `#2563eb` (light). Use `bg-code` for code blocks, `bg-white/[0.12] border border-white/[0.06]` for inline code/badges, `bg-white/[0.08]` for kbd shortcuts. Blue accent is reserved for interactive elements only (buttons, active nav, links). Ember orange (#f97316) is deprecated — do not use.
**97. Railway Object Storage (S3 bucket) is provisioned:** Bucket `resolutionflow-uploads` on Railway canvas. Variables: `STORAGE_ENDPOINT`, `STORAGE_ACCESS_KEY`, `STORAGE_SECRET_KEY`, `STORAGE_BUCKET_NAME`, `STORAGE_REGION` — mapped via variable references on the `patherly` backend service. Accessed via boto3 in `storage_service.py`. Pillow (`Pillow>=10.0.0`) + `libjpeg-dev`/`zlib1g-dev` in Dockerfile for image resize. **97. Railway Object Storage (S3 bucket) is provisioned:** Bucket `resolutionflow-uploads` on Railway canvas. Variables: `STORAGE_ENDPOINT`, `STORAGE_ACCESS_KEY`, `STORAGE_SECRET_KEY`, `STORAGE_BUCKET_NAME`, `STORAGE_REGION` — mapped via variable references on the `patherly` backend service. Accessed via boto3 in `storage_service.py`. Pillow (`Pillow>=10.0.0`) + `libjpeg-dev`/`zlib1g-dev` in Dockerfile for image resize.
@@ -369,7 +369,11 @@ gh run view <id> --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi
**103. Docker not available in code-server container:** The dev environment runs code-server inside Docker on the VPS. The `docker` CLI is not available inside the code-server container. To query the database, use the VPS SSH session: `docker exec resolutionflow_postgres psql -U postgres -d resolutionflow -t -c "SQL"`. Python is also not available in the container. **103. Docker not available in code-server container:** The dev environment runs code-server inside Docker on the VPS. The `docker` CLI is not available inside the code-server container. To query the database, use the VPS SSH session: `docker exec resolutionflow_postgres psql -U postgres -d resolutionflow -t -c "SQL"`. Python is also not available in the container.
--- **104. `landing.css` uses self-contained `--lp-*` color variables:** The landing page defines its own color palette at the top of `landing.css` (`--lp-bg`, `--lp-accent`, `--lp-text-*`, etc.). Never use `var(--color-*)` theme tokens in `landing.css` — they may resolve incorrectly outside the app shell context. Extend the `--lp-*` palette for any new landing page colors.
**105. `npm run build` fails with `EACCES: permission denied` on `dist/` in code-server:** This is a filesystem permission issue in the Docker environment, not a TypeScript error — the TS compilation completes successfully. Use `npx tsc -b` to verify TypeScript cleanly without needing to write to `dist/`.
**106. Guard async "select item → load data → apply state" flows with a ref:** When a component lets the user switch between items (chat sessions, flows, scripts) and loads data asynchronously on each switch, the load for item A can complete *after* the user has already switched to item B — overwriting B's state with A's stale data. Fix pattern: keep a `currentSelectionRef = useRef(initialId)` and update it synchronously whenever the selection changes (in every creation/switch path). After every `await`, bail out if `currentSelectionRef.current !== thisItemId`. See `AssistantChatPage.tsx` `selectChat` for the reference implementation (`currentChatRef`).
## RBAC & Permissions ## RBAC & Permissions
@@ -386,16 +390,16 @@ gh run view <id> --json jobs --jq '.jobs[] | {name: .name, conclusion: .conclusi
**Source of truth:** [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) — always read this before making visual or UI decisions. **Source of truth:** [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) — always read this before making visual or UI decisions.
- **Theme:** Flat, high-contrast dark theme (Sentry/PostHog-inspired). No glass morphism, no backdrop blur, no ambient orbs, no gradient backgrounds on surfaces. Light mode planned. - **Theme:** Flat, high-contrast dark theme (Sentry/PostHog-inspired). No glass morphism, no backdrop blur, no ambient orbs, no gradient backgrounds on surfaces. Light mode fully specified (v6).
- **Backgrounds:** `bg-page` (`#1a1c23`), `bg-sidebar` (`#10121a`), `bg-card` (`#22252e`), `bg-elevated` (`#2e3140`) - **Backgrounds:** `bg-page` (`#16181f`), `bg-sidebar` (`#0e1016`), `bg-card` (`#1e2028`), `bg-elevated` (`#2a2d38`)
- **Cards:** `bg-card` with 1px `border-default` (`#2e3240`), 8px radius. No shadows, no blur, no gradients. Hover: `border-hover` (`#3d4252`) - **Cards:** `bg-card` with 1px `border-default` (`#2a2e3a`), 8px radius. No shadows, no blur, no gradients. Hover: `border-hover` (`#3d4252`)
- **Buttons:** Primary: solid `accent` (#f97316), white text, 5px radius. Ghost: transparent + 1px border, hover `bg-elevated` - **Buttons:** Primary: solid `accent` (#60a5fa dark / #2563eb light), white text, 5px radius. Ghost: transparent + 1px border, hover `bg-elevated`
- **Inputs:** `bg-input` (`#282b35`) with 1px `border-default`, 5px radius. Focus: `border-color: accent` + `box-shadow: 0 0 0 2px accent-dim` - **Inputs:** `bg-input` (`#252830`) with 1px `border-default`, 5px radius. Focus: `border-color: accent` + `box-shadow: 0 0 0 2px accent-dim`
- **Text:** `text-heading` (`#f0f2f5`) → `text-primary` (`#e2e5eb`) → `text-muted-foreground` (`#848b9b`) → `text-muted` (`#4f5666`). NEVER use `text-secondary` — in Tailwind v4 it maps to a surface color (#2e3140), not a text color. - **Text:** `text-heading` (`#f0f2f5`) → `text-primary` (`#e2e5eb`) → `text-muted-foreground` (`#848b9b`) → `text-muted` (`#4f5666`). NEVER use `text-secondary` — in Tailwind v4 it maps to a surface color, not a text color.
- **Borders:** `border-default` (`#2e3240`), `border-hover` (`#3d4252`) - **Borders:** `border-default` (`#2a2e3a`), `border-hover` (`#3d4252`)
- **Functional colors:** `#34d399` (success), `#eab308` (warning), `#f87171` (danger) — each with `-dim` variant at 10% opacity - **Functional colors:** `#34d399` (success), `#fbbf24` (warning/amber), `#f87171` (danger), `#67e8f9` (info/cyan) — each with `-dim` variant at 10% opacity
- **Accent:** Ember orange `#f97316` — used sparingly (≤5% of UI). `accent-dim` = `rgba(249,115,22,0.10)`, `accent-text` = `#fdba74` - **Accent:** Electric blue `#60a5fa` (dark) / `#2563eb` (light) — used sparingly (≤5% of UI). `accent-dim` = `rgba(96,165,250,0.10)`, `accent-text` = `#93c5fd`
- **Deprecated:** Do NOT use `glass-card`, `glass-stat`, `bg-gradient-brand`, `text-gradient-brand`, `backdrop-filter: blur()`, ambient orbs, purple gradients, or cyan accent (`#22d3ee`) - **Deprecated:** Do NOT use `glass-card`, `glass-stat`, `bg-gradient-brand`, `text-gradient-brand`, `backdrop-filter: blur()`, ambient orbs, purple gradients, ember orange (`#f97316`), or cyan (`#22d3ee`) as accent — cyan is now the info color only
--- ---
@@ -514,3 +518,105 @@ When a feature, fix, or significant piece of work is finished and merged/committ
| Bugs & Fixes | CLAUDE.md → Critical Lessons Learned section | | Bugs & Fixes | CLAUDE.md → Critical Lessons Learned section |
| Design System | [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) | | Design System | [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) |
| Dev Environment | [DEV-ENV.md](DEV-ENV.md) — 46.202.92.250 setup, Docker, CORS, networking | | Dev Environment | [DEV-ENV.md](DEV-ENV.md) — 46.202.92.250 setup, Docker, CORS, networking |
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **resolutionflow** (14787 symbols, 31366 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
## Always Do
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
## When Debugging
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
3. `READ gitnexus://repo/resolutionflow/process/{processName}` — trace the full execution flow step by step
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
## When Refactoring
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
## Never Do
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
## Tools Quick Reference
| Tool | When to use | Command |
|------|-------------|---------|
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
## Impact Risk Levels
| Depth | Meaning | Action |
|-------|---------|--------|
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
## Resources
| Resource | Use for |
|----------|---------|
| `gitnexus://repo/resolutionflow/context` | Codebase overview, check index freshness |
| `gitnexus://repo/resolutionflow/clusters` | All functional areas |
| `gitnexus://repo/resolutionflow/processes` | All execution flows |
| `gitnexus://repo/resolutionflow/process/{name}` | Step-by-step execution trace |
## Self-Check Before Finishing
Before completing any code modification task, verify:
1. `gitnexus_impact` was run for all modified symbols
2. No HIGH/CRITICAL risk warnings were ignored
3. `gitnexus_detect_changes()` confirms changes match expected scope
4. All d=1 (WILL BREAK) dependents were updated
## Keeping the Index Fresh
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
```bash
npx gitnexus analyze
```
If the index previously included embeddings, preserve them by adding `--embeddings`:
```bash
npx gitnexus analyze --embeddings
```
To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
## CLI
| Task | Read this skill file |
|------|---------------------|
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
<!-- gitnexus:end -->

View File

@@ -0,0 +1,29 @@
"""add unique constraint to script_templates.slug
Revision ID: 070
Revises: 069
Create Date: 2026-04-01
"""
from alembic import op
revision = "070"
down_revision = "069"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_unique_constraint(
"uq_script_templates_slug",
"script_templates",
["slug"],
)
def downgrade() -> None:
op.drop_constraint(
"uq_script_templates_slug",
"script_templates",
type_="unique",
)

View File

@@ -3,6 +3,7 @@ from typing import Annotated
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db from app.core.database import get_db
@@ -67,6 +68,12 @@ async def create_session(
current_user: Annotated[User, Depends(get_current_active_user)], current_user: Annotated[User, Depends(get_current_active_user)],
) -> ScriptBuilderSessionDetail: ) -> ScriptBuilderSessionDetail:
"""Start a new Script Builder session.""" """Start a new Script Builder session."""
# Acquire per-user advisory lock so concurrent create requests are serialized.
# Without this, two simultaneous requests both read count < limit and both
# insert, exceeding MAX_SESSIONS_PER_USER.
user_lock_key = hash(str(current_user.id)) % (2**62)
await db.execute(text("SELECT pg_advisory_xact_lock(:key)"), {"key": user_lock_key})
# Enforce max concurrent sessions # Enforce max concurrent sessions
count = await script_builder_service.count_user_sessions(db, current_user.id) count = await script_builder_service.count_user_sessions(db, current_user.id)
if count >= MAX_SESSIONS_PER_USER: if count >= MAX_SESSIONS_PER_USER:

View File

@@ -48,7 +48,7 @@ class ScriptTemplate(Base):
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
) )
name: Mapped[str] = mapped_column(String(200), nullable=False) name: Mapped[str] = mapped_column(String(200), nullable=False)
slug: Mapped[str] = mapped_column(String(200), nullable=False, index=True) slug: Mapped[str] = mapped_column(String(200), nullable=False, unique=True, index=True)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
use_case: Mapped[Optional[str]] = mapped_column(Text, nullable=True) use_case: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
script_body: Mapped[str] = mapped_column(Text, nullable=False) script_body: Mapped[str] = mapped_column(Text, nullable=False)

View File

@@ -127,6 +127,7 @@ class SessionDocumentation(BaseModel):
diagnostic_steps: list[DocumentationStep] diagnostic_steps: list[DocumentationStep]
resolution_summary: str | None = None resolution_summary: str | None = None
escalation_reason: str | None = None escalation_reason: str | None = None
follow_up_recommendations: list[str] = []
total_steps: int total_steps: int
duration_display: str | None = None duration_display: str | None = None
generated_at: datetime generated_at: datetime
@@ -146,7 +147,7 @@ class StatusUpdateRequest(BaseModel):
"""Generate a mid-session or post-session status update.""" """Generate a mid-session or post-session status update."""
audience: str = Field( audience: str = Field(
..., ...,
pattern="^(ticket_notes|client_update|email_draft)$", pattern="^(ticket_notes|client_update|email_draft|request_info)$",
description="Who is this update for?", description="Who is this update for?",
) )
length: str = Field( length: str = Field(

View File

@@ -77,6 +77,9 @@ scope narrows it to this endpoint.
- JSON array of objects with `text` (required) and `context` (optional, 1 sentence) - JSON array of objects with `text` (required) and `context` (optional, 1 sentence)
- 1-3 questions per response - 1-3 questions per response
- Do NOT ask questions inline in your prose. ALL questions go in the marker. - Do NOT ask questions inline in your prose. ALL questions go in the marker.
- If the engineer's message contains tasks marked `_(not yet completed)_`, re-include \
those as questions/actions in your next response UNLESS you are ≥75% confident the \
information is no longer needed to resolve the issue. Default to keeping them.
**[ACTIONS] marker format:** **[ACTIONS] marker format:**
- JSON array of objects with `label` (required), `command` (optional), `description` (required) - JSON array of objects with `label` (required), `command` (optional), `description` (required)
@@ -155,6 +158,8 @@ To create a fork, append this marker AFTER your [QUESTIONS]/[ACTIONS] markers:
Every single response MUST contain [QUESTIONS] and/or [ACTIONS] markers with valid JSON. \ Every single response MUST contain [QUESTIONS] and/or [ACTIONS] markers with valid JSON. \
No exceptions. Not even when forking. A response without at least one of these markers \ No exceptions. Not even when forking. A response without at least one of these markers \
will crash the UI. If you are unsure, include both. The markers are REQUIRED output, not optional. will crash the UI. If you are unsure, include both. The markers are REQUIRED output, not optional.
If any tasks in the engineer's message are marked `_(not yet completed)_`, re-include them \
in your markers unless you are ≥75% confident that information is no longer relevant.
""" """

View File

@@ -911,16 +911,36 @@ async def generate_status_update(
steps_summary = [] steps_summary = []
for step in sorted(session.steps, key=lambda s: s.step_order): for step in sorted(session.steps, key=lambda s: s.step_order):
content = step.content or {} content = step.content or {}
text = content.get("text", "") if content.get("type") in ("resolution_suggestion", "briefing", "status_update"):
response = step.free_text_input or step.selected_option or ("Skipped" if step.was_skipped else None) continue
text = content.get("text", "").strip()
if not text:
continue
# Resolve option label instead of raw machine value
response = None
if step.was_skipped:
response = "Skipped"
elif step.selected_option and step.options_presented:
for opt in step.options_presented:
if opt.get("value") == step.selected_option:
response = opt.get("label", step.selected_option)
break
else:
response = step.selected_option
elif step.selected_option:
response = step.selected_option
elif step.free_text_input:
response = step.free_text_input
outcome = None outcome = None
if step.action_result: if step.action_result:
outcome = "Succeeded" if step.action_result.get("success") else "Did not resolve" outcome = "Succeeded" if step.action_result.get("success") else "Did not resolve"
entry = f"Step {step.step_order + 1}: {text}" entry = f"{step.step_order + 1}. {text}"
if response: if response and response != "Skipped":
entry += f"\n Engineer response: {response}" entry += f" {response}"
elif response == "Skipped":
entry += " (skipped)"
if outcome: if outcome:
entry += f"\n Outcome: {outcome}" entry += f" [{outcome}]"
steps_summary.append(entry) steps_summary.append(entry)
steps_text = "\n".join(steps_summary) if steps_summary else "No diagnostic steps yet." steps_text = "\n".join(steps_summary) if steps_summary else "No diagnostic steps yet."
@@ -929,13 +949,8 @@ async def generate_status_update(
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
ref_time = session.resolved_at or now ref_time = session.resolved_at or now
delta = ref_time - session.created_at delta = ref_time - session.created_at
total_minutes = int(delta.total_seconds() / 60) total_hrs = round(delta.total_seconds() / 3600, 2)
if total_minutes < 60: time_display = f"{total_hrs} hrs"
time_display = f"{total_minutes} minutes"
else:
hours = total_minutes // 60
remaining = total_minutes % 60
time_display = f"{hours}h {remaining}m"
# Extract client name from intake or ticket data # Extract client name from intake or ticket data
client_name = None client_name = None
@@ -1135,8 +1150,9 @@ def _build_status_update_prompt(
Rules: Rules:
- Be technical, concise, and factual - Be technical, concise, and factual
- Use markdown formatting (bold headers, bullet lists) - Use plain text with simple section headers (no markdown bold/bullets — PSA renders raw text)
- Include: current status, steps completed, findings, what's been ruled out, next steps - Structure as: current status paragraph, then "What We Know" section, then next steps
- "What We Know" should list confirmed findings, ruled-out causes, and open questions — keep each item to one line
- Do NOT soften language or add pleasantries - Do NOT soften language or add pleasantries
- Do NOT include greetings or sign-offs - Do NOT include greetings or sign-offs
- {length_instruction} - {length_instruction}
@@ -1147,28 +1163,54 @@ Output ONLY the update text. No JSON, no markdown code fences, no preamble."""
elif audience == "client_update": elif audience == "client_update":
client_greeting = f"Address the client as '{client_name}'" if client_name else "Use a generic greeting like 'Hi'" client_greeting = f"Address the client as '{client_name}'" if client_name else "Use a generic greeting like 'Hi'"
return f"""You are generating a client-facing {context_label}. context_guidance = {
"status": "We're actively working on it. Describe progress made so far and what comes next without giving a timeline.",
"resolution": "This is good news — the issue is resolved. Summarize what was wrong and what was done in plain language.",
"escalation": "Be reassuring — explain that a specialist is being brought in to assist, not that something failed.",
}.get(context, "")
return f"""You are generating a brief client-facing {context_label}.
Rules: Rules:
- Be professional, reassuring, and non-technical - Be professional, reassuring, and non-technical
- NEVER use technical jargon (no "transport rules", "MX records", "DNS", "registry", "GPO", etc.) - NEVER use technical jargon (no "transport rules", "MX records", "DNS", "registry", "GPO", "connector", etc.)
- NEVER include server names, IP addresses, internal tool names, or technical identifiers - NEVER include server names, IP addresses, internal tool names, or ticket IDs
- Explain findings in plain language a non-technical business owner would understand - Explain findings in plain language a non-technical business owner would understand
- {client_greeting} - {client_greeting}
- Sign off with: {engineer_name} - Sign off with: {engineer_name}
- {length_instruction} - {length_instruction}
{"- This is good news — the issue is resolved. Summarize what was wrong and what was done in plain language." if context == "resolution" else ""} - {context_guidance}
{"- Be reassuring — explain that a specialist is being brought in, not that something failed." if context == "escalation" else ""}
Output ONLY the update text. No JSON, no markdown code fences, no preamble.""" Output ONLY the update text. No JSON, no markdown code fences, no preamble."""
elif audience == "request_info":
client_greeting = f"Address the client as '{client_name}'" if client_name else "Use a generic greeting like 'Hi'"
return f"""You are generating a brief, professional message requesting information from the client.
Rules:
- Be friendly, concise, and non-technical
- Start with one sentence explaining what you're currently working on (plain language, no jargon)
- Then list the specific questions you need answered, as a numbered list
- Each question should be clear and answerable by a non-technical user
- NEVER use technical jargon, server names, IP addresses, or internal tool names
- {client_greeting}
- Sign off with: {engineer_name}
- Keep it short — this is a targeted ask, not a status update
Output ONLY the message text. No JSON, no markdown code fences, no preamble."""
else: # email_draft else: # email_draft
client_greeting = f"Address the client as '{client_name}'" if client_name else "Use a generic greeting like 'Hi'" client_greeting = f"Address the client as '{client_name}'" if client_name else "Use a generic greeting like 'Hi'"
subject_hints = { subject_hints = {
"status": "Update: [brief issue description]", "status": "Update: [brief issue description]",
"resolution": "Resolved: [brief issue description]", "resolution": "Resolved: [brief issue description]",
"escalation": "Update: [brief issue description] — Specialist Review", "escalation": "Update: [brief issue description] — Specialist Assistance",
"need_info": "Quick Question: [brief issue description]",
} }
context_guidance = {
"status": "We're actively working on it. Describe progress and next steps without giving a timeline.",
"resolution": "This is good news — the issue is resolved. Summarize what was wrong and what was done in plain language.",
"escalation": "Be reassuring — explain that a specialist is being brought in to assist, not that something failed.",
}.get(context, "")
return f"""You are generating a complete email draft for client communication. return f"""You are generating a complete email draft for client communication.
Rules: Rules:
@@ -1177,15 +1219,63 @@ Rules:
- {client_greeting} - {client_greeting}
- Be professional, reassuring, and non-technical - Be professional, reassuring, and non-technical
- NEVER use technical jargon, server names, IP addresses, or internal tool names - NEVER use technical jargon, server names, IP addresses, or internal tool names
- Include a professional sign-off with: - Include a professional sign-off with: {engineer_name}
{engineer_name}
- {length_instruction} - {length_instruction}
{"- This is good news — the issue is resolved." if context == "resolution" else ""} - {context_guidance}
{"- Be reassuring — explain that a specialist is being brought in." if context == "escalation" else ""}
Output ONLY the email text (Subject + body). No JSON, no markdown code fences, no preamble.""" Output ONLY the email text (Subject + body). No JSON, no markdown code fences, no preamble."""
def _build_what_we_know(session: AISession) -> str:
"""Build a 'What We Know' summary from evidence_items (cockpit) or derived from steps.
When the cockpit branch merges, session.evidence_items will be populated by the AI
with confirmed/ruled_out/pending classifications. Until then, we derive findings
from completed diagnostic steps.
"""
evidence_items = getattr(session, 'evidence_items', None)
if evidence_items:
confirmed = [e['text'] for e in evidence_items if e.get('status') == 'confirmed']
ruled_out = [e['text'] for e in evidence_items if e.get('status') == 'ruled_out']
pending = [e['text'] for e in evidence_items if e.get('status') == 'pending']
parts = []
if confirmed:
parts.append("Confirmed:\n" + "\n".join(f" - {t}" for t in confirmed))
if ruled_out:
parts.append("Ruled out:\n" + "\n".join(f" - {t}" for t in ruled_out))
if pending:
parts.append("Still investigating:\n" + "\n".join(f" - {t}" for t in pending))
return "\n".join(parts)
# Derive from completed steps
findings = []
for step in sorted(session.steps or [], key=lambda s: s.step_order):
content = step.content or {}
if content.get("type") in ("resolution_suggestion", "briefing", "status_update"):
continue
description = content.get("text", "").strip()
if not description or step.was_skipped:
continue
response = None
if step.selected_option and step.options_presented:
for opt in step.options_presented:
if opt.get("value") == step.selected_option:
response = opt.get("label", step.selected_option)
break
else:
response = step.selected_option
elif step.selected_option:
response = step.selected_option
elif step.free_text_input:
response = step.free_text_input
if response:
findings.append(f"{description}{response}")
if not findings:
return ""
return "Findings so far:\n" + "\n".join(f" - {f}" for f in findings)
def _build_status_update_context( def _build_status_update_context(
session: AISession, session: AISession,
steps_text: str, steps_text: str,
@@ -1206,24 +1296,17 @@ def _build_status_update_context(
if session.psa_ticket_id: if session.psa_ticket_id:
parts.append(f"Ticket ID: {session.psa_ticket_id}") parts.append(f"Ticket ID: {session.psa_ticket_id}")
parts.append(f"\nDiagnostic steps:\n{steps_text}") what_we_know = _build_what_we_know(session)
if what_we_know:
parts.append(f"\nWhat we know:\n{what_we_know}")
parts.append(f"\nDiagnostic steps taken:\n{steps_text}")
if context == "resolution" and session.resolution_summary: if context == "resolution" and session.resolution_summary:
parts.append(f"\nResolution: {session.resolution_summary}") parts.append(f"\nResolution: {session.resolution_summary}")
if context == "escalation" and session.escalation_reason: if context == "escalation" and session.escalation_reason:
parts.append(f"\nEscalation reason: {session.escalation_reason}") parts.append(f"\nEscalation reason: {session.escalation_reason}")
# Include recent conversation messages for richer context
messages = session.conversation_messages or []
if messages:
recent = messages[-10:] # Last 10 messages
convo_text = "\n".join(
f"{'Engineer' if m['role'] == 'user' else 'FlowPilot'}: {m['content'][:300]}"
for m in recent
if isinstance(m, dict) and "role" in m and "content" in m
)
parts.append(f"\nRecent conversation:\n{convo_text}")
return "\n".join(parts) return "\n".join(parts)
@@ -1420,6 +1503,7 @@ def _create_step_from_parsed(
def _generate_documentation(session: AISession) -> SessionDocumentation: def _generate_documentation(session: AISession) -> SessionDocumentation:
"""Generate structured documentation from a session's steps.""" """Generate structured documentation from a session's steps."""
diagnostic_steps = [] diagnostic_steps = []
follow_up_recommendations: list[str] = []
for step in session.steps: for step in session.steps:
content = step.content or {} content = step.content or {}
@@ -1459,6 +1543,12 @@ def _generate_documentation(session: AISession) -> SessionDocumentation:
outcome=outcome, outcome=outcome,
)) ))
# Collect follow-up recommendations from resolution suggestion steps
if content.get("type") == "resolution_suggestion":
recs = content.get("follow_up_recommendations", [])
if isinstance(recs, list):
follow_up_recommendations.extend(recs)
# Calculate duration # Calculate duration
duration_display = None duration_display = None
if session.resolved_at and session.created_at: if session.resolved_at and session.created_at:
@@ -1484,6 +1574,7 @@ def _generate_documentation(session: AISession) -> SessionDocumentation:
diagnostic_steps=diagnostic_steps, diagnostic_steps=diagnostic_steps,
resolution_summary=session.resolution_summary, resolution_summary=session.resolution_summary,
escalation_reason=session.escalation_reason, escalation_reason=session.escalation_reason,
follow_up_recommendations=follow_up_recommendations,
total_steps=session.step_count, total_steps=session.step_count,
duration_display=duration_display, duration_display=duration_display,
generated_at=datetime.now(timezone.utc), generated_at=datetime.now(timezone.utc),

View File

@@ -57,181 +57,199 @@ def _format_datetime(dt: datetime | None) -> str:
return dt.strftime("%Y-%m-%d %I:%M %p UTC") return dt.strftime("%Y-%m-%d %I:%M %p UTC")
def _get_engineer_response(step) -> str | None:
"""Extract the engineer's response label from a step."""
if step.was_skipped:
return "Skipped"
if step.selected_option and step.options_presented:
for opt in step.options_presented:
if opt.get("value") == step.selected_option:
return opt.get("label", step.selected_option)
return step.selected_option
if step.selected_option:
return step.selected_option
if step.free_text_input:
return step.free_text_input
return None
def format_resolution_note(session: AISession, include_steps: bool = True) -> str: def format_resolution_note(session: AISession, include_steps: bool = True) -> str:
"""Format a resolved session as a plain-text note for CW.""" """Format a resolved session as a plain-text note for CW."""
lines = [
"═══ FlowPilot Session Documentation ═══",
f"Session: {session.id}",
]
# Engineer name from relationship if loaded, otherwise user_id
engineer_name = getattr(session, 'user', None) engineer_name = getattr(session, 'user', None)
if engineer_name and hasattr(engineer_name, 'name'): engineer_display = engineer_name.name if engineer_name and hasattr(engineer_name, 'name') else "Unknown"
lines.append(f"Engineer: {engineer_name.name}")
lines.extend([ duration_str = ""
f"Date: {_format_datetime(session.resolved_at)}",
f"Started: {_format_datetime(session.created_at)}",
f"Ended: {_format_datetime(session.resolved_at)}",
])
# Duration
if session.resolved_at and session.created_at: if session.resolved_at and session.created_at:
delta = session.resolved_at - session.created_at delta = session.resolved_at - session.created_at
minutes = int(delta.total_seconds() / 60) total_hrs = round(delta.total_seconds() / 3600, 2)
if minutes < 60: duration_str = f"{total_hrs} hrs"
lines.append(f"Duration: {minutes}m")
else:
lines.append(f"Duration: {minutes // 60}h {minutes % 60}m")
lines.append("") lines = [
lines.append("── Problem ──") f"FlowPilot Session — {engineer_display}{duration_str}",
lines.append(session.problem_summary or "No summary available") f"Problem: {session.problem_summary or 'No summary available'}",
if session.problem_domain: ]
lines.append(f"Domain: {session.problem_domain}")
# Diagnostic steps # Diagnostic steps
if include_steps and session.steps: if include_steps and session.steps:
lines.append("") lines.append("")
lines.append("── Diagnosis Path ──") lines.append("Steps:")
for step in session.steps: for step in session.steps:
content = step.content or {} content = step.content or {}
step_type = content.get("type", step.step_type).capitalize() step_type = content.get("type", "")
description = content.get("text", "") if step_type == "resolution_suggestion":
continue # Not a diagnostic step
response_text = "" description = content.get("text", "").strip()
if step.was_skipped: if not description:
response_text = "Skipped" continue
elif step.selected_option: response = _get_engineer_response(step)
# Try to find the label line = f"{step.step_order + 1}. {description}"
if step.options_presented: if response and response != "Skipped":
for opt in step.options_presented: line += f"{response}"
if opt.get("value") == step.selected_option: elif response == "Skipped":
response_text = opt.get("label", step.selected_option) line += " (skipped)"
break lines.append(line)
else:
response_text = step.selected_option
else:
response_text = step.selected_option
elif step.free_text_input:
response_text = step.free_text_input
lines.append(f"{step.step_order + 1}. [{step_type}] {description}")
if response_text:
lines.append(f" → Response: {response_text}")
if step.action_result:
result = step.action_result
outcome = "Succeeded" if result.get("success") else "Did not resolve"
if details := result.get("details"):
outcome += f"{details}"
lines.append(f" → Result: {outcome}")
# Resolution # Resolution
lines.append("") lines.append("")
lines.append("── Resolution ──") lines.append(f"Resolution: {session.resolution_summary or 'No resolution summary'}")
lines.append(session.resolution_summary or "No resolution summary")
if session.resolution_action: if session.resolution_action:
lines.append(session.resolution_action) lines.append(session.resolution_action)
# Confidence # Follow-up recommendations from resolution suggestion step
lines.append("") follow_ups: list[str] = []
lines.append("── AI Confidence ──") for step in session.steps:
lines.append(f"Final confidence: {session.confidence_tier} ({session.confidence_score:.0%})") content = step.content or {}
if content.get("type") == "resolution_suggestion":
recs = content.get("follow_up_recommendations", [])
if isinstance(recs, list):
follow_ups.extend(recs)
if follow_ups:
lines.append("")
lines.append("Follow-up:")
for rec in follow_ups:
lines.append(f"- {rec}")
# Timing section (always present) # Timing
lines.append("") lines.append("")
lines.append("── Session Timing ──")
lines.append(f"Start: {_format_datetime(session.created_at)}") lines.append(f"Start: {_format_datetime(session.created_at)}")
lines.append(f"End: {_format_datetime(session.resolved_at)}") lines.append(f"End: {_format_datetime(session.resolved_at)}")
if session.resolved_at and session.created_at: if session.resolved_at and session.created_at:
delta = session.resolved_at - session.created_at delta = session.resolved_at - session.created_at
minutes = int(delta.total_seconds() / 60) total_hrs = round(delta.total_seconds() / 3600, 2)
lines.append(f"Total: {minutes // 60}h {minutes % 60}m" if minutes >= 60 else f"Total: {minutes}m") lines.append(f"Total: {total_hrs} hrs")
lines.append("") lines.append("")
lines.append("Generated by ResolutionFlow FlowPilot") lines.append("Generated by ResolutionFlow")
return "\n".join(lines) return "\n".join(lines)
def _derive_what_we_know(session: AISession) -> tuple[list[str], list[str], list[str]]:
"""Return (confirmed, ruled_out, pending) findings.
Uses session.evidence_items when the cockpit branch is merged; falls back
to deriving from completed diagnostic steps.
"""
evidence_items = getattr(session, 'evidence_items', None)
if evidence_items:
confirmed = [e['text'] for e in evidence_items if e.get('status') == 'confirmed']
ruled_out = [e['text'] for e in evidence_items if e.get('status') == 'ruled_out']
pending = [e['text'] for e in evidence_items if e.get('status') == 'pending']
return confirmed, ruled_out, pending
# Derive from completed steps — all answered steps become findings
findings = []
for step in sorted(session.steps or [], key=lambda s: s.step_order):
content = step.content or {}
if content.get("type") in ("resolution_suggestion", "briefing", "status_update"):
continue
description = content.get("text", "").strip()
if not description or step.was_skipped:
continue
response = _get_engineer_response(step)
if response:
findings.append(f"{description}{response}")
return findings, [], []
def format_escalation_note(session: AISession, include_steps: bool = True) -> str: def format_escalation_note(session: AISession, include_steps: bool = True) -> str:
"""Format an escalated session as a plain-text note for CW.""" """Format an escalated session as a plain-text note for CW."""
engineer_obj = getattr(session, 'user', None)
engineer_display = engineer_obj.name if engineer_obj and hasattr(engineer_obj, 'name') else "Unknown"
escalated_to_obj = getattr(session, 'escalated_to', None)
escalated_to_display = escalated_to_obj.name if escalated_to_obj and hasattr(escalated_to_obj, 'name') else None
escalated_at = session.resolved_at or datetime.now(timezone.utc)
duration_str = ""
if session.created_at:
delta = escalated_at - session.created_at
total_hrs = round(delta.total_seconds() / 3600, 2)
duration_str = f"{total_hrs} hrs"
header = f"FlowPilot Escalation — {engineer_display}{duration_str}"
if escalated_to_display:
header += f"{escalated_to_display}"
lines = [ lines = [
"═══ FlowPilot Escalation Documentation ═══", header,
f"Session: {session.id}", f"Problem: {session.problem_summary or 'No summary available'}",
] ]
engineer_name = getattr(session, 'user', None) # Work completed with responses
if engineer_name and hasattr(engineer_name, 'name'):
lines.append(f"Escalated by: {engineer_name.name}")
escalated_to = getattr(session, 'escalated_to', None)
if escalated_to and hasattr(escalated_to, 'name'):
lines.append(f"Escalated to: {escalated_to.name}")
else:
lines.append("Escalated to: Unassigned")
lines.extend([
f"Date: {_format_datetime(session.resolved_at or datetime.now(timezone.utc))}",
f"Started: {_format_datetime(session.created_at)}",
])
if session.resolved_at and session.created_at:
delta = session.resolved_at - session.created_at
minutes = int(delta.total_seconds() / 60)
lines.append(f"Duration: {minutes // 60}h {minutes % 60}m" if minutes >= 60 else f"Duration: {minutes}m")
lines.append("")
lines.append("── Problem ──")
lines.append(session.problem_summary or "No summary available")
# Work completed
if include_steps and session.steps: if include_steps and session.steps:
lines.append("") lines.append("")
lines.append("── Work Completed ──") lines.append("Work completed:")
for step in session.steps: for step in sorted(session.steps, key=lambda s: s.step_order):
content = step.content or {} content = step.content or {}
description = content.get("text", "") if content.get("type") in ("resolution_suggestion", "briefing", "status_update"):
lines.append(f"{step.step_order + 1}. {description}") continue
description = content.get("text", "").strip()
if not description:
continue
response = _get_engineer_response(step)
line = f"{step.step_order + 1}. {description}"
if response and response != "Skipped":
line += f"{response}"
elif response == "Skipped":
line += " (skipped)"
lines.append(line)
# What We Know
confirmed, ruled_out, pending = _derive_what_we_know(session)
if confirmed or ruled_out or pending:
lines.append("")
lines.append("What we know:")
for f in confirmed:
lines.append(f"{f}")
for f in ruled_out:
lines.append(f"{f}")
for f in pending:
lines.append(f" ? {f}")
# Escalation reason # Escalation reason
lines.append("") lines.append("")
lines.append("── Escalation Reason ──") lines.append(f"Escalation reason: {session.escalation_reason or 'No reason provided'}")
lines.append(session.escalation_reason or "No reason provided")
# Escalation package details # Suggested next steps from escalation package
pkg = session.escalation_package or {} pkg = session.escalation_package or {}
if hypotheses := pkg.get("remaining_hypotheses"):
lines.append("")
lines.append("── Remaining Hypotheses ──")
if isinstance(hypotheses, list):
for h in hypotheses:
lines.append(f"- {h}")
else:
lines.append(str(hypotheses))
if suggestions := pkg.get("suggested_next_steps"): if suggestions := pkg.get("suggested_next_steps"):
lines.append("") lines.append("")
lines.append("── Suggested Next Steps ──") lines.append("Suggested next steps:")
if isinstance(suggestions, list): items = suggestions if isinstance(suggestions, list) else [str(suggestions)]
for s in suggestions: for s in items:
lines.append(f"- {s}") lines.append(f"- {s}")
else:
lines.append(str(suggestions))
# Timing # Timing
lines.append("") lines.append("")
lines.append("── Session Timing ──")
lines.append(f"Start: {_format_datetime(session.created_at)}") lines.append(f"Start: {_format_datetime(session.created_at)}")
escalated_at = session.resolved_at or datetime.now(timezone.utc)
lines.append(f"Escalated: {_format_datetime(escalated_at)}") lines.append(f"Escalated: {_format_datetime(escalated_at)}")
if session.created_at: if session.created_at:
delta = escalated_at - session.created_at delta = escalated_at - session.created_at
minutes = int(delta.total_seconds() / 60) total_hrs = round(delta.total_seconds() / 3600, 2)
lines.append(f"Total: {minutes // 60}h {minutes % 60}m" if minutes >= 60 else f"Total: {minutes}m") lines.append(f"Total: {total_hrs} hrs")
lines.append("") lines.append("")
lines.append("Generated by ResolutionFlow FlowPilot") lines.append("Generated by ResolutionFlow")
return "\n".join(lines) return "\n".join(lines)

View File

@@ -83,19 +83,55 @@ class ResolutionOutputGenerator:
return output return output
def _build_session_context(self, session: AISession) -> str: def _build_session_context(self, session: AISession) -> str:
intake = session.intake_content or {}
intake_text = intake.get("text", "") or str(intake)
parts = [ parts = [
f"Problem: {session.problem_summary or 'Unknown'}", f"Problem: {session.problem_summary or 'Unknown'}",
f"Domain: {session.problem_domain or 'Unknown'}", f"Domain: {session.problem_domain or 'Unknown'}",
f"Original intake: {intake_text[:300]}",
f"Resolution: {session.resolution_summary or 'Not specified'}", f"Resolution: {session.resolution_summary or 'Not specified'}",
f"Steps taken: {session.step_count}",
] ]
msgs = session.conversation_messages or []
if msgs: steps = sorted(session.steps or [], key=lambda s: s.step_order)
parts.append("\nConversation highlights:") diagnostic = []
for msg in msgs[-10:]: follow_ups: list[str] = []
role = msg.get("role", "unknown") for step in steps:
content = msg.get("content", "")[:200] content = step.content or {}
parts.append(f" [{role}]: {content}") step_type = content.get("type", "")
if step_type == "resolution_suggestion":
recs = content.get("follow_up_recommendations", [])
if isinstance(recs, list):
follow_ups.extend(recs)
continue
description = content.get("text", "").strip()
if not description:
continue
response = None
if step.was_skipped:
response = "skipped"
elif step.selected_option and step.options_presented:
for opt in step.options_presented:
if opt.get("value") == step.selected_option:
response = opt.get("label", step.selected_option)
break
else:
response = step.selected_option
elif step.selected_option:
response = step.selected_option
elif step.free_text_input:
response = step.free_text_input
entry = f" {step.step_order + 1}. {description}"
if response and response != "skipped":
entry += f"{response}"
diagnostic.append(entry)
if diagnostic:
parts.append("\nDiagnostic steps:")
parts.extend(diagnostic)
if follow_ups:
parts.append("\nRecommended follow-up:")
parts.extend(f" - {r}" for r in follow_ups)
return "\n".join(parts) return "\n".join(parts)
def _psa_notes_prompt(self, context: str) -> str: def _psa_notes_prompt(self, context: str) -> str:

View File

@@ -5,7 +5,8 @@ from datetime import datetime, timezone
from typing import Optional from typing import Optional
from uuid import UUID from uuid import UUID
from sqlalchemy import select, func from sqlalchemy import select, func, text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -169,6 +170,12 @@ async def send_message(
user_content: str, user_content: str,
) -> ScriptBuilderMessageResponse: ) -> ScriptBuilderMessageResponse:
"""Send a user message and get AI response with generated script.""" """Send a user message and get AI response with generated script."""
# Acquire per-session advisory lock to prevent concurrent message count races.
# Two simultaneous sends to the same session would otherwise both read the same
# count, both pass the limit check, and both insert — exceeding the cap.
session_lock_key = hash(str(session.id)) % (2**62)
await db.execute(text("SELECT pg_advisory_xact_lock(:key)"), {"key": session_lock_key})
# Count existing messages for the session # Count existing messages for the session
msg_count_result = await db.execute( msg_count_result = await db.execute(
select(func.count(ScriptBuilderMessage.id)).where( select(func.count(ScriptBuilderMessage.id)).where(
@@ -344,36 +351,48 @@ async def save_to_library(
raise ValueError("Default 'AI Generated' category not found. Run migrations.") raise ValueError("Default 'AI Generated' category not found. Run migrations.")
resolved_category_id = default_cat resolved_category_id = default_cat
# Generate unique slug # Generate slug. Use a UUID suffix on first attempt to prevent concurrent
# saves with the same name from hitting the unique constraint on slug.
base_slug = name.lower().replace(" ", "-").replace("_", "-")[:80] base_slug = name.lower().replace(" ", "-").replace("_", "-")[:80]
base_slug = re.sub(r"[^a-z0-9\-]", "", base_slug) base_slug = re.sub(r"[^a-z0-9\-]", "", base_slug)
slug = base_slug
# Check uniqueness
existing = await db.execute(
select(ScriptTemplate.id).where(ScriptTemplate.slug == slug)
)
if existing.scalar_one_or_none():
slug = f"{base_slug}-{uuid_mod.uuid4().hex[:6]}"
template = ScriptTemplate( # Check if the base slug is already taken; if not, use it clean (no suffix).
id=uuid_mod.uuid4(), # If taken, or if the insert races with a concurrent request, retry with a
category_id=resolved_category_id, # fresh UUID suffix. The unique constraint on script_templates.slug is the
created_by=user_id, # authoritative guard — the application check just avoids unnecessary retries.
team_id=team_id if share_with_team else None, existing = await db.execute(
name=name, select(ScriptTemplate.id).where(ScriptTemplate.slug == base_slug)
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,
) )
db.add(template) slug = base_slug if not existing.scalar_one_or_none() else f"{base_slug}-{uuid_mod.uuid4().hex[:6]}"
await db.flush()
return template for attempt in range(3):
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,
)
db.add(template)
try:
await db.flush()
return template
except IntegrityError as exc:
if "uq_script_templates_slug" not in str(exc.orig) or attempt == 2:
raise
await db.rollback()
slug = f"{base_slug}-{uuid_mod.uuid4().hex[:8]}"
raise RuntimeError("Failed to generate a unique slug after 3 attempts")

View File

@@ -0,0 +1,363 @@
# MSP Assistant Harness — Design Spec
**Date:** 2026-04-01
**Status:** Draft — pending user review
**Source:** MSP_Assistant_Harness_Implementation_Plan.docx (v2.0, March 2026) + brainstorming session
---
## Context
The `/assistant` page currently works as a generic AI chat surface with a task lane side panel and a chat sidebar for session history. It functions well but reads as "AI chat with extras" rather than an MSP engineer's operational tool.
The goal is to reframe the page as a **live triage cockpit** — the place where an engineer opens a ticket, works through it from intake to resolution, and closes with a structured handoff artifact. The underlying session, branching, and chat architecture is preserved. What changes is layout hierarchy, information density, field labelling, and the conclude output.
Scope is broader than the original docx: includes all required backend changes to support the frontend properly.
---
## Design Decisions
### 1. Overall Layout — Stacked Zones
```
┌─────────────────────────────────────────────┐
│ Incident Header (labelled fields, 1 row) │
├────────────────────────┬────────────────────┤
│ │ FlowPilot Asks │
│ Steps Checklist │ (quick replies) │
│ (left, ~55%) ├────────────────────┤
│ │ What We Know │
│ │ (evidence list) │
├────────────────────────┴────────────────────┤ ← drag handle
│ Conversation Log (muted, darker bg) │
├─────────────────────────────────────────────┤
│ Compose area │
└─────────────────────────────────────────────┘
```
- Work zone (top) and conversation log (bottom) are **drag-resizable** via a handle
- Default split: ~55% work zone, ~45% chat
- Existing left sidebar (session history) unchanged
- Compose area is always pinned to bottom, spans full width
- `workZoneHeight` persisted to `localStorage` so split survives refresh
### 2. Incident Header
Single row with explicit micro-labels above each field:
```
CLIENT DEVICE CATEGORY HYPOTHESIS
Contoso Ltd ✏ jsmith-desktop ✏ DNS / Network ✏ Corrupted DNS cache on NIC ✏
[CW #48291] [Resolve ▾] [⋯]
```
- Each field has its own `✏` icon (visible on hover) that opens an inline edit popover
- Fields populate from: (a) intake form on session create, (b) AI-inferred updates mid-session via `triage_update`, (c) manual engineer edits via `PATCH /ai-sessions/{id}/triage`
- PSA ticket number shown if linked; action buttons (Resolve, overflow menu) on the right
- Empty fields show muted placeholder text — never blank
### 3. Work Zone — Steps + FlowPilot Asks + What We Know
**Left panel (~55%): ordered step checklist**
- Steps displayed as a vertical list: `✓` completed, `→` active (blue border, white text), `○` pending
- Active step is visually distinct
- "Generate Script" CTA appears at the bottom when a script-generation step is active
**Right panel (~45%): two stacked mini-panels**
- **FlowPilot Asks** (top, amber label): current question from AI. When `options` are provided, renders as quick-reply buttons — clicking a button submits that answer as a chat message. When no `options`, renders a compact free-text input. Panel is empty/hidden when no pending question.
- **What We Know** (bottom, muted label): running evidence list. Each entry: `✓ confirmed` / `✗ ruled out` / `? pending`. AI appends via `triage_update.evidence_items`; engineer can manually add or edit entries.
### 4. Conversation Log Zone
- Lives below the work zone, separated by a **drag handle**
- Background: `#13151c` (one step darker than page) — visually recedes
- Label: "CONVERSATION LOG" in muted colour (`text-muted`)
- Messages are compact: `you:` / `fp:` prefixes instead of full name/avatar bubbles
- Scrolls independently
- Not collapsible — drag handle gives control
### 5. Conclude / Handoff Modal (redesigned)
On opening "Close Case":
1. **Header**: "Close Case — [Client Name]" + outcome selector (Resolved / Escalated / Parked)
2. **Structured fields** — pre-filled by streaming `/handoff-draft`, all editable:
- **Root Cause** (short text input)
- **Resolution** (what fixed it)
- **Steps Taken** (list, auto-populated from step checklist)
- **Recommendations** (next steps / preventive actions)
3. **Output destinations** (checkboxes): Post to CW ticket note / Save to Knowledge Base / Send client summary
4. **Confirm** button — triggers resolve/escalate/pause and passes structured fields into `ResolutionOutputGenerator`
The existing `SessionResolutionOutput` model and `ResolutionOutputGenerator` service are reused. The `/handoff-draft` stream starts immediately on modal open — the engineer can begin editing while fields fill in.
---
## Backend Changes Required
### 1. New AISession columns (Alembic migration)
Add to `ai_sessions` table:
| Column | Type | Purpose |
|--------|------|---------|
| `client_name` | `VARCHAR(255)` | MSP client name for incident header |
| `asset_name` | `VARCHAR(255)` | Device / asset / user being worked on |
| `issue_category` | `VARCHAR(100)` | Human-readable category (e.g. "DNS / Networking") |
| `triage_hypothesis` | `TEXT` | Current working hypothesis — AI-updated + engineer-editable |
| `evidence_items` | `JSONB` | "What We Know" list — persisted for session resume |
`evidence_items` format: `[{ "text": str, "status": "confirmed" | "ruled_out" | "pending" }]`
Note: `problem_domain` (existing) is an internal classifier slug. `issue_category` is the human-readable display label for the header. Both coexist.
### 2. New PATCH endpoint — triage metadata
```
PATCH /ai-sessions/{session_id}/triage
Auth: require_engineer_or_admin
Body: { client_name?, asset_name?, issue_category?, triage_hypothesis?, evidence_items? }
Response: { id, client_name, asset_name, issue_category, triage_hypothesis, evidence_items }
```
Used when the engineer edits any header field or evidence list manually.
### 3. Updated schemas — TriageUpdate and QuestionItem.options
**New `TriageUpdate` model** (returned in chat response when AI infers session context):
```python
class TriageUpdate(BaseModel):
client_name: str | None = None
asset_name: str | None = None
issue_category: str | None = None
triage_hypothesis: str | None = None
evidence_items: list[dict] | None = None # appends to existing list
```
**Updated `ChatMessageResponse`:**
```python
class ChatMessageResponse(BaseModel):
# existing fields unchanged...
triage_update: TriageUpdate | None = None
```
**Updated `QuestionItem`** — add `options` for quick-reply buttons:
```python
class QuestionItem(BaseModel):
text: str
context: str = ""
options: list[str] | None = None # quick-reply labels; null = free-text fallback
```
### 4. unified_chat_service.py — triage extraction
After generating each AI response, run a lightweight extraction to populate `triage_update`. Implementation options (pick one during implementation):
- **Option A (recommended):** Embed structured extraction in the system prompt using an `[TRIAGE_UPDATE]` marker, similar to existing `[QUESTIONS]` / `[ACTIONS]` markers. AI emits the block if it has new triage signals; service parses it.
- **Option B:** Post-response extraction pass using a fast model (`claude-haiku-4-5`) with the last 3 messages as context.
When `triage_update` contains non-null fields, the service auto-PATCHes the session record (so fields are persisted) AND returns `triage_update` in the response for the frontend to update the header immediately.
### 5. New streaming endpoint — handoff draft
```
POST /ai-sessions/{session_id}/handoff-draft
Auth: require_engineer_or_admin
Response: StreamingResponse (text/event-stream)
```
Streams a structured handoff JSON object:
```json
{ "root_cause": "...", "resolution": "...", "steps_taken": ["..."], "recommendations": "..." }
```
Built from session context: `problem_summary`, `triage_hypothesis`, `evidence_items`, `conversation_messages` (last 20), step checklist from saved task lane state.
### 6. Updated conclude schemas
Add optional structured fields to `ResolveSessionRequest` and `EscalateSessionRequest`:
```python
root_cause: str | None = None
steps_taken: list[str] | None = None
recommendations: str | None = None
```
Pass these into `ResolutionOutputGenerator._build_session_context()` to enrich `psa_ticket_notes` and `client_summary` outputs.
### 7. Session read endpoint — include new triage fields
Ensure the session detail response (`GET /ai-sessions/{id}`) returns the new fields so the frontend can restore header state on session resume.
---
## Frontend Changes Required
### 1. AssistantChatPage layout refactor
Replace current layout (sidebar + chat column + TaskLane side panel) with the stacked cockpit layout described above.
**New state:**
- `triageMeta: TriageMeta``{ client_name, asset_name, issue_category, triage_hypothesis, evidence_items }`
- `workZoneHeight: number` — persisted to `localStorage('rf-assistant-work-zone-height')`
**On session load / resume:** populate `triageMeta` from the session response (new fields).
**On AI response:** if `response.triage_update` is non-null, merge into `triageMeta` (partial update, preserve existing non-null values unless AI overwrites).
### 2. New component: `IncidentHeader`
```
frontend/src/components/assistant/IncidentHeader.tsx
```
Props: `triageMeta`, `psaTicketId`, `sessionId`, `onFieldSave(field, value)`, `onResolve`, `onOverflow`
- Renders labelled single-row header
- Each field: micro-label + value + `✏` icon (visible on hover)
- `✏` opens an `EditPopover` (small popover with text input + Save/Cancel)
- On Save: calls `aiSessionsApi.updateTriage(sessionId, { [field]: value })`
- Empty field shows muted placeholder (e.g. "Unknown client")
### 3. Refactored component: `StepsPanel` (from TaskLane)
```
frontend/src/components/assistant/StepsPanel.tsx
```
Same `activeActions` data source. Renders as ordered checklist:
- Completed: `✓` + strikethrough label, muted
- Active: `→` + blue left border, white text, full opacity
- Pending: `○` + muted text
Script generation CTA: shown at bottom when the active step has `command` containing "script" or when AI has flagged it.
### 4. New component: `FlowPilotAsks`
```
frontend/src/components/assistant/FlowPilotAsks.tsx
```
Props: `questions: QuestionItem[]`, `onAnswer(answer: string)`
- Shows first unanswered question (or empty/hidden state if none)
- When `question.options` is non-null: renders as button row, clicking calls `onAnswer(option)`
- When `question.options` is null: renders compact text input with Send button
- `onAnswer` calls `handleSend` in the parent page with the answer text
### 5. New component: `WhatWeKnow`
```
frontend/src/components/assistant/WhatWeKnow.tsx
```
Props: `items: EvidenceItem[]`, `onAdd(text, status)`, `onEdit(index, text, status)`
- Renders evidence list with status icons: `✓` (confirmed, green), `✗` (ruled out, red), `?` (pending, muted)
- "+ Add finding" link at bottom opens an inline input row
- Items are editable inline (click to edit)
- State lives in `AssistantChatPage` as part of `triageMeta.evidence_items`, synced to backend via `PATCH /triage`
### 6. Drag handle — resizable split
Implement as a thin handle bar between work zone and conversation log. On drag:
- Update `workZoneHeight` in state
- Persist to `localStorage`
On mount: restore from `localStorage`, default to `55%` of available height.
### 7. Compact conversation log
Replace current `<ChatMessage>` bubble rendering in the log zone with a compact list:
```
you: Can't resolve external DNS, internal fine
fp: Ping passed — layer 3 OK. Run nslookup google.com.
you: Timed out on 1.1.1.1 too.
```
`ChatMessage` component still used for rich rendering (suggested flows, forks) but in a more compact variant. Full bubble rendering available on hover/expand if needed.
### 8. Redesigned `ConcludeSessionModal`
Replaces current simple textarea with the structured handoff form. On open:
1. Call `aiSessionsApi.getHandoffDraft(sessionId)` — streaming — populate fields as stream arrives
2. Render outcome selector + 4 structured fields (all `<textarea>` with labels)
3. Render output destination checkboxes
4. On Confirm: call resolve/escalate/pause with enriched request body
### 9. MSP-native language pass
| Old | New |
|-----|-----|
| "AI Assistant" | "FlowPilot" |
| "New Chat" | "New Case" |
| "Messages" | "Conversation Log" |
| "Task Lane" (panel header) | "Steps" |
| "Conclude" | "Close Case" |
| "Chat history" (sidebar label) | "Case History" |
---
## What This Is NOT
- Not a redesign of the FlowPilot session page (`/pilot`) — separate page, untouched
- Not a rebuild of session, branching, or PSA architecture
- Not a new data model for conversations — `conversation_messages` JSONB is unchanged
- Not a mobile-first redesign — mobile degrades cleanly but desktop is primary
---
## Verification
### Harness Feel Test (primary — subjective)
- Open `/assistant`, start a new case: does the page read as an MSP triage cockpit within 3 seconds without reading labels?
- Is the current active step obvious without scrolling through chat?
- Do FlowPilot Asks quick-reply buttons submit answers and update the steps list?
- Does the incident header update mid-session as the AI infers context?
- Drag the handle, refresh: does the split restore correctly?
### Functional Regression
- Free-text chat, image paste, suggested flows, forks, branching: all work
- Session pause, resume, and handoff end-to-end: works
- ConcludeSessionModal resolves / escalates / parks correctly
- Handoff draft streams and pre-fills the modal fields
- Manual header edit saves and persists across reload
### MSP Scenario Coverage (from docx)
Run end-to-end: single-user endpoint issue · M365/tenant-wide issue · network/VPN outage · escalation and resume after handoff.
### Backend Checks
```bash
# Migration
alembic upgrade head
# Verify new columns
psql -U postgres -d resolutionflow -c "\d ai_sessions" | grep -E "client_name|asset_name|issue_category|triage_hypothesis|evidence_items"
# Smoke test endpoints (with valid token)
curl -X PATCH .../ai-sessions/{id}/triage -d '{"client_name":"Test"}'
curl -X POST .../ai-sessions/{id}/handoff-draft # should stream JSON
```
---
## Critical Files
| File | Change |
|------|--------|
| `backend/app/models/ai_session.py` | Add 5 new columns |
| `backend/app/schemas/ai_session.py` | Add `TriageUpdate`, extend `QuestionItem`, update request/response schemas |
| `backend/app/api/endpoints/ai_sessions.py` | Add `PATCH /{id}/triage`, `POST /{id}/handoff-draft` |
| `backend/app/services/unified_chat_service.py` | Extract and return `triage_update` per AI response |
| `backend/app/services/resolution_output_generator.py` | Accept structured handoff fields in context builder |
| `backend/alembic/versions/NNN_add_triage_fields_to_ai_sessions.py` | Sequential migration (check `ls backend/alembic/versions/ \| sort \| tail -1` for NNN) |
| `frontend/src/pages/AssistantChatPage.tsx` | Full layout refactor — cockpit structure |
| `frontend/src/components/assistant/IncidentHeader.tsx` | New component |
| `frontend/src/components/assistant/StepsPanel.tsx` | Refactored from `TaskLane` |
| `frontend/src/components/assistant/FlowPilotAsks.tsx` | New component |
| `frontend/src/components/assistant/WhatWeKnow.tsx` | New component |
| `frontend/src/components/assistant/ConcludeSessionModal.tsx` | Redesigned |
| `frontend/src/api/aiSessions.ts` | Add `updateTriage()`, `getHandoffDraft()` |
| `frontend/src/types/ai-session.ts` | Add `TriageUpdate`, `TriageMeta`, `EvidenceItem`; extend `QuestionItem` |

View File

@@ -0,0 +1,609 @@
# MSP Assistant Harness — Super Plan
**Date:** 2026-04-01
**Status:** Approved — ready to execute
**Sources:** `MSP_Assistant_Harness_Implementation_Plan.docx` (v2.0) + `2026-04-01-msp-assistant-harness-design.md` (brainstorming session)
---
## Goal
Reframe `/assistant` from a generic AI chat surface into a **live MSP triage cockpit**. An engineer arrives with an open ticket; the page immediately reads as their operational tool — not an AI chatbot that's been adapted for IT work.
The change is a UI and data layer reframe. The existing session, branching, PSA, and conclude architecture is preserved and extended, not rebuilt.
### Key Architectural Choices
This plan explicitly chooses:
- **`FlowPilot`** as the primary page/product label (not "Assistant Harness")
- **Backend triage + handoff contracts required in v1** — not deferred to a later phase
- **Desktop-first cockpit layout** with clean mobile degradation
- **Explicit persisted triage fields** on the session model, not purely derived/computed header state
- **Prompt-embedded structured extraction** (`[TRIAGE_UPDATE]` marker) as the primary AI triage path, with post-response model pass only as fallback
- **Sidebar visual demotion** — existing sidebar stays but is visually de-emphasized so the cockpit reads as an operations surface, not a chat app
---
## What Phase 0 Resolved
The brainstorming session (2026-04-01) locked these decisions. They are not open questions.
| Question | Decision |
|----------|----------|
| Layout structure | Stacked zones: incident header → work zone → (drag handle) → conversation log → compose |
| Incident header style | Single row, explicit micro-labels above each field, per-field `✏` edit |
| Work zone left panel | Ordered step checklist (✓ / → / ○) |
| Work zone right panel | Two stacked mini-panels: FlowPilot Asks (top) + What We Know (bottom) |
| Chat zone treatment | Drag-resizable split, compact `you:` / `fp:` prefix style, darker background |
| Chat collapsibility | Not collapsible — drag handle gives control |
| Scope | Includes all required backend changes, not UI-layer only |
| Conclude modal | Fully redesigned as structured handoff artifact |
| Page label | "FlowPilot" (not "AI Assistant") |
| "New Chat" label | "New Case" |
| "Conclude" label | "Close Case" |
| Hypothesis language | "Hypothesis" (direct, not softened to "working theory") |
| What We Know editability | Engineer-editable + AI-appended |
| Header field population | Intake form + AI-inferred mid-session + manual engineer override |
---
## Cockpit Layout
```
┌─────────────────────────────────────────────────────────────┐
│ [Left sidebar — Case History, unchanged] │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ INCIDENT HEADER (single row, labelled fields) │ │
│ │ CLIENT DEVICE CATEGORY HYPOTHESIS │ │
│ │ Contoso ✏ jsmith-04 ✏ DNS/Net ✏ Cache fail ✏ │ │
│ │ [CW #48291][Resolve⋯]│ │
│ ├───────────────────────┬───────────────────────────────┤ │
│ │ │ ▸ FLOWPILOT ASKS (amber) │ │
│ │ STEPS (~55%) │ Did nslookup time out? │ │
│ │ ✓ Ping 8.8.8.8 │ [Time out] [Wrong IP] [Both] │ │
│ │ → nslookup ←active ├───────────────────────────────┤ │
│ │ ○ Flush DNS │ WHAT WE KNOW │ │
│ │ ○ Check NIC │ ✓ Gateway reachable │ │
│ │ │ ✗ DNS 1.1.1.1 — timeout │ │
│ │ [⚡ Generate Script] │ ? DNS 8.8.8.8 — pending │ │
│ ├───────────────────────┴───── ≡ drag handle ───────────┤ │
│ │ CONVERSATION LOG (compact, darker bg) │ │
│ │ you: Can't resolve external DNS, internal fine │ │
│ │ fp: Ping test passed. Run nslookup google.com. │ │
│ │ you: Timed out on 1.1.1.1 too. │ │
│ ├───────────────────────────────────────────────────────┤ │
│ │ Describe next finding or ask FlowPilot... [Send] │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
---
## Contract Decisions (Codex Readiness Review)
The following decisions were flagged as ambiguous by the Codex readiness review. Each is now resolved.
### RED — Canonical handoff artifact
**Decision:** `ResolutionOutputGenerator` is the single canonical generator. Everything else is transport or UI.
- `POST /handoff-draft` is a **preview** endpoint — streams a draft for the conclude modal UI. Does not persist. Does not generate final artifacts.
- On confirm (resolve/escalate), the page calls the existing resolve/escalate endpoints, which trigger `ResolutionOutputGenerator.generate_all()` as today. The structured fields from the modal (`root_cause`, `steps_taken`, `recommendations`) are passed into `_build_session_context()` to enrich the final outputs.
- `/documentation/stream` and `/status-update` remain untouched — they are separate transport channels for the same canonical outputs.
- `/handoff-draft` is **assistant-only** in v1 (not shared with guided FlowPilot sessions on `/pilot`).
### RED — AI vs manual field authority
**Decision:** Manual edits win. AI does not overwrite manual edits.
| Rule | Behavior |
|------|----------|
| AI auto-fill | Only fills fields that are currently `null` or empty. Never overwrites a non-null value. |
| Manual edit | Persists immediately via `PATCH /triage`. Sets the field as "manually set." |
| AI after manual edit | AI may **suggest** an update (shown as a subtle inline prompt: "FlowPilot suggests: Contoso Corp → Contoso Ltd"), but does not auto-write. |
| Evidence items — AI | Appends new items only. Does not modify or remove existing items. |
| Evidence items — engineer | Full authority: add, edit status, edit text, remove. |
Implementation: add a `triage_manual_fields` set (stored in frontend `localStorage` per session) tracking which fields the engineer has manually edited. AI `triage_update` skips those fields unless the engineer explicitly accepts the suggestion.
### RED — `evidence_items` write model
**Decision:** Full-list replacement for all writes. Keep it simple.
- `PATCH /triage` sends the complete `evidence_items` array. Backend replaces the stored array.
- AI appends: frontend receives `triage_update.evidence_items`, appends to the current local list, then PATCHes the full merged list.
- Engineer edits: frontend modifies the local list, PATCHes the full list.
- No partial-update or append-only semantics on the backend. The frontend is the merge authority.
### YELLOW — TaskLane persistence in StepsPanel
**Decision:** `StepsPanel` is presentation only. All persistence behavior stays in `AssistantChatPage`.
`TaskLane` currently owns sessionStorage drafts, debounced backend saves, and restoration. In the cockpit refactor:
- `AssistantChatPage` lifts all persistence logic out of `TaskLane` into the page (or a custom hook like `useTaskPersistence`)
- `StepsPanel` receives `activeActions` as a prop and renders them — no persistence responsibility
- `TaskLane.tsx` remains in the codebase untouched (other pages may still use it)
### YELLOW — Quick-reply submission semantics
**Decision:** Quick replies are **immediate-send** controls.
- Clicking a quick-reply button calls `handleSend(option)` — the answer goes directly to the AI as a chat message
- No local-only "select then send" workflow
- The answer appears in the conversation log as a regular `you:` message
- This is a full-stack change: prompt instructions must tell the AI to include `options` on constrained questions, parser must extract them, schema must carry them, frontend must render and submit them
### YELLOW — `issue_category` format
**Decision:** Free text in v1. No controlled taxonomy.
- AI infers a human-readable category string (e.g., "DNS / Networking", "Microsoft 365", "Active Directory")
- Engineer can edit to any value via the header `✏` popover
- Future: may introduce a taxonomy dropdown populated from session history — but not in v1
### YELLOW — `asset_name` when user and device differ
**Decision:** Free text. The engineer enters whatever is most operationally relevant.
- Could be a device name ("jsmith-desktop-04"), a user ("John Smith"), or both ("jsmith-desktop-04 / John Smith")
- AI infers from conversation context — typically the entity being troubleshot
- No enforced format in v1
### YELLOW — Structured conclude fields persistence
**Decision:** Structured conclude fields (`root_cause`, `steps_taken`, `recommendations`) are **passed through to `ResolutionOutputGenerator`** but are NOT stored as separate session columns.
- They arrive in the resolve/escalate request body
- `_build_session_context()` uses them to generate richer PSA notes and client summaries
- The generated outputs (stored in `session_resolution_outputs`) are the persisted artifacts
- If we later need the raw structured fields, add columns then — not speculatively now
### Fallback — `[TRIAGE_UPDATE]` unreliability
**Decision:** If prompt-embedded extraction proves unreliable after testing against 5 real sessions:
1. **First fallback:** Post-response extraction using `claude-haiku-4-5` with last 3 messages as context. Cheap, fast, decoupled from the main prompt.
2. **Second fallback:** Fully manual header — engineer fills in fields, AI never auto-updates. Cockpit still works; it just requires more manual input.
Gate: Phase 2 step 15 ("verify extraction in a live session") must pass before wiring `triage_update` into the visible header.
---
## Implementation Guardrails
These are hard rules during implementation, not suggestions.
1. **Do not let AI write speculative values into the header.** Every AI-inferred field must trace to ticket data or explicit conversation evidence. If the AI can't ground it, the field stays empty.
2. **Do not redesign conclude UX until the canonical handoff source-of-truth is wired.** Phase 6 (conclude modal) depends on Phase 1 (backend) being stable.
3. **Do not treat `TaskLane` as presentation-only until its persistence behavior has been lifted.** Extract persistence into a hook or the page before building `StepsPanel`.
4. **Do not wire header auto-updates from `[TRIAGE_UPDATE]` until real-session reliability is tested.** Phase 2 step 15 is a gate.
5. **Run `npx tsc -b` after every phase.** Do not batch TypeScript error fixes (lesson #92).
---
## Non-Goals
- No redesign of `/pilot` (FlowPilot session page) — separate page, untouched
- No rebuild of session, branching, or PSA architecture
- No new data model for conversations — `conversation_messages` JSONB unchanged
- No mobile-first redesign — mobile degrades cleanly, desktop is primary
- No generic "assistant polish" that does not tighten the harness
---
## Backend Changes
### B1 — Alembic migration `071`
File: `backend/alembic/versions/071_add_triage_fields_to_ai_sessions.py`
Add to `ai_sessions`:
| Column | Type | Notes |
|--------|------|-------|
| `client_name` | `VARCHAR(255)` | MSP client for incident header |
| `asset_name` | `VARCHAR(255)` | Device / user being worked on |
| `issue_category` | `VARCHAR(100)` | Human-readable category ("DNS / Networking") |
| `triage_hypothesis` | `TEXT` | Working hypothesis — AI-updated + editable |
| `evidence_items` | `JSONB` | What We Know list — persisted for resume |
`evidence_items` schema: `[{ "text": str, "status": "confirmed" | "ruled_out" | "pending" }]`
Note: existing `problem_domain` is an internal classifier slug and is unchanged. `issue_category` is the human-readable display label. Both coexist.
### B2 — Updated schemas (`backend/app/schemas/ai_session.py`)
**New `TriageUpdate`:**
```python
class TriageUpdate(BaseModel):
client_name: str | None = None
asset_name: str | None = None
issue_category: str | None = None
triage_hypothesis: str | None = None
evidence_items: list[dict] | None = None # appends to existing list
```
**Updated `ChatMessageResponse`:**
```python
class ChatMessageResponse(BaseModel):
# ... existing fields unchanged ...
triage_update: TriageUpdate | None = None
```
**Updated `QuestionItem`** — add quick-reply options:
```python
class QuestionItem(BaseModel):
text: str
context: str = ""
options: list[str] | None = None # quick-reply labels; null → free-text input
```
**Updated `ResolveSessionRequest` / `EscalateSessionRequest`:**
```python
root_cause: str | None = None
steps_taken: list[str] | None = None
recommendations: str | None = None
```
### B3 — New `PATCH /ai-sessions/{id}/triage` endpoint
```
PATCH /ai-sessions/{session_id}/triage
Auth: require_engineer_or_admin
Body: { client_name?, asset_name?, issue_category?, triage_hypothesis?, evidence_items? }
Response: { id, client_name, asset_name, issue_category, triage_hypothesis, evidence_items }
```
Called on every manual header field edit. Partial update — only supplied fields are written.
### B4 — New `POST /ai-sessions/{id}/handoff-draft` endpoint
```
POST /ai-sessions/{session_id}/handoff-draft
Auth: require_engineer_or_admin
Response: StreamingResponse (text/event-stream)
```
Streams structured handoff JSON built from session context:
```json
{ "root_cause": "...", "resolution": "...", "steps_taken": ["..."], "recommendations": "..." }
```
Uses: `problem_summary`, `triage_hypothesis`, `evidence_items`, last 20 `conversation_messages`, saved task lane state.
Called immediately on conclude modal open — engineer can edit while stream fills in.
### B5 — `unified_chat_service.py` — triage extraction
After each AI response, extract triage signals and return as `triage_update`.
**Recommended approach:** Add a `[TRIAGE_UPDATE]` structured marker to the system prompt, following the existing `[QUESTIONS]` / `[ACTIONS]` / `[FORK]` marker pattern. The AI emits the block only when it has new signal:
```
[TRIAGE_UPDATE]
client_name: Contoso Ltd
issue_category: DNS / Networking
triage_hypothesis: Corrupted DNS cache on NIC
evidence_items:
- confirmed: Gateway 192.168.1.1 reachable
- ruled_out: DNS 1.1.1.1 — timeout
[/TRIAGE_UPDATE]
```
Service parses this, strips it from `display_content`, auto-PATCHes the session record, and returns `triage_update` in the response.
### B6 — `resolution_output_generator.py` — accept structured fields
Update `_build_session_context()` to incorporate `root_cause`, `steps_taken`, and `recommendations` when supplied, producing richer `psa_ticket_notes` and `client_summary` outputs.
### B7 — Session detail response — expose new triage fields
`GET /ai-sessions/{id}` (and the session list item) must return the 5 new fields so the frontend can restore header state on session load and resume.
---
## Frontend Changes
### F1 — `AssistantChatPage.tsx` — cockpit layout refactor
Replace current layout (sidebar + chat column + TaskLane right rail) with the stacked cockpit structure.
**New state:**
- `triageMeta: TriageMeta``{ client_name, asset_name, issue_category, triage_hypothesis, evidence_items }`
- `workZoneHeight: number` — persisted to `localStorage('rf-assistant-work-zone-height')`
**On session load / resume:** populate `triageMeta` from session response new fields.
**On AI response:** if `response.triage_update` is non-null, merge into `triageMeta` (partial — preserve existing non-null values unless AI explicitly overwrites).
**Work zone layout:** left `StepsPanel` + right column with `FlowPilotAsks` stacked above `WhatWeKnow`.
**Chat zone layout:** compact `ConversationLog` below drag handle, independent scroll.
### F2 — New `IncidentHeader.tsx`
```
frontend/src/components/assistant/IncidentHeader.tsx
```
Props: `triageMeta: TriageMeta`, `psaTicketId: string | null`, `sessionId: string`, `onFieldSave(field, value)`, `onResolve()`, `onOverflow()`
- Single-row bar with micro-labels (CLIENT / DEVICE / CATEGORY / HYPOTHESIS)
- Each field: `✏` icon visible on hover → opens inline `EditPopover` (text input + Save/Cancel)
- On Save: calls `aiSessionsApi.updateTriage(sessionId, { [field]: value })`
- Empty fields: muted placeholder ("Unknown client", "No device specified", etc.)
- Right side: PSA ticket badge (if linked) + Resolve button + `⋯` overflow menu
### F3 — Refactored `StepsPanel.tsx` (from `TaskLane`)
```
frontend/src/components/assistant/StepsPanel.tsx
```
Preserves all `TaskLane` data logic and persistence. Changes rendering only:
| State | Icon | Style |
|-------|------|-------|
| Completed | `✓` | Strikethrough, muted, green icon |
| Active | `→` | Blue left border, white text, full opacity |
| Pending | `○` | Muted text |
Script generation CTA: shown at bottom when active step `command` references "script" or AI has flagged it.
`TaskLane.tsx` can remain for now (no renames required in this phase) — `StepsPanel` is a new component that consumes the same `activeActions` prop.
### F4 — New `FlowPilotAsks.tsx`
```
frontend/src/components/assistant/FlowPilotAsks.tsx
```
Props: `questions: QuestionItem[]`, `onAnswer(answer: string)`
- Renders first unanswered question
- `question.options` non-null → button row; clicking calls `onAnswer(option)`
- `question.options` null → compact text input + Send
- `onAnswer` calls parent's `handleSend` with the answer string
- Hidden entirely when `questions` is empty
### F5 — New `WhatWeKnow.tsx`
```
frontend/src/components/assistant/WhatWeKnow.tsx
```
Props: `items: EvidenceItem[]`, `onAdd(text, status)`, `onEdit(index, text, status)`
- Evidence list: `✓` confirmed (green) / `✗` ruled out (red) / `?` pending (muted)
- "+ Add finding" inline entry at bottom
- Click any item to edit inline
- State lives in `AssistantChatPage` (`triageMeta.evidence_items`), synced to backend via `PATCH /triage`
### F6 — Drag-resizable split
Thin handle bar between work zone and conversation log. On drag: update `workZoneHeight` in state, persist to `localStorage`. On mount: restore, default `55%`.
### F7 — Compact `ConversationLog` rendering
Replace current full `<ChatMessage>` bubbles in the log zone with a compact list: `you: ...` / `fp: ...` prefix style, tighter line height, no avatars. `ChatMessage` can still be used for rich content (forks, suggested flows) in a compact variant. Individual messages should support click-to-expand for full rendering when the engineer needs to re-read a longer response or review a suggested flow.
### F8 — Redesigned `ConcludeSessionModal.tsx`
On open:
1. Call `aiSessionsApi.getHandoffDraft(sessionId)` (streaming) — fields fill in as stream arrives
2. Render: outcome selector (Resolved / Escalated / Parked)
3. Render 4 structured editable fields: Root Cause, Resolution, Steps Taken, Recommendations
4. Render output destination checkboxes: Post to CW note / Save to KB / Send client summary
5. Confirm → call resolve/escalate/pause with enriched request body including structured fields
### F9 — Sidebar visual demotion
The existing `ChatSidebar` stays functionally unchanged but should be visually softened so the cockpit — not the session list — reads as the primary surface. Specific changes:
- Reduce sidebar background contrast (use `bg-sidebar` or one step darker)
- Reduce sidebar header prominence (smaller label, no bold "Chat History" heading)
- Rename "Chat History" → "Case History" (part of language pass)
- Default sidebar to collapsed state on first cockpit load (existing collapse toggle + `localStorage`)
### F10 — MSP-native language pass
| Old | New |
|-----|-----|
| "AI Assistant" (page title, meta) | "FlowPilot" |
| "New Chat" | "New Case" |
| "Messages" | "Conversation Log" |
| "Task Lane" (panel label) | "Steps" |
| "Conclude" | "Close Case" |
| "Chat history" (sidebar label) | "Case History" |
| Compose placeholder | "Describe finding, paste log output, or ask FlowPilot..." |
### F11 — New API methods (`aiSessions.ts`)
```typescript
updateTriage(sessionId: string, fields: Partial<TriageMeta>): Promise<TriageMeta>
getHandoffDraft(sessionId: string): AsyncGenerator<HandoffDraftChunk>
```
### F12 — New types (`types/ai-session.ts`)
```typescript
interface TriageMeta {
client_name: string | null
asset_name: string | null
issue_category: string | null
triage_hypothesis: string | null
evidence_items: EvidenceItem[]
}
interface EvidenceItem {
text: string
status: 'confirmed' | 'ruled_out' | 'pending'
}
interface TriageUpdate extends Partial<TriageMeta> {}
// Extend existing:
interface QuestionItem {
text: string
context: string
options?: string[] // new
}
```
---
## Phased Execution Order
### Phase 1 — Backend Foundation
> Lock backend schema and API changes first so the cockpit can be built against stable session contracts.
1. Write migration `071` — add 5 columns to `ai_sessions`
2. Run `alembic upgrade head`, verify columns
3. Update `AISession` model with new mapped columns
4. Add `TriageUpdate` schema, extend `QuestionItem`, extend `ChatMessageResponse`
5. Extend `ResolveSessionRequest` / `EscalateSessionRequest` with structured fields
6. Add `PATCH /{id}/triage` endpoint
7. Add `POST /{id}/handoff-draft` streaming endpoint
8. Update `GET /ai-sessions/{id}` response to include new triage fields
9. Update `resolution_output_generator._build_session_context()` to use structured fields
10. Run backend tests — `pytest --override-ini="addopts="`
### Phase 2 — Triage Extraction (AI layer)
11. Add `[TRIAGE_UPDATE]` marker to `unified_chat_service.py` system prompt
12. Implement `_parse_triage_update_marker()` in the service (follow existing `_parse_questions_marker` / `_parse_actions_marker` pattern)
13. Auto-PATCH session on non-null `triage_update` (respect manual-edit authority: skip fields in `triage_manual_fields`)
14. Add `options` generation instructions to `[QUESTIONS]` system prompt section
15. **GATE:** Verify extraction in 5 real sessions. If `[TRIAGE_UPDATE]` is emitted reliably (≥4/5), proceed. Otherwise switch to Haiku post-response fallback before wiring into the header.
### Phase 3 — New Frontend Types + API
16. Add `TriageMeta`, `EvidenceItem`, `TriageUpdate` to `types/ai-session.ts`
17. Extend `QuestionItem` type
18. Add `updateTriage()` and `getHandoffDraft()` to `aiSessions.ts`
### Phase 4 — New Work Zone Components
19. Extract `TaskLane` persistence logic into `useTaskPersistence` hook (sessionStorage drafts, debounced saves, restoration) — prerequisite for StepsPanel
20. Build `IncidentHeader.tsx` with `EditPopover`
21. Build `StepsPanel.tsx` (presentation only — receives props from hook)
22. Build `FlowPilotAsks.tsx`
23. Build `WhatWeKnow.tsx`
### Phase 5 — Page Layout Refactor
24. Refactor `AssistantChatPage.tsx` — implement stacked cockpit layout
25. Wire `triageMeta` state, session load population, `triage_update` merge (with `triage_manual_fields` guard)
26. Implement drag-resizable split with `localStorage` persistence
27. Compact `ConversationLog` rendering (with click-to-expand for long messages)
### Phase 6 — Handoff Modal + Language Pass + Sidebar
28. Redesign `ConcludeSessionModal.tsx` — structured handoff form (calls `/handoff-draft` for preview, confirms via existing resolve/escalate endpoints which trigger `ResolutionOutputGenerator`)
29. Sidebar visual demotion — background, label prominence, default-collapsed
30. MSP-native language pass across all assistant components
31. Update `<PageMeta>` title
### Phase 7 — QA + Hardening
32. `npx tsc -b` — fix any TypeScript errors
33. `npm run build` — production build clean
34. Functional regression: all chat flows, session switching, conclude/resume
35. Harness feel test: cockpit within 3 seconds?
36. Mobile viewport check
37. Stress test: 50+ messages, 10+ steps, long outputs
---
## Risks and Mitigations
| Risk | Mitigation |
|------|-----------|
| `[TRIAGE_UPDATE]` marker extraction is unreliable — AI doesn't emit it consistently | Gate Phase 2 on a pass/fail test with 5 real sessions before wiring it to the header. Fall back to Option B (post-response Haiku pass) if needed. |
| Header fields feel fabricated — AI guesses wrong client or hypothesis | Show confidence-aware placeholder copy ("FlowPilot is building context…") until a field has real data. Never invent. |
| Task lane visual promotion breaks established chat patterns | Keep all send/respond behavior intact. Change hierarchy only. Verify every task-lane state transition manually. |
| Handoff modal exposes weak underlying summaries | Reuse existing `ResolutionOutputGenerator` output where possible. Add guardrail copy for empty fields. |
| Mobile loses compose or step access | Test responsive layout as a first-class deliverable in Phase 7, not a final sweep. Enforce scroll isolation between all zones. |
| `tsc -b` errors after component refactor | Run `npx tsc -b` after every phase. Trace unused imports/props immediately — don't batch (lesson #92). |
---
## Test Plan
### Harness Feel (primary, subjective)
- Does the page read as an MSP triage cockpit within 3 seconds on first load?
- Is the active step obvious without reading chat?
- Do FlowPilot Asks quick-reply buttons work and update the step list?
- Does the incident header update mid-session as AI learns context?
- Drag handle, refresh — does split restore?
- Does the conclude modal look like a case handoff or a chat closure?
### Functional Regression
- New session (no PSA) — header degrades gracefully
- New session (with CW ticket) — header populates from ticket data
- Send message → `triage_update` updates header
- Click quick-reply button → answer submitted, step advances
- Add finding to What We Know → persisted via PATCH
- Edit header field via `✏` → saved and survives refresh
- Conclude as Resolved → handoff draft fills modal → post to CW note
- Conclude as Escalated → same
- Pause and resume → triage header restores from saved session fields
- Session switching (currentChatRef guard) — no stale state
- Image paste, forks, suggested flows — all still work
### MSP Scenarios (from docx)
1. Single-user endpoint issue (basic triage flow, script generation)
2. M365 / tenant-wide issue (multi-user context, issue category)
3. Network / VPN outage (asset targeting, hypothesis tracking)
4. Escalation and resume (session persistence, structured handoff)
### Edge Cases
- 50+ messages — layout hierarchy stays intact
- 10+ steps — step panel scrolls, compose remains accessible
- Long issue titles / hypothesis text — header truncates gracefully
- Missing PSA context — placeholder copy, not blank fields
- Narrow mobile viewport — all zones reachable
### Backend Checks
```bash
# Migration
alembic upgrade head
psql -U postgres -d resolutionflow -c "\d ai_sessions" | grep -E "client_name|asset_name|issue_category|triage_hypothesis|evidence_items"
# Triage PATCH
curl -X PATCH http://localhost:8000/ai-sessions/{id}/triage \
-H "Authorization: Bearer $TOKEN" \
-d '{"client_name":"Test Client","triage_hypothesis":"Cache corruption"}'
# Handoff draft stream
curl -X POST http://localhost:8000/ai-sessions/{id}/handoff-draft \
-H "Authorization: Bearer $TOKEN"
```
---
## Assumptions
- Desktop is the primary target; mobile must remain usable but does not drive the layout.
- `/assistant` remains the chat-session cockpit; `/pilot` is out of scope.
- New triage fields are **additive** — they do not replace `problem_summary`, `problem_domain`, `ticket_data`, or `conversation_messages`.
- `issue_category` is the operator-facing display field; `problem_domain` remains the internal classifier. Both coexist.
- `evidence_items` is editable by both AI and engineer; engineer edits persist through the triage PATCH endpoint.
- PSA context is optional — every triage header field must degrade gracefully when PSA is absent or session is free-text-only.
- The existing `TaskLane.tsx` component remains in the codebase — `StepsPanel` is a new component that consumes the same props with different rendering. No risky renames during this work.
---
## Critical Files
| File | Change |
|------|--------|
| `backend/alembic/versions/071_add_triage_fields_to_ai_sessions.py` | New migration |
| `backend/app/models/ai_session.py` | Add 5 new mapped columns |
| `backend/app/schemas/ai_session.py` | `TriageUpdate`, `QuestionItem.options`, extended request/response schemas |
| `backend/app/api/endpoints/ai_sessions.py` | `PATCH /triage`, `POST /handoff-draft` |
| `backend/app/services/unified_chat_service.py` | `[TRIAGE_UPDATE]` marker extraction, auto-PATCH |
| `backend/app/services/resolution_output_generator.py` | Structured fields in context builder |
| `frontend/src/types/ai-session.ts` | `TriageMeta`, `EvidenceItem`, `TriageUpdate`; extend `QuestionItem` |
| `frontend/src/api/aiSessions.ts` | `updateTriage()`, `getHandoffDraft()` |
| `frontend/src/pages/AssistantChatPage.tsx` | Full cockpit layout refactor |
| `frontend/src/components/assistant/IncidentHeader.tsx` | New |
| `frontend/src/components/assistant/StepsPanel.tsx` | New (from TaskLane logic) |
| `frontend/src/components/assistant/FlowPilotAsks.tsx` | New |
| `frontend/src/components/assistant/WhatWeKnow.tsx` | New |
| `frontend/src/components/assistant/ConcludeSessionModal.tsx` | Redesigned |

View File

@@ -189,7 +189,7 @@ function ChatItem({
return ( return (
<div <div
onClick={onSelect} onClick={confirming ? e => e.stopPropagation() : onSelect}
className={cn( className={cn(
'group flex items-center gap-2 px-3 py-2.5 mx-1.5 rounded-lg cursor-pointer transition-colors', 'group flex items-center gap-2 px-3 py-2.5 mx-1.5 rounded-lg cursor-pointer transition-colors',
confirming confirming

View File

@@ -130,6 +130,14 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
} }
}, [handleMouseMove, handleMouseUp]) }, [handleMouseMove, handleMouseUp])
// Refs so the debounced save always uses the latest questions/actions/tasks
const questionsRef = useRef(questions)
const actionsRef = useRef(actions)
const tasksRef = useRef(tasks)
useEffect(() => { questionsRef.current = questions }, [questions])
useEffect(() => { actionsRef.current = actions }, [actions])
useEffect(() => { tasksRef.current = tasks }, [tasks])
// Save task state to sessionStorage on every change + debounce to backend // Save task state to sessionStorage on every change + debounce to backend
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => { useEffect(() => {
@@ -139,9 +147,9 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
if (saveTimerRef.current) clearTimeout(saveTimerRef.current) if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
saveTimerRef.current = setTimeout(() => { saveTimerRef.current = setTimeout(() => {
aiSessionsApi.saveTaskLane(sessionId, { aiSessionsApi.saveTaskLane(sessionId, {
questions: questions.map(q => ({ text: q.text, context: q.context })), questions: questionsRef.current.map(q => ({ text: q.text, context: q.context })),
actions: actions.map(a => ({ label: a.label, command: a.command, description: a.description })), actions: actionsRef.current.map(a => ({ label: a.label, command: a.command, description: a.description })),
responses: tasks as unknown as Array<Record<string, unknown>>, responses: tasksRef.current as unknown as Array<Record<string, unknown>>,
}).catch(() => { /* silent — best-effort save */ }) }).catch(() => { /* silent — best-effort save */ })
}, 2000) }, 2000)
return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current) } return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current) }

View File

@@ -1,65 +1,19 @@
import { useState } from 'react' import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link } from 'react-router-dom'
import { Plus, ChevronDown, Sparkles, FolderTree, ListOrdered } from 'lucide-react' import { Plus, ChevronDown, FolderTree, ListOrdered } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { editorAIApi } from '@/api/editorAI'
import { apiClient } from '@/api/client'
import { AIPromptDialog } from '@/components/editor-ai/AIPromptDialog'
type AIFlowType = 'troubleshooting' | 'procedural' | 'maintenance'
interface CreateFlowDropdownProps { interface CreateFlowDropdownProps {
aiEnabled: boolean
className?: string className?: string
/** Button label — defaults to "Create Flow" */ /** Button label — defaults to "Create Flow" */
label?: string label?: string
} }
export function CreateFlowDropdown({ export function CreateFlowDropdown({
aiEnabled,
className, className,
label = 'Create Flow', label = 'Create Flow',
}: CreateFlowDropdownProps) { }: CreateFlowDropdownProps) {
const [showMenu, setShowMenu] = useState(false) const [showMenu, setShowMenu] = useState(false)
const [aiPromptOpen, setAiPromptOpen] = useState(false)
const [aiPromptFlowType, setAiPromptFlowType] = useState<AIFlowType>('troubleshooting')
const navigate = useNavigate()
const handleAIGenerate = async (prompt: string) => {
// Start an AI session
const session = await editorAIApi.startSession(
aiPromptFlowType === 'maintenance' ? 'procedural' : aiPromptFlowType
)
const sessionId = session.session_id
// Send the user's prompt
await editorAIApi.sendMessage({
sessionId,
content: prompt,
actionType: 'generate_full',
})
// Generate the full flow
await editorAIApi.generateFull(sessionId)
// Import to create the tree
const { data: importResult } = await apiClient.post(
`/ai/chat/sessions/${sessionId}/import`,
{}
)
const treeId = importResult.tree_id
// Navigate to the editor
if (aiPromptFlowType === 'troubleshooting') {
navigate(`/trees/${treeId}/edit`, {
state: { aiPanelOpen: true, sessionId },
})
} else {
navigate(`/flows/${treeId}/edit`, {
state: { aiPanelOpen: true, sessionId },
})
}
}
return ( return (
<div className={cn('relative', className)}> <div className={cn('relative', className)}>
@@ -74,43 +28,25 @@ export function CreateFlowDropdown({
{showMenu && ( {showMenu && (
<> <>
<div className="fixed inset-0 z-10" onClick={() => setShowMenu(false)} /> <div className="fixed inset-0 z-10" onClick={() => setShowMenu(false)} />
<div className="absolute right-0 z-20 mt-1 w-64 rounded-lg border border-border bg-card p-1 shadow-xl"> <div className="absolute right-0 z-20 mt-1 w-60 rounded-lg border border-border bg-card p-1 shadow-xl">
{/* Troubleshooting */}
<Link <Link
to="/trees/new" to="/trees/new"
onClick={() => setShowMenu(false)} onClick={() => setShowMenu(false)}
className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent" className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-[var(--color-bg-elevated)] transition-colors"
> >
<FolderTree className="h-4 w-4 text-muted-foreground" /> <FolderTree className="h-4 w-4 text-muted-foreground" />
<div className="flex-1"> <div className="flex-1">
<div className="font-medium">Troubleshooting Tree</div> <div className="font-medium">Troubleshooting Flow</div>
<div className="text-xs text-muted-foreground">Branching decision flow</div> <div className="text-xs text-muted-foreground">Branching decision flow</div>
</div> </div>
</Link> </Link>
{aiEnabled && (
<button
type="button"
onClick={() => {
setShowMenu(false)
setAiPromptFlowType('troubleshooting')
setAiPromptOpen(true)
}}
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm text-foreground hover:bg-accent"
>
<Sparkles className="h-3.5 w-3.5 text-primary ml-0.5" />
<div className="text-left">
<div className="text-xs text-primary font-medium">Build with Flow Assist</div>
</div>
</button>
)}
<div className="my-1 border-t border-border" /> <div className="my-1 border-t border-border" />
{/* Procedural */}
<Link <Link
to="/flows/new" to="/flows/new"
onClick={() => setShowMenu(false)} onClick={() => setShowMenu(false)}
className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent" className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-[var(--color-bg-elevated)] transition-colors"
> >
<ListOrdered className="h-4 w-4 text-muted-foreground" /> <ListOrdered className="h-4 w-4 text-muted-foreground" />
<div className="flex-1"> <div className="flex-1">
@@ -118,51 +54,9 @@ export function CreateFlowDropdown({
<div className="text-xs text-muted-foreground">Step-by-step procedure</div> <div className="text-xs text-muted-foreground">Step-by-step procedure</div>
</div> </div>
</Link> </Link>
{aiEnabled && (
<button
type="button"
onClick={() => {
setShowMenu(false)
setAiPromptFlowType('procedural')
setAiPromptOpen(true)
}}
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm text-foreground hover:bg-accent"
>
<Sparkles className="h-3.5 w-3.5 text-primary ml-0.5" />
<div className="text-left">
<div className="text-xs text-primary font-medium">Build with Flow Assist</div>
</div>
</button>
)}
<div className="my-1 border-t border-border" />
{aiEnabled && (
<button
type="button"
onClick={() => {
setShowMenu(false)
setAiPromptFlowType('procedural')
setAiPromptOpen(true)
}}
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm text-foreground hover:bg-accent"
>
<Sparkles className="h-3.5 w-3.5 text-primary ml-0.5" />
<div className="text-left">
<div className="text-xs text-primary font-medium">Build with Flow Assist</div>
</div>
</button>
)}
</div> </div>
</> </>
)} )}
<AIPromptDialog
isOpen={aiPromptOpen}
onClose={() => setAiPromptOpen(false)}
onGenerate={handleAIGenerate}
flowType={aiPromptFlowType}
/>
</div> </div>
) )
} }

View File

@@ -34,11 +34,11 @@ export function TagBadges({
}} }}
disabled={!onTagClick} disabled={!onTagClick}
className={cn( className={cn(
'rounded-full font-sans text-xs transition-colors', 'rounded-full font-sans transition-colors',
size === 'sm' ? 'px-2 py-0.5 text-xs' : 'px-2.5 py-1 text-sm', size === 'sm' ? 'px-2 py-0.5 text-xs' : 'px-2.5 py-1 text-sm',
variant === 'default' variant === 'default'
? 'bg-accent text-muted-foreground hover:bg-accent' ? 'bg-[var(--color-bg-elevated)] text-muted-foreground border border-border hover:text-foreground hover:border-[var(--color-border-hover)]'
: 'bg-accent/50 text-muted-foreground hover:bg-accent', : 'bg-[rgba(255,255,255,0.04)] text-muted-foreground border border-border hover:text-foreground',
!onTagClick && 'cursor-default' !onTagClick && 'cursor-default'
)} )}
> >
@@ -48,9 +48,9 @@ export function TagBadges({
{hiddenCount > 0 && ( {hiddenCount > 0 && (
<span <span
className={cn( className={cn(
'rounded-full font-sans text-xs', 'rounded-full font-sans',
size === 'sm' ? 'px-2 py-0.5 text-xs' : 'px-2.5 py-1 text-sm', size === 'sm' ? 'px-2 py-0.5 text-xs' : 'px-2.5 py-1 text-sm',
'bg-accent/50 text-muted-foreground' 'bg-[rgba(255,255,255,0.04)] text-muted-foreground border border-border'
)} )}
title={tags.slice(maxVisible).join(', ')} title={tags.slice(maxVisible).join(', ')}
> >

View File

@@ -3,12 +3,21 @@ import { useNavigate } from 'react-router-dom'
import { AlertTriangle, Clock, Hash, Ticket, Loader2, RefreshCw } from 'lucide-react' import { AlertTriangle, Clock, Hash, Ticket, Loader2, RefreshCw } from 'lucide-react'
import { aiSessionsApi } from '@/api' import { aiSessionsApi } from '@/api'
import type { AISessionSummary } from '@/types/ai-session' import type { AISessionSummary } from '@/types/ai-session'
import { timeAgo } from '@/lib/timeAgo'
interface EscalationQueueProps { interface EscalationQueueProps {
onPickup?: (sessionId: string) => void onPickup?: (sessionId: string) => void
onCountChange?: (count: number) => void
} }
export function EscalationQueue({ onPickup }: EscalationQueueProps) { function waitTimeColor(createdAt: string): string {
const hours = (Date.now() - new Date(createdAt).getTime()) / 3_600_000
if (hours >= 4) return '#f87171' // danger
if (hours >= 1) return '#fbbf24' // warning/amber
return '#848b9b' // muted
}
export function EscalationQueue({ onPickup, onCountChange }: EscalationQueueProps) {
const navigate = useNavigate() const navigate = useNavigate()
const [sessions, setSessions] = useState<AISessionSummary[]>([]) const [sessions, setSessions] = useState<AISessionSummary[]>([])
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
@@ -19,7 +28,12 @@ export function EscalationQueue({ onPickup }: EscalationQueueProps) {
setError(null) setError(null)
try { try {
const data = await aiSessionsApi.getEscalationQueue() const data = await aiSessionsApi.getEscalationQueue()
setSessions(data) // Sort oldest-first — longest waiting = most urgent
const sorted = [...data].sort(
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
)
setSessions(sorted)
onCountChange?.(sorted.length)
} catch { } catch {
setError('Failed to load escalation queue') setError('Failed to load escalation queue')
} finally { } finally {
@@ -29,6 +43,7 @@ export function EscalationQueue({ onPickup }: EscalationQueueProps) {
useEffect(() => { useEffect(() => {
loadQueue() loadQueue()
// eslint-disable-next-line react-hooks/exhaustive-deps -- load once on mount
}, []) }, [])
const handlePickup = (sessionId: string) => { const handlePickup = (sessionId: string) => {
@@ -80,7 +95,7 @@ export function EscalationQueue({ onPickup }: EscalationQueueProps) {
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between px-1"> <div className="flex items-center justify-between px-1">
<h3 className="font-sans text-xs text-[0.625rem] uppercase tracking-wider text-text-muted"> <h3 className="font-sans text-[0.625rem] uppercase tracking-wider text-muted-foreground">
Awaiting pickup ({sessions.length}) Awaiting pickup ({sessions.length})
</h3> </h3>
<button <button
@@ -93,7 +108,7 @@ export function EscalationQueue({ onPickup }: EscalationQueueProps) {
</div> </div>
{sessions.map((session) => ( {sessions.map((session) => (
<div key={session.id} className="card-interactive p-3 sm:p-4 space-y-3"> <div key={session.id} className="card-flat p-3 sm:p-4 space-y-3">
<div> <div>
<p className="text-sm font-semibold text-foreground"> <p className="text-sm font-semibold text-foreground">
{session.problem_summary || 'Untitled session'} {session.problem_summary || 'Untitled session'}
@@ -107,7 +122,7 @@ export function EscalationQueue({ onPickup }: EscalationQueueProps) {
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground"> <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
{session.problem_domain && ( {session.problem_domain && (
<span className="font-sans text-xs rounded-md bg-accent-dim px-1.5 py-0.5 text-[0.5625rem] uppercase tracking-wider text-primary"> <span className="font-sans rounded-md bg-accent-dim px-1.5 py-0.5 text-[0.5625rem] uppercase tracking-wider text-accent-text">
{session.problem_domain} {session.problem_domain}
</span> </span>
)} )}
@@ -115,24 +130,29 @@ export function EscalationQueue({ onPickup }: EscalationQueueProps) {
<Hash size={10} /> <Hash size={10} />
{session.step_count} steps {session.step_count} steps
</span> </span>
<span className="flex items-center gap-1"> <span
className="flex items-center gap-1 font-medium"
style={{ color: waitTimeColor(session.created_at) }}
>
<Clock size={10} /> <Clock size={10} />
{new Date(session.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} {timeAgo(session.created_at)}
</span> </span>
{session.psa_ticket_id && ( {session.psa_ticket_id && (
<span className="flex items-center gap-1 text-primary"> <span className="flex items-center gap-1 text-accent-text">
<Ticket size={10} /> <Ticket size={10} />
#{session.psa_ticket_id} #{session.psa_ticket_id}
</span> </span>
)} )}
</div> </div>
<button <div className="flex justify-end">
onClick={() => handlePickup(session.id)} <button
className="w-full min-h-[44px] rounded-lg bg-primary text-white px-4 py-2 text-sm font-semibold hover:brightness-110 active:scale-[0.98] transition-all" onClick={() => handlePickup(session.id)}
> className="rounded-lg bg-primary text-white px-4 py-2 text-sm font-semibold hover:brightness-110 active:scale-[0.98] transition-all"
Pick Up Session >
</button> Pick Up
</button>
</div>
</div> </div>
))} ))}
</div> </div>

View File

@@ -187,6 +187,23 @@ export function SessionDocView({
))} ))}
</div> </div>
{/* Follow-up recommendations */}
{documentation.follow_up_recommendations.length > 0 && (
<div className="card-flat p-3 sm:p-4">
<h4 className="font-sans text-xs text-[0.625rem] uppercase tracking-wider text-muted-foreground mb-2">
Follow-up
</h4>
<ul className="space-y-1">
{documentation.follow_up_recommendations.map((rec, i) => (
<li key={i} className="flex items-start gap-2 text-sm text-foreground">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-primary" />
{rec}
</li>
))}
</ul>
</div>
)}
{/* Rating */} {/* Rating */}
{onRate && ( {onRate && (
<div className="card-flat p-3 sm:p-4 text-center"> <div className="card-flat p-3 sm:p-4 text-center">

View File

@@ -1,5 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { FileText, User, Mail, Zap, AlignLeft, Copy, Check, RotateCcw, ArrowLeftRight, Loader2 } from 'lucide-react' import { FileText, User, Mail, HelpCircle, Zap, AlignLeft, Copy, Check, RotateCcw, ArrowLeftRight, Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast' import { toast } from '@/lib/toast'
import type { StatusUpdateAudience, StatusUpdateLength, StatusUpdateContext, StatusUpdateResponse } from '@/types/ai-session' import type { StatusUpdateAudience, StatusUpdateLength, StatusUpdateContext, StatusUpdateResponse } from '@/types/ai-session'
@@ -12,10 +12,11 @@ interface StatusUpdateModalProps {
hasPsaTicket?: boolean hasPsaTicket?: boolean
} }
const AUDIENCES: { value: StatusUpdateAudience; icon: typeof FileText; label: string; description: string }[] = [ const AUDIENCES: { value: StatusUpdateAudience; icon: typeof FileText; label: string; description: string; skipLength?: boolean }[] = [
{ value: 'ticket_notes', icon: FileText, label: 'Ticket Notes', description: 'Technical, for your PSA' }, { value: 'ticket_notes', icon: FileText, label: 'Ticket Notes', description: 'Technical, for your PSA' },
{ value: 'client_update', icon: User, label: 'Client Update', description: 'Professional, non-technical' }, { value: 'client_update', icon: User, label: 'Client Update', description: 'Professional, non-technical' },
{ value: 'email_draft', icon: Mail, label: 'Email Draft', description: 'Full email with subject line' }, { value: 'email_draft', icon: Mail, label: 'Email Draft', description: 'Full email with subject line' },
{ value: 'request_info', icon: HelpCircle, label: 'Request Information', description: 'Ask the client specific questions', skipLength: true },
] ]
const LENGTHS: { value: StatusUpdateLength; icon: typeof Zap; label: string; description: string }[] = [ const LENGTHS: { value: StatusUpdateLength; icon: typeof Zap; label: string; description: string }[] = [
@@ -38,9 +39,24 @@ export function StatusUpdateModal({ open, onClose, onGenerate, context = 'status
escalation: 'Share Escalation', escalation: 'Share Escalation',
} }
const handleAudienceSelect = (value: StatusUpdateAudience) => { const handleAudienceSelect = async (value: StatusUpdateAudience) => {
setAudience(value) setAudience(value)
setStep('length') const opt = AUDIENCES.find(a => a.value === value)
if (opt?.skipLength) {
// Skip length selection — always concise for request_info
setLength('quick')
setStep('generating')
try {
const res = await onGenerate(value, 'quick', context)
setResult(res)
setStep('result')
} catch {
setStep('audience')
setAudience(null)
}
} else {
setStep('length')
}
} }
const handleLengthSelect = async (value: StatusUpdateLength) => { const handleLengthSelect = async (value: StatusUpdateLength) => {
@@ -170,7 +186,7 @@ export function StatusUpdateModal({ open, onClose, onGenerate, context = 'status
<div className="flex flex-col items-center justify-center py-8 gap-3"> <div className="flex flex-col items-center justify-center py-8 gap-3">
<Loader2 size={24} className="animate-spin text-accent" /> <Loader2 size={24} className="animate-spin text-accent" />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Generating {audience === 'email_draft' ? 'email draft' : audience === 'client_update' ? 'client update' : 'ticket notes'}... {audience === 'request_info' ? 'Drafting information request...' : audience === 'email_draft' ? 'Generating email draft...' : audience === 'client_update' ? 'Generating client update...' : 'Generating ticket notes...'}
</p> </p>
</div> </div>
)} )}

View File

@@ -47,11 +47,15 @@ export function Sidebar() {
const [flyoutIndex, setFlyoutIndex] = useState<string | null>(null) const [flyoutIndex, setFlyoutIndex] = useState<string | null>(null)
const flyoutTimeout = useRef<ReturnType<typeof setTimeout> | null>(null) const flyoutTimeout = useRef<ReturnType<typeof setTimeout> | null>(null)
const sidebarRef = useRef<HTMLElement>(null) const sidebarRef = useRef<HTMLElement>(null)
const statsRequestId = useRef(0)
/* ── Stats fetching ───────────────────────────────── */ /* ── Stats fetching ───────────────────────────────── */
const refreshStats = useCallback(() => { const refreshStats = useCallback(() => {
sidebarApi.getStats().then(setStats).catch(() => {}) const requestId = ++statsRequestId.current
sidebarApi.getStats()
.then(data => { if (requestId === statsRequestId.current) setStats(data) })
.catch(() => {})
}, []) }, [])
useEffect(() => { refreshStats() }, [location.pathname, refreshStats]) useEffect(() => { refreshStats() }, [location.pathname, refreshStats])
@@ -84,8 +88,7 @@ export function Sidebar() {
badge: stats?.tree_counts.total || undefined, badge: stats?.tree_counts.total || undefined,
matchPaths: ['/trees', '/flows', '/my-trees', '/step-library', '/review-queue'], matchPaths: ['/trees', '/flows', '/my-trees', '/step-library', '/review-queue'],
children: [ children: [
{ href: '/trees', label: 'Guided Flows', count: stats?.tree_counts.total || undefined }, { href: '/trees', label: 'Flow Library', count: stats?.tree_counts.total || undefined },
{ href: '/trees?type=troubleshooting', label: 'Troubleshooting', count: stats?.tree_counts.troubleshooting || undefined },
{ href: '/trees?type=procedural', label: 'Projects', count: stats?.tree_counts.procedural || undefined }, { href: '/trees?type=procedural', label: 'Projects', count: stats?.tree_counts.procedural || undefined },
{ href: '/step-library', label: 'Solutions Library' }, { href: '/step-library', label: 'Solutions Library' },
{ href: '/review-queue', label: 'Review Queue' }, { href: '/review-queue', label: 'Review Queue' },
@@ -123,12 +126,11 @@ export function Sidebar() {
title: 'KNOWLEDGE', title: 'KNOWLEDGE',
items: [ items: [
{ {
href: '/trees', icon: GitBranch, label: 'Guided Flows', shortLabel: 'Flows', href: '/trees', icon: GitBranch, label: 'Flow Library', shortLabel: 'Flows',
badge: stats?.tree_counts.total || undefined, badge: stats?.tree_counts.total || undefined,
matchPaths: ['/trees', '/flows', '/my-trees'], matchPaths: ['/trees', '/flows', '/my-trees'],
children: [ children: [
{ href: '/trees', label: 'All Flows' }, { href: '/trees', label: 'Flow Library' },
{ href: '/trees?type=troubleshooting', label: 'Troubleshooting', count: stats?.tree_counts.troubleshooting || undefined },
{ href: '/trees?type=procedural', label: 'Projects', count: stats?.tree_counts.procedural || undefined }, { href: '/trees?type=procedural', label: 'Projects', count: stats?.tree_counts.procedural || undefined },
], ],
}, },

View File

@@ -41,7 +41,7 @@ export function CollapsibleEditorSection({
onClick={handleToggle} onClick={handleToggle}
aria-expanded={isExpanded} aria-expanded={isExpanded}
aria-controls={sectionId} aria-controls={sectionId}
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-accent/50" className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-white/[0.05]"
> >
<ChevronRight <ChevronRight
className={cn( className={cn(

View File

@@ -30,7 +30,7 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
<span className="text-sm font-medium text-muted-foreground">Edit Section Header</span> <span className="text-sm font-medium text-muted-foreground">Edit Section Header</span>
<button <button
onClick={onCollapse} onClick={onCollapse}
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground" className="rounded p-1 text-muted-foreground hover:bg-elevated hover:text-foreground"
> >
<ChevronUp className="h-4 w-4" /> <ChevronUp className="h-4 w-4" />
</button> </button>
@@ -42,7 +42,7 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
value={step.title} value={step.title}
onChange={(e) => onUpdate({ title: e.target.value })} onChange={(e) => onUpdate({ title: e.target.value })}
placeholder="Section title" placeholder="Section title"
className="w-full rounded border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20" className="w-full rounded border border-border bg-elevated px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20"
/> />
</div> </div>
</div> </div>
@@ -54,14 +54,14 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
{/* Header */} {/* Header */}
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-accent text-xs font-medium text-foreground"> <span className="flex h-6 w-6 items-center justify-center rounded-full bg-white/[0.10] text-xs font-medium text-foreground">
{stepNumber} {stepNumber}
</span> </span>
<span className="text-sm font-medium text-foreground">Edit Step</span> <span className="text-sm font-medium text-foreground">Edit Step</span>
</div> </div>
<button <button
onClick={onCollapse} onClick={onCollapse}
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground" className="rounded p-1 text-muted-foreground hover:bg-elevated hover:text-foreground"
> >
<ChevronUp className="h-4 w-4" /> <ChevronUp className="h-4 w-4" />
</button> </button>
@@ -75,7 +75,7 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
type="text" type="text"
value={step.title} value={step.title}
onChange={(e) => onUpdate({ title: e.target.value })} onChange={(e) => onUpdate({ title: e.target.value })}
className="w-full rounded border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20" className="w-full rounded border border-border bg-elevated px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20"
/> />
</div> </div>
@@ -91,7 +91,7 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
onChange={(e) => onUpdate({ estimated_minutes: e.target.value ? parseInt(e.target.value) : undefined })} onChange={(e) => onUpdate({ estimated_minutes: e.target.value ? parseInt(e.target.value) : undefined })}
placeholder="—" placeholder="—"
min={1} min={1}
className="w-full rounded border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20" className="w-full rounded border border-border bg-elevated px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20"
/> />
</div> </div>
@@ -103,7 +103,7 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
onChange={(e) => onUpdate({ description: e.target.value })} onChange={(e) => onUpdate({ description: e.target.value })}
placeholder="Step instructions. Use [VAR:name] for variables." placeholder="Step instructions. Use [VAR:name] for variables."
rows={4} rows={4}
className="w-full rounded border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20" className="w-full rounded border border-border bg-elevated px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20"
/> />
{availableVariables.length > 0 && ( {availableVariables.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1"> <div className="mt-1 flex flex-wrap gap-1">
@@ -132,44 +132,44 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
onChange={(e) => onUpdate({ commands: e.target.value || undefined })} onChange={(e) => onUpdate({ commands: e.target.value || undefined })}
placeholder="Install-WindowsFeature AD-Domain-Services -IncludeManagementTools" placeholder="Install-WindowsFeature AD-Domain-Services -IncludeManagementTools"
rows={3} rows={3}
className="w-full rounded border border-border bg-card px-3 py-2 font-mono text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20" className="w-full rounded border border-border bg-elevated px-3 py-2 font-mono text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20"
/> />
</div> </div>
{/* More Options toggle */} {/* Content Type */}
<div>
<label className="mb-1 block text-xs font-medium text-muted-foreground">Type</label>
<div className="flex gap-1">
{CONTENT_TYPE_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => onUpdate({ content_type: opt.value })}
className={cn(
'rounded px-2 py-1 text-xs font-medium transition-colors',
step.content_type === opt.value
? 'bg-white/15 ' + opt.color
: 'text-muted-foreground hover:bg-elevated hover:text-foreground'
)}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Advanced Options toggle */}
<button <button
type="button" type="button"
onClick={() => setShowMore(!showMore)} onClick={() => setShowMore(!showMore)}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-muted-foreground" className="flex items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
> >
<Settings2 className="h-3 w-3" /> <Settings2 className="h-3 w-3" />
More Options Advanced Options
{showMore ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />} {showMore ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
</button> </button>
{showMore && ( {showMore && (
<div className="space-y-4 border-t border-border pt-4"> <div className="space-y-4 border-t border-border pt-4">
{/* Content Type */}
<div>
<label className="mb-1 block text-xs font-medium text-muted-foreground">Content Type</label>
<div className="flex gap-1">
{CONTENT_TYPE_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => onUpdate({ content_type: opt.value })}
className={cn(
'rounded px-2 py-1 text-xs font-medium transition-colors',
step.content_type === opt.value
? 'bg-white/15 ' + opt.color
: 'text-muted-foreground hover:bg-accent hover:text-muted-foreground'
)}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Warning text */} {/* Warning text */}
{(step.content_type === 'warning' || step.warning_text) && ( {(step.content_type === 'warning' || step.warning_text) && (
<div> <div>
@@ -195,7 +195,7 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
value={step.expected_outcome || ''} value={step.expected_outcome || ''}
onChange={(e) => onUpdate({ expected_outcome: e.target.value || undefined })} onChange={(e) => onUpdate({ expected_outcome: e.target.value || undefined })}
placeholder="Server should respond with..." placeholder="Server should respond with..."
className="w-full rounded border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20" className="w-full rounded border border-border bg-elevated px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20"
/> />
</div> </div>
@@ -211,7 +211,7 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
value={step.verification_prompt || ''} value={step.verification_prompt || ''}
onChange={(e) => onUpdate({ verification_prompt: e.target.value || undefined })} onChange={(e) => onUpdate({ verification_prompt: e.target.value || undefined })}
placeholder="Confirm the role was installed" placeholder="Confirm the role was installed"
className="w-full rounded border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20" className="w-full rounded border border-border bg-elevated px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20"
/> />
</div> </div>
<div> <div>
@@ -219,7 +219,7 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
<select <select
value={step.verification_type || ''} value={step.verification_type || ''}
onChange={(e) => onUpdate({ verification_type: e.target.value as 'checkbox' | 'text_input' || undefined })} onChange={(e) => onUpdate({ verification_type: e.target.value as 'checkbox' | 'text_input' || undefined })}
className="w-full rounded border border-border bg-card px-3 py-2 text-sm text-foreground focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20" className="w-full rounded border border-border bg-elevated px-3 py-2 text-sm text-foreground focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20"
> >
<option value="">None</option> <option value="">None</option>
<option value="checkbox">Checkbox (confirm done)</option> <option value="checkbox">Checkbox (confirm done)</option>
@@ -240,7 +240,7 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
value={step.reference_url || ''} value={step.reference_url || ''}
onChange={(e) => onUpdate({ reference_url: e.target.value || undefined })} onChange={(e) => onUpdate({ reference_url: e.target.value || undefined })}
placeholder="https://learn.microsoft.com/..." placeholder="https://learn.microsoft.com/..."
className="w-full rounded border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20" className="w-full rounded border border-border bg-elevated px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20"
/> />
</div> </div>
<div className="flex items-end pb-1"> <div className="flex items-end pb-1">
@@ -267,7 +267,7 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
onChange={(e) => onUpdate({ onChange={(e) => onUpdate({
library_visibility: e.target.value === '' ? undefined : e.target.value as 'team' | 'public' library_visibility: e.target.value === '' ? undefined : e.target.value as 'team' | 'public'
})} })}
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20" className="w-full rounded-lg border border-border bg-elevated px-3 py-2 text-sm text-foreground focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20"
> >
<option value="">Inherit from flow</option> <option value="">Inherit from flow</option>
<option value="team">Team only</option> <option value="team">Team only</option>

View File

@@ -1,5 +1,5 @@
import { useRef, useEffect, useCallback, type ReactNode } from 'react' import { useRef, useEffect, useCallback, type ReactNode } from 'react'
import { Plus, GripVertical, Trash2, ChevronDown, CheckCircle2, AlertTriangle, Info, Zap, Shield, SeparatorHorizontal } from 'lucide-react' import { Plus, GripVertical, Trash2, ChevronDown, CheckCircle2, AlertTriangle, Info, Zap, ListOrdered, SeparatorHorizontal } from 'lucide-react'
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core' import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'
import type { DragEndEvent } from '@dnd-kit/core' import type { DragEndEvent } from '@dnd-kit/core'
import { SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable' import { SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable'
@@ -104,7 +104,7 @@ export function StepList({ onStepContextMenu }: StepListProps) {
<div> <div>
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-muted-foreground" /> <ListOrdered className="h-5 w-5 text-muted-foreground" />
<h2 className="text-lg font-semibold text-foreground">Steps</h2> <h2 className="text-lg font-semibold text-foreground">Steps</h2>
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
({procedureSteps.length} step{procedureSteps.length !== 1 ? 's' : ''} ({procedureSteps.length} step{procedureSteps.length !== 1 ? 's' : ''}
@@ -114,14 +114,14 @@ export function StepList({ onStepContextMenu }: StepListProps) {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={() => addSectionHeader()} onClick={() => addSectionHeader()}
className="flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground" className="flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm text-muted-foreground hover:bg-elevated hover:text-foreground"
> >
<SeparatorHorizontal className="h-3.5 w-3.5" /> <SeparatorHorizontal className="h-3.5 w-3.5" />
Add Section Add Section
</button> </button>
<button <button
onClick={() => addStep()} onClick={() => addStep()}
className="flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground" className="flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm text-muted-foreground hover:bg-elevated hover:text-foreground"
> >
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
Add Step Add Step
@@ -138,7 +138,7 @@ export function StepList({ onStepContextMenu }: StepListProps) {
return ( return (
<div <div
key={step.id} key={step.id}
className="flex items-center gap-2 rounded-lg border border-dashed border-border bg-accent/50 px-3 py-2" className="flex items-center gap-2 rounded-lg border border-dashed border-border bg-elevated/40 px-3 py-2"
> >
<CheckCircle2 className="h-4 w-4 text-emerald-400/50" /> <CheckCircle2 className="h-4 w-4 text-emerald-400/50" />
<input <input
@@ -238,7 +238,7 @@ export function StepList({ onStepContextMenu }: StepListProps) {
<div <div
className={cn( className={cn(
'group flex flex-col rounded-xl border border-border px-3 py-2.5 transition-colors', 'group flex flex-col rounded-xl border border-border px-3 py-2.5 transition-colors',
'hover:border-primary/30 hover:bg-accent/50', 'hover:border-white/[0.15] hover:bg-elevated',
isGhost && 'border-l-2 border-dashed border-l-primary/40! opacity-60' isGhost && 'border-l-2 border-dashed border-l-primary/40! opacity-60'
)} )}
data-step-id={step.id} data-step-id={step.id}
@@ -254,7 +254,7 @@ export function StepList({ onStepContextMenu }: StepListProps) {
<GripVertical className="h-4 w-4" /> <GripVertical className="h-4 w-4" />
</button> </button>
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent text-xs font-medium text-muted-foreground"> <span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-white/[0.10] text-xs font-medium text-muted-foreground">
{stepNumber} {stepNumber}
</span> </span>
@@ -277,7 +277,7 @@ export function StepList({ onStepContextMenu }: StepListProps) {
<button <button
onClick={() => setExpandedStepId(step.id)} onClick={() => setExpandedStepId(step.id)}
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground" className="shrink-0 rounded p-1 text-muted-foreground hover:bg-elevated hover:text-foreground"
> >
<ChevronDown className="h-3.5 w-3.5" /> <ChevronDown className="h-3.5 w-3.5" />
</button> </button>
@@ -324,7 +324,7 @@ export function StepList({ onStepContextMenu }: StepListProps) {
{/* Add step button at bottom */} {/* Add step button at bottom */}
<button <button
onClick={() => addStep()} onClick={() => addStep()}
className="mt-3 flex w-full items-center justify-center gap-1.5 rounded-lg border border-dashed border-border py-2 text-sm text-muted-foreground transition-colors hover:border-primary/30 hover:text-muted-foreground" className="mt-3 flex w-full items-center justify-center gap-1.5 rounded-lg border border-dashed border-white/20 py-2 text-sm text-muted-foreground transition-colors hover:border-primary/40 hover:bg-elevated/30 hover:text-foreground"
> >
<Plus className="h-3.5 w-3.5" /> <Plus className="h-3.5 w-3.5" />
Add Step Add Step

View File

@@ -52,7 +52,10 @@ export function StepChecklist({ steps, currentStepIndex, completedStepIds, onSte
) : ( ) : (
<Circle className="h-4 w-4 shrink-0 text-muted-foreground" /> <Circle className="h-4 w-4 shrink-0 text-muted-foreground" />
)} )}
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-accent text-[10px] font-medium"> <span className={cn(
'flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[10px] font-bold',
isCurrent ? 'bg-[#0e1016] text-accent' : 'bg-accent text-[#0e1016]'
)}>
{index + 1} {index + 1}
</span> </span>
<span className="min-w-0 flex-1 flex items-center gap-1.5 overflow-hidden"> <span className="min-w-0 flex-1 flex items-center gap-1.5 overflow-hidden">

View File

@@ -91,7 +91,7 @@ export function StepDetail({
<div className="space-y-4"> <div className="space-y-4">
{/* Step header */} {/* Step header */}
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-accent text-sm font-semibold text-foreground"> <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-accent text-sm font-bold text-[#0e1016]">
{stepNumber} {stepNumber}
</span> </span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">

View File

@@ -1,17 +1,27 @@
import { useState, useRef, useCallback, useEffect } from 'react' import { useState, useRef, useCallback, useEffect } from 'react'
import { Send } from 'lucide-react' import { Send, Terminal, UserPlus, HardDrive, RotateCcw } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
const SUGGESTIONS: { icon: LucideIcon; label: string }[] = [
{ icon: UserPlus, label: 'Create a new AD user' },
{ icon: HardDrive, label: 'Check disk space on all servers' },
{ icon: RotateCcw, label: 'Restart a Windows service' },
{ icon: Terminal, label: 'Reset MFA for a user' },
]
interface ScriptBuilderInputProps { interface ScriptBuilderInputProps {
onSend: (content: string) => void onSend: (content: string) => void
disabled: boolean disabled: boolean
placeholder?: string placeholder?: string
showSuggestions?: boolean
} }
export function ScriptBuilderInput({ export function ScriptBuilderInput({
onSend, onSend,
disabled, disabled,
placeholder = 'Describe the script you need...', placeholder = 'Describe the script you need...',
showSuggestions = false,
}: ScriptBuilderInputProps) { }: ScriptBuilderInputProps) {
const [value, setValue] = useState('') const [value, setValue] = useState('')
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null)
@@ -44,35 +54,54 @@ export function ScriptBuilderInput({
const canSend = value.trim().length > 0 && !disabled const canSend = value.trim().length > 0 && !disabled
return ( return (
<div className="flex items-end gap-2 p-3 border-t" style={{ borderColor: 'var(--color-border-default)' }}> <div className="border-t border-border p-3 space-y-2">
<textarea <div className="flex items-end gap-2">
ref={textareaRef} <textarea
value={value} ref={textareaRef}
onChange={(e) => setValue(e.target.value)} value={value}
onKeyDown={handleKeyDown} onChange={(e) => setValue(e.target.value)}
placeholder={placeholder} onKeyDown={handleKeyDown}
disabled={disabled} placeholder={placeholder}
rows={1} disabled={disabled}
className={cn( rows={1}
"flex-1 resize-none rounded-xl px-4 py-2.5 text-sm", className={cn(
"bg-card border border-border text-foreground placeholder:text-muted-foreground", "flex-1 resize-none rounded-xl px-4 py-2.5 text-sm",
"focus:outline-none focus:border-[rgba(96,165,250,0.3)] transition-colors", "bg-card border border-border text-foreground placeholder:text-muted-foreground",
"disabled:opacity-50" "focus:outline-none focus:border-[rgba(249,115,22,0.25)] focus:ring-1 focus:ring-[rgba(249,115,22,0.1)] transition-colors",
)} "disabled:opacity-50"
style={{ maxHeight: 120 }} )}
/> style={{ maxHeight: 120 }}
<button />
onClick={handleSend} <button
disabled={!canSend} onClick={handleSend}
className={cn( disabled={!canSend}
"shrink-0 flex items-center justify-center w-10 h-10 rounded-xl transition-all", className={cn(
canSend "shrink-0 flex items-center justify-center w-10 h-10 rounded-xl transition-all",
? "bg-primary text-white hover:brightness-110 active:scale-[0.98]" canSend
: "bg-[rgba(255,255,255,0.04)] text-text-muted cursor-not-allowed" ? "bg-primary text-white hover:brightness-110 active:scale-[0.98]"
)} : "bg-[var(--color-bg-elevated)] text-muted-foreground cursor-not-allowed"
> )}
<Send size={18} /> >
</button> <Send size={18} />
</button>
</div>
{showSuggestions && (
<div className="flex flex-wrap gap-2">
{SUGGESTIONS.map(({ icon: Icon, label }) => (
<button
key={label}
type="button"
disabled={disabled}
onClick={() => { if (!disabled) onSend(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] disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none"
>
<Icon size={11} className="text-muted shrink-0 group-hover:text-[#f97316] transition-colors" />
{label}
</button>
))}
</div>
)}
</div> </div>
) )
} }

View File

@@ -5,7 +5,6 @@ import bash from 'react-syntax-highlighter/dist/esm/languages/hljs/bash'
import python from 'react-syntax-highlighter/dist/esm/languages/hljs/python' import python from 'react-syntax-highlighter/dist/esm/languages/hljs/python'
import atomOneDark from 'react-syntax-highlighter/dist/esm/styles/hljs/atom-one-dark' import atomOneDark from 'react-syntax-highlighter/dist/esm/styles/hljs/atom-one-dark'
import { Eye, Copy, Check, BookmarkPlus } from 'lucide-react' import { Eye, Copy, Check, BookmarkPlus } from 'lucide-react'
import { cn } from '@/lib/utils'
SyntaxHighlighter.registerLanguage('powershell', powershell) SyntaxHighlighter.registerLanguage('powershell', powershell)
SyntaxHighlighter.registerLanguage('bash', bash) SyntaxHighlighter.registerLanguage('bash', bash)
@@ -52,10 +51,10 @@ export function ScriptCodeBlock({
} }
return ( return (
<div className="mt-3 rounded-lg border bg-[rgba(0,0,0,0.3)] border-[rgba(255,255,255,0.06)] overflow-hidden"> <div className="mt-3 rounded-lg border border-border bg-[var(--color-bg-code)] overflow-hidden">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-[rgba(255,255,255,0.06)]"> <div className="flex items-center justify-between px-3 py-2 border-b border-border">
<span className="font-mono text-xs text-accent truncate"> <span className="font-mono text-xs text-accent-text truncate">
{filename || 'script'} {filename || 'script'}
</span> </span>
{lineCount != null && ( {lineCount != null && (
@@ -85,40 +84,31 @@ export function ScriptCodeBlock({
{previewLines} {previewLines}
</SyntaxHighlighter> </SyntaxHighlighter>
{remainingLines > 0 && ( {remainingLines > 0 && (
<div className="px-3 pb-2 font-mono text-[0.625rem] text-text-muted"> <div className="px-3 pb-2 font-mono text-[0.625rem] text-muted-foreground">
{"··· "}{remainingLines} more line{remainingLines !== 1 ? 's' : ''} {"··· "}{remainingLines} more line{remainingLines !== 1 ? 's' : ''}
</div> </div>
)} )}
</button> </button>
{/* Action buttons */} {/* Action buttons */}
<div className="flex items-center gap-2 px-3 py-2 border-t border-[rgba(255,255,255,0.06)]"> <div className="flex items-center gap-2 px-3 py-2 border-t border-border">
<button <button
onClick={onViewFull} onClick={onViewFull}
className={cn( className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition-all bg-primary text-white hover:brightness-110 active:scale-[0.98]"
"flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition-all",
"bg-primary text-white hover:brightness-110 active:scale-[0.98]"
)}
> >
<Eye size={14} /> <Eye size={14} />
View Full Script View Full Script
</button> </button>
<button <button
onClick={handleCopy} onClick={handleCopy}
className={cn( className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors border border-border text-foreground hover:border-[var(--color-border-hover)] hover:bg-[var(--color-bg-elevated)]"
"flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors",
"bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground hover:border-[rgba(255,255,255,0.12)]"
)}
> >
{copied ? <Check size={14} className="text-success" /> : <Copy size={14} />} {copied ? <Check size={14} className="text-success" /> : <Copy size={14} />}
{copied ? 'Copied' : 'Copy'} {copied ? 'Copied' : 'Copy'}
</button> </button>
<button <button
onClick={(e) => { e.stopPropagation(); onSave() }} onClick={(e) => { e.stopPropagation(); onSave() }}
className={cn( className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors border border-default text-secondary hover:text-primary hover:border-hover hover:bg-elevated"
"flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors",
"bg-success-dim border border-success/20 text-success hover:bg-emerald-500/15"
)}
> >
<BookmarkPlus size={14} /> <BookmarkPlus size={14} />
Save to Library Save to Library

View File

@@ -2,7 +2,6 @@ import { useEffect, useState } from 'react'
import { Light as SyntaxHighlighter } from 'react-syntax-highlighter' import { Light as SyntaxHighlighter } from 'react-syntax-highlighter'
import atomOneDark from 'react-syntax-highlighter/dist/esm/styles/hljs/atom-one-dark' import atomOneDark from 'react-syntax-highlighter/dist/esm/styles/hljs/atom-one-dark'
import { X, Copy, Check, BookmarkPlus } from 'lucide-react' import { X, Copy, Check, BookmarkPlus } from 'lucide-react'
import { cn } from '@/lib/utils'
const LANGUAGE_MAP: Record<string, string> = { const LANGUAGE_MAP: Record<string, string> = {
powershell: 'powershell', powershell: 'powershell',
@@ -55,44 +54,38 @@ export function ScriptPreviewModal({
return ( return (
<div <div
className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center" className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center"
onClick={(e) => { if (e.target === e.currentTarget) onClose() }} onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
> >
<div className="bg-card rounded-xl border border-[rgba(255,255,255,0.08)] max-w-[900px] w-full mx-4 max-h-[85vh] flex flex-col"> <div className="bg-card rounded-xl border border-border max-w-[900px] w-full mx-4 max-h-[85vh] flex flex-col">
{/* Header */} {/* 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 justify-between px-5 py-3.5 border-b border-border">
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-3 min-w-0">
<span className="font-mono text-sm text-accent truncate"> <span className="font-mono text-sm text-accent-text truncate">
{filename || 'script'} {filename || 'script'}
</span> </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"> <span className="shrink-0 font-mono text-[0.625rem] uppercase tracking-wider px-2 py-0.5 rounded-full bg-[var(--color-bg-elevated)] text-muted-foreground">
{LANGUAGE_LABELS[language] || language} {LANGUAGE_LABELS[language] || language}
</span> </span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={handleCopy} onClick={handleCopy}
className={cn( className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors border border-border text-foreground hover:border-[var(--color-border-hover)] hover:bg-[var(--color-bg-elevated)]"
"flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors",
"bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground hover:border-[rgba(255,255,255,0.12)]"
)}
> >
{copied ? <Check size={14} className="text-success" /> : <Copy size={14} />} {copied ? <Check size={14} className="text-success" /> : <Copy size={14} />}
{copied ? 'Copied' : 'Copy'} {copied ? 'Copied' : 'Copy'}
</button> </button>
<button <button
onClick={onSave} onClick={onSave}
className={cn( className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors bg-accent text-white hover:brightness-110"
"flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors",
"bg-success-dim border border-success/20 text-success hover:bg-emerald-500/15"
)}
> >
<BookmarkPlus size={14} /> <BookmarkPlus size={14} />
Save to Library Save to Library
</button> </button>
<button <button
onClick={onClose} onClick={onClose}
className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors" className="p-1.5 rounded-lg text-muted-foreground hover:text-foreground hover:bg-[var(--color-bg-elevated)] transition-colors"
> >
<X size={18} /> <X size={18} />
</button> </button>
@@ -125,16 +118,13 @@ export function ScriptPreviewModal({
</div> </div>
{/* Footer */} {/* Footer */}
<div className="flex items-center justify-between px-5 py-3 border-t border-[rgba(255,255,255,0.06)]"> <div className="flex items-center justify-between px-5 py-3 border-t border-border">
<span className="font-mono text-[0.625rem] text-muted-foreground"> <span className="font-mono text-[0.625rem] text-muted-foreground">
{lineCount} line{lineCount !== 1 ? 's' : ''} {lineCount} line{lineCount !== 1 ? 's' : ''}
</span> </span>
<button <button
onClick={onClose} onClick={onClose}
className={cn( className="px-4 py-1.5 rounded-lg text-xs font-medium transition-colors border border-border text-foreground hover:border-[var(--color-border-hover)] hover:bg-[var(--color-bg-elevated)]"
"px-4 py-1.5 rounded-lg text-xs font-medium transition-colors",
"bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground hover:border-[rgba(255,255,255,0.12)]"
)}
> >
Close & Return to Chat Close & Return to Chat
</button> </button>

View File

@@ -108,7 +108,7 @@ export function ParameterDetectorStepper({
{/* Progress */} {/* Progress */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-xs font-medium text-foreground"> <p className="text-xs font-medium text-foreground">
Candidate {currentIndex + 1} of {candidates.length} Variable {currentIndex + 1} of {candidates.length}
</p> </p>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{candidates.map((_, i) => ( {candidates.map((_, i) => (
@@ -127,7 +127,7 @@ export function ParameterDetectorStepper({
{/* Matched line */} {/* Matched line */}
<div className="rounded-lg bg-black/20 px-3 py-2"> <div className="rounded-lg bg-black/20 px-3 py-2">
<p className="font-sans text-xs text-amber-400 break-all"> <p className="font-sans text-xs text-warning break-all">
{current.matchedLine} {current.matchedLine}
</p> </p>
<p className="font-sans text-xs text-[0.5rem] text-muted-foreground mt-1"> <p className="font-sans text-xs text-[0.5rem] text-muted-foreground mt-1">
@@ -145,7 +145,7 @@ export function ParameterDetectorStepper({
placeholder="param_key" placeholder="param_key"
/> />
{existingKeys.includes(key) && ( {existingKeys.includes(key) && (
<p className="text-[0.625rem] text-amber-400 mt-0.5">Key already exists consider a different name</p> <p className="text-[0.625rem] text-warning mt-0.5">Key already exists consider a different name</p>
)} )}
</div> </div>
<div> <div>
@@ -174,7 +174,7 @@ export function ParameterDetectorStepper({
<select <select
value={type} value={type}
onChange={e => setType(e.target.value as ScriptParameter['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(96,165,250,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(249,115,22,0.25)] focus:ring-1 focus:ring-[rgba(249,115,22,0.1)]"
> >
{PARAM_TYPES.map(t => ( {PARAM_TYPES.map(t => (
<option key={t.value} value={t.value}>{t.label}</option> <option key={t.value} value={t.value}>{t.label}</option>

View File

@@ -92,7 +92,7 @@ export function ParameterizeAndSavePanel({
if (detected.length > 0) { if (detected.length > 0) {
setShowStepper(true) setShowStepper(true)
} else { } else {
setDetectionSummary('No parameters detected — script will be saved as-is. Parameter detection currently supports PowerShell only.') setDetectionSummary('No configurable values found — the script will be saved as-is. Variable detection currently supports PowerShell only.')
} }
}, []) }, [])
@@ -298,7 +298,7 @@ export function ParameterizeAndSavePanel({
'disabled:opacity-50 disabled:cursor-not-allowed' 'disabled:opacity-50 disabled:cursor-not-allowed'
)} )}
> >
Detect Parameters Find Variables
</button> </button>
</section> </section>
)} )}
@@ -313,7 +313,7 @@ export function ParameterizeAndSavePanel({
<pre className="p-3 text-xs font-mono text-foreground whitespace-pre-wrap break-all"> <pre className="p-3 text-xs font-mono text-foreground whitespace-pre-wrap break-all">
{workingScript.split(/({{.*?}})/).map((part, i) => {workingScript.split(/({{.*?}})/).map((part, i) =>
/^{{.*}}$/.test(part) /^{{.*}}$/.test(part)
? <span key={i} className="text-amber-400 font-semibold">{part}</span> ? <span key={i} className="text-warning font-semibold">{part}</span>
: <span key={i}>{part}</span> : <span key={i}>{part}</span>
)} )}
</pre> </pre>
@@ -332,7 +332,7 @@ export function ParameterizeAndSavePanel({
{showStepper && candidates.length > 0 && ( {showStepper && candidates.length > 0 && (
<section className="space-y-2"> <section className="space-y-2">
<p className="font-sans text-xs uppercase tracking-widest text-muted-foreground"> <p className="font-sans text-xs uppercase tracking-widest text-muted-foreground">
Detected Parameters Configurable Variables
</p> </p>
<ParameterDetectorStepper <ParameterDetectorStepper
candidates={candidates} candidates={candidates}
@@ -348,7 +348,7 @@ export function ParameterizeAndSavePanel({
{parameters.length > 0 && !showStepper && ( {parameters.length > 0 && !showStepper && (
<section className="space-y-2"> <section className="space-y-2">
<p className="font-sans text-xs uppercase tracking-widest text-muted-foreground"> <p className="font-sans text-xs uppercase tracking-widest text-muted-foreground">
Parameters ({parameters.length}) Variables ({parameters.length})
</p> </p>
<div className="space-y-1"> <div className="space-y-1">
{parameters.map((p) => ( {parameters.map((p) => (
@@ -357,7 +357,7 @@ export function ParameterizeAndSavePanel({
className="flex items-center justify-between rounded-lg bg-elevated px-3 py-2" className="flex items-center justify-between rounded-lg bg-elevated px-3 py-2"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<code className="text-xs font-mono text-amber-400">{`{{${p.key}}}`}</code> <code className="text-xs font-mono text-warning">{`{{${p.key}}}`}</code>
<span className="text-xs text-muted-foreground">{p.label}</span> <span className="text-xs text-muted-foreground">{p.label}</span>
</div> </div>
<span className="text-[0.625rem] text-muted-foreground uppercase tracking-wide"> <span className="text-[0.625rem] text-muted-foreground uppercase tracking-wide">

View File

@@ -3,9 +3,9 @@ import { cn } from '@/lib/utils'
import type { ScriptTemplateListItem } from '@/types' import type { ScriptTemplateListItem } from '@/types'
const COMPLEXITY_CLASSES: Record<ScriptTemplateListItem['complexity'], string> = { const COMPLEXITY_CLASSES: Record<ScriptTemplateListItem['complexity'], string> = {
beginner: 'text-emerald-400 bg-emerald-400/10', beginner: 'text-success bg-success-dim',
intermediate: 'text-amber-400 bg-amber-400/10', intermediate: 'text-warning bg-warning-dim',
advanced: 'text-rose-500 bg-rose-500/10', advanced: 'text-danger bg-danger-dim',
} }
interface Props { interface Props {
@@ -28,7 +28,7 @@ export function TemplateCard({ template, onConfigure }: Props) {
<div className="flex items-center gap-1.5 shrink-0"> <div className="flex items-center gap-1.5 shrink-0">
{template.requires_elevation && ( {template.requires_elevation && (
<span title="Requires administrator elevation"> <span title="Requires administrator elevation">
<ShieldAlert size={13} className="text-amber-400" /> <ShieldAlert size={13} className="text-warning" />
</span> </span>
)} )}
<span className={cn('font-sans text-[0.625rem] uppercase tracking-wide px-1.5 py-0.5 rounded', COMPLEXITY_CLASSES[template.complexity])}> <span className={cn('font-sans text-[0.625rem] uppercase tracking-wide px-1.5 py-0.5 rounded', COMPLEXITY_CLASSES[template.complexity])}>
@@ -62,7 +62,7 @@ export function TemplateCard({ template, onConfigure }: Props) {
<button <button
type="button" type="button"
onClick={() => onConfigure(template.id)} onClick={() => onConfigure(template.id)}
className="shrink-0 bg-primary/10 border border-primary/20 text-primary text-xs px-2.5 py-1 rounded-md hover:bg-primary/20 transition-colors" className="shrink-0 bg-accent-dim border border-primary/20 text-accent-text text-xs px-2.5 py-1 rounded-md hover:bg-primary/20 transition-colors"
> >
Configure Configure
</button> </button>

View File

@@ -208,8 +208,8 @@ export function StepDetailModal({ stepId, onClose, onInsert }: StepDetailModalPr
className={cn( className={cn(
'flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors', 'flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors',
copiedCommandIndex === index copiedCommandIndex === index
? 'bg-emerald-400/10 text-emerald-400' ? 'bg-success-dim text-success'
: 'bg-accent text-muted-foreground hover:bg-accent hover:text-foreground' : 'border border-border bg-[var(--color-bg-elevated)] text-muted-foreground hover:text-foreground hover:border-[var(--color-border-hover)]'
)} )}
> >
{copiedCommandIndex === index ? ( {copiedCommandIndex === index ? (

View File

@@ -224,7 +224,7 @@ export function StepForm({ onSubmit, onCancel, initialData, submitLabel, isSubmi
<button <button
type="button" type="button"
onClick={addCommand} onClick={addCommand}
className="flex items-center gap-1 rounded-md bg-accent px-2 py-1 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground" className="flex items-center gap-1 rounded-md border border-border bg-[var(--color-bg-elevated)] px-2 py-1 text-xs font-medium text-muted-foreground hover:text-foreground hover:border-[var(--color-border-hover)]"
> >
<Plus className="h-3 w-3" /> <Plus className="h-3 w-3" />
Add Command Add Command
@@ -304,7 +304,7 @@ export function StepForm({ onSubmit, onCancel, initialData, submitLabel, isSubmi
<button <button
type="button" type="button"
onClick={addTag} onClick={addTag}
className="rounded-md bg-accent px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground" className="rounded-md border border-border bg-[var(--color-bg-elevated)] px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:border-[var(--color-border-hover)]"
> >
Add Add
</button> </button>

View File

@@ -226,7 +226,7 @@ export function StepLibraryBrowser({ onInsert, onCreateNew, showCreateButton = f
'rounded-full px-2.5 py-1 text-xs transition-colors', 'rounded-full px-2.5 py-1 text-xs transition-colors',
selectedTag === tag.tag selectedTag === tag.tag
? 'bg-primary text-white' ? 'bg-primary text-white'
: 'bg-accent text-muted-foreground hover:bg-accent' : 'border border-border bg-[var(--color-bg-elevated)] text-muted-foreground hover:text-foreground hover:border-[var(--color-border-hover)]'
)} )}
> >
{tag.tag} ({tag.count}) {tag.tag} ({tag.count})

View File

@@ -1,7 +1,7 @@
import * as Sentry from "@sentry/react"; import * as Sentry from "@sentry/react";
Sentry.init({ Sentry.init({
dsn: import.meta.env.VITE_SENTRY_DSN || "https://23937b8c0cea2484f6a9d5b97d0b7d4b@o4511005918887936.ingest.us.sentry.io/4511005926883328", dsn: import.meta.env.VITE_SENTRY_DSN,
environment: import.meta.env.MODE, environment: import.meta.env.MODE,
integrations: [ integrations: [

View File

@@ -2,7 +2,6 @@ import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { Building2, Users, Mail, Crown, Loader2, AlertCircle, Check, X, Settings, FolderTree, Server, RefreshCw, MessageSquareText, UserCog, AlertTriangle, Clock, Plug, Palette, ShieldCheck } from 'lucide-react' import { Building2, Users, Mail, Crown, Loader2, AlertCircle, Check, X, Settings, FolderTree, Server, RefreshCw, MessageSquareText, UserCog, AlertTriangle, Clock, Plug, Palette, ShieldCheck } from 'lucide-react'
import { PageMeta } from '@/components/common/PageMeta' import { PageMeta } from '@/components/common/PageMeta'
import { BrandingSettings } from '@/components/settings/BrandingSettings'
import { accountsApi } from '@/api/accounts' import { accountsApi } from '@/api/accounts'
import type { Account, AccountMember, AccountInvite } from '@/types' import type { Account, AccountMember, AccountInvite } from '@/types'
import { TransferOwnershipModal } from '@/components/account/TransferOwnershipModal' import { TransferOwnershipModal } from '@/components/account/TransferOwnershipModal'
@@ -22,7 +21,6 @@ export function AccountSettingsPage() {
const { isAccountOwner } = usePermissions() const { isAccountOwner } = usePermissions()
const { plan, limits, usage } = useSubscription() const { plan, limits, usage } = useSubscription()
const { defaultExportFormat, setDefaultExportFormat } = useUserPreferencesStore() const { defaultExportFormat, setDefaultExportFormat } = useUserPreferencesStore()
const user = useAuthStore((s) => s.user)
const subscription = useAuthStore((s) => s.subscription) const subscription = useAuthStore((s) => s.subscription)
const [account, setAccount] = useState<Account | null>(null) const [account, setAccount] = useState<Account | null>(null)
@@ -45,8 +43,6 @@ export function AccountSettingsPage() {
const [inviteEmail, setInviteEmail] = useState('') const [inviteEmail, setInviteEmail] = useState('')
const [inviteRole, setInviteRole] = useState('engineer') const [inviteRole, setInviteRole] = useState('engineer')
const [isInviting, setIsInviting] = useState(false) const [isInviting, setIsInviting] = useState(false)
const [inviteError, setInviteError] = useState<string | null>(null)
const [inviteSuccess, setInviteSuccess] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
loadData() loadData()
@@ -86,7 +82,9 @@ export function AccountSettingsPage() {
const updated = await accountsApi.updateMyAccount({ name: editedName.trim() }) const updated = await accountsApi.updateMyAccount({ name: editedName.trim() })
setAccount(updated) setAccount(updated)
setIsEditingName(false) setIsEditingName(false)
toast.success('Account name updated')
} catch (err) { } catch (err) {
toast.error('Failed to update account name')
console.error('Failed to update account name:', err) console.error('Failed to update account name:', err)
} finally { } finally {
setIsSavingName(false) setIsSavingName(false)
@@ -98,17 +96,14 @@ export function AccountSettingsPage() {
if (!inviteEmail.trim()) return if (!inviteEmail.trim()) return
setIsInviting(true) setIsInviting(true)
setInviteError(null)
setInviteSuccess(null)
try { try {
await accountsApi.createInvite({ email: inviteEmail.trim(), role: inviteRole }) await accountsApi.createInvite({ email: inviteEmail.trim(), role: inviteRole })
setInviteSuccess(`Invitation sent to ${inviteEmail}`) toast.success(`Invitation sent to ${inviteEmail}`)
setInviteEmail('') setInviteEmail('')
// Refresh invites list
const invitesData = await accountsApi.getInvites() const invitesData = await accountsApi.getInvites()
setInvites(invitesData) setInvites(invitesData)
} catch (err) { } catch (err) {
setInviteError('Failed to send invitation') toast.error('Failed to send invitation')
console.error(err) console.error(err)
} finally { } finally {
setIsInviting(false) setIsInviting(false)
@@ -135,7 +130,9 @@ export function AccountSettingsPage() {
try { try {
await accountsApi.removeMember(userId) await accountsApi.removeMember(userId)
setMembers(members.filter((m) => m.id !== userId)) setMembers(members.filter((m) => m.id !== userId))
toast.success('Member removed')
} catch (err) { } catch (err) {
toast.error('Failed to remove member')
console.error('Failed to remove member:', err) console.error('Failed to remove member:', err)
} }
} }
@@ -150,7 +147,7 @@ export function AccountSettingsPage() {
if (error) { if (error) {
return ( return (
<div className="rounded-md border border-red-400/20 bg-red-400/10 p-4 text-red-400"> <div className="rounded-md border border-danger/20 bg-danger-dim p-4 text-danger">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<AlertCircle className="h-5 w-5" /> <AlertCircle className="h-5 w-5" />
{error} {error}
@@ -230,7 +227,7 @@ export function AccountSettingsPage() {
{isAccountOwner && ( {isAccountOwner && (
<button <button
onClick={() => setIsEditingName(true)} onClick={() => setIsEditingName(true)}
className="text-xs text-foreground hover:underline" className="rounded px-1.5 py-0.5 text-xs text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
> >
Edit Edit
</button> </button>
@@ -261,9 +258,9 @@ export function AccountSettingsPage() {
<span <span
className={cn( className={cn(
'inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-medium', 'inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-medium',
plan === 'free' && 'bg-accent text-muted-foreground', plan === 'free' && 'bg-muted text-muted-foreground',
plan === 'pro' && 'bg-accent text-foreground', plan === 'pro' && 'bg-accent-dim text-accent-text',
plan === 'team' && 'bg-accent text-foreground' plan === 'team' && 'bg-accent-dim text-accent-text'
)} )}
> >
<Crown className="h-3.5 w-3.5" /> <Crown className="h-3.5 w-3.5" />
@@ -273,11 +270,11 @@ export function AccountSettingsPage() {
<span <span
className={cn( className={cn(
'inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium', 'inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium',
sub.status === 'active' && 'bg-green-500/10 text-emerald-400', sub.status === 'active' && 'bg-success-dim text-success',
sub.status === 'trialing' && 'bg-blue-500/10 text-blue-400', sub.status === 'trialing' && 'bg-info-dim text-info',
sub.status === 'past_due' && 'bg-yellow-500/10 text-yellow-400', sub.status === 'past_due' && 'bg-warning-dim text-warning',
sub.status === 'canceled' && 'bg-red-400/10 text-red-400', sub.status === 'canceled' && 'bg-danger-dim text-danger',
sub.status === 'orphaned' && 'bg-accent text-muted-foreground' sub.status === 'orphaned' && 'bg-muted text-muted-foreground'
)} )}
> >
{sub.status.charAt(0).toUpperCase() + sub.status.slice(1).replace('_', ' ')} {sub.status.charAt(0).toUpperCase() + sub.status.slice(1).replace('_', ' ')}
@@ -295,7 +292,7 @@ export function AccountSettingsPage() {
{limits && usage && ( {limits && usage && (
<div className="mt-4 grid gap-3 sm:grid-cols-3"> <div className="mt-4 grid gap-3 sm:grid-cols-3">
<UsageStat <UsageStat
label="Trees" label="Flows"
current={usage.tree_count} current={usage.tree_count}
max={limits.max_trees} max={limits.max_trees}
/> />
@@ -350,12 +347,13 @@ export function AccountSettingsPage() {
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{member.account_role === 'owner' ? ( {member.account_role === 'owner' ? (
<span className="rounded-full px-2.5 py-0.5 text-xs font-medium bg-accent text-foreground"> <span className="rounded-full px-2.5 py-0.5 text-xs font-medium bg-accent-dim text-accent-text">
owner owner
</span> </span>
) : ( ) : (
<select <select
value={member.account_role} value={member.account_role}
aria-label={`Role for ${member.name}`}
onChange={async (e) => { onChange={async (e) => {
try { try {
const updated = await accountsApi.updateMemberRole(member.id, e.target.value) const updated = await accountsApi.updateMemberRole(member.id, e.target.value)
@@ -375,15 +373,15 @@ export function AccountSettingsPage() {
</select> </select>
)} )}
{!member.is_active && ( {!member.is_active && (
<span className="rounded-full bg-red-400/10 px-2 py-0.5 text-xs text-red-400"> <span className="rounded-full bg-danger-dim px-2 py-0.5 text-xs text-danger">
Inactive Inactive
</span> </span>
)} )}
{member.account_role !== 'owner' && ( {member.account_role !== 'owner' && (
<button <button
onClick={() => handleRemoveMember(member.id)} onClick={() => handleRemoveMember(member.id)}
className="text-muted-foreground hover:text-red-400" className="p-1 text-muted-foreground hover:text-danger"
title="Remove member" aria-label={`Remove ${member.name}`}
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</button> </button>
@@ -438,12 +436,6 @@ export function AccountSettingsPage() {
</Button> </Button>
</div> </div>
{inviteError && (
<p className="text-sm text-red-400">{inviteError}</p>
)}
{inviteSuccess && (
<p className="text-sm text-emerald-400">{inviteSuccess}</p>
)}
</form> </form>
{/* Pending Invites */} {/* Pending Invites */}
@@ -467,14 +459,14 @@ export function AccountSettingsPage() {
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="rounded-full bg-accent px-2.5 py-0.5 text-xs text-muted-foreground"> <span className="rounded-full bg-muted px-2.5 py-0.5 text-xs text-muted-foreground">
{invite.role} {invite.role}
</span> </span>
<button <button
onClick={() => handleResendInvite(invite.id)} onClick={() => handleResendInvite(invite.id)}
disabled={resendingId === invite.id} disabled={resendingId === invite.id}
className="text-muted-foreground hover:text-foreground disabled:opacity-50" className="p-1 text-muted-foreground hover:text-foreground disabled:opacity-50"
title="Resend invite" aria-label={`Resend invite to ${invite.email}`}
> >
{resendingId === invite.id ? ( {resendingId === invite.id ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
@@ -494,7 +486,7 @@ export function AccountSettingsPage() {
{/* Profile Settings Link */} {/* Profile Settings Link */}
<Link <Link
to="/account/profile" to="/account/profile"
className="bg-card border border-border rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-border transition-all" className="bg-card border border-border rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-border-hover transition-colors"
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<UserCog className="h-5 w-5 text-muted-foreground" /> <UserCog className="h-5 w-5 text-muted-foreground" />
@@ -506,95 +498,89 @@ export function AccountSettingsPage() {
<span className="text-muted-foreground group-hover:text-foreground transition-colors">&rarr;</span> <span className="text-muted-foreground group-hover:text-foreground transition-colors">&rarr;</span>
</Link> </Link>
{/* Team Categories Link (owners only) */} {/* Team Settings section (owners only) */}
{isAccountOwner && ( {isAccountOwner && (
<Link <>
to="/account/categories" <p className="pt-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">
className="bg-card border border-border rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-border transition-all" Team Settings
> </p>
<div className="flex items-center gap-3">
<FolderTree className="h-5 w-5 text-muted-foreground" />
<div>
<h2 className="text-lg font-semibold text-foreground">Team Categories</h2>
<p className="text-sm text-muted-foreground">Manage tree categories for your team</p>
</div>
</div>
<span className="text-muted-foreground group-hover:text-foreground transition-colors">&rarr;</span>
</Link>
)}
{/* Target Lists Link (owners only) */} <Link
{isAccountOwner && ( to="/account/categories"
<Link className="bg-card border border-border rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-border-hover transition-colors"
to="/account/target-lists" >
className="bg-card border border-border rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-border transition-all" <div className="flex items-center gap-3">
> <FolderTree className="h-5 w-5 text-muted-foreground" />
<div className="flex items-center gap-3"> <div>
<Server className="h-5 w-5 text-muted-foreground" /> <h2 className="text-lg font-semibold text-foreground">Team Categories</h2>
<div> <p className="text-sm text-muted-foreground">Manage flow categories for your team</p>
<h2 className="text-lg font-semibold text-foreground">Target Lists</h2> </div>
<p className="text-sm text-muted-foreground">Saved server lists for maintenance flow batch launching</p>
</div> </div>
</div> <span className="text-muted-foreground group-hover:text-foreground transition-colors">&rarr;</span>
<span className="text-muted-foreground group-hover:text-foreground transition-colors">&rarr;</span> </Link>
</Link>
)}
{/* Chat Retention Link (owners only) */} <Link
{isAccountOwner && ( to="/account/target-lists"
<Link className="bg-card border border-border rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-border-hover transition-colors"
to="/account/chat-retention" >
className="bg-card border border-border rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-border transition-all" <div className="flex items-center gap-3">
> <Server className="h-5 w-5 text-muted-foreground" />
<div className="flex items-center gap-3"> <div>
<Clock className="h-5 w-5 text-muted-foreground" /> <h2 className="text-lg font-semibold text-foreground">Target Lists</h2>
<div> <p className="text-sm text-muted-foreground">Saved server and device lists for your team</p>
<h2 className="text-lg font-semibold text-foreground">Chat Retention</h2> </div>
<p className="text-sm text-muted-foreground">Configure AI assistant conversation retention policies</p>
</div> </div>
</div> <span className="text-muted-foreground group-hover:text-foreground transition-colors">&rarr;</span>
<span className="text-muted-foreground group-hover:text-foreground transition-colors">&rarr;</span> </Link>
</Link>
)}
{/* Integrations Link (owners only) */} <Link
{isAccountOwner && ( to="/account/chat-retention"
<Link className="bg-card border border-border rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-border-hover transition-colors"
to="/account/integrations" >
className="bg-card border border-border rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-border transition-all" <div className="flex items-center gap-3">
> <Clock className="h-5 w-5 text-muted-foreground" />
<div className="flex items-center gap-3"> <div>
<Plug className="h-5 w-5 text-muted-foreground" /> <h2 className="text-lg font-semibold text-foreground">Chat Retention</h2>
<div> <p className="text-sm text-muted-foreground">Configure AI assistant conversation retention policies</p>
<h2 className="text-lg font-semibold text-foreground">Integrations</h2> </div>
<p className="text-sm text-muted-foreground">Connect your PSA to sync session documentation to tickets</p>
</div> </div>
</div> <span className="text-muted-foreground group-hover:text-foreground transition-colors">&rarr;</span>
<span className="text-muted-foreground group-hover:text-foreground transition-colors">&rarr;</span> </Link>
</Link>
)}
{/* Branding Link (owners only) */} <Link
{isAccountOwner && ( to="/account/integrations"
<Link className="bg-card border border-border rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-border-hover transition-colors"
to="/account/branding" >
className="bg-card border border-border rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-border transition-all" <div className="flex items-center gap-3">
> <Plug className="h-5 w-5 text-muted-foreground" />
<div className="flex items-center gap-3"> <div>
<Palette className="h-5 w-5 text-muted-foreground" /> <h2 className="text-lg font-semibold text-foreground">Integrations</h2>
<div> <p className="text-sm text-muted-foreground">Connect your PSA to sync session documentation to tickets</p>
<h2 className="text-lg font-semibold text-foreground">Branding</h2> </div>
<p className="text-sm text-muted-foreground">Customize logo, accent color, and company name</p>
</div> </div>
</div> <span className="text-muted-foreground group-hover:text-foreground transition-colors">&rarr;</span>
<span className="text-muted-foreground group-hover:text-foreground transition-colors">&rarr;</span> </Link>
</Link>
<Link
to="/account/branding"
className="bg-card border border-border rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-border-hover transition-colors"
>
<div className="flex items-center gap-3">
<Palette className="h-5 w-5 text-muted-foreground" />
<div>
<h2 className="text-lg font-semibold text-foreground">Branding</h2>
<p className="text-sm text-muted-foreground">Customize logo, accent color, and company name</p>
</div>
</div>
<span className="text-muted-foreground group-hover:text-foreground transition-colors">&rarr;</span>
</Link>
</>
)} )}
{/* Feedback Link (all users) */} {/* Feedback Link (all users) */}
<Link <Link
to="/feedback" to="/feedback"
className="bg-card border border-border rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-border transition-all" className="bg-card border border-border rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-border-hover transition-colors"
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<MessageSquareText className="h-5 w-5 text-muted-foreground" /> <MessageSquareText className="h-5 w-5 text-muted-foreground" />
@@ -606,11 +592,6 @@ export function AccountSettingsPage() {
<span className="text-muted-foreground group-hover:text-foreground transition-colors">&rarr;</span> <span className="text-muted-foreground group-hover:text-foreground transition-colors">&rarr;</span>
</Link> </Link>
{/* Branding Section (owners only) */}
{isAccountOwner && user?.team_id && (
<BrandingSettings teamId={user.team_id} />
)}
{/* Preferences Section */} {/* Preferences Section */}
<div className="bg-card border border-border rounded-xl p-4 sm:p-6"> <div className="bg-card border border-border rounded-xl p-4 sm:p-6">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -647,13 +628,13 @@ export function AccountSettingsPage() {
</select> </select>
</div> </div>
</div> </div>
{/* SSO Section (Task 11) */} {/* SSO Section */}
{isAccountOwner && ( {isAccountOwner && (
<div className="bg-card border border-border rounded-xl p-4 sm:p-6"> <div className="bg-card border border-border rounded-xl p-4 sm:p-6">
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<ShieldCheck className="h-5 w-5 text-muted-foreground" /> <ShieldCheck className="h-5 w-5 text-muted-foreground" />
<h2 className="text-lg font-semibold text-foreground">Single Sign-On (SSO)</h2> <h2 className="text-lg font-semibold text-foreground">Single Sign-On (SSO)</h2>
<span className="inline-flex items-center rounded-full bg-accent-dim px-2.5 py-0.5 text-xs font-sans text-xs font-medium text-primary"> <span className="inline-flex items-center rounded-full bg-accent-dim px-2.5 py-0.5 text-xs font-medium text-primary">
Enterprise Enterprise
</span> </span>
</div> </div>
@@ -665,8 +646,8 @@ export function AccountSettingsPage() {
href="mailto:support@resolutionflow.com?subject=SSO%20Setup%20Request" href="mailto:support@resolutionflow.com?subject=SSO%20Setup%20Request"
className={cn( className={cn(
'inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium', 'inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium',
'bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground', 'bg-muted border border-border text-foreground',
'hover:border-[rgba(255,255,255,0.12)] transition-all' 'hover:border-border-hover transition-colors'
)} )}
> >
Contact Us Contact Us
@@ -675,9 +656,9 @@ export function AccountSettingsPage() {
)} )}
{/* Danger Zone */} {/* Danger Zone */}
<div className="rounded-xl border border-rose-500/20 p-4 sm:p-6"> <div className="rounded-xl border border-danger/20 p-4 sm:p-6">
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
<AlertTriangle className="h-5 w-5 text-rose-500" /> <AlertTriangle className="h-5 w-5 text-danger" />
<h2 className="text-lg font-semibold text-foreground">Danger Zone</h2> <h2 className="text-lg font-semibold text-foreground">Danger Zone</h2>
</div> </div>
@@ -693,7 +674,7 @@ export function AccountSettingsPage() {
variant="secondary" variant="secondary"
size="sm" size="sm"
onClick={() => setShowTransferModal(true)} onClick={() => setShowTransferModal(true)}
className="border-amber-500/30 text-amber-400 hover:bg-amber-500/10" className="border-warning/30 text-warning hover:bg-warning-dim"
> >
Transfer Transfer
</Button> </Button>
@@ -774,7 +755,7 @@ function UsageStat({
<p <p
className={cn( className={cn(
'mt-1 text-lg font-semibold', 'mt-1 text-lg font-semibold',
isAtLimit ? 'text-red-400' : isNearLimit ? 'text-yellow-400' : 'text-foreground' isAtLimit ? 'text-danger' : isNearLimit ? 'text-warning' : 'text-foreground'
)} )}
> >
{current} {current}
@@ -783,11 +764,11 @@ function UsageStat({
</span> </span>
</p> </p>
{!isUnlimited && ( {!isUnlimited && (
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-accent"> <div className="mt-2 h-1.5 overflow-hidden rounded-full bg-muted">
<div <div
className={cn( className={cn(
'h-full rounded-full transition-all', 'h-full rounded-full transition-all',
isAtLimit ? 'bg-red-400' : isNearLimit ? 'bg-yellow-500' : 'bg-primary' isAtLimit ? 'bg-danger' : isNearLimit ? 'bg-warning' : 'bg-primary'
)} )}
style={{ width: `${percentage}%` }} style={{ width: `${percentage}%` }}
/> />

View File

@@ -12,7 +12,7 @@ import { analytics } from '@/lib/analytics'
import { toast } from '@/lib/toast' import { toast } from '@/lib/toast'
import { ChatSidebar, ChatSidebarCollapsedBar } from '@/components/assistant/ChatSidebar' import { ChatSidebar, ChatSidebarCollapsedBar } from '@/components/assistant/ChatSidebar'
import { ChatMessage } from '@/components/assistant/ChatMessage' import { ChatMessage } from '@/components/assistant/ChatMessage'
import { TaskLane } from '@/components/assistant/TaskLane' import { TaskLane, clearTaskState } from '@/components/assistant/TaskLane'
import { ConcludeSessionModal } from '@/components/assistant/ConcludeSessionModal' import { ConcludeSessionModal } from '@/components/assistant/ConcludeSessionModal'
import { StatusUpdateModal } from '@/components/flowpilot/StatusUpdateModal' import { StatusUpdateModal } from '@/components/flowpilot/StatusUpdateModal'
import type { ChatListItem, ConclusionOutcome } from '@/types/assistant-chat' import type { ChatListItem, ConclusionOutcome } from '@/types/assistant-chat'
@@ -81,6 +81,9 @@ export default function AssistantChatPage() {
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const dragCounterRef = useRef(0) const dragCounterRef = useRef(0)
const prefillHandledRef = useRef(false) const prefillHandledRef = useRef(false)
// Tracks the most recently requested active chat ID so in-flight selectChat
// calls that complete after the user switches chats don't clobber new state.
const currentChatRef = useRef<string | null>(activeChatId)
// Persist active chat ID to sessionStorage // Persist active chat ID to sessionStorage
useEffect(() => { useEffect(() => {
@@ -214,6 +217,7 @@ export default function AssistantChatPage() {
} }
const selectChat = useCallback(async (chatId: string) => { const selectChat = useCallback(async (chatId: string) => {
currentChatRef.current = chatId
setActiveChatId(chatId) setActiveChatId(chatId)
// Clear TaskLane when switching chats — will restore from backend if available // Clear TaskLane when switching chats — will restore from backend if available
setShowTaskLane(false) setShowTaskLane(false)
@@ -221,6 +225,10 @@ export default function AssistantChatPage() {
setActiveActions([]) setActiveActions([])
try { try {
const detail = await aiSessionsApi.getSession(chatId) const detail = await aiSessionsApi.getSession(chatId)
// Guard: if the user switched to a different chat while this API call was
// in flight (e.g. clicked "New Chat"), discard stale results so we don't
// clobber the new session's task lane state.
if (currentChatRef.current !== chatId) return
setMessages( setMessages(
(detail.conversation_messages || []).map(m => ({ (detail.conversation_messages || []).map(m => ({
role: m.role as 'user' | 'assistant', role: m.role as 'user' | 'assistant',
@@ -234,7 +242,7 @@ export default function AssistantChatPage() {
if (q.length > 0 || a.length > 0) { if (q.length > 0 || a.length > 0) {
// Pre-load user's saved responses into sessionStorage BEFORE setting props // Pre-load user's saved responses into sessionStorage BEFORE setting props
// so TaskLane can restore them on mount/prop-change // so TaskLane can restore them on mount/prop-change
const responses = (detail.pending_task_lane as Record<string, unknown>).responses as unknown[] | undefined const responses = detail.pending_task_lane.responses
if (responses && responses.length > 0) { if (responses && responses.length > 0) {
try { try {
sessionStorage.setItem(`rf-tasklane-state:${chatId}`, JSON.stringify(responses)) sessionStorage.setItem(`rf-tasklane-state:${chatId}`, JSON.stringify(responses))
@@ -251,6 +259,15 @@ export default function AssistantChatPage() {
}, []) }, [])
const handleNewChat = async () => { const handleNewChat = async () => {
// Invalidate currentChatRef BEFORE the await so any in-flight handleSend/handleTaskSubmit
// for the previous session sees a mismatch and bails — prevents stale task lane appearing
// in the new empty session (same pattern as selectChat, which sets ref before its await).
currentChatRef.current = null
// Clear stale state immediately — don't wait for API to return
setShowTaskLane(false)
setActiveQuestions([])
setActiveActions([])
setMessages([])
try { try {
const session = await aiSessionsApi.createChatSession({ const session = await aiSessionsApi.createChatSession({
intake_type: 'free_text', intake_type: 'free_text',
@@ -264,13 +281,9 @@ export default function AssistantChatPage() {
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
updated_at: new Date().toISOString(), updated_at: new Date().toISOString(),
} }
currentChatRef.current = session.session_id
setChats(prev => [chatItem, ...prev]) setChats(prev => [chatItem, ...prev])
setActiveChatId(session.session_id) setActiveChatId(session.session_id)
setMessages([])
// Clear TaskLane from previous session
setShowTaskLane(false)
setActiveQuestions([])
setActiveActions([])
} catch { } catch {
toast.error('Failed to create chat') toast.error('Failed to create chat')
} }
@@ -306,11 +319,14 @@ export default function AssistantChatPage() {
setMessages(prev => [...prev, { role: 'user', content: userMessage }]) setMessages(prev => [...prev, { role: 'user', content: userMessage }])
setLoading(true) setLoading(true)
const sentForChatId = activeChatId
try { try {
const response = await aiSessionsApi.sendChatMessage(activeChatId, { const response = await aiSessionsApi.sendChatMessage(activeChatId, {
message: userMessage, message: userMessage,
upload_ids: completedUploadIds.length > 0 ? completedUploadIds : undefined, upload_ids: completedUploadIds.length > 0 ? completedUploadIds : undefined,
}) })
// Guard: discard if user switched to a different chat while this was in flight
if (currentChatRef.current !== sentForChatId) return
analytics.aiFeatureUsed({ feature: 'assistant_chat' }) analytics.aiFeatureUsed({ feature: 'assistant_chat' })
setMessages(prev => [ setMessages(prev => [
...prev, ...prev,
@@ -318,19 +334,20 @@ export default function AssistantChatPage() {
]) ])
setChats(prev => setChats(prev =>
prev.map(c => prev.map(c =>
c.id === activeChatId c.id === sentForChatId
? { ...c, message_count: c.message_count + 2, title: c.message_count === 0 ? userMessage.slice(0, 100) : c.title, updated_at: new Date().toISOString() } ? { ...c, message_count: c.message_count + 2, title: c.message_count === 0 ? userMessage.slice(0, 100) : c.title, updated_at: new Date().toISOString() }
: c : c
) )
) )
// Load branches if fork was created // Load branches if fork was created
if (response.fork && activeChatId) { if (response.fork && sentForChatId) {
branching.loadBranches(activeChatId) branching.loadBranches(sentForChatId)
} }
// Show task lane if AI sent questions or actions // Show task lane if AI sent questions or actions
const hasQuestions = response.questions && response.questions.length > 0 const hasQuestions = response.questions && response.questions.length > 0
const hasActions = response.actions && response.actions.length > 0 const hasActions = response.actions && response.actions.length > 0
if (hasQuestions || hasActions) { if (hasQuestions || hasActions) {
clearTaskState(sentForChatId)
setActiveQuestions(response.questions || []) setActiveQuestions(response.questions || [])
setActiveActions(response.actions || []) setActiveActions(response.actions || [])
setShowTaskLane(true) setShowTaskLane(true)
@@ -349,7 +366,8 @@ export default function AssistantChatPage() {
const handleTaskSubmit = async (responses: Array<{ type: string; state: string; value: string; text?: string; label?: string }>) => { const handleTaskSubmit = async (responses: Array<{ type: string; state: string; value: string; text?: string; label?: string }>) => {
if (!activeChatId || loading) return if (!activeChatId || loading) return
// Format task responses into a structured message for the AI // Format task responses into a structured message for the AI.
// Pending tasks are included so the AI knows they weren't completed yet.
const parts: string[] = [] const parts: string[] = []
for (const r of responses) { for (const r of responses) {
const name = r.type === 'question' ? `Q: ${r.text}` : r.label || 'Check' const name = r.type === 'question' ? `Q: ${r.text}` : r.label || 'Check'
@@ -357,6 +375,8 @@ export default function AssistantChatPage() {
parts.push(`**${name}:**\n\`\`\`\n${r.value.trim()}\n\`\`\``) parts.push(`**${name}:**\n\`\`\`\n${r.value.trim()}\n\`\`\``)
} else if (r.state === 'skipped') { } else if (r.state === 'skipped') {
parts.push(`**${name}:** _(skipped)_`) parts.push(`**${name}:** _(skipped)_`)
} else {
parts.push(`**${name}:** _(not yet completed)_`)
} }
} }
const userMessage = parts.join('\n\n') const userMessage = parts.join('\n\n')
@@ -364,18 +384,22 @@ export default function AssistantChatPage() {
setMessages(prev => [...prev, { role: 'user', content: userMessage }]) setMessages(prev => [...prev, { role: 'user', content: userMessage }])
setLoading(true) setLoading(true)
const sentForChatId = activeChatId
try { try {
const response = await aiSessionsApi.sendChatMessage(activeChatId, { message: userMessage }) const response = await aiSessionsApi.sendChatMessage(activeChatId, { message: userMessage })
// Guard: discard if user switched to a different chat while this was in flight
if (currentChatRef.current !== sentForChatId) return
setMessages(prev => [ setMessages(prev => [
...prev, ...prev,
{ role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows, fork: response.fork, actions: response.actions, questions: response.questions }, { role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows, fork: response.fork, actions: response.actions, questions: response.questions },
]) ])
if (response.fork && activeChatId) { if (response.fork && sentForChatId) {
branching.loadBranches(activeChatId) branching.loadBranches(sentForChatId)
} }
// Update task lane based on AI response // Update task lane based on AI response
const hasQuestions = response.questions && response.questions.length > 0 const hasQuestions = response.questions && response.questions.length > 0
const hasActions = response.actions && response.actions.length > 0 const hasActions = response.actions && response.actions.length > 0
clearTaskState(sentForChatId)
if (hasQuestions || hasActions) { if (hasQuestions || hasActions) {
setActiveQuestions(response.questions || []) setActiveQuestions(response.questions || [])
setActiveActions(response.actions || []) setActiveActions(response.actions || [])
@@ -416,6 +440,12 @@ export default function AssistantChatPage() {
} }
const handleResumeNew = async (summary: string) => { const handleResumeNew = async (summary: string) => {
// Invalidate currentChatRef BEFORE the await — same guard as handleNewChat
currentChatRef.current = null
// Clear stale state immediately — don't wait for API to return
setShowTaskLane(false)
setActiveQuestions([])
setActiveActions([])
try { try {
const resumePrompt = `I'm continuing a previous troubleshooting session. Here's where we left off:\n\n${summary}\n\nPlease review this context and help me continue from where we stopped.` const resumePrompt = `I'm continuing a previous troubleshooting session. Here's where we left off:\n\n${summary}\n\nPlease review this context and help me continue from where we stopped.`
const session = await aiSessionsApi.createChatSession({ const session = await aiSessionsApi.createChatSession({
@@ -430,6 +460,7 @@ export default function AssistantChatPage() {
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
updated_at: new Date().toISOString(), updated_at: new Date().toISOString(),
} }
currentChatRef.current = session.session_id
setChats(prev => [chatItem, ...prev]) setChats(prev => [chatItem, ...prev])
setActiveChatId(session.session_id) setActiveChatId(session.session_id)
setMessages([{ role: 'user', content: resumePrompt }]) setMessages([{ role: 'user', content: resumePrompt }])

View File

@@ -1,20 +1,27 @@
import { useState } from 'react'
import { AlertTriangle } from 'lucide-react' import { AlertTriangle } from 'lucide-react'
import { EscalationQueue } from '@/components/flowpilot' import { EscalationQueue } from '@/components/flowpilot'
export default function EscalationQueuePage() { export default function EscalationQueuePage() {
const [count, setCount] = useState<number | null>(null)
return ( return (
<div className="mx-auto max-w-3xl p-6"> <div className="mx-auto max-w-4xl p-6">
<div className="flex items-center gap-3 mb-6"> <div className="flex items-center gap-3 mb-6">
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-500/10"> <span className="flex h-8 w-8 items-center justify-center rounded-lg bg-warning-dim">
<AlertTriangle size={16} className="text-amber-400" /> <AlertTriangle size={16} className="text-warning" />
</span> </span>
<div> <div>
<h1 className="font-heading text-xl font-bold text-foreground">Escalation Queue</h1> <h1 className="font-heading text-xl font-bold text-foreground">Escalation Queue</h1>
<p className="text-sm text-muted-foreground">Sessions from your team waiting for pickup</p> <p className="text-sm text-muted-foreground">
{count !== null && count > 0
? `${count} session${count !== 1 ? 's' : ''} waiting for pickup`
: 'Sessions from your team waiting for pickup'}
</p>
</div> </div>
</div> </div>
<EscalationQueue /> <EscalationQueue onCountChange={setCount} />
</div> </div>
) )
} }

View File

@@ -5,21 +5,44 @@ import '@/styles/landing.css'
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000' const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
const FAQ_ITEMS = [
{
q: 'How is this different from just using ChatGPT?',
a: 'FlowPilot is purpose-built for MSP troubleshooting. It understands your stack (AD, Exchange, networking, VPN), captures every diagnostic step as you work, and generates formatted ticket notes ready for your PSA. ChatGPT doesn\u2019t build documentation and can\u2019t push notes to ConnectWise.',
},
{
q: 'Is my data safe?',
a: 'Troubleshooting sessions are encrypted and isolated per team. We never use your data to train AI models. You control what gets documented and exported.',
},
{
q: 'What PSA tools do you integrate with?',
a: 'Launching with ConnectWise PSA \u2014 session documentation exports directly as internal ticket notes. Atera and Syncro integrations are next. During beta, you can copy formatted notes into any PSA.',
},
{
q: 'What counts as a \u201csession\u201d?',
a: 'One session = one troubleshooting conversation. Describe an issue, work through it with FlowPilot, resolve it. Whether that takes 2 minutes or 2 hours, it\u2019s one session. Free plan: 20 sessions/month. Pro and Team: unlimited.',
},
{
q: 'What if FlowPilot gets it wrong?',
a: 'FlowPilot is a copilot, not autopilot. Every suggestion is a recommendation \u2014 you decide what to act on. And because every step is documented, you always have a full audit trail of what was tried and why.',
},
]
export default function LandingPage() { export default function LandingPage() {
const [navScrolled, setNavScrolled] = useState(false) const [navScrolled, setNavScrolled] = useState(false)
const [mobileMenuOpen, setMobileMenuOpen] = useState(false) const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [betaEmail, setBetaEmail] = useState('') const [betaEmail, setBetaEmail] = useState('')
const [betaStatus, setBetaStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle') const [betaStatus, setBetaStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle')
const [betaError, setBetaError] = useState('')
const [openFaq, setOpenFaq] = useState<number | null>(null)
const mobileMenuRef = useRef<HTMLDivElement>(null) const mobileMenuRef = useRef<HTMLDivElement>(null)
// Nav scroll effect
useEffect(() => { useEffect(() => {
const handleScroll = () => setNavScrolled(window.scrollY > 40) const handleScroll = () => setNavScrolled(window.scrollY > 40)
window.addEventListener('scroll', handleScroll) window.addEventListener('scroll', handleScroll)
return () => window.removeEventListener('scroll', handleScroll) return () => window.removeEventListener('scroll', handleScroll)
}, []) }, [])
// Close mobile menu on click outside
useEffect(() => { useEffect(() => {
function handleClickOutside(e: MouseEvent) { function handleClickOutside(e: MouseEvent) {
if (mobileMenuRef.current && !mobileMenuRef.current.contains(e.target as Node)) { if (mobileMenuRef.current && !mobileMenuRef.current.contains(e.target as Node)) {
@@ -32,10 +55,8 @@ export default function LandingPage() {
} }
}, [mobileMenuOpen]) }, [mobileMenuOpen])
// Close mobile menu on scroll to section
const handleMobileNavClick = () => setMobileMenuOpen(false) const handleMobileNavClick = () => setMobileMenuOpen(false)
// Scroll reveal
useEffect(() => { useEffect(() => {
const els = document.querySelectorAll('.landing-reveal') const els = document.querySelectorAll('.landing-reveal')
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
@@ -52,442 +73,402 @@ export default function LandingPage() {
const handleBetaSubmit = useCallback(async (e: React.FormEvent) => { const handleBetaSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (!betaEmail.trim() || betaStatus === 'sending') return const trimmed = betaEmail.trim()
if (!trimmed || betaStatus === 'sending') return
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) {
setBetaStatus('error')
setBetaError('Enter a valid email address.')
return
}
setBetaStatus('sending') setBetaStatus('sending')
setBetaError('')
try { try {
const resp = await fetch(`${API_URL}/api/v1/beta-signup`, { const resp = await fetch(`${API_URL}/api/v1/beta-signup`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: betaEmail }), body: JSON.stringify({ email: trimmed }),
}) })
if (!resp.ok) throw new Error('Signup failed') if (!resp.ok) throw new Error('Signup failed')
setBetaStatus('sent') setBetaStatus('sent')
setBetaEmail('') setBetaEmail('')
} catch { } catch {
setBetaStatus('error') setBetaStatus('error')
setTimeout(() => setBetaStatus('idle'), 3000) setBetaError('Could not complete signup. Please try again or email hello@resolutionflow.com.')
} }
}, [betaEmail, betaStatus]) }, [betaEmail, betaStatus])
const toggleFaq = (index: number) => {
setOpenFaq(prev => prev === index ? null : index)
}
return ( return (
<> <>
<PageMeta <PageMeta
title="ResolutionFlow From Issue to Resolution, Documented" title="ResolutionFlow \u2014 From Issue to Resolution, Documented"
description="Your AI troubleshooting copilot. Describe the issue, get help fixing it, and get clean ticket notes automatically." description="Your AI troubleshooting copilot. Describe the issue, get help fixing it, and get clean ticket notes \u2014 automatically."
/> />
<div className="landing-page"> <div className="landing-page">
<div className="landing-ambient-glow" /> <a href="#main" className="landing-skip-link">Skip to content</a>
<div className="landing-grid-pattern" />
<div className="landing-page-content"> {/* Navigation */}
{/* Navigation */} <nav className={`landing-nav ${navScrolled ? 'scrolled' : ''}`} ref={mobileMenuRef}>
<nav className={`landing-nav ${navScrolled ? 'scrolled' : ''}`} ref={mobileMenuRef}> <div className="landing-nav-inner">
<div className="landing-nav-inner"> <a href="#" className="landing-nav-logo">
<a href="#" className="landing-nav-logo"> <div className="landing-nav-logo-icon">
<div className="landing-nav-logo-icon"> <svg viewBox="0 0 24 24" fill="none" stroke="#000" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<svg viewBox="0 0 24 24" fill="none" stroke="#000" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"> <circle cx="12" cy="5" r="2" />
<circle cx="12" cy="5" r="2"/> <line x1="12" y1="7" x2="12" y2="11" />
<line x1="12" y1="7" x2="12" y2="11"/> <circle cx="6" cy="15" r="2" />
<circle cx="6" cy="15" r="2"/> <circle cx="18" cy="15" r="2" />
<circle cx="18" cy="15" r="2"/> <line x1="12" y1="11" x2="6" y2="13" />
<line x1="12" y1="11" x2="6" y2="13"/> <line x1="12" y1="11" x2="18" y2="13" />
<line x1="12" y1="11" x2="18" y2="13"/> </svg>
</svg>
</div>
<div className="landing-nav-wordmark">Resolution<span>Flow</span></div>
</a>
<ul className="landing-nav-links">
<li><a href="#features">Features</a></li>
<li><a href="#how-it-works">How It Works</a></li>
<li><a href="#pricing">Pricing</a></li>
</ul>
<div className="landing-nav-cta">
<Link to="/login" className="landing-btn-ghost">Sign In</Link>
<Link to="/register" className="landing-btn-primary">Get Started Free</Link>
</div> </div>
<button <div className="landing-nav-wordmark">Resolution<span>Flow</span></div>
className={`landing-hamburger ${mobileMenuOpen ? 'open' : ''}`} </a>
onClick={() => setMobileMenuOpen(v => !v)} <ul className="landing-nav-links">
aria-label="Toggle menu" <li><a href="#features">Features</a></li>
aria-expanded={mobileMenuOpen} <li><a href="#how-it-works">How It Works</a></li>
> <li><a href="#pricing">Pricing</a></li>
<span /> <li><a href="#faq">FAQ</a></li>
<span /> </ul>
<span /> <div className="landing-nav-cta">
</button> <Link to="/login" className="landing-btn-ghost">Sign In</Link>
<Link to="/register" className="landing-btn-primary">Get Started Free</Link>
</div> </div>
{mobileMenuOpen && ( <button
<div className="landing-mobile-menu"> className={`landing-hamburger ${mobileMenuOpen ? 'open' : ''}`}
<a href="#features" onClick={handleMobileNavClick}>Features</a> onClick={() => setMobileMenuOpen(v => !v)}
<a href="#how-it-works" onClick={handleMobileNavClick}>How It Works</a> aria-label="Toggle menu"
<a href="#pricing" onClick={handleMobileNavClick}>Pricing</a> aria-expanded={mobileMenuOpen}
<div className="landing-mobile-menu-divider" /> >
<Link to="/login" onClick={handleMobileNavClick}>Sign In</Link> <span /><span /><span />
<Link to="/register" className="landing-btn-primary" onClick={handleMobileNavClick} style={{ textAlign: 'center' }}>Get Started Free</Link> </button>
</div> </div>
)} {mobileMenuOpen && (
</nav> <div className="landing-mobile-menu">
<a href="#features" onClick={handleMobileNavClick}>Features</a>
<a href="#how-it-works" onClick={handleMobileNavClick}>How It Works</a>
<a href="#pricing" onClick={handleMobileNavClick}>Pricing</a>
<a href="#faq" onClick={handleMobileNavClick}>FAQ</a>
<div className="landing-mobile-menu-divider" />
<Link to="/login" onClick={handleMobileNavClick}>Sign In</Link>
<Link to="/register" className="landing-btn-primary" onClick={handleMobileNavClick} style={{ textAlign: 'center' }}>Get Started Free</Link>
</div>
)}
</nav>
{/* Hero */} <main id="main" className="landing-main">
{/* Hero — left-aligned, two columns */}
<section className="landing-hero"> <section className="landing-hero">
<div className="landing-hero-badge">Now in Beta Join early access</div> <div className="landing-hero-inner">
<h1> <div className="landing-hero-content">
Resolve tickets faster.<br /> <div className="landing-hero-badge">Now in Beta</div>
<span className="landing-gradient-text">Notes write themselves.</span> <h1>
</h1> Resolve tickets faster.<br />
<p className="landing-hero-sub"> <span className="landing-hero-accent">Notes write themselves.</span>
ResolutionFlow is your AI troubleshooting copilot. Describe the issue, get expert guidance fixing it, and get clean ticket documentation without writing a single note. </h1>
</p> <p className="landing-hero-sub">
<div className="landing-hero-actions"> Your AI troubleshooting copilot for MSPs. Describe the issue, get expert guidance, and get clean ticket documentation &mdash; without writing a single note.
<Link to="/register" className="landing-btn-hero-primary">Start Free</Link> </p>
<a href="#how-it-works" className="landing-btn-hero-secondary">See How It Works</a> <div className="landing-hero-actions">
<Link to="/register" className="landing-btn-hero-primary">Start Free</Link>
<a href="#how-it-works" className="landing-btn-hero-secondary">See How It Works</a>
</div>
<p className="landing-hero-credibility">
Built by a 15-year MSP veteran who got tired of empty ticket notes.
</p>
</div>
</div> </div>
</section> </section>
{/* Social Proof Bar */} {/* Problem — asymmetric: headline left, cards right */}
<div className="landing-social-proof-bar"> <section id="problem" className="landing-section landing-section-alt landing-reveal">
<p>Built by MSP engineers, for MSP engineers</p>
<div className="landing-proof-stats">
<div className="landing-proof-stat">
<div className="number">15+</div>
<div className="label">Years MSP Experience</div>
</div>
<div className="landing-proof-stat">
<div className="number">70%</div>
<div className="label">Less Time on Documentation</div>
</div>
<div className="landing-proof-stat">
<div className="number">100%</div>
<div className="label">Auto-Generated Documentation</div>
</div>
</div>
</div>
{/* App Preview */}
<div className="landing-app-preview">
<div className="landing-preview-window">
<div className="landing-preview-titlebar">
<div className="landing-preview-tab">
<div className="landing-tab-icon" />
ResolutionFlow
<span className="landing-tab-close">&times;</span>
</div>
<div className="landing-preview-url-bar">
<div className="landing-preview-url">
<span className="landing-lock-icon">&#128274;</span>
app.resolutionflow.com/pilot
</div>
</div>
<div className="landing-preview-window-controls">
<div className="landing-win-btn">
<svg viewBox="0 0 12 12"><line x1="2" y1="6" x2="10" y2="6"/></svg>
</div>
<div className="landing-win-btn">
<svg viewBox="0 0 12 12"><rect x="2" y="2" width="8" height="8" rx="0.5"/></svg>
</div>
<div className="landing-win-btn close">
<svg viewBox="0 0 12 12"><line x1="2" y1="2" x2="10" y2="10"/><line x1="10" y1="2" x2="2" y2="10"/></svg>
</div>
</div>
</div>
<div className="landing-preview-body">
<div className="landing-preview-sidebar">
<div className="landing-preview-sidebar-item active">
<div className="dot" style={{ background: '#60a5fa' }} />
FlowPilot
</div>
<div className="landing-preview-sidebar-item">
<div className="dot" style={{ background: '#34d399' }} />
Session History
</div>
<div className="landing-preview-sidebar-item">
<div className="dot" style={{ background: '#a78bfa' }} />
Guided Flows
</div>
<div className="landing-preview-sidebar-item">
<div className="dot" style={{ background: '#2dd4bf' }} />
Scripts
</div>
<div className="landing-preview-sidebar-item">
<div className="dot" style={{ background: '#38bdf8' }} />
Analytics
</div>
</div>
<div className="landing-preview-canvas">
<div className="landing-mock-session">
<div className="landing-mock-chat-line">
<span className="label">You:</span>
<span className="text">User can&apos;t access shared drive after password reset</span>
</div>
<div className="landing-mock-chat-line">
<span className="label" style={{ color: '#60a5fa' }}>FlowPilot:</span>
<span className="text">This is likely a cached credential issue. Let&apos;s check a few things:</span>
</div>
<div className="landing-mock-chat-line">
<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: '#60a5fa' }}>FlowPilot:</span>
<span className="text">2. Open Credential Manager &rarr; remove saved entries for the share</span>
</div>
<div className="landing-mock-chat-line doc">
<span className="label">Auto-doc:</span>
<span className="text">3 steps captured &#10003;</span>
</div>
</div>
</div>
</div>
</div>
</div>
<div className="landing-section-divider" />
{/* Problem Section */}
<section id="problem" className="landing-reveal">
<div className="landing-section-inner"> <div className="landing-section-inner">
<div className="landing-section-label">The Problem</div> <div className="landing-problem-layout">
<h2 className="landing-section-title">Documentation is broken.<br />Everyone knows it.</h2> <div className="landing-problem-headline">
<div className="landing-section-desc"> <div className="landing-section-label">The Problem</div>
Engineers don&apos;t want to write it. Managers hate chasing it. Clients never see it. The same issues get solved from scratch every time. <h2>Documentation is broken.<br />Everyone knows it.</h2>
</div> <p>Engineers don&apos;t want to write it. Managers hate chasing it. Clients never see it. The same issues get solved from scratch &mdash; every time.</p>
<div className="landing-problem-grid"> </div>
<ProblemCard icon="&#9201;" color="red" title="1525 min lost per ticket" description="Engineers spend more time documenting what they did than actually doing it. After a complex issue, writing notes is the last thing anyone wants to do." /> <div className="landing-problem-grid">
<ProblemCard icon="&#128203;" color="amber" title="Vague, useless notes" description={`"Fixed Outlook" tells you nothing. Documentation written under pressure tends toward generalities that help nobody the second time around.`} /> <ProblemCard icon="&#9201;" color="red" title="15&ndash;25 min lost per ticket" description="More time documenting than resolving. After a complex issue, writing notes is the last thing anyone does." />
<ProblemCard icon="&#128260;" color="slate" title="Knowledge walks out the door" description="When a senior engineer leaves, years of tribal knowledge disappear overnight. New hires spend months building up what was never captured." /> <ProblemCard icon="&#128203;" color="amber" title="Vague, useless notes" description={`"Fixed Outlook" tells no one anything. Notes under pressure are always too vague to help next time.`} />
<ProblemCard icon="&#129504;" color="violet" title="Context switching kills speed" description="Jumping between the issue, documentation tools, PSA tickets, and knowledge bases fragments focus and slows resolution." /> <ProblemCard icon="&#128260;" color="slate" title="Knowledge walks out the door" description="When a senior engineer leaves, years of tribal knowledge vanish overnight." />
<ProblemCard icon="&#129504;" color="violet" title="Context switching kills speed" description="Jumping between the issue, docs, PSA tickets, and knowledge bases fragments focus." />
</div>
</div> </div>
</div> </div>
</section> </section>
<div className="landing-section-divider" /> {/* Equation */}
{/* Brand Equation */}
<div className="landing-equation-section landing-reveal"> <div className="landing-equation-section landing-reveal">
<div className="landing-section-label">The Answer</div> <div className="landing-equation-inner">
<div className="landing-brand-equation"> <div className="landing-section-label">The Answer</div>
<span className="landing-eq-item">Resolution</span> <div className="landing-brand-equation">
<span className="landing-eq-operator">+</span> <span className="landing-eq-item">Resolution</span>
<span className="landing-eq-item">Documentation</span> <span className="landing-eq-operator">+</span>
<span className="landing-eq-operator">&minus;</span> <span className="landing-eq-item">Documentation</span>
<span className="landing-eq-item">Time</span> <span className="landing-eq-operator">&minus;</span>
<span className="landing-eq-operator">=</span> <span className="landing-eq-item">Time</span>
<span className="landing-eq-result">ResolutionFlow</span> <span className="landing-eq-operator">=</span>
<span className="landing-eq-result">ResolutionFlow</span>
</div>
<p className="landing-equation-desc">
What if documentation was a <em>byproduct</em> of solving the issue &mdash; not a separate task?
</p>
</div> </div>
<p className="landing-equation-desc">
What if documentation was a <em>byproduct</em> of solving the issue not a separate task? What if every ticket your team touched had clean, detailed notes without anyone writing them?
</p>
</div> </div>
<div className="landing-section-divider" /> {/* How It Works — zigzag */}
<section id="how-it-works" className="landing-section landing-reveal">
{/* How It Works */}
<section id="how-it-works" className="landing-reveal">
<div className="landing-section-inner"> <div className="landing-section-inner">
<div className="landing-section-label">How It Works</div> <div className="landing-section-label">How It Works</div>
<h2 className="landing-section-title">Three steps. Zero note-writing.</h2> <h2 className="landing-section-title">Three steps. Zero note-writing.</h2>
<div className="landing-section-desc"> </div>
Just describe the issue. FlowPilot handles the rest. <div className="landing-zigzag">
</div> <div className="landing-zigzag-step">
<div className="landing-steps-container"> <div className="landing-zigzag-text">
<div className="landing-step-card"> <div className="landing-zigzag-number">01</div>
<h3>Describe the Issue</h3> <h3>Describe the Issue</h3>
<p>Type what&apos;s happening, paste an error message, or drop a screenshot. FlowPilot understands MSP environments AD, Exchange, networking, VPN, you name it.</p> <p>Type what&apos;s happening, paste an error, or drop a screenshot. FlowPilot understands MSP environments &mdash; AD, Exchange, networking, VPN, you name it.</p>
<div className="landing-step-visual"> </div>
<div className="landing-mock-editor"> <div className="landing-zigzag-visual">
<div className="landing-mock-node step" style={{ fontSize: '0.7rem', padding: '8px 12px' }}>&#128172; &ldquo;User can&apos;t access shared drive after password reset, getting Access Denied in Event Viewer&rdquo;</div> <div className="landing-mock-input">
</div> <span className="landing-mock-input-icon">&#128172;</span>
<span>User can&apos;t access shared drive after password reset, getting Access Denied in Event Viewer</span>
</div> </div>
</div> </div>
</div>
<div className="landing-step-card"> <div className="landing-zigzag-step reverse">
<div className="landing-zigzag-text">
<div className="landing-zigzag-number">02</div>
<h3>Troubleshoot Together</h3> <h3>Troubleshoot Together</h3>
<p>FlowPilot acts like a senior engineer on the call with you. It suggests next steps, provides commands to run, and captures every action documentation builds itself as you work.</p> <p>FlowPilot acts like a senior engineer on the call. It suggests next steps, provides commands, and captures every action &mdash; documentation builds itself as you work.</p>
<div className="landing-step-visual"> </div>
<div className="landing-mock-session"> <div className="landing-zigzag-visual">
<div className="landing-mock-chat-line"> <div className="landing-mock-session compact">
<span className="label">FlowPilot:</span> <div className="landing-mock-chat-line ai">
<span className="text">Is the user on VPN?</span> <span className="label">FlowPilot</span>
</div> <span className="text">Is the user on VPN?</span>
<div className="landing-mock-chat-line"> </div>
<span className="label" style={{ color: '#848b9b' }}>Engineer:</span> <div className="landing-mock-chat-line user">
<span className="text">Yes, Cisco AnyConnect</span> <span className="label">You</span>
</div> <span className="text">Yes, Cisco AnyConnect</span>
<div className="landing-mock-chat-line"> </div>
<span className="label">FlowPilot:</span> <div className="landing-mock-chat-line ai">
<span className="text">Check split tunnel config &rarr;</span> <span className="label">FlowPilot</span>
</div> <span className="text">Check split tunnel config &rarr;</span>
<div className="landing-mock-chat-line doc"> </div>
<span className="label">Auto-doc:</span> <div className="landing-mock-chat-line doc">
<span className="text">Step captured &#10003;</span> <span className="label">Auto-doc</span>
</div> <span className="text">Step captured &#10003;</span>
</div> </div>
</div> </div>
</div> </div>
</div>
<div className="landing-step-card"> <div className="landing-zigzag-step">
<div className="landing-zigzag-text">
<div className="landing-zigzag-number">03</div>
<h3>Resolve &amp; Document</h3> <h3>Resolve &amp; Document</h3>
<p>Hit resolve and get clean, timestamped ticket notes ready to paste into ConnectWise, Atera, or Syncro. Every step you took, every command you ran, documented automatically.</p> <p>Hit resolve and get clean, timestamped ticket notes &mdash; ready to paste into ConnectWise, Atera, or Syncro. Every step documented automatically.</p>
<div className="landing-step-visual"> </div>
<div className="landing-mock-ticket"> <div className="landing-zigzag-visual">
<div className="landing-mock-ticket-header">ConnectWise Ticket #48291</div> <div className="landing-mock-ticket">
<div className="landing-mock-ticket-line"><span className="time">10:04</span><span className="check">&#10003;</span><span>Verified VPN connection active</span></div> <div className="landing-mock-ticket-header">ConnectWise Ticket #48291</div>
<div className="landing-mock-ticket-line"><span className="time">10:06</span><span className="check">&#10003;</span><span>Split tunnel misconfigured fixed</span></div> <div className="landing-mock-ticket-line"><span className="time">10:04</span><span className="check">&#10003;</span><span>Verified VPN connection active</span></div>
<div className="landing-mock-ticket-line"><span className="time">10:08</span><span className="check">&#10003;</span><span>Confirmed Outlook sync restored</span></div> <div className="landing-mock-ticket-line"><span className="time">10:06</span><span className="check">&#10003;</span><span>Split tunnel misconfigured &mdash; fixed</span></div>
<div className="landing-mock-ticket-line"><span className="time">10:09</span><span className="check">&#10003;</span><span>Resolution: VPN split tunnel updated</span></div> <div className="landing-mock-ticket-line"><span className="time">10:08</span><span className="check">&#10003;</span><span>Confirmed Outlook sync restored</span></div>
</div> <div className="landing-mock-ticket-line"><span className="time">10:09</span><span className="check">&#10003;</span><span>Resolution: VPN split tunnel updated</span></div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</section> </section>
<div className="landing-section-divider" />
{/* Features */} {/* Features */}
<section id="features" className="landing-reveal"> <section id="features" className="landing-section landing-section-alt landing-reveal">
<div className="landing-section-inner"> <div className="landing-section-inner">
<div className="landing-section-label">Features</div> <div className="landing-section-label">Features</div>
<h2 className="landing-section-title">Troubleshoot faster.<br />Document everything. Automatically.</h2> <h2 className="landing-section-title">Everything you need to troubleshoot faster.</h2>
<div className="landing-feature-highlight">
<div className="landing-feature-highlight-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3" /><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" /></svg>
</div>
<div className="landing-feature-highlight-content">
<h3>FlowPilot &mdash; Your AI Copilot</h3>
<p>Like having a senior engineer on every call. Describe the issue, get expert troubleshooting guidance, and documentation writes itself &mdash; as a byproduct of solving the problem.</p>
</div>
</div>
<div className="landing-features-grid"> <div className="landing-features-grid">
<FeatureCard <FeatureCard
highlight icon={<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2" /><line x1="9" y1="3" x2="9" y2="21" /></svg>}
icon={<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>} title="Guided Flows"
title="FlowPilot — Your AI Copilot" description="Build step-by-step troubleshooting paths your team can follow. Great for onboarding and consistency."
description="Like having a senior engineer on every call. Describe the issue, get expert troubleshooting guidance, and documentation writes itself — as a byproduct of solving the problem."
/> />
<FeatureCard <FeatureCard
icon={<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="9" y1="3" x2="9" y2="21"/></svg>} icon={<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><polyline points="14 2 14 8 20 8" /><line x1="16" y1="13" x2="8" y2="13" /><line x1="16" y1="17" x2="8" y2="17" /></svg>}
title="Guided Troubleshooting Flows" title="Zero Empty Tickets"
description="Build step-by-step troubleshooting paths your team can follow. Great for standard procedures, onboarding new engineers, or ensuring consistency." description="Every session generates timestamped notes, formatted for your PSA. No more empty ticket closures."
/> />
<FeatureCard <FeatureCard
icon={<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>} icon={<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><path d="M23 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" /></svg>}
title="Zero Empty Ticket Notes" title="Team Knowledge"
description="Every troubleshooting session generates timestamped, detailed notes — formatted for your PSA. Your team will never close a ticket with empty notes again." description="Solutions are saved and surfaced when the next engineer hits a similar issue."
/> />
<FeatureCard <FeatureCard
icon={<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>} icon={<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12" /></svg>}
title="Team Knowledge That Grows" title="Session Analytics"
description="Every resolved session makes your team smarter. Solutions are saved and surfaced automatically when the next engineer hits a similar issue." description="Track resolution times, identify recurring issues, and measure team performance."
/> />
<FeatureCard <FeatureCard
icon={<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>} icon={<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2" /><line x1="8" y1="21" x2="16" y2="21" /><line x1="12" y1="17" x2="12" y2="21" /></svg>}
title="Session History & Analytics"
description="See every troubleshooting session your team has run. Track resolution times, identify common issues, and measure team performance."
/>
<FeatureCard
icon={<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>}
title="PSA Integration" title="PSA Integration"
description="Connect directly to ConnectWise, Atera, and Syncro. Export session docs straight to tickets — no copy-paste needed." description="Connect to ConnectWise, Atera, and Syncro. Push session docs straight to tickets."
/> />
</div> </div>
</div> </div>
</section> </section>
<div className="landing-section-divider" />
{/* Pricing */} {/* Pricing */}
<section id="pricing" className="landing-reveal"> <section id="pricing" className="landing-section landing-reveal">
<div className="landing-section-inner"> <div className="landing-section-inner">
<div className="landing-section-label">Pricing</div> <div className="landing-section-label">Pricing</div>
<h2 className="landing-section-title">Simple pricing. No surprises.</h2> <h2 className="landing-section-title">Simple pricing. No surprises.</h2>
<div className="landing-section-desc">Start free. Upgrade when your team is ready.</div> <p className="landing-section-desc">Start free. Upgrade when your team is ready.</p>
<div className="landing-pricing-grid"> <div className="landing-pricing-grid">
<PricingCard <PricingCard
name="Free" name="Free"
target="For individual techs evaluating" target="Individual techs evaluating"
amount="$0" amount="$0"
note="Free forever" note="Free forever"
features={['3 decision trees', '20 sessions per month', 'Auto-documentation export', 'Session history (30 days)', 'Community support']} features={['3 guided flows', '20 sessions per month', 'Auto-documentation export', '30-day session history']}
btnLabel="Get Started" btnLabel="Get Started"
btnStyle="outline" btnStyle="outline"
plan="free"
/> />
<PricingCard <PricingCard
featured featured
name="Pro" name="Pro"
target="For small MSPs with 15 techs" target="Small MSPs &middot; 1&ndash;5 techs"
amount="$15" amount="$15"
period="/user/mo" period="/user/mo"
note="Billed monthly or annually" note="Billed monthly or annually"
features={['Unlimited decision trees', 'Unlimited sessions', 'FlowPilot AI copilot', 'Auto-documentation export', 'Full session history', 'Flow templates library', 'Priority support']} features={['Unlimited flows & sessions', 'FlowPilot AI copilot', 'Full session history', 'Flow templates library', 'Priority support']}
btnLabel="Start Free Trial" btnLabel="Start Free Trial"
btnStyle="filled" btnStyle="filled"
plan="pro"
/> />
<PricingCard <PricingCard
name="Team" name="Team"
target="For growing MSPs with 525 techs" target="Growing MSPs &middot; 5&ndash;25 techs"
amount="$25" amount="$25"
period="/user/mo" period="/user/mo"
note="Billed monthly or annually" note="Billed monthly or annually"
features={['Everything in Pro', 'PSA integration (ConnectWise, Atera, Syncro)', 'Team analytics dashboard', 'Session sharing & collaboration', 'Client context system', 'Role-based permissions', 'Dedicated support']} features={['Everything in Pro', 'PSA integration', 'Team analytics dashboard', 'Session sharing', 'Role-based permissions', 'Dedicated support']}
btnLabel="Start Free Trial" btnLabel="Start Free Trial"
btnStyle="outline" btnStyle="outline"
plan="team"
/> />
</div> </div>
<p className="landing-pricing-session-note">One session = one troubleshooting conversation, regardless of length.</p>
<p className="landing-pricing-enterprise"> <p className="landing-pricing-enterprise">
Need Enterprise (25+ techs, SSO, custom branding)?{' '} Enterprise (25+ techs, SSO, custom branding)?{' '}
<a href="mailto:hello@resolutionflow.com">Contact us</a> <a href="mailto:hello@resolutionflow.com">Let&apos;s talk</a>
</p> </p>
</div> </div>
</section> </section>
<div className="landing-section-divider" /> {/* FAQ */}
<section id="faq" className="landing-section landing-section-alt landing-reveal">
{/* Testimonial */} <div className="landing-section-inner">
<div className="landing-testimonial-section landing-reveal"> <div className="landing-section-label">FAQ</div>
<div className="landing-testimonial-quote"> <h2 className="landing-section-title">Common questions</h2>
We used to spend more time writing ticket notes than solving the actual issue. Now it just&hellip; happens. The documentation writes itself while we work. <div className="landing-faq-list">
{FAQ_ITEMS.map((item, i) => (
<div key={i} className={`landing-faq-item ${openFaq === i ? 'open' : ''}`}>
<button
className="landing-faq-trigger"
onClick={() => toggleFaq(i)}
aria-expanded={openFaq === i}
>
<span>{item.q}</span>
<span className="landing-faq-icon" aria-hidden="true">{openFaq === i ? '\u2212' : '+'}</span>
</button>
<div className="landing-faq-answer" role="region">
<p>{item.a}</p>
</div>
</div>
))}
</div>
</div> </div>
<div className="landing-testimonial-author"> </section>
<strong>Beta Tester</strong> MSP Engineer, Southeast US
{/* Founder — replaces anonymous testimonial */}
<div className="landing-founder-section landing-reveal">
<div className="landing-founder-inner">
<div className="landing-section-label">Why We Built This</div>
<blockquote>
After 15 years in the MSP trenches, I got tired of the same cycle: solve the issue in 10 minutes, spend 20 minutes writing notes about it. Or worse &mdash; close the ticket with &ldquo;Fixed issue&rdquo; because there&apos;s no time. ResolutionFlow is the tool I wanted on every call.
</blockquote>
<div className="landing-founder-name">&mdash; Michael, Founder</div>
</div> </div>
</div> </div>
<div className="landing-section-divider" />
{/* CTA */} {/* CTA */}
<section className="landing-cta-section landing-reveal"> <section className="landing-cta-section landing-reveal">
<h2>Ready to never write ticket notes again?</h2> <div className="landing-cta-inner">
<p>Join the beta. Troubleshoot your next ticket with FlowPilot and see the documentation write itself.</p> <h2>Ready to stop writing ticket notes?</h2>
<form className="landing-cta-email-form" onSubmit={handleBetaSubmit}> <p>Join the beta. Troubleshoot your next ticket with FlowPilot.</p>
<input <form className="landing-cta-email-form" onSubmit={handleBetaSubmit} noValidate>
type="email" <div className="landing-cta-input-wrap">
className="landing-cta-email-input" <input
placeholder="you@yourmsp.com" type="email"
value={betaEmail} className="landing-cta-email-input"
onChange={e => setBetaEmail(e.target.value)} placeholder="you@yourmsp.com"
required value={betaEmail}
/> onChange={e => {
<button type="submit" className="landing-btn-hero-primary" style={{ whiteSpace: 'nowrap' }} disabled={betaStatus === 'sending'}> setBetaEmail(e.target.value)
{betaStatus === 'sending' ? 'Joining...' : betaStatus === 'sent' ? 'Joined!' : 'Join Beta'} if (betaStatus === 'error') { setBetaStatus('idle'); setBetaError('') }
</button> }}
</form> required
{betaStatus === 'sent' && ( aria-describedby="beta-status"
<p className="landing-cta-success">Thanks! We&apos;ll be in touch with beta access details.</p> />
)} <button type="submit" className="landing-btn-hero-primary" disabled={betaStatus === 'sending'}>
{betaStatus === 'error' && ( {betaStatus === 'sending' ? 'Joining\u2026' : betaStatus === 'sent' ? 'Joined!' : 'Join Beta'}
<p className="landing-cta-error">Something went wrong. Please try again.</p> </button>
)} </div>
<p className="landing-cta-fine-print">Free to start. No credit card required.</p> <div id="beta-status" aria-live="polite" className="landing-cta-status">
{betaStatus === 'sent' && (
<p className="landing-cta-success">You&apos;re in. We&apos;ll send beta access details soon.</p>
)}
{betaStatus === 'error' && betaError && (
<p className="landing-cta-error">{betaError}</p>
)}
</div>
</form>
<p className="landing-cta-fine-print">Free to start. No credit card required.</p>
</div>
</section> </section>
{/* Footer */} {/* Footer */}
<footer className="landing-footer"> <footer className="landing-footer">
<div className="landing-footer-inner"> <div className="landing-footer-inner">
<div className="landing-footer-left"> <div className="landing-footer-left">
<div className="landing-nav-logo-icon" style={{ width: 28, height: 28, borderRadius: 8 }}> <div className="landing-nav-logo-icon" style={{ width: 24, height: 24, borderRadius: 6 }}>
<svg viewBox="0 0 24 24" fill="none" stroke="#000" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ width: 16, height: 16 }}> <svg viewBox="0 0 24 24" fill="none" stroke="#000" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ width: 14, height: 14 }}>
<circle cx="12" cy="5" r="2"/> <circle cx="12" cy="5" r="2" />
<line x1="12" y1="7" x2="12" y2="11"/> <line x1="12" y1="7" x2="12" y2="11" />
<circle cx="6" cy="15" r="2"/> <circle cx="6" cy="15" r="2" />
<circle cx="18" cy="15" r="2"/> <circle cx="18" cy="15" r="2" />
<line x1="12" y1="11" x2="6" y2="13"/> <line x1="12" y1="11" x2="6" y2="13" />
<line x1="12" y1="11" x2="18" y2="13"/> <line x1="12" y1="11" x2="18" y2="13" />
</svg> </svg>
</div> </div>
<span className="landing-footer-copy">&copy; 2026 ResolutionFlow. All rights reserved.</span> <span className="landing-footer-copy">&copy; 2026 ResolutionFlow</span>
</div> </div>
<ul className="landing-footer-links"> <ul className="landing-footer-links">
<li><Link to="/privacy">Privacy</Link></li> <li><Link to="/privacy">Privacy</Link></li>
@@ -496,7 +477,7 @@ export default function LandingPage() {
</ul> </ul>
</div> </div>
</footer> </footer>
</div> </main>
</div> </div>
</> </>
) )
@@ -517,11 +498,11 @@ function ProblemCard({ icon, color, title, description }: {
) )
} }
function FeatureCard({ icon, title, description, highlight }: { function FeatureCard({ icon, title, description }: {
icon: React.ReactNode; title: string; description: string; highlight?: boolean icon: React.ReactNode; title: string; description: string
}) { }) {
return ( return (
<div className={`landing-feature-card ${highlight ? 'highlight' : ''}`}> <div className="landing-feature-card">
<div className="landing-feature-icon">{icon}</div> <div className="landing-feature-icon">{icon}</div>
<h3>{title}</h3> <h3>{title}</h3>
<p>{description}</p> <p>{description}</p>
@@ -529,12 +510,13 @@ function FeatureCard({ icon, title, description, highlight }: {
) )
} }
function PricingCard({ name, target, amount, period, note, features, btnLabel, btnStyle, featured }: { function PricingCard({ name, target, amount, period, note, features, btnLabel, btnStyle, featured, plan }: {
name: string; target: string; amount: string; period?: string; note: string name: string; target: string; amount: string; period?: string; note: string
features: string[]; btnLabel: string; btnStyle: 'outline' | 'filled'; featured?: boolean features: string[]; btnLabel: string; btnStyle: 'outline' | 'filled'; featured?: boolean; plan: string
}) { }) {
return ( return (
<div className={`landing-pricing-card ${featured ? 'featured' : ''}`}> <div className={`landing-pricing-card ${featured ? 'featured' : ''}`}>
{featured && <div className="landing-pricing-badge">Most Popular</div>}
<div className="landing-pricing-plan-name">{name}</div> <div className="landing-pricing-plan-name">{name}</div>
<div className="landing-pricing-target">{target}</div> <div className="landing-pricing-target">{target}</div>
<div className="landing-pricing-price"> <div className="landing-pricing-price">
@@ -545,7 +527,7 @@ function PricingCard({ name, target, amount, period, note, features, btnLabel, b
<ul className="landing-pricing-features"> <ul className="landing-pricing-features">
{features.map(f => <li key={f}>{f}</li>)} {features.map(f => <li key={f}>{f}</li>)}
</ul> </ul>
<Link to="/register" className={`landing-pricing-btn ${btnStyle}`}>{btnLabel}</Link> <Link to={`/register?plan=${plan}`} className={`landing-pricing-btn ${btnStyle}`}>{btnLabel}</Link>
</div> </div>
) )
} }

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useNavigate, Link } from 'react-router-dom' import { useNavigate, Link } from 'react-router-dom'
import { Play, Pencil, Share2, Trash2, GitBranch, Clock, TrendingUp, FolderTree, Plus, ListOrdered, ChevronDown, Wrench } from 'lucide-react' import { Play, Pencil, Share2, Trash2, GitBranch, Clock, TrendingUp, FolderTree, Plus, ListOrdered, Wrench } from 'lucide-react'
import { PageMeta } from '@/components/common/PageMeta' import { PageMeta } from '@/components/common/PageMeta'
import { StaggerList } from '@/components/common/StaggerList' import { StaggerList } from '@/components/common/StaggerList'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
@@ -16,6 +16,7 @@ import { useAuthStore } from '@/store/authStore'
import { usePermissions } from '@/hooks/usePermissions' import { usePermissions } from '@/hooks/usePermissions'
import { toast } from '@/lib/toast' import { toast } from '@/lib/toast'
import { ForkModal } from '@/components/library/ForkModal' import { ForkModal } from '@/components/library/ForkModal'
import { CreateFlowDropdown } from '@/components/common/CreateFlowDropdown'
interface TreeWithStats extends TreeListItem { interface TreeWithStats extends TreeListItem {
lastUsed?: string lastUsed?: string
@@ -35,7 +36,6 @@ export function MyTreesPage() {
const [isDeleting, setIsDeleting] = useState(false) const [isDeleting, setIsDeleting] = useState(false)
const [treeToShare, setTreeToShare] = useState<TreeWithStats | null>(null) const [treeToShare, setTreeToShare] = useState<TreeWithStats | null>(null)
const [showShareModal, setShowShareModal] = useState(false) const [showShareModal, setShowShareModal] = useState(false)
const [showCreateMenu, setShowCreateMenu] = useState(false)
const [forkTarget, setForkTarget] = useState<TreeWithStats | null>(null) const [forkTarget, setForkTarget] = useState<TreeWithStats | null>(null)
useEffect(() => { useEffect(() => {
@@ -129,55 +129,7 @@ export function MyTreesPage() {
</p> </p>
</div> </div>
{canCreateTrees && ( {canCreateTrees && (
<div className="relative"> <CreateFlowDropdown label="Create New" />
<Button
onClick={() => setShowCreateMenu(!showCreateMenu)}
>
<Plus className="h-4 w-4" />
Create New
<ChevronDown className="h-3.5 w-3.5" />
</Button>
{showCreateMenu && (
<>
<div className="fixed inset-0 z-10" onClick={() => setShowCreateMenu(false)} />
<div className="absolute right-0 z-20 mt-1 w-56 rounded-lg border border-border bg-card p-1 shadow-xl">
<Link
to="/trees/new"
onClick={() => setShowCreateMenu(false)}
className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent"
>
<FolderTree className="h-4 w-4 text-muted-foreground" />
<div>
<div className="font-medium">Troubleshooting Tree</div>
<div className="text-xs text-muted-foreground">Branching decision flow</div>
</div>
</Link>
<Link
to="/flows/new"
onClick={() => setShowCreateMenu(false)}
className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent"
>
<ListOrdered className="h-4 w-4 text-muted-foreground" />
<div>
<div className="font-medium">Procedural Flow</div>
<div className="text-xs text-muted-foreground">Step-by-step procedure</div>
</div>
</Link>
<Link
to="/flows/new?type=maintenance"
onClick={() => setShowCreateMenu(false)}
className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent"
>
<Wrench className="h-4 w-4 text-amber-400" />
<div>
<div className="font-medium">Maintenance Flow</div>
<div className="text-xs text-muted-foreground">Scheduled multi-target tasks</div>
</div>
</Link>
</div>
</>
)}
</div>
)} )}
</div> </div>

View File

@@ -241,9 +241,9 @@ export function ProceduralEditorPage() {
// Summary strings for collapsed sections // Summary strings for collapsed sections
const detailsSummary = [ const detailsSummary = [
name ? `"${name}"` : '"Untitled"',
tags.length > 0 ? `${tags.length} tag${tags.length !== 1 ? 's' : ''}` : 'No tags', tags.length > 0 ? `${tags.length} tag${tags.length !== 1 ? 's' : ''}` : 'No tags',
isPublic ? 'Public' : 'Private', isPublic ? 'Public' : 'Private',
description ? `${description.slice(0, 40)}${description.length > 40 ? '\u2026' : ''}` : 'No description',
].join(' \u00b7 ') ].join(' \u00b7 ')
const scheduleSummary = getScheduleSummary(schedule, scheduleTargetList) const scheduleSummary = getScheduleSummary(schedule, scheduleTargetList)
@@ -265,37 +265,42 @@ export function ProceduralEditorPage() {
{/* Main content column */} {/* Main content column */}
<div className="flex min-w-0 flex-1 flex-col overflow-hidden"> <div className="flex min-w-0 flex-1 flex-col overflow-hidden">
{/* Toolbar — sticky */} {/* Toolbar — sticky */}
<div className="flex shrink-0 items-center justify-between border-b border-border bg-card px-4 py-2"> <div className="flex shrink-0 items-center gap-3 border-b border-border bg-sidebar px-4 py-2.5">
<div className="flex items-center gap-3"> <button
<button onClick={() => navigate('/trees')}
onClick={() => navigate('/trees')} className="shrink-0 rounded p-1.5 text-muted-foreground transition-colors hover:bg-white/[0.08] hover:text-foreground"
className="rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-foreground" >
> <ArrowLeft className="h-4 w-4" />
<ArrowLeft className="h-5 w-5" /> </button>
</button>
<div className="flex items-center gap-2"> <div className="flex min-w-0 flex-1 items-center gap-2">
{isMaintenance {isMaintenance
? <Wrench className="h-5 w-5 text-amber-400" /> ? <Wrench className="h-4 w-4 shrink-0 text-amber-400" />
: <ListOrdered className="h-5 w-5 text-muted-foreground" />} : <ListOrdered className="h-4 w-4 shrink-0 text-muted-foreground" />}
<h1 className="text-lg font-bold text-foreground"> <input
{isEditMode ? `Edit ${flowLabel}` : `New ${flowLabel}`} type="text"
{name && <span className="ml-2 font-normal text-muted-foreground"> {name}</span>} value={name}
</h1> onChange={(e) => setName(e.target.value)}
</div> placeholder={`Untitled ${flowLabel}`}
className="min-w-0 flex-1 bg-transparent text-sm font-semibold text-heading placeholder:text-muted-foreground focus:outline-none"
/>
{isDirty && (
<span
title="Unsaved changes"
className="h-1.5 w-1.5 shrink-0 rounded-full bg-amber-400"
/>
)}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
{isDirty && (
<span className="text-xs text-muted-foreground">Unsaved changes</span>
)}
<button <button
onClick={() => editorAI.isOpen ? editorAI.closePanel() : editorAI.openPanel()} onClick={() => editorAI.isOpen ? editorAI.closePanel() : editorAI.openPanel()}
title="Toggle AI Assist panel" title="Toggle AI Assist panel"
className={cn( className={cn(
'flex items-center gap-1.5 rounded-md border border-border px-3 py-2 text-sm font-medium transition-colors', 'flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm font-medium transition-colors',
editorAI.isOpen editorAI.isOpen
? 'bg-accent-dim text-primary border-primary/30' ? 'border-primary/30 bg-accent-dim text-primary'
: 'text-muted-foreground hover:bg-accent hover:text-foreground' : 'text-muted-foreground hover:bg-white/[0.08] hover:text-foreground'
)} )}
> >
<Sparkles className="h-4 w-4" /> <Sparkles className="h-4 w-4" />
@@ -318,8 +323,8 @@ export function ProceduralEditorPage() {
</div> </div>
</div> </div>
{/* Collapsible sections */} {/* Config zone */}
<div className="shrink-0"> <div className="shrink-0 border-b border-border bg-card">
<CollapsibleEditorSection <CollapsibleEditorSection
title="Details" title="Details"
icon={<Settings className="h-4 w-4" />} icon={<Settings className="h-4 w-4" />}
@@ -328,17 +333,6 @@ export function ProceduralEditorPage() {
onToggle={() => toggleSection('details')} onToggle={() => toggleSection('details')}
> >
<div className="space-y-4"> <div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-muted-foreground">Name</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Domain Controller Build"
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20"
/>
</div>
<div> <div>
<label className="mb-1 block text-sm font-medium text-muted-foreground">Description</label> <label className="mb-1 block text-sm font-medium text-muted-foreground">Description</label>
<textarea <textarea
@@ -397,25 +391,27 @@ export function ProceduralEditorPage() {
)} )}
</div> </div>
{/* Step List */} {/* Step canvas */}
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4"> <div className="min-h-0 flex-1 overflow-y-auto bg-page">
{validationErrors.length > 0 && ( <div className="px-5 py-5">
<div className="px-4 py-3"> {validationErrors.length > 0 && (
<ValidationSummary <div className="mb-4">
errors={validationErrors.map((e): ValidationError => ({ <ValidationSummary
nodeId: e.stepId, errors={validationErrors.map((e): ValidationError => ({
field: e.field, nodeId: e.stepId,
message: e.message, field: e.field,
severity: e.severity, message: e.message,
}))} severity: e.severity,
onSelectNode={handleSelectStep} }))}
onFixWithAI={handleFixWithAI} onSelectNode={handleSelectStep}
isFixing={isFixing} onFixWithAI={handleFixWithAI}
itemLabel="step" isFixing={isFixing}
/> itemLabel="step"
</div> />
)} </div>
<StepList onStepContextMenu={editorAI.openContextMenu} /> )}
<StepList onStepContextMenu={editorAI.openContextMenu} />
</div>
</div> </div>
{editorAI.contextMenu && ( {editorAI.contextMenu && (

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useRef } from 'react'
import { useSearchParams } from 'react-router-dom' import { useSearchParams } from 'react-router-dom'
import { Terminal } from 'lucide-react' import { Terminal } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@@ -21,6 +21,11 @@ export default function ScriptBuilderPage() {
const [messages, setMessages] = useState<ScriptBuilderMessage[]>([]) const [messages, setMessages] = useState<ScriptBuilderMessage[]>([])
const [language, setLanguage] = useState('powershell') const [language, setLanguage] = useState('powershell')
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
// Ref-based lock: prevents two concurrent handleSend calls (e.g. FlowPilot
// handoff useEffect + user keystroke) from each calling createSession() and
// creating two orphaned sessions. React state updates are async so isLoading
// alone can't guard across two calls in the same render cycle.
const creatingSessionRef = useRef(false)
const [previewScript, setPreviewScript] = useState<{ script: string; filename: string | null } | null>(null) const [previewScript, setPreviewScript] = useState<{ script: string; filename: string | null } | null>(null)
const [showSaveDialog, setShowSaveDialog] = useState(false) const [showSaveDialog, setShowSaveDialog] = useState(false)
const [handoffProcessed, setHandoffProcessed] = useState(false) const [handoffProcessed, setHandoffProcessed] = useState(false)
@@ -75,8 +80,19 @@ export default function ScriptBuilderPage() {
// Create session if needed // Create session if needed
let currentSession = session let currentSession = session
if (!currentSession) { if (!currentSession) {
currentSession = await scriptBuilderApi.createSession(effectiveLanguage) if (creatingSessionRef.current) {
setSession(currentSession) // Another concurrent call is already creating the session; drop this send.
setIsLoading(false)
setMessages((prev) => prev.slice(0, -1))
return
}
creatingSessionRef.current = true
try {
currentSession = await scriptBuilderApi.createSession(effectiveLanguage)
setSession(currentSession)
} finally {
creatingSessionRef.current = false
}
} }
// Send message // Send message
@@ -189,6 +205,7 @@ export default function ScriptBuilderPage() {
<ScriptBuilderInput <ScriptBuilderInput
onSend={(content) => handleSend(content)} onSend={(content) => handleSend(content)}
disabled={isLoading} disabled={isLoading}
showSuggestions={messages.length === 0}
/> />
</div> </div>

View File

@@ -11,13 +11,13 @@ import { ParameterizeAndSavePanel } from '@/components/scripts/ParameterizeAndSa
import { scriptsApi } from '@/api' import { scriptsApi } from '@/api'
import type { ScriptParameter } from '@/types' import type { ScriptParameter } from '@/types'
type LibraryTab = 'mine' | 'team' type LibraryTab = 'all' | 'mine' | 'team'
export default function ScriptLibraryPage() { export default function ScriptLibraryPage() {
const [paneMode, setPaneMode] = useState<'browse' | 'configure'>('browse') const [paneMode, setPaneMode] = useState<'browse' | 'configure'>('browse')
// inputValue owned here so it survives Configure ↔ Browse transitions // inputValue owned here so it survives Configure ↔ Browse transitions
const [inputValue, setInputValue] = useState('') const [inputValue, setInputValue] = useState('')
const [activeTab, setActiveTab] = useState<LibraryTab>('mine') const [activeTab, setActiveTab] = useState<LibraryTab>('all')
const loadCategories = useScriptGeneratorStore(s => s.loadCategories) const loadCategories = useScriptGeneratorStore(s => s.loadCategories)
const loadTemplates = useScriptGeneratorStore(s => s.loadTemplates) const loadTemplates = useScriptGeneratorStore(s => s.loadTemplates)
@@ -52,13 +52,19 @@ export default function ScriptLibraryPage() {
parameters_schema: payload.parameters_schema, parameters_schema: payload.parameters_schema,
}) })
setShowImportPanel(false) setShowImportPanel(false)
const filters = activeTab === 'mine' ? { mine: true } : { shared: true } const filters =
activeTab === 'mine' ? { mine: true } :
activeTab === 'team' ? { shared: true } :
{}
loadTemplates(filters) loadTemplates(filters)
} }
useEffect(() => { useEffect(() => {
loadCategories().then(() => { loadCategories().then(() => {
const filters = activeTab === 'mine' ? { mine: true } : { shared: true } const filters =
activeTab === 'mine' ? { mine: true } :
activeTab === 'team' ? { shared: true } :
{}
loadTemplates(filters) loadTemplates(filters)
}) })
}, [loadCategories, loadTemplates, activeTab]) }, [loadCategories, loadTemplates, activeTab])
@@ -97,39 +103,48 @@ export default function ScriptLibraryPage() {
<div> <div>
<h1 className="text-2xl font-heading font-bold text-foreground">Script Library</h1> <h1 className="text-2xl font-heading font-bold text-foreground">Script Library</h1>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Browse PowerShell templates, fill in parameters, and generate ready-to-run scripts. Browse templates, fill in parameters, and generate ready-to-run scripts.
</p> </p>
</div>
<div className="flex items-center gap-2">
{isEngineer && ( {isEngineer && (
<div className="flex items-center gap-2 mt-2"> <>
<Link <Link
to="/scripts/manage" 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" className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground px-2.5 py-1.5 rounded-lg transition-colors"
> >
<Settings size={12} className="group-hover:rotate-90 transition-transform duration-300" /> <Settings size={12} />
Manage Templates Manage
</Link> </Link>
<button <button
type="button" type="button"
onClick={() => setShowImportPanel(true)} 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" className="inline-flex items-center gap-1.5 rounded-lg border border-border px-3 py-2 text-sm text-muted-foreground hover:text-foreground hover:border-[var(--color-border-hover)] transition-colors"
> >
<FileUp size={12} /> <FileUp size={14} />
New from Script Import Script
</button> </button>
</div> </>
)} )}
<Link
to="/script-builder"
className="inline-flex items-center gap-2 bg-primary text-white font-semibold rounded-lg px-4 py-2 text-sm hover:brightness-110 active:scale-[0.98] transition-all"
>
<Wand2 size={14} />
Build New Script
</Link>
</div> </div>
<Link
to="/script-builder"
className="inline-flex items-center gap-2 bg-primary text-white font-semibold rounded-lg px-4 py-2 hover:brightness-110 active:scale-[0.98] transition-all"
>
<Wand2 size={16} />
Build a New Script
</Link>
</div> </div>
{/* Tab bar */} {/* Tab bar */}
<div className="flex gap-6 border-b border-border"> <div className="flex gap-6 border-b border-border">
<button
type="button"
onClick={() => onTabChange('all')}
className={`pb-2 text-sm font-medium transition-colors ${tabClass('all')}`}
>
All Scripts
</button>
<button <button
type="button" type="button"
onClick={() => onTabChange('mine')} onClick={() => onTabChange('mine')}

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,13 @@
import { useEffect, useState, useCallback, useRef } from 'react' import { useEffect, useState, useCallback, useRef } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
import { X, RotateCcw, Play, FileUp } from 'lucide-react' import { X, FileUp } from 'lucide-react'
import { PageMeta } from '@/components/common/PageMeta' import { PageMeta } from '@/components/common/PageMeta'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { FlowIllustration } from '@/components/common/EmptyStateIllustrations' import { FlowIllustration } from '@/components/common/EmptyStateIllustrations'
import { treesApi } from '@/api/trees' import { treesApi } from '@/api/trees'
import { categoriesApi } from '@/api/categories' import { categoriesApi } from '@/api/categories'
import { foldersApi } from '@/api/folders' import { foldersApi } from '@/api/folders'
import { sessionsApi } from '@/api/sessions' import type { TreeListItem, CategoryListItem, FolderListItem, IntakeFormField } from '@/types'
import type { TreeListItem, CategoryListItem, FolderListItem, Session, IntakeFormField } from '@/types'
import { FolderEditModal } from '@/components/library/FolderEditModal' import { FolderEditModal } from '@/components/library/FolderEditModal'
import { ForkModal } from '@/components/library/ForkModal' import { ForkModal } from '@/components/library/ForkModal'
import { ExportFlowModal } from '@/components/library/ExportFlowModal' import { ExportFlowModal } from '@/components/library/ExportFlowModal'
@@ -21,11 +20,10 @@ import { TreeListView } from '@/components/library/TreeListView'
import { TreeTableView } from '@/components/library/TreeTableView' import { TreeTableView } from '@/components/library/TreeTableView'
import { ViewToggle } from '@/components/library/ViewToggle' import { ViewToggle } from '@/components/library/ViewToggle'
import { SortDropdown } from '@/components/library/SortDropdown' import { SortDropdown } from '@/components/library/SortDropdown'
import { cn, safeGetItem } from '@/lib/utils' import { cn } from '@/lib/utils'
import { getSessionResumePath, getTreeNavigatePath } from '@/lib/routing' import { getTreeNavigatePath } from '@/lib/routing'
import { usePermissions } from '@/hooks/usePermissions' import { usePermissions } from '@/hooks/usePermissions'
import { useUserPreferencesStore } from '@/store/userPreferencesStore' import { useUserPreferencesStore } from '@/store/userPreferencesStore'
import { useCachedQuota } from '@/hooks/useCachedQuota'
import { CreateFlowDropdown } from '@/components/common/CreateFlowDropdown' import { CreateFlowDropdown } from '@/components/common/CreateFlowDropdown'
import { Spinner } from '@/components/common/Spinner' import { Spinner } from '@/components/common/Spinner'
import { EmptyState } from '@/components/common/EmptyState' import { EmptyState } from '@/components/common/EmptyState'
@@ -94,25 +92,6 @@ export function TreeLibraryPage() {
// AI builder state // AI builder state
const { aiEnabled } = useCachedQuota()
// Repeat Last Session
const lastSessionData = (() => {
const raw = safeGetItem('last-session')
if (!raw) return null
try { return JSON.parse(raw) as { tree_id: string; tree_name: string; client_name: string; ticket_number: string; tree_type?: string } }
catch { return null }
})()
// Incomplete sessions for auto-recovery
const [incompleteSessions, setIncompleteSessions] = useState<Session[]>([])
const [dismissedSessionIds, setDismissedSessionIds] = useState<Set<string>>(() => {
try {
const raw = sessionStorage.getItem('dismissed-sessions')
return raw ? new Set(JSON.parse(raw) as string[]) : new Set()
} catch { return new Set() }
})
const loadFolders = useCallback(async () => { const loadFolders = useCallback(async () => {
try { try {
const foldersData = await foldersApi.list() const foldersData = await foldersApi.list()
@@ -122,30 +101,6 @@ export function TreeLibraryPage() {
} }
}, []) }, [])
// Load incomplete sessions on mount
useEffect(() => {
sessionsApi.list({ completed: false, size: 5 })
.then(setIncompleteSessions)
.catch((err) => console.error('Failed to load incomplete sessions:', err))
}, [])
const dismissSession = (sessionId: string) => {
const next = new Set(dismissedSessionIds)
next.add(sessionId)
setDismissedSessionIds(next)
try { sessionStorage.setItem('dismissed-sessions', JSON.stringify([...next])) } catch { /* */ }
}
const visibleIncompleteSessions = incompleteSessions.filter(s => !dismissedSessionIds.has(s.id))
const formatTimeAgo = (dateString: string) => {
const diff = Math.floor((Date.now() - new Date(dateString).getTime()) / 1000)
if (diff < 60) return 'just now'
if (diff < 3600) return `${Math.floor(diff / 60)} min ago`
if (diff < 86400) return `${Math.floor(diff / 3600)} hr ago`
return `${Math.floor(diff / 86400)} days ago`
}
// Load categories once on mount (they rarely change) // Load categories once on mount (they rarely change)
useEffect(() => { useEffect(() => {
categoriesApi.list() categoriesApi.list()
@@ -196,15 +151,18 @@ export function TreeLibraryPage() {
loadTrees() loadTrees()
return return
} }
const requestId = ++loadTreesRequestId.current
setIsLoading(true) setIsLoading(true)
try { try {
const results = await treesApi.search(searchQuery) const results = await treesApi.search(searchQuery)
if (requestId !== loadTreesRequestId.current) return
setTrees(results) setTrees(results)
} catch (err) { } catch (err) {
if (requestId !== loadTreesRequestId.current) return
toast.error('Failed to search flows') toast.error('Failed to search flows')
console.error(err) console.error(err)
} finally { } finally {
setIsLoading(false) if (requestId === loadTreesRequestId.current) setIsLoading(false)
} }
} }
@@ -311,11 +269,7 @@ export function TreeLibraryPage() {
<FileUp className="h-4 w-4" /> <FileUp className="h-4 w-4" />
Import Import
</Button> </Button>
<CreateFlowDropdown <CreateFlowDropdown label="Create New" />
aiEnabled={aiEnabled}
label="Create New"
/>
</div> </div>
)} )}
</div> </div>
@@ -436,59 +390,6 @@ export function TreeLibraryPage() {
</div> </div>
)} )}
{/* Incomplete Session Recovery */}
{visibleIncompleteSessions.length > 0 && (
<div className="mb-6 space-y-2">
{visibleIncompleteSessions.map(s => (
<div key={s.id} className="bg-card border border-border flex items-center justify-between rounded-xl p-4">
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-foreground">
{s.tree_snapshot?.name || 'Unknown tree'}
</p>
<p className="text-sm text-muted-foreground">
{s.client_name && `${s.client_name} · `}
{s.started_at ? `Started ${formatTimeAgo(s.started_at)}` : 'Not started'}
</p>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={() => navigate(getSessionResumePath(s.tree_id, s.tree_snapshot?.tree_type), { state: { sessionId: s.id } })}
>
<Play className="h-3.5 w-3.5" />
Resume
</Button>
<button
onClick={() => dismissSession(s.id)}
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
))}
</div>
)}
{/* Repeat Last Session */}
{lastSessionData && (
<div className="mb-6">
<button
onClick={() => navigate(getSessionResumePath(lastSessionData.tree_id, lastSessionData.tree_type), {
state: { prefillClientName: lastSessionData.client_name, prefillTicketNumber: lastSessionData.ticket_number },
})}
className={cn(
'flex items-center gap-2 rounded-lg border border-border px-4 py-2.5 text-sm text-muted-foreground',
'hover:border-border hover:bg-accent hover:text-foreground'
)}
>
<RotateCcw className="h-4 w-4" />
Repeat: {lastSessionData.tree_name}
{lastSessionData.client_name && ` (${lastSessionData.client_name})`}
</button>
</div>
)}
{/* Loading State */} {/* Loading State */}
{isLoading ? ( {isLoading ? (
<div className="flex justify-center py-12"> <div className="flex justify-center py-12">
@@ -512,7 +413,7 @@ export function TreeLibraryPage() {
description="Flows guide your team through proven resolution paths, capturing every decision along the way." description="Flows guide your team through proven resolution paths, capturing every decision along the way."
action={ action={
canCreateTrees ? ( canCreateTrees ? (
<CreateFlowDropdown aiEnabled={aiEnabled} label="Create a Flow" /> <CreateFlowDropdown label="Create a Flow" />
) : undefined ) : undefined
} }
learnMoreLink="/guides/creating-flows" learnMoreLink="/guides/creating-flows"

View File

@@ -687,7 +687,7 @@ export function TreeNavigationPage() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h1 className="text-xl font-bold font-heading text-foreground">{tree.name}</h1> <h1 className="text-xl font-bold font-heading text-foreground">{tree.name}</h1>
{timerDisplay && ( {timerDisplay && (
<span className="flex items-center gap-1.5 rounded-full bg-accent px-2 py-0.5 text-[0.6875rem] font-sans text-xs text-muted-foreground"> <span className="flex items-center gap-1.5 rounded-full bg-accent px-2 py-0.5 text-[0.6875rem] font-sans text-xs text-[#0e1016]">
<Clock className="h-4 w-4" /> <Clock className="h-4 w-4" />
{timerDisplay} {timerDisplay}
</span> </span>
@@ -874,7 +874,7 @@ export function TreeNavigationPage() {
<Spinner size="sm" className="h-4 w-4 border-t-foreground" /> <Spinner size="sm" className="h-4 w-4 border-t-foreground" />
</span> </span>
) : ( ) : (
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded bg-accent text-xs font-medium text-muted-foreground"> <span className="flex h-6 w-6 shrink-0 items-center justify-center rounded bg-accent text-xs font-medium text-[#0e1016]">
{index + 1} {index + 1}
</span> </span>
) )

View File

@@ -14,6 +14,7 @@ interface ScriptGeneratorState {
selectedTemplate: ScriptTemplateDetail | null selectedTemplate: ScriptTemplateDetail | null
searchQuery: string searchQuery: string
activeCategoryId: string | null // null = "All" activeCategoryId: string | null // null = "All"
tabFilters: { mine?: boolean; shared?: boolean } // current tab's ownership filter
isLoadingTemplates: boolean // drives skeleton in ScriptTemplateList isLoadingTemplates: boolean // drives skeleton in ScriptTemplateList
isLoadingDetail: boolean // drives spinner in ScriptConfigurePane isLoadingDetail: boolean // drives spinner in ScriptConfigurePane
@@ -48,6 +49,7 @@ export const useScriptGeneratorStore = create<ScriptGeneratorState>()((set, get)
selectedTemplate: null, selectedTemplate: null,
searchQuery: '', searchQuery: '',
activeCategoryId: null, activeCategoryId: null,
tabFilters: {},
isLoadingTemplates: false, isLoadingTemplates: false,
isLoadingDetail: false, isLoadingDetail: false,
paramValues: {}, paramValues: {},
@@ -68,6 +70,10 @@ export const useScriptGeneratorStore = create<ScriptGeneratorState>()((set, get)
}, },
loadTemplates: async (filters) => { loadTemplates: async (filters) => {
// When filters are provided (e.g. tab change), persist them so that
// subsequent setCategory/setSearch calls reuse the same ownership filter.
const resolvedFilters = filters !== undefined ? filters : get().tabFilters
if (filters !== undefined) set({ tabFilters: filters })
set({ isLoadingTemplates: true }) set({ isLoadingTemplates: true })
try { try {
const { activeCategoryId, categories, searchQuery } = get() const { activeCategoryId, categories, searchQuery } = get()
@@ -75,8 +81,8 @@ export const useScriptGeneratorStore = create<ScriptGeneratorState>()((set, get)
const params: { category_slug?: string; search?: string; mine?: boolean; shared?: boolean } = {} const params: { category_slug?: string; search?: string; mine?: boolean; shared?: boolean } = {}
if (category) params.category_slug = category.slug if (category) params.category_slug = category.slug
if (searchQuery) params.search = searchQuery if (searchQuery) params.search = searchQuery
if (filters?.mine) params.mine = true if (resolvedFilters.mine) params.mine = true
if (filters?.shared) params.shared = true if (resolvedFilters.shared) params.shared = true
const templates = await scriptsApi.getTemplates(params) const templates = await scriptsApi.getTemplates(params)
set({ templates, isLoadingTemplates: false }) set({ templates, isLoadingTemplates: false })
} catch { } catch {

File diff suppressed because it is too large Load Diff

View File

@@ -117,6 +117,7 @@ export interface SessionDocumentation {
diagnostic_steps: DocumentationStep[] diagnostic_steps: DocumentationStep[]
resolution_summary: string | null resolution_summary: string | null
escalation_reason: string | null escalation_reason: string | null
follow_up_recommendations: string[]
total_steps: number total_steps: number
duration_display: string | null duration_display: string | null
generated_at: string generated_at: string
@@ -131,7 +132,7 @@ export interface SessionCloseResponse {
member_mapping_warning: string | null member_mapping_warning: string | null
} }
export type StatusUpdateAudience = 'ticket_notes' | 'client_update' | 'email_draft' export type StatusUpdateAudience = 'ticket_notes' | 'client_update' | 'email_draft' | 'request_info'
export type StatusUpdateLength = 'quick' | 'detailed' export type StatusUpdateLength = 'quick' | 'detailed'
export type StatusUpdateContext = 'status' | 'resolution' | 'escalation' export type StatusUpdateContext = 'status' | 'resolution' | 'escalation'
@@ -195,7 +196,7 @@ export interface AISessionDetail extends AISessionSummary {
ticket_data: Record<string, unknown> | null ticket_data: Record<string, unknown> | null
steps: AISessionStepResponse[] steps: AISessionStepResponse[]
conversation_messages: Array<{ role: string; content: string }> conversation_messages: Array<{ role: string; content: string }>
pending_task_lane: { questions: QuestionItem[]; actions: ActionItem[] } | null pending_task_lane: { questions: QuestionItem[]; actions: ActionItem[]; responses?: Array<Record<string, unknown>> } | null
is_branching: boolean is_branching: boolean
active_branch_id: string | null active_branch_id: string | null
} }