refactor: dashboard design critique fixes #122
@@ -34,7 +34,7 @@
|
|||||||
|
|
||||||
**Theme:** Dark mode primary (charcoal palette). Light mode planned but not yet implemented.
|
**Theme:** Dark mode primary (charcoal palette). Light mode planned but not yet implemented.
|
||||||
|
|
||||||
**Accent:** Ember orange (#f97316) — conveys urgency fitting a troubleshooting context. Used sparingly (max 5% of UI). Warning uses yellow (#eab308), not amber, to stay distinct.
|
**Accent:** Electric blue (#60a5fa dark / #2563eb light) — conveys trust, precision, and reliability fitting a troubleshooting tool MSP engineers depend on during outages. Used sparingly (max 5% of UI). Warning uses amber (#fbbf24), info uses cyan (#67e8f9).
|
||||||
|
|
||||||
**Hard rules:** No glassmorphism, no gradient surfaces, no ambient orbs, no backdrop blur, no decorative shadows at rest. Elevation = lighter surface + border, not shadow.
|
**Hard rules:** No glassmorphism, no gradient surfaces, no ambient orbs, no backdrop blur, no decorative shadows at rest. Elevation = lighter surface + border, not shadow.
|
||||||
|
|
||||||
|
|||||||
20
CHANGELOG.md
20
CHANGELOG.md
@@ -11,6 +11,26 @@ All notable changes to ResolutionFlow are documented here.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [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`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.10.0] - 2026-03-21
|
## [0.10.0] - 2026-03-21
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -369,6 +369,10 @@ 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/`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## RBAC & Permissions
|
## RBAC & Permissions
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import { Plus, Pin, Trash2, MessageSquare, History, X } from 'lucide-react'
|
import { Plus, Pin, Trash2, MessageSquare, History, X } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import type { ChatListItem } from '@/types/assistant-chat'
|
import type { ChatListItem } from '@/types/assistant-chat'
|
||||||
@@ -84,7 +85,7 @@ export function ChatSidebar({
|
|||||||
<div className="flex-1 overflow-y-auto py-2">
|
<div className="flex-1 overflow-y-auto py-2">
|
||||||
{pinnedChats.length > 0 && (
|
{pinnedChats.length > 0 && (
|
||||||
<div className="px-3 mb-1">
|
<div className="px-3 mb-1">
|
||||||
<span className="font-sans text-xs text-[0.625rem] uppercase tracking-widest text-muted-foreground">
|
<span className="font-sans text-[0.625rem] uppercase tracking-widest text-muted-foreground">
|
||||||
Pinned
|
Pinned
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -184,39 +185,65 @@ function ChatItem({
|
|||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
onTogglePin: () => void
|
onTogglePin: () => void
|
||||||
}) {
|
}) {
|
||||||
|
const [confirming, setConfirming] = useState(false)
|
||||||
|
|
||||||
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',
|
||||||
isActive
|
confirming
|
||||||
? 'bg-accent-dim text-foreground'
|
? 'bg-rose-500/10 border border-rose-500/20'
|
||||||
: 'text-muted-foreground hover:bg-input hover:text-foreground'
|
: isActive
|
||||||
|
? 'bg-accent-dim text-foreground'
|
||||||
|
: 'text-muted-foreground hover:bg-input hover:text-foreground'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<MessageSquare size={14} className="shrink-0" />
|
<MessageSquare size={14} className="shrink-0" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-[0.8125rem] font-medium truncate">{chat.title}</div>
|
{confirming ? (
|
||||||
<div className="text-[0.6875rem] text-muted-foreground">
|
<div className="flex items-center gap-2">
|
||||||
{chat.message_count} messages
|
<span className="text-[0.75rem] text-rose-400 font-medium">Delete?</span>
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); onDelete(); setConfirming(false) }}
|
||||||
|
className="text-[0.6875rem] font-medium text-rose-400 hover:text-rose-300 px-1.5 py-0.5 rounded bg-rose-500/15 hover:bg-rose-500/25 transition-colors"
|
||||||
|
>
|
||||||
|
Yes
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); setConfirming(false) }}
|
||||||
|
className="text-[0.6875rem] text-muted-foreground hover:text-foreground px-1.5 py-0.5"
|
||||||
|
>
|
||||||
|
No
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-[0.8125rem] font-medium truncate">{chat.title}</div>
|
||||||
|
<div className="text-[0.6875rem] text-muted-foreground">
|
||||||
|
{chat.message_count} messages
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!confirming && (
|
||||||
|
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); onTogglePin() }}
|
||||||
|
className="p-1 rounded hover:bg-white/[0.08]"
|
||||||
|
title={chat.pinned ? 'Unpin' : 'Pin'}
|
||||||
|
>
|
||||||
|
<Pin size={12} className={chat.pinned ? 'text-primary' : ''} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={e => { e.stopPropagation(); setConfirming(true) }}
|
||||||
|
className="p-1 rounded hover:bg-white/[0.08] text-muted-foreground hover:text-rose-400"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
<button
|
|
||||||
onClick={e => { e.stopPropagation(); onTogglePin() }}
|
|
||||||
className="p-1 rounded hover:bg-white/[0.08]"
|
|
||||||
title={chat.pinned ? 'Unpin' : 'Pin'}
|
|
||||||
>
|
|
||||||
<Pin size={12} className={chat.pinned ? 'text-primary' : ''} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={e => { e.stopPropagation(); onDelete() }}
|
|
||||||
className="p-1 rounded hover:bg-white/[0.08] text-muted-foreground hover:text-rose-400"
|
|
||||||
title="Delete"
|
|
||||||
>
|
|
||||||
<Trash2 size={12} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -225,8 +225,8 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="relative bg-sidebar border-l border-default flex flex-col shrink-0 animate-in slide-in-from-right-4 duration-200"
|
className="relative border-l border-default flex flex-col shrink-0 animate-slide-in-right"
|
||||||
style={{ width: panelWidth }}
|
style={{ background: 'var(--color-bg-page)', width: panelWidth }}
|
||||||
>
|
>
|
||||||
{/* Resize grip handle */}
|
{/* Resize grip handle */}
|
||||||
<div
|
<div
|
||||||
@@ -234,16 +234,13 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
|||||||
className="absolute left-0 top-0 bottom-0 w-[6px] cursor-col-resize z-20 flex items-center justify-center group hover:bg-accent/10 transition-colors"
|
className="absolute left-0 top-0 bottom-0 w-[6px] cursor-col-resize z-20 flex items-center justify-center group hover:bg-accent/10 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-[3px] opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="flex flex-col gap-[3px] opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<div className="w-[3px] h-[3px] rounded-full bg-muted-foreground" />
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
<div className="w-[3px] h-[3px] rounded-full bg-muted-foreground" />
|
<div key={i} className="w-[3px] h-[3px] rounded-full bg-muted-foreground" />
|
||||||
<div className="w-[3px] h-[3px] rounded-full bg-muted-foreground" />
|
))}
|
||||||
<div className="w-[3px] h-[3px] rounded-full bg-muted-foreground" />
|
|
||||||
<div className="w-[3px] h-[3px] rounded-full bg-muted-foreground" />
|
|
||||||
<div className="w-[3px] h-[3px] rounded-full bg-muted-foreground" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="px-4 py-3 border-b border-default flex items-center justify-between shrink-0">
|
<div className="px-4 py-3 border-b border-default flex items-center justify-between shrink-0" style={{ borderTop: '2px solid var(--color-accent)' }}>
|
||||||
<h3 className="font-heading text-sm font-bold text-heading flex items-center gap-2">
|
<h3 className="font-heading text-sm font-bold text-heading flex items-center gap-2">
|
||||||
Tasks
|
Tasks
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
@@ -266,7 +263,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
|||||||
{/* ── Questions Section ── */}
|
{/* ── Questions Section ── */}
|
||||||
{questionTasks.length > 0 && (
|
{questionTasks.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<div className="sticky top-0 z-10 bg-sidebar pb-2">
|
<div className="sticky top-0 z-10 pb-2" style={{ background: 'var(--color-bg-page)' }}>
|
||||||
<div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[1.2px] text-muted-foreground pl-0.5">
|
<div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[1.2px] text-muted-foreground pl-0.5">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
|
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
|
||||||
Questions
|
Questions
|
||||||
@@ -282,16 +279,19 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
|||||||
|
|
||||||
if (q.state === 'done') {
|
if (q.state === 'done') {
|
||||||
return (
|
return (
|
||||||
<div key={idx} className="rounded-lg border border-success/20 bg-success-dim/20 p-3 mb-2 cursor-pointer hover:border-success/40 transition-colors" onClick={() => updateTask(idx, { state: 'active' })}>
|
<div key={idx} className="rounded-lg border-l-[3px] border-l-success border border-success/25 bg-success-dim/30 p-3 mb-2 cursor-pointer hover:border-success/40 transition-colors" onClick={() => updateTask(idx, { state: 'active' })}>
|
||||||
<div className="text-[0.8125rem] text-muted-foreground">{q.text}</div>
|
<div className="flex items-center gap-1.5">
|
||||||
<div className="text-[0.75rem] text-muted-foreground mt-1 italic">"{q.value}"</div>
|
<Check size={12} className="text-success shrink-0" />
|
||||||
|
<span className="text-[0.8125rem] text-foreground">{q.text}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[0.75rem] text-muted-foreground mt-1 pl-5 italic truncate">"{q.value}"</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (q.state === 'skipped') {
|
if (q.state === 'skipped') {
|
||||||
return (
|
return (
|
||||||
<div key={idx} className="rounded-lg border border-default/50 bg-elevated/20 p-3 mb-2 opacity-50">
|
<div key={idx} className="rounded-lg border border-default/50 bg-elevated/20 p-3 mb-2 opacity-60 cursor-pointer hover:opacity-80 hover:border-default transition-all" onClick={() => updateTask(idx, { state: 'pending' })} title="Click to restore">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<div className="text-[0.8125rem] text-muted-foreground line-through">{q.text}</div>
|
<div className="text-[0.8125rem] text-muted-foreground line-through">{q.text}</div>
|
||||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Skipped</span>
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Skipped</span>
|
||||||
@@ -342,9 +342,10 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => updateTask(idx, { state: 'skipped' })}
|
onClick={() => updateTask(idx, { state: 'skipped' })}
|
||||||
className="flex items-center gap-1 rounded-md px-2.5 py-1.5 text-[0.75rem] text-muted-foreground hover:text-heading"
|
className="p-1.5 rounded-md text-muted-foreground hover:text-heading hover:bg-elevated/50 transition-colors"
|
||||||
|
title="Skip this question"
|
||||||
>
|
>
|
||||||
<SkipForward size={11} /> Skip
|
<SkipForward size={13} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -357,7 +358,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
|||||||
{/* ── Checks Section ── */}
|
{/* ── Checks Section ── */}
|
||||||
{actionTasks.length > 0 && (
|
{actionTasks.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<div className="sticky top-0 z-10 bg-sidebar pb-2">
|
<div className="sticky top-0 z-10 pb-2" style={{ background: 'var(--color-bg-page)' }}>
|
||||||
<div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[1.2px] text-muted-foreground pl-0.5">
|
<div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[1.2px] text-muted-foreground pl-0.5">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-[#60a5fa]" />
|
<span className="w-1.5 h-1.5 rounded-full bg-[#60a5fa]" />
|
||||||
Diagnostic Checks
|
Diagnostic Checks
|
||||||
@@ -401,10 +402,10 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
|||||||
|
|
||||||
if (a.state === 'done') {
|
if (a.state === 'done') {
|
||||||
return (
|
return (
|
||||||
<div key={idx} className="rounded-lg border border-success/20 bg-success-dim/20 p-3 mb-2 cursor-pointer hover:border-success/40 transition-colors" onClick={() => updateTask(idx, { state: 'active' })}>
|
<div key={idx} className="rounded-lg border-l-[3px] border-l-success border border-success/25 bg-success-dim/30 p-3 mb-2 cursor-pointer hover:border-success/40 transition-colors" onClick={() => updateTask(idx, { state: 'active' })}>
|
||||||
<div className="flex justify-between">
|
<div className="flex items-center gap-1.5">
|
||||||
<div className="text-[0.8125rem] font-medium text-heading">{a.label}</div>
|
<Check size={12} className="text-success shrink-0" />
|
||||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-success">✓ Done</span>
|
<span className="text-[0.8125rem] font-medium text-foreground flex-1">{a.label}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -412,7 +413,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
|||||||
|
|
||||||
if (a.state === 'skipped') {
|
if (a.state === 'skipped') {
|
||||||
return (
|
return (
|
||||||
<div key={idx} className="rounded-lg border border-default/50 bg-elevated/20 p-3 mb-2 opacity-50">
|
<div key={idx} className="rounded-lg border border-default/50 bg-elevated/20 p-3 mb-2 opacity-60 cursor-pointer hover:opacity-80 hover:border-default transition-all" onClick={() => updateTask(idx, { state: 'pending' })} title="Click to restore">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<div className="text-[0.8125rem] text-muted-foreground line-through">{a.label}</div>
|
<div className="text-[0.8125rem] text-muted-foreground line-through">{a.label}</div>
|
||||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Skipped</span>
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Skipped</span>
|
||||||
@@ -464,24 +465,19 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-2 flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
|
<div className="mt-2 flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => updateTask(idx, { state: 'active' })}
|
onClick={() => updateTask(idx, { state: 'active' })}
|
||||||
className="flex items-center justify-center gap-1 rounded-md border border-accent/40 bg-accent-dim/30 px-2.5 py-1.5 text-[0.75rem] font-medium text-accent-text hover:bg-accent-dim/50 transition-colors"
|
className="flex items-center gap-1 rounded-md border border-accent/40 bg-accent-dim/30 px-2.5 py-1.5 text-[0.75rem] font-medium text-accent-text hover:bg-accent-dim/50 transition-colors"
|
||||||
>
|
>
|
||||||
<Clipboard size={11} /> Paste Result
|
<Clipboard size={11} /> {a.command ? 'Paste Output' : 'Answer'}
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => updateTask(idx, { state: 'active' })}
|
|
||||||
className="flex items-center justify-center gap-1 rounded-md border border-default bg-elevated/50 px-2.5 py-1.5 text-[0.75rem] font-medium text-heading hover:bg-elevated transition-colors"
|
|
||||||
>
|
|
||||||
Type Answer
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => updateTask(idx, { state: 'skipped' })}
|
onClick={() => updateTask(idx, { state: 'skipped' })}
|
||||||
className="flex items-center justify-center gap-1 rounded-md px-2.5 py-1.5 text-[0.75rem] text-muted-foreground hover:text-heading"
|
className="p-1.5 rounded-md text-muted-foreground hover:text-heading hover:bg-elevated/50 transition-colors"
|
||||||
|
title="Skip this check"
|
||||||
>
|
>
|
||||||
<SkipForward size={11} /> Skip
|
<SkipForward size={13} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -495,19 +491,24 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
|||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="p-3 border-t border-default shrink-0">
|
<div className="p-3 border-t border-default shrink-0">
|
||||||
{/* Progress bar */}
|
{/* Progress bar */}
|
||||||
<div className="flex gap-1 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
{tasks.map((t, i) => (
|
<div className="flex gap-1 flex-1">
|
||||||
<div
|
{tasks.map((t, i) => (
|
||||||
key={i}
|
<div
|
||||||
className={cn(
|
key={i}
|
||||||
'flex-1 h-[3px] rounded-full',
|
className={cn(
|
||||||
t.state === 'done' ? 'bg-success' :
|
'flex-1 h-[5px] rounded-full transition-colors',
|
||||||
t.state === 'skipped' ? 'bg-muted' :
|
t.state === 'done' ? 'bg-success' :
|
||||||
t.state === 'active' ? 'bg-accent' :
|
t.state === 'skipped' ? 'bg-muted-foreground/30' :
|
||||||
'bg-elevated'
|
t.state === 'active' ? 'bg-accent' :
|
||||||
)}
|
'bg-border-default'
|
||||||
/>
|
)}
|
||||||
))}
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="text-[0.625rem] font-medium text-muted-foreground tabular-nums shrink-0">
|
||||||
|
{handledCount}/{totalCount}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/* Collapsible preview */}
|
{/* Collapsible preview */}
|
||||||
{anyHandled && (
|
{anyHandled && (
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(', ')}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -4,16 +4,7 @@ import { Clock, ArrowRight, Route, MessageCircle } from 'lucide-react'
|
|||||||
import { aiSessionsApi } from '@/api/aiSessions'
|
import { aiSessionsApi } from '@/api/aiSessions'
|
||||||
import type { AISessionSummary } from '@/types/ai-session'
|
import type { AISessionSummary } from '@/types/ai-session'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { timeAgo } from '@/lib/timeAgo'
|
||||||
function timeAgo(dateStr: string): string {
|
|
||||||
const diffMs = Date.now() - new Date(dateStr).getTime()
|
|
||||||
const minutes = Math.floor(diffMs / 60000)
|
|
||||||
if (minutes < 1) return 'just now'
|
|
||||||
if (minutes < 60) return `${minutes}m ago`
|
|
||||||
const hours = Math.floor(minutes / 60)
|
|
||||||
if (hours < 24) return `${hours}h ago`
|
|
||||||
return `${Math.floor(hours / 24)}d ago`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ActiveFlowPilotSessions({ hideHeader = false }: { hideHeader?: boolean }) {
|
export function ActiveFlowPilotSessions({ hideHeader = false }: { hideHeader?: boolean }) {
|
||||||
const [sessions, setSessions] = useState<AISessionSummary[]>([])
|
const [sessions, setSessions] = useState<AISessionSummary[]>([])
|
||||||
@@ -68,7 +59,7 @@ export function ActiveFlowPilotSessions({ hideHeader = false }: { hideHeader?: b
|
|||||||
)}
|
)}
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'font-sans text-xs text-[0.5625rem] uppercase px-1.5 py-0.5 rounded',
|
'font-sans text-[0.5625rem] uppercase px-1.5 py-0.5 rounded',
|
||||||
session.confidence_tier === 'guided' && 'bg-emerald-400/10 text-emerald-400',
|
session.confidence_tier === 'guided' && 'bg-emerald-400/10 text-emerald-400',
|
||||||
session.confidence_tier === 'exploring' && 'bg-amber-400/10 text-amber-400',
|
session.confidence_tier === 'exploring' && 'bg-amber-400/10 text-amber-400',
|
||||||
session.confidence_tier === 'discovery' && 'bg-blue-400/10 text-blue-400',
|
session.confidence_tier === 'discovery' && 'bg-blue-400/10 text-blue-400',
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Network, Code2, ListChecks, ArrowRight } from 'lucide-react'
|
import { Network, Code2, ListChecks, ArrowRight, ChevronRight } from 'lucide-react'
|
||||||
import { sidebarApi } from '@/api'
|
import { sidebarApi } from '@/api'
|
||||||
|
import { Skeleton } from '@/components/ui/Skeleton'
|
||||||
|
|
||||||
export function KnowledgeBaseCards() {
|
export function KnowledgeBaseCards() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [flowCount, setFlowCount] = useState(0)
|
const [flowCount, setFlowCount] = useState(0)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
sidebarApi.getStats()
|
sidebarApi.getStats()
|
||||||
.then((stats) => setFlowCount(stats.tree_counts.total))
|
.then((stats) => setFlowCount(stats.tree_counts.total))
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
@@ -33,21 +36,33 @@ export function KnowledgeBaseCards() {
|
|||||||
Browse <ArrowRight size={10} />
|
Browse <ArrowRight size={10} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 divide-x" style={{ borderColor: 'var(--color-border-default)' }}>
|
<div className="py-1">
|
||||||
{items.map((item) => (
|
{loading ? Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3 px-5 py-3" style={{ borderBottom: i < 2 ? '1px solid var(--color-border-default)' : undefined }}>
|
||||||
|
<Skeleton className="h-8 w-8 rounded-md shrink-0" />
|
||||||
|
<Skeleton className="h-4 flex-1 max-w-24" />
|
||||||
|
<Skeleton className="h-5 w-8" />
|
||||||
|
</div>
|
||||||
|
)) : items.map((item, i) => (
|
||||||
<button
|
<button
|
||||||
key={item.label}
|
key={item.label}
|
||||||
onClick={() => navigate(item.href)}
|
onClick={() => navigate(item.href)}
|
||||||
className="flex flex-col items-center gap-2 py-5 rounded-lg hover:bg-card-hover transition-all duration-350"
|
className="flex w-full items-center gap-3 px-5 py-3 text-left hover:bg-[var(--color-bg-card-hover)] transition-colors group"
|
||||||
style={{ transition: 'transform 350ms cubic-bezier(0.34, 1.56, 0.64, 1), background 200ms ease' }}
|
style={{
|
||||||
onMouseEnter={e => { e.currentTarget.style.transform = 'translateY(-4px)' }}
|
borderBottom: i < items.length - 1 ? '1px solid var(--color-border-default)' : undefined,
|
||||||
onMouseLeave={e => { e.currentTarget.style.transform = 'translateY(0)' }}
|
}}
|
||||||
>
|
>
|
||||||
<item.icon size={20} style={{ color: item.color }} />
|
<span
|
||||||
<p className="font-heading text-xl font-extrabold text-foreground">{item.value}</p>
|
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md"
|
||||||
<p className="font-sans text-xs text-[0.5625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
style={{ backgroundColor: `${item.color}15` }}
|
||||||
{item.label}
|
>
|
||||||
</p>
|
<item.icon size={15} style={{ color: item.color }} />
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 text-sm font-medium text-foreground">{item.label}</span>
|
||||||
|
<span className="font-heading text-base font-bold text-foreground tabular-nums mr-1">
|
||||||
|
{item.value}
|
||||||
|
</span>
|
||||||
|
<ChevronRight size={14} className="text-muted-foreground opacity-0 -translate-x-1 group-hover:opacity-100 group-hover:translate-x-0 transition-all" />
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,16 +3,7 @@ import { Link, useNavigate } from 'react-router-dom'
|
|||||||
import { AlertTriangle } from 'lucide-react'
|
import { AlertTriangle } from 'lucide-react'
|
||||||
import { aiSessionsApi } from '@/api/aiSessions'
|
import { aiSessionsApi } from '@/api/aiSessions'
|
||||||
import type { AISessionSummary } from '@/types/ai-session'
|
import type { AISessionSummary } from '@/types/ai-session'
|
||||||
|
import { timeAgo } from '@/lib/timeAgo'
|
||||||
function timeAgo(dateStr: string): string {
|
|
||||||
const diffMs = Date.now() - new Date(dateStr).getTime()
|
|
||||||
const minutes = Math.floor(diffMs / 60000)
|
|
||||||
if (minutes < 1) return 'just now'
|
|
||||||
if (minutes < 60) return `${minutes}m ago`
|
|
||||||
const hours = Math.floor(minutes / 60)
|
|
||||||
if (hours < 24) return `${hours}h ago`
|
|
||||||
return `${Math.floor(hours / 24)}d ago`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PendingEscalations() {
|
export function PendingEscalations() {
|
||||||
const [escalations, setEscalations] = useState<AISessionSummary[]>([])
|
const [escalations, setEscalations] = useState<AISessionSummary[]>([])
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { CheckCircle, Clock, TrendingUp, Timer } from 'lucide-react'
|
|||||||
import type { LucideIcon } from 'lucide-react'
|
import type { LucideIcon } from 'lucide-react'
|
||||||
import { sidebarApi } from '@/api'
|
import { sidebarApi } from '@/api'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Skeleton } from '@/components/ui/Skeleton'
|
||||||
|
|
||||||
interface StatCard {
|
interface StatCard {
|
||||||
label: string
|
label: string
|
||||||
@@ -19,6 +20,8 @@ export function PerformanceCards() {
|
|||||||
const [resolved, setResolved] = useState(0)
|
const [resolved, setResolved] = useState(0)
|
||||||
const [active, setActive] = useState(0)
|
const [active, setActive] = useState(0)
|
||||||
const [totalMinutes, setTotalMinutes] = useState(0)
|
const [totalMinutes, setTotalMinutes] = useState(0)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
sidebarApi.getStats()
|
sidebarApi.getStats()
|
||||||
@@ -27,9 +30,31 @@ export function PerformanceCards() {
|
|||||||
setActive(stats.active_count)
|
setActive(stats.active_count)
|
||||||
setTotalMinutes(stats.total_session_minutes_today)
|
setTotalMinutes(stats.total_session_minutes_today)
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => setError(true))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<div key={i} className="card-flat p-4 space-y-2">
|
||||||
|
<Skeleton className="h-3 w-2/3" />
|
||||||
|
<Skeleton className="h-7 w-1/2" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="card-flat p-4 text-center">
|
||||||
|
<p className="text-sm text-muted-foreground">Unable to load performance data</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const avgMttr = resolved > 0 ? Math.round(totalMinutes / resolved) : 0
|
const avgMttr = resolved > 0 ? Math.round(totalMinutes / resolved) : 0
|
||||||
|
|
||||||
const cards: StatCard[] = [
|
const cards: StatCard[] = [
|
||||||
@@ -70,14 +95,13 @@ export function PerformanceCards() {
|
|||||||
<button
|
<button
|
||||||
key={card.label}
|
key={card.label}
|
||||||
onClick={() => navigate(card.href)}
|
onClick={() => navigate(card.href)}
|
||||||
className="card-interactive p-4 text-left fade-in"
|
className="card-flat p-4 text-left fade-in hover:border-[var(--color-border-hover)] transition-colors cursor-pointer"
|
||||||
style={{
|
style={{
|
||||||
animationDelay: `${400 + i * 60}ms`,
|
animationDelay: `${400 + i * 60}ms`,
|
||||||
borderLeft: `3px solid ${card.iconColor}`,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<p className="font-sans text-xs text-[0.5625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
<p className="font-sans text-[0.5625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
||||||
{card.label}
|
{card.label}
|
||||||
</p>
|
</p>
|
||||||
<card.icon size={14} style={{ color: card.iconColor }} />
|
<card.icon size={14} style={{ color: card.iconColor }} />
|
||||||
|
|||||||
@@ -3,18 +3,7 @@ import { Link, useNavigate } from 'react-router-dom'
|
|||||||
import { CheckCircle, AlertTriangle, XCircle, ArrowRight, MessageCircle } from 'lucide-react'
|
import { CheckCircle, AlertTriangle, XCircle, ArrowRight, MessageCircle } from 'lucide-react'
|
||||||
import { aiSessionsApi } from '@/api/aiSessions'
|
import { aiSessionsApi } from '@/api/aiSessions'
|
||||||
import type { AISessionSummary } from '@/types/ai-session'
|
import type { AISessionSummary } from '@/types/ai-session'
|
||||||
|
import { timeAgo } from '@/lib/timeAgo'
|
||||||
function timeAgo(dateStr: string): string {
|
|
||||||
const diffMs = Date.now() - new Date(dateStr).getTime()
|
|
||||||
const minutes = Math.floor(diffMs / 60000)
|
|
||||||
if (minutes < 1) return 'just now'
|
|
||||||
if (minutes < 60) return `${minutes}m ago`
|
|
||||||
const hours = Math.floor(minutes / 60)
|
|
||||||
if (hours < 24) return `${hours}h ago`
|
|
||||||
const days = Math.floor(hours / 24)
|
|
||||||
if (days === 1) return 'yesterday'
|
|
||||||
return `${days}d ago`
|
|
||||||
}
|
|
||||||
|
|
||||||
const STATUS_CONFIG: Record<string, { icon: typeof CheckCircle; color: string }> = {
|
const STATUS_CONFIG: Record<string, { icon: typeof CheckCircle; color: string }> = {
|
||||||
resolved: { icon: CheckCircle, color: '#34d399' },
|
resolved: { icon: CheckCircle, color: '#34d399' },
|
||||||
|
|||||||
@@ -3,17 +3,20 @@ import { useNavigate } from 'react-router-dom'
|
|||||||
import { Users, AlertTriangle, Activity, ArrowRight } from 'lucide-react'
|
import { Users, AlertTriangle, Activity, ArrowRight } from 'lucide-react'
|
||||||
import { usePermissions } from '@/hooks/usePermissions'
|
import { usePermissions } from '@/hooks/usePermissions'
|
||||||
import { aiSessionsApi } from '@/api/aiSessions'
|
import { aiSessionsApi } from '@/api/aiSessions'
|
||||||
|
import { Skeleton } from '@/components/ui/Skeleton'
|
||||||
|
|
||||||
export function TeamSummary() {
|
export function TeamSummary() {
|
||||||
const { isAccountOwner } = usePermissions()
|
const { isAccountOwner } = usePermissions()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [escalationCount, setEscalationCount] = useState(0)
|
const [escalationCount, setEscalationCount] = useState(0)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAccountOwner) return
|
if (!isAccountOwner) { setLoading(false); return }
|
||||||
aiSessionsApi.getEscalationQueue()
|
aiSessionsApi.getEscalationQueue()
|
||||||
.then((esc) => setEscalationCount(esc.length))
|
.then((esc) => setEscalationCount(esc.length))
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false))
|
||||||
}, [isAccountOwner])
|
}, [isAccountOwner])
|
||||||
|
|
||||||
if (!isAccountOwner) return null
|
if (!isAccountOwner) return null
|
||||||
@@ -38,21 +41,26 @@ export function TeamSummary() {
|
|||||||
Manage <ArrowRight size={10} />
|
Manage <ArrowRight size={10} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 divide-x" style={{ borderColor: 'var(--color-border-default)' }}>
|
<div className="p-4 space-y-3">
|
||||||
{items.map((item) => (
|
{loading ? Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3 px-3 py-2.5">
|
||||||
|
<Skeleton className="h-4 w-4 rounded shrink-0" />
|
||||||
|
<Skeleton className="h-4 flex-1 max-w-28" />
|
||||||
|
<Skeleton className="h-5 w-8" />
|
||||||
|
</div>
|
||||||
|
)) : items.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.label}
|
key={item.label}
|
||||||
onClick={() => navigate(item.href)}
|
onClick={() => navigate(item.href)}
|
||||||
className="flex flex-col items-center gap-2 py-5 rounded-lg hover:bg-card-hover transition-all duration-350"
|
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 hover:bg-[var(--color-bg-card-hover)] transition-colors group"
|
||||||
style={{ transition: 'transform 350ms cubic-bezier(0.34, 1.56, 0.64, 1), background 200ms ease' }}
|
|
||||||
onMouseEnter={e => { e.currentTarget.style.transform = 'translateY(-4px)' }}
|
|
||||||
onMouseLeave={e => { e.currentTarget.style.transform = 'translateY(0)' }}
|
|
||||||
>
|
>
|
||||||
<item.icon size={20} style={{ color: item.color }} />
|
<item.icon size={15} style={{ color: item.color }} className="shrink-0" />
|
||||||
<p className="font-heading text-xl font-extrabold text-foreground">{item.value}</p>
|
<span className="flex-1 text-left text-[0.8125rem] text-muted-foreground group-hover:text-foreground transition-colors">
|
||||||
<p className="font-sans text-xs text-[0.5625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
|
||||||
{item.label}
|
{item.label}
|
||||||
</p>
|
</span>
|
||||||
|
<span className="font-heading text-lg font-bold text-foreground tabular-nums">
|
||||||
|
{item.value}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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) => {
|
||||||
@@ -50,7 +65,7 @@ export function EscalationQueue({ onPickup }: EscalationQueueProps) {
|
|||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="py-12 text-center">
|
<div className="py-12 text-center">
|
||||||
<p className="text-sm text-rose-400">{error}</p>
|
<p className="text-sm text-danger">{error}</p>
|
||||||
<button
|
<button
|
||||||
onClick={loadQueue}
|
onClick={loadQueue}
|
||||||
className="mt-2 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
className="mt-2 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
@@ -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,13 +108,13 @@ 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'}
|
||||||
</p>
|
</p>
|
||||||
{session.escalation_reason && (
|
{session.escalation_reason && (
|
||||||
<p className="mt-1 text-xs text-amber-400 line-clamp-2">
|
<p className="mt-1 text-xs text-warning line-clamp-2">
|
||||||
Reason: {session.escalation_reason}
|
Reason: {session.escalation_reason}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -10,14 +10,7 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { notificationsApi } from '@/api/notifications'
|
import { notificationsApi } from '@/api/notifications'
|
||||||
import type { AppNotification } from '@/types/notification'
|
import type { AppNotification } from '@/types/notification'
|
||||||
|
import { timeAgo } from '@/lib/timeAgo'
|
||||||
function timeAgo(dateStr: string): string {
|
|
||||||
const diff = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000)
|
|
||||||
if (diff < 60) return 'just now'
|
|
||||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
|
|
||||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
|
|
||||||
return `${Math.floor(diff / 86400)}d ago`
|
|
||||||
}
|
|
||||||
|
|
||||||
function EventIcon({ event }: { event: string }) {
|
function EventIcon({ event }: { event: string }) {
|
||||||
switch (event) {
|
switch (event) {
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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-blue-400 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-emerald-400" /> : <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-border text-muted-foreground hover: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-emerald-500/10 border border-emerald-500/20 text-emerald-400 hover:bg-emerald-500/15"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<BookmarkPlus size={14} />
|
<BookmarkPlus size={14} />
|
||||||
Save to Library
|
Save to Library
|
||||||
|
|||||||
@@ -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-blue-400 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-emerald-400" /> : <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-primary 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-emerald-500/10 border border-emerald-500/20 text-emerald-400 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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export function Skeleton({ className, ...props }: SkeletonProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'animate-pulse rounded-lg bg-brand-border',
|
'animate-pulse rounded-lg bg-[var(--color-border-default)]',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -82,6 +82,7 @@
|
|||||||
--animate-fade-in: fade-in 200ms ease-out both;
|
--animate-fade-in: fade-in 200ms ease-out both;
|
||||||
--animate-fade-in-up: fade-in-up 200ms ease-out both;
|
--animate-fade-in-up: fade-in-up 200ms ease-out both;
|
||||||
--animate-slide-in-left: slide-in-from-left 200ms ease-out;
|
--animate-slide-in-left: slide-in-from-left 200ms ease-out;
|
||||||
|
--animate-slide-in-right: slide-in-from-right 200ms ease-out both;
|
||||||
--animate-slide-in-bottom: slide-in-from-bottom 200ms ease-out both;
|
--animate-slide-in-bottom: slide-in-from-bottom 200ms ease-out both;
|
||||||
--animate-scale-in: scale-in 150ms ease-out both;
|
--animate-scale-in: scale-in 150ms ease-out both;
|
||||||
--animate-fade: fadeIn 300ms ease both;
|
--animate-fade: fadeIn 300ms ease both;
|
||||||
@@ -95,6 +96,9 @@
|
|||||||
@keyframes slide-in-from-left {
|
@keyframes slide-in-from-left {
|
||||||
from { transform: translateX(-100%); } to { transform: translateX(0); }
|
from { transform: translateX(-100%); } to { transform: translateX(0); }
|
||||||
}
|
}
|
||||||
|
@keyframes slide-in-from-right {
|
||||||
|
from { opacity: 0; transform: translateX(16px); } to { opacity: 1; transform: translateX(0); }
|
||||||
|
}
|
||||||
@keyframes slide-in-from-bottom {
|
@keyframes slide-in-from-bottom {
|
||||||
from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: translateY(0); }
|
from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: translateY(0); }
|
||||||
}
|
}
|
||||||
|
|||||||
14
frontend/src/lib/timeAgo.ts
Normal file
14
frontend/src/lib/timeAgo.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Formats a date string as a relative time (e.g., "5m ago", "2h ago", "yesterday").
|
||||||
|
*/
|
||||||
|
export function timeAgo(dateStr: string): string {
|
||||||
|
const diffMs = Date.now() - new Date(dateStr).getTime()
|
||||||
|
const minutes = Math.floor(diffMs / 60000)
|
||||||
|
if (minutes < 1) return 'just now'
|
||||||
|
if (minutes < 60) return `${minutes}m ago`
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
if (hours < 24) return `${hours}h ago`
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
if (days === 1) return 'yesterday'
|
||||||
|
return `${days}d ago`
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,453 @@ 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 — 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 className="landing-hero-visual">
|
||||||
|
<div className="landing-preview-window">
|
||||||
|
<div className="landing-preview-titlebar">
|
||||||
|
<div className="landing-preview-dots"><span /><span /><span /></div>
|
||||||
|
<div className="landing-preview-url">
|
||||||
|
<span className="landing-lock-icon">🔒</span>
|
||||||
|
app.resolutionflow.com/pilot
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="landing-preview-body">
|
||||||
|
<div className="landing-mock-session">
|
||||||
|
<div className="landing-chat-animated" style={{ '--chat-index': 0 } as React.CSSProperties}>
|
||||||
|
<div className="landing-mock-chat-line user">
|
||||||
|
<span className="label">You</span>
|
||||||
|
<span className="text">User can't access shared drive after password reset</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="landing-chat-animated" style={{ '--chat-index': 1 } as React.CSSProperties}>
|
||||||
|
<div className="landing-typing-indicator">
|
||||||
|
<span /><span /><span />
|
||||||
|
<span className="landing-typing-label">FlowPilot is thinking…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="landing-chat-animated" style={{ '--chat-index': 2 } as React.CSSProperties}>
|
||||||
|
<div className="landing-mock-chat-line ai">
|
||||||
|
<span className="label">FlowPilot</span>
|
||||||
|
<span className="text">Likely a cached credential issue. Let's check:</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="landing-chat-animated" style={{ '--chat-index': 3 } as React.CSSProperties}>
|
||||||
|
<div className="landing-mock-chat-line ai">
|
||||||
|
<span className="label">FlowPilot</span>
|
||||||
|
<span className="text">1. Run <code>klist purge</code> to clear Kerberos tickets</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="landing-chat-animated" style={{ '--chat-index': 4 } as React.CSSProperties}>
|
||||||
|
<div className="landing-mock-chat-line ai">
|
||||||
|
<span className="label">FlowPilot</span>
|
||||||
|
<span className="text">2. Credential Manager → remove saved share entries</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="landing-chat-animated" style={{ '--chat-index': 5 } as React.CSSProperties}>
|
||||||
|
<div className="landing-mock-chat-line doc">
|
||||||
|
<span className="label">Auto-doc</span>
|
||||||
|
<span className="text">3 steps captured ✓</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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">×</span>
|
|
||||||
</div>
|
|
||||||
<div className="landing-preview-url-bar">
|
|
||||||
<div className="landing-preview-url">
|
|
||||||
<span className="landing-lock-icon">🔒</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'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'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 → 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 ✓</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'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't want to write it. Managers hate chasing it. Clients never see it. The same issues get solved from scratch — every time.</p>
|
||||||
<div className="landing-problem-grid">
|
</div>
|
||||||
<ProblemCard icon="⏱" color="red" title="15–25 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="📋" 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="⏱" color="red" title="15–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="🔄" 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="📋" 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="🧠" 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="🔄" color="slate" title="Knowledge walks out the door" description="When a senior engineer leaves, years of tribal knowledge vanish overnight." />
|
||||||
|
<ProblemCard icon="🧠" 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">−</span>
|
<span className="landing-eq-item">Documentation</span>
|
||||||
<span className="landing-eq-item">Time</span>
|
<span className="landing-eq-operator">−</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 — 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'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's happening, paste an error, or drop a screenshot. FlowPilot understands MSP environments — 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' }}>💬 “User can't access shared drive after password reset, getting Access Denied in Event Viewer”</div>
|
<div className="landing-mock-input">
|
||||||
</div>
|
<span className="landing-mock-input-icon">💬</span>
|
||||||
|
<span>User can'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 — 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 →</span>
|
<span className="label">FlowPilot</span>
|
||||||
</div>
|
<span className="text">Check split tunnel config →</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 ✓</span>
|
<span className="label">Auto-doc</span>
|
||||||
</div>
|
<span className="text">Step captured ✓</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 & Document</h3>
|
<h3>Resolve & 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 — 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">✓</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">✓</span><span>Split tunnel misconfigured — fixed</span></div>
|
<div className="landing-mock-ticket-line"><span className="time">10:04</span><span className="check">✓</span><span>Verified VPN connection active</span></div>
|
||||||
<div className="landing-mock-ticket-line"><span className="time">10:08</span><span className="check">✓</span><span>Confirmed Outlook sync restored</span></div>
|
<div className="landing-mock-ticket-line"><span className="time">10:06</span><span className="check">✓</span><span>Split tunnel misconfigured — fixed</span></div>
|
||||||
<div className="landing-mock-ticket-line"><span className="time">10:09</span><span className="check">✓</span><span>Resolution: VPN split tunnel updated</span></div>
|
<div className="landing-mock-ticket-line"><span className="time">10:08</span><span className="check">✓</span><span>Confirmed Outlook sync restored</span></div>
|
||||||
</div>
|
<div className="landing-mock-ticket-line"><span className="time">10:09</span><span className="check">✓</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 — Your AI Copilot</h3>
|
||||||
|
<p>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.</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 1–5 techs"
|
target="Small MSPs · 1–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 5–25 techs"
|
target="Growing MSPs · 5–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'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… 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 — close the ticket with “Fixed issue” because there's no time. ResolutionFlow is the tool I wanted on every call.
|
||||||
|
</blockquote>
|
||||||
|
<div className="landing-founder-name">— 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'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're in. We'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">© 2026 ResolutionFlow. All rights reserved.</span>
|
<span className="landing-footer-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 +528,7 @@ export default function LandingPage() {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@@ -517,11 +549,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 +561,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 +578,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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { ActiveFlowPilotSessions } from '@/components/dashboard/ActiveFlowPilotS
|
|||||||
import { PerformanceCards } from '@/components/dashboard/PerformanceCards'
|
import { PerformanceCards } from '@/components/dashboard/PerformanceCards'
|
||||||
import { KnowledgeBaseCards } from '@/components/dashboard/KnowledgeBaseCards'
|
import { KnowledgeBaseCards } from '@/components/dashboard/KnowledgeBaseCards'
|
||||||
import { TeamSummary } from '@/components/dashboard/TeamSummary'
|
import { TeamSummary } from '@/components/dashboard/TeamSummary'
|
||||||
import { GreetingStatStrip } from '@/components/dashboard/GreetingStatStrip'
|
|
||||||
|
|
||||||
function SectionLabel({ children, action }: { children: React.ReactNode; action?: React.ReactNode }) {
|
function SectionLabel({ children, action }: { children: React.ReactNode; action?: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
@@ -37,18 +36,14 @@ export function QuickStartPage() {
|
|||||||
<div className="overflow-y-auto h-full">
|
<div className="overflow-y-auto h-full">
|
||||||
<PageMeta title="ResolutionFlow" />
|
<PageMeta title="ResolutionFlow" />
|
||||||
<div className="max-w-4xl mx-auto px-6 pt-12 pb-12">
|
<div className="max-w-4xl mx-auto px-6 pt-12 pb-12">
|
||||||
{/* Hero: Greeting + Stat Strip */}
|
{/* Hero: Greeting */}
|
||||||
<div className="flex items-end justify-between mb-8 animate-fade-in-up">
|
<div className="mb-8 animate-fade-in-up">
|
||||||
<div>
|
<p className="font-sans text-xs uppercase tracking-[0.12em] text-muted-foreground mb-1">
|
||||||
<p className="font-sans text-xs uppercase tracking-[0.12em] text-muted-foreground mb-1">
|
{dayOfWeek}, {formattedDate}
|
||||||
{dayOfWeek}, {formattedDate}
|
</p>
|
||||||
</p>
|
<h1 className="font-heading text-3xl sm:text-4xl font-extrabold tracking-tight text-[#f0f2f5] leading-tight">
|
||||||
<h1 className="font-heading text-3xl sm:text-4xl font-extrabold tracking-tight text-[#f0f2f5] leading-tight">
|
Good {greeting}, {firstName}.
|
||||||
Good {greeting},<br className="hidden sm:block" />
|
</h1>
|
||||||
{firstName}.
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<GreetingStatStrip />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chat-style input */}
|
{/* Chat-style input */}
|
||||||
|
|||||||
@@ -189,6 +189,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>
|
||||||
|
|
||||||
|
|||||||
@@ -97,35 +97,37 @@ 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 */}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,6 @@ import { cn, safeGetItem } from '@/lib/utils'
|
|||||||
import { getSessionResumePath, getTreeNavigatePath } from '@/lib/routing'
|
import { getSessionResumePath, 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,7 +93,6 @@ export function TreeLibraryPage() {
|
|||||||
|
|
||||||
// AI builder state
|
// AI builder state
|
||||||
|
|
||||||
const { aiEnabled } = useCachedQuota()
|
|
||||||
|
|
||||||
// Repeat Last Session
|
// Repeat Last Session
|
||||||
const lastSessionData = (() => {
|
const lastSessionData = (() => {
|
||||||
@@ -311,11 +309,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>
|
||||||
@@ -512,7 +506,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"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user