diff --git a/.impeccable.md b/.impeccable.md index 5b5813d6..b3ce9531 100644 --- a/.impeccable.md +++ b/.impeccable.md @@ -34,7 +34,7 @@ **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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 05fde67b..cf59720d 100644 --- a/CHANGELOG.md +++ b/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 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 25af73c8..78ab6de5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -369,6 +369,10 @@ gh run view --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. +**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 diff --git a/frontend/src/components/assistant/ChatSidebar.tsx b/frontend/src/components/assistant/ChatSidebar.tsx index 133f0a60..9c450050 100644 --- a/frontend/src/components/assistant/ChatSidebar.tsx +++ b/frontend/src/components/assistant/ChatSidebar.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react' import { Plus, Pin, Trash2, MessageSquare, History, X } from 'lucide-react' import { cn } from '@/lib/utils' import type { ChatListItem } from '@/types/assistant-chat' @@ -84,7 +85,7 @@ export function ChatSidebar({
{pinnedChats.length > 0 && (
- + Pinned
@@ -184,39 +185,65 @@ function ChatItem({ onDelete: () => void onTogglePin: () => void }) { + const [confirming, setConfirming] = useState(false) + return (
e.stopPropagation() : onSelect} className={cn( 'group flex items-center gap-2 px-3 py-2.5 mx-1.5 rounded-lg cursor-pointer transition-colors', - isActive - ? 'bg-accent-dim text-foreground' - : 'text-muted-foreground hover:bg-input hover:text-foreground' + confirming + ? 'bg-rose-500/10 border border-rose-500/20' + : isActive + ? 'bg-accent-dim text-foreground' + : 'text-muted-foreground hover:bg-input hover:text-foreground' )} >
-
{chat.title}
-
- {chat.message_count} messages + {confirming ? ( +
+ Delete? + + +
+ ) : ( + <> +
{chat.title}
+
+ {chat.message_count} messages +
+ + )} +
+ {!confirming && ( +
+ +
-
-
- - -
+ )}
) } diff --git a/frontend/src/components/assistant/TaskLane.tsx b/frontend/src/components/assistant/TaskLane.tsx index 7a6a5850..590f34b3 100644 --- a/frontend/src/components/assistant/TaskLane.tsx +++ b/frontend/src/components/assistant/TaskLane.tsx @@ -225,8 +225,8 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa return (
{/* Resize grip handle */}
-
-
-
-
-
-
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))}
{/* Header */} -
+

Tasks 0 && (
-
+
Questions @@ -282,16 +279,19 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa if (q.state === 'done') { return ( -
updateTask(idx, { state: 'active' })}> -
{q.text}
-
"{q.value}"
+
updateTask(idx, { state: 'active' })}> +
+ + {q.text} +
+
"{q.value}"
) } if (q.state === 'skipped') { return ( -
+
updateTask(idx, { state: 'pending' })} title="Click to restore">
{q.text}
Skipped @@ -342,9 +342,10 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
)} @@ -357,7 +358,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa {/* ── Checks Section ── */} {actionTasks.length > 0 && (
-
+
Diagnostic Checks @@ -401,10 +402,10 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa if (a.state === 'done') { return ( -
updateTask(idx, { state: 'active' })}> -
-
{a.label}
- ✓ Done +
updateTask(idx, { state: 'active' })}> +
+ + {a.label}
) @@ -412,7 +413,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa if (a.state === 'skipped') { return ( -
+
updateTask(idx, { state: 'pending' })} title="Click to restore">
{a.label}
Skipped @@ -464,24 +465,19 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
) : ( -
+
-
)} @@ -495,19 +491,24 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa {/* Footer */}
{/* Progress bar */} -
- {tasks.map((t, i) => ( -
- ))} +
+
+ {tasks.map((t, i) => ( +
+ ))} +
+ + {handledCount}/{totalCount} +
{/* Collapsible preview */} {anyHandled && ( diff --git a/frontend/src/components/common/CreateFlowDropdown.tsx b/frontend/src/components/common/CreateFlowDropdown.tsx index 69a005fa..9e61a1bd 100644 --- a/frontend/src/components/common/CreateFlowDropdown.tsx +++ b/frontend/src/components/common/CreateFlowDropdown.tsx @@ -1,65 +1,19 @@ import { useState } from 'react' -import { Link, useNavigate } from 'react-router-dom' -import { Plus, ChevronDown, Sparkles, FolderTree, ListOrdered } from 'lucide-react' +import { Link } from 'react-router-dom' +import { Plus, ChevronDown, FolderTree, ListOrdered } from 'lucide-react' 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 { - aiEnabled: boolean className?: string /** Button label — defaults to "Create Flow" */ label?: string } export function CreateFlowDropdown({ - aiEnabled, className, label = 'Create Flow', }: CreateFlowDropdownProps) { const [showMenu, setShowMenu] = useState(false) - const [aiPromptOpen, setAiPromptOpen] = useState(false) - const [aiPromptFlowType, setAiPromptFlowType] = useState('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 (
@@ -74,43 +28,25 @@ export function CreateFlowDropdown({ {showMenu && ( <>
setShowMenu(false)} /> -
- {/* Troubleshooting */} +
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" >
-
Troubleshooting Tree
+
Troubleshooting Flow
Branching decision flow
- {aiEnabled && ( - - )}
- {/* Procedural */} 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" >
@@ -118,51 +54,9 @@ export function CreateFlowDropdown({
Step-by-step procedure
- {aiEnabled && ( - - )} - -
- - {aiEnabled && ( - - )}
)} - - setAiPromptOpen(false)} - onGenerate={handleAIGenerate} - flowType={aiPromptFlowType} - />
) } diff --git a/frontend/src/components/common/TagBadges.tsx b/frontend/src/components/common/TagBadges.tsx index 2cb22eb2..75723814 100644 --- a/frontend/src/components/common/TagBadges.tsx +++ b/frontend/src/components/common/TagBadges.tsx @@ -34,11 +34,11 @@ export function TagBadges({ }} disabled={!onTagClick} 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', variant === 'default' - ? 'bg-accent text-muted-foreground hover:bg-accent' - : 'bg-accent/50 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-[rgba(255,255,255,0.04)] text-muted-foreground border border-border hover:text-foreground', !onTagClick && 'cursor-default' )} > @@ -48,9 +48,9 @@ export function TagBadges({ {hiddenCount > 0 && ( diff --git a/frontend/src/components/dashboard/ActiveFlowPilotSessions.tsx b/frontend/src/components/dashboard/ActiveFlowPilotSessions.tsx index 41093810..3f6e8b08 100644 --- a/frontend/src/components/dashboard/ActiveFlowPilotSessions.tsx +++ b/frontend/src/components/dashboard/ActiveFlowPilotSessions.tsx @@ -4,16 +4,7 @@ import { Clock, ArrowRight, Route, MessageCircle } from 'lucide-react' import { aiSessionsApi } from '@/api/aiSessions' import type { AISessionSummary } from '@/types/ai-session' import { cn } from '@/lib/utils' - -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` -} +import { timeAgo } from '@/lib/timeAgo' export function ActiveFlowPilotSessions({ hideHeader = false }: { hideHeader?: boolean }) { const [sessions, setSessions] = useState([]) @@ -68,7 +59,7 @@ export function ActiveFlowPilotSessions({ hideHeader = false }: { hideHeader?: b )} { sidebarApi.getStats() .then((stats) => setFlowCount(stats.tree_counts.total)) .catch(() => {}) + .finally(() => setLoading(false)) }, []) const items = [ @@ -33,21 +36,33 @@ export function KnowledgeBaseCards() { Browse
-
- {items.map((item) => ( +
+ {loading ? Array.from({ length: 3 }).map((_, i) => ( +
+ + + +
+ )) : items.map((item, i) => ( ))}
diff --git a/frontend/src/components/dashboard/PendingEscalations.tsx b/frontend/src/components/dashboard/PendingEscalations.tsx index 2d36a561..7db7955a 100644 --- a/frontend/src/components/dashboard/PendingEscalations.tsx +++ b/frontend/src/components/dashboard/PendingEscalations.tsx @@ -3,16 +3,7 @@ import { Link, useNavigate } from 'react-router-dom' import { AlertTriangle } from 'lucide-react' import { aiSessionsApi } from '@/api/aiSessions' import type { AISessionSummary } from '@/types/ai-session' - -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` -} +import { timeAgo } from '@/lib/timeAgo' export function PendingEscalations() { const [escalations, setEscalations] = useState([]) diff --git a/frontend/src/components/dashboard/PerformanceCards.tsx b/frontend/src/components/dashboard/PerformanceCards.tsx index 6ae67aa6..c8dbb701 100644 --- a/frontend/src/components/dashboard/PerformanceCards.tsx +++ b/frontend/src/components/dashboard/PerformanceCards.tsx @@ -4,6 +4,7 @@ import { CheckCircle, Clock, TrendingUp, Timer } from 'lucide-react' import type { LucideIcon } from 'lucide-react' import { sidebarApi } from '@/api' import { cn } from '@/lib/utils' +import { Skeleton } from '@/components/ui/Skeleton' interface StatCard { label: string @@ -19,6 +20,8 @@ export function PerformanceCards() { const [resolved, setResolved] = useState(0) const [active, setActive] = useState(0) const [totalMinutes, setTotalMinutes] = useState(0) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(false) useEffect(() => { sidebarApi.getStats() @@ -27,9 +30,31 @@ export function PerformanceCards() { setActive(stats.active_count) setTotalMinutes(stats.total_session_minutes_today) }) - .catch(() => {}) + .catch(() => setError(true)) + .finally(() => setLoading(false)) }, []) + if (loading) { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ + +
+ ))} +
+ ) + } + + if (error) { + return ( +
+

Unable to load performance data

+
+ ) + } + const avgMttr = resolved > 0 ? Math.round(totalMinutes / resolved) : 0 const cards: StatCard[] = [ @@ -70,14 +95,13 @@ export function PerformanceCards() {
-
- {items.map((item) => ( +
+ {loading ? Array.from({ length: 3 }).map((_, i) => ( +
+ + + +
+ )) : items.map((item) => ( ))}
diff --git a/frontend/src/components/flowpilot/EscalationQueue.tsx b/frontend/src/components/flowpilot/EscalationQueue.tsx index af2de015..20e865f1 100644 --- a/frontend/src/components/flowpilot/EscalationQueue.tsx +++ b/frontend/src/components/flowpilot/EscalationQueue.tsx @@ -3,12 +3,21 @@ import { useNavigate } from 'react-router-dom' import { AlertTriangle, Clock, Hash, Ticket, Loader2, RefreshCw } from 'lucide-react' import { aiSessionsApi } from '@/api' import type { AISessionSummary } from '@/types/ai-session' +import { timeAgo } from '@/lib/timeAgo' interface EscalationQueueProps { 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 [sessions, setSessions] = useState([]) const [isLoading, setIsLoading] = useState(true) @@ -19,7 +28,12 @@ export function EscalationQueue({ onPickup }: EscalationQueueProps) { setError(null) try { 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 { setError('Failed to load escalation queue') } finally { @@ -29,6 +43,7 @@ export function EscalationQueue({ onPickup }: EscalationQueueProps) { useEffect(() => { loadQueue() + // eslint-disable-next-line react-hooks/exhaustive-deps -- load once on mount }, []) const handlePickup = (sessionId: string) => { @@ -50,7 +65,7 @@ export function EscalationQueue({ onPickup }: EscalationQueueProps) { if (error) { return (
-

{error}

+

{error}

+
+ +
))}
diff --git a/frontend/src/components/layout/NotificationsPanel.tsx b/frontend/src/components/layout/NotificationsPanel.tsx index 8f634173..a261a068 100644 --- a/frontend/src/components/layout/NotificationsPanel.tsx +++ b/frontend/src/components/layout/NotificationsPanel.tsx @@ -10,14 +10,7 @@ import { } from 'lucide-react' import { notificationsApi } from '@/api/notifications' import type { AppNotification } from '@/types/notification' - -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` -} +import { timeAgo } from '@/lib/timeAgo' function EventIcon({ event }: { event: string }) { switch (event) { diff --git a/frontend/src/components/script-builder/ScriptBuilderInput.tsx b/frontend/src/components/script-builder/ScriptBuilderInput.tsx index 47bb10ab..87cf508d 100644 --- a/frontend/src/components/script-builder/ScriptBuilderInput.tsx +++ b/frontend/src/components/script-builder/ScriptBuilderInput.tsx @@ -1,17 +1,27 @@ 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' +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 { onSend: (content: string) => void disabled: boolean placeholder?: string + showSuggestions?: boolean } export function ScriptBuilderInput({ onSend, disabled, placeholder = 'Describe the script you need...', + showSuggestions = false, }: ScriptBuilderInputProps) { const [value, setValue] = useState('') const textareaRef = useRef(null) @@ -44,35 +54,54 @@ export function ScriptBuilderInput({ const canSend = value.trim().length > 0 && !disabled return ( -
-