feat: copilot-first dashboard — chat-style input with file support

Redesign dashboard as copilot-first experience:
- Large auto-growing textarea with paste/drag-drop file support
- Attach button for native file picker (images, logs, docs)
- Paste Logs expandable textarea for raw error output
- Suggestion chips for common issues (VPN, Outlook, lockout, etc.)
- Remove Guided/Chat toggle — copilot is always the default
- Collapsible Dashboard section for stats/KB/team summary
- Centered layout with "What are you troubleshooting?" hero

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-23 03:56:37 +00:00
parent 6e01a4d04b
commit 36be22e407
2 changed files with 370 additions and 99 deletions

View File

@@ -1,27 +1,50 @@
import { useState, useRef, useEffect } from 'react'
import { useState, useRef, useEffect, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { Sparkles, MessageCircle } from 'lucide-react'
import { Send, Paperclip, Terminal, Loader2, X, RotateCcw, ImagePlus } from 'lucide-react'
import { cn } from '@/lib/utils'
import { uploadsApi } from '@/api/uploads'
import type { FileUploadResponse, PendingUpload } from '@/types/upload'
type SessionMode = 'guided' | 'chat'
const SUGGESTIONS = [
'VPN not connecting',
'Outlook not syncing',
'User locked out',
'Slow internet',
'Printer issues',
'MFA problems',
]
const ACCEPTED_FILE_TYPES = 'image/png,image/jpeg,image/gif,image/webp,.txt,.log,.csv,.pdf,.docx'
export function StartSessionInput() {
const [mode, setMode] = useState<SessionMode>('guided')
const [value, setValue] = useState('')
const [showLogs, setShowLogs] = useState(false)
const [logContent, setLogContent] = useState('')
const [pendingUploads, setPendingUploads] = useState<PendingUpload[]>([])
const [isDragOver, setIsDragOver] = useState(false)
const navigate = useNavigate()
const inputRef = useRef<HTMLInputElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const dragCounterRef = useRef(0)
useEffect(() => { inputRef.current?.focus() }, [])
useEffect(() => { textareaRef.current?.focus() }, [])
// Auto-grow textarea
useEffect(() => {
const el = textareaRef.current
if (!el) return
el.style.height = 'auto'
el.style.height = `${Math.min(el.scrollHeight, 200)}px`
}, [value])
const handleSubmit = () => {
const trimmed = value.trim()
if (!trimmed) return
if (mode === 'guided') {
navigate('/pilot', { state: { prefill: trimmed } })
} else {
navigate('/assistant', { state: { prefill: trimmed } })
const state: Record<string, unknown> = { prefill: trimmed }
if (logContent.trim()) {
state.logs = logContent.trim()
}
navigate('/pilot', { state })
}
const handleKeyDown = (e: React.KeyboardEvent) => {
@@ -31,58 +54,290 @@ export function StartSessionInput() {
}
}
const handleSuggestionClick = (suggestion: string) => {
navigate('/pilot', { state: { prefill: suggestion } })
}
// ── File handling ──────────────────────────────
const processFiles = useCallback((files: File[]) => {
if (files.length === 0) return
const newUploads: PendingUpload[] = files.map((file) => ({
id: crypto.randomUUID(),
file,
preview: file.type.startsWith('image/') ? URL.createObjectURL(file) : '',
status: 'uploading' as const,
}))
setPendingUploads((prev) => [...prev, ...newUploads])
newUploads.forEach((upload) => {
uploadsApi
.upload(upload.file)
.then((result) => {
setPendingUploads((prev) =>
prev.map((u) => u.id === upload.id ? { ...u, status: 'done' as const, result } : u)
)
})
.catch((err) => {
const errorMsg = err?.response?.status === 503
? 'File uploads not available'
: err?.message || 'Upload failed'
setPendingUploads((prev) =>
prev.map((u) => u.id === upload.id ? { ...u, status: 'error' as const, error: errorMsg } : u)
)
})
})
}, [])
const handlePaste = useCallback((e: React.ClipboardEvent<HTMLTextAreaElement>) => {
const items = e.clipboardData?.items
if (!items) return
const imageFiles: File[] = []
for (let i = 0; i < items.length; i++) {
if (items[i].type.startsWith('image/')) {
const file = items[i].getAsFile()
if (file) imageFiles.push(file)
}
}
if (imageFiles.length > 0) {
e.preventDefault()
processFiles(imageFiles)
}
}, [processFiles])
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
}, [])
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault()
dragCounterRef.current++
if (dragCounterRef.current === 1) setIsDragOver(true)
}, [])
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault()
dragCounterRef.current--
if (dragCounterRef.current === 0) setIsDragOver(false)
}, [])
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
dragCounterRef.current = 0
setIsDragOver(false)
processFiles(Array.from(e.dataTransfer.files))
}, [processFiles])
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
processFiles(Array.from(e.target.files))
e.target.value = '' // reset so same file can be re-selected
}
}, [processFiles])
const handleRemoveUpload = useCallback((uploadId: string) => {
setPendingUploads((prev) => {
const toRemove = prev.find((u) => u.id === uploadId)
if (toRemove?.preview) URL.revokeObjectURL(toRemove.preview)
return prev.filter((u) => u.id !== uploadId)
})
}, [])
const retryUpload = useCallback((uploadId: string) => {
const upload = pendingUploads.find((u) => u.id === uploadId)
if (!upload) return
setPendingUploads((prev) =>
prev.map((u) => u.id === uploadId ? { ...u, status: 'uploading' as const, error: undefined } : u)
)
uploadsApi
.upload(upload.file)
.then((result) => {
setPendingUploads((prev) =>
prev.map((u) => u.id === uploadId ? { ...u, status: 'done' as const, result } : u)
)
})
.catch((err) => {
setPendingUploads((prev) =>
prev.map((u) => u.id === uploadId ? { ...u, status: 'error' as const, error: err?.message || 'Upload failed' } : u)
)
})
}, [pendingUploads])
// Cleanup blob URLs on unmount
useEffect(() => {
return () => {
pendingUploads.forEach((u) => { if (u.preview) URL.revokeObjectURL(u.preview) })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const hasContent = value.trim().length > 0
return (
<div className="card-flat overflow-hidden">
<div className="px-5 py-4 sm:px-6 sm:py-5">
<div className="relative">
<Sparkles
size={18}
className="absolute left-4 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<input
ref={inputRef}
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="What are you troubleshooting?"
className="w-full rounded-xl border border-border bg-background py-3.5 pl-11 pr-4 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20"
/>
</div>
<div className="mt-3 flex items-center justify-between">
<div className="flex items-center gap-1 rounded-lg bg-secondary p-0.5">
<button
type="button"
onClick={() => setMode('guided')}
className={cn(
'flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-all duration-150',
mode === 'guided'
? 'bg-accent-dim text-foreground tab-active-shadow'
: 'text-muted-foreground hover:text-foreground'
)}
>
<Sparkles size={12} />
Guided
</button>
<button
type="button"
onClick={() => setMode('chat')}
className={cn(
'flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-all duration-150',
mode === 'chat'
? 'bg-accent-dim text-foreground tab-active-shadow'
: 'text-muted-foreground hover:text-foreground'
)}
>
<MessageCircle size={12} />
Open Chat
</button>
<div
className="w-full"
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* Main input area */}
<div className={cn(
'relative rounded-2xl border bg-card transition-all',
isDragOver ? 'border-primary/50 bg-primary/5' : 'border-border',
'focus-within:border-[rgba(6,182,212,0.3)] focus-within:ring-1 focus-within:ring-primary/20'
)}>
{/* Drag overlay */}
{isDragOver && (
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-2xl border-2 border-dashed border-primary/50 bg-primary/5 pointer-events-none">
<div className="flex items-center gap-2 text-sm text-primary">
<ImagePlus size={18} />
Drop files to attach
</div>
</div>
<span className="text-[0.625rem] text-muted-foreground">
Press Enter to start
</span>
)}
{/* Textarea */}
<textarea
ref={textareaRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder="Describe the issue, paste an error message, or drop a screenshot..."
rows={3}
className="w-full resize-none bg-transparent px-5 pt-5 pb-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
style={{ minHeight: '80px', maxHeight: '200px' }}
/>
{/* Thumbnail strip */}
{pendingUploads.length > 0 && (
<div className="flex gap-2 flex-wrap px-5 pb-2">
{pendingUploads.map((upload) => (
<div key={upload.id} className="relative w-14 h-14 rounded-lg overflow-hidden border border-border bg-background">
{upload.preview ? (
<img src={upload.preview} alt="" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-[0.5rem] text-muted-foreground px-1 text-center">
{upload.file.name.split('.').pop()?.toUpperCase()}
</div>
)}
{upload.status === 'uploading' && (
<div className="absolute inset-0 bg-background/60 flex items-center justify-center">
<Loader2 size={14} className="animate-spin text-primary" />
</div>
)}
{upload.status === 'done' && (
<button
type="button"
onClick={() => handleRemoveUpload(upload.id)}
className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-background border border-border flex items-center justify-center hover:bg-rose-500/20 transition-colors"
>
<X size={8} className="text-muted-foreground" />
</button>
)}
{upload.status === 'error' && (
<div
className="absolute inset-0 bg-rose-500/20 border-2 border-rose-500 flex items-center justify-center cursor-pointer"
onClick={() => retryUpload(upload.id)}
>
<RotateCcw size={12} className="text-rose-500" />
</div>
)}
</div>
))}
</div>
)}
{/* Logs textarea (expandable) */}
{showLogs && (
<div className="px-5 pb-2">
<div className="flex items-center justify-between mb-1">
<span className="text-[0.625rem] uppercase tracking-wide text-muted-foreground font-sans">Paste logs or error output</span>
<button type="button" onClick={() => { setShowLogs(false); setLogContent('') }} className="text-muted-foreground hover:text-foreground">
<X size={14} />
</button>
</div>
<textarea
value={logContent}
onChange={(e) => setLogContent(e.target.value)}
placeholder="Paste event viewer logs, error messages, PowerShell output..."
rows={4}
className="w-full resize-none rounded-lg border border-border bg-background p-3 font-mono text-xs text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-none"
/>
</div>
)}
{/* Bottom toolbar */}
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border/50">
<div className="flex items-center gap-1">
{/* Attach button */}
<input
ref={fileInputRef}
type="file"
multiple
accept={ACCEPTED_FILE_TYPES}
onChange={handleFileSelect}
className="hidden"
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
title="Attach files"
>
<Paperclip size={14} />
<span className="hidden sm:inline">Attach</span>
</button>
{/* Paste Logs button */}
{!showLogs && (
<button
type="button"
onClick={() => setShowLogs(true)}
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors"
title="Paste logs or error output"
>
<Terminal size={14} />
<span className="hidden sm:inline">Paste Logs</span>
</button>
)}
</div>
{/* Send button */}
<button
type="button"
onClick={handleSubmit}
disabled={!hasContent}
className={cn(
'flex h-8 w-8 items-center justify-center rounded-lg transition-all',
hasContent
? 'bg-primary text-white hover:brightness-110 active:scale-95'
: 'bg-secondary text-muted-foreground cursor-not-allowed'
)}
title="Start session"
>
<Send size={15} />
</button>
</div>
</div>
{/* Suggestion chips */}
<div className="flex flex-wrap gap-2 mt-3">
{SUGGESTIONS.map((s) => (
<button
key={s}
type="button"
onClick={() => handleSuggestionClick(s)}
className="rounded-full border border-border px-3 py-1.5 text-xs text-muted-foreground hover:text-foreground hover:border-primary/30 hover:bg-primary/5 transition-colors"
>
{s}
</button>
))}
</div>
</div>
)
}

View File

@@ -1,3 +1,4 @@
import { useState } from 'react'
import { PageMeta } from '@/components/common/PageMeta'
import { useAuthStore } from '@/store/authStore'
import { StartSessionInput } from '@/components/dashboard/StartSessionInput'
@@ -7,61 +8,76 @@ import { PerformanceCards } from '@/components/dashboard/PerformanceCards'
import { KnowledgeBaseCards } from '@/components/dashboard/KnowledgeBaseCards'
import { TeamSummary } from '@/components/dashboard/TeamSummary'
import { RecentFlowPilotSessions } from '@/components/dashboard/RecentFlowPilotSessions'
import { OnboardingChecklist } from '@/components/dashboard/OnboardingChecklist'
import { ChevronDown } from 'lucide-react'
import { cn } from '@/lib/utils'
export function QuickStartPage() {
const user = useAuthStore((s) => s.user)
const [dashboardExpanded, setDashboardExpanded] = useState(false)
const greeting = new Date().getHours() < 12
? 'morning'
: new Date().getHours() < 18
? 'afternoon'
: 'evening'
return (
<div className="overflow-y-auto h-full">
<PageMeta title="Dashboard" />
<div className="p-6 space-y-5 max-w-5xl mx-auto">
{/* Greeting */}
<div className="fade-in" style={{ animationDelay: '100ms' }}>
<h1 className="font-heading text-3xl font-extrabold tracking-tight text-foreground">
Good{' '}
{new Date().getHours() < 12
? 'morning'
: new Date().getHours() < 18
? 'afternoon'
: 'evening'}
, {user?.name?.split(' ')[0] || 'there'}
<PageMeta title="ResolutionFlow" />
<div className="max-w-3xl mx-auto px-6 pt-12 pb-8">
{/* Hero: Greeting + Input */}
<div className="text-center mb-6">
<h1 className="font-heading text-2xl font-extrabold tracking-tight text-foreground">
Good {greeting}, {user?.name?.split(' ')[0] || 'there'}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
{new Date().toLocaleDateString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric',
})}
<p className="mt-1 text-base text-muted-foreground">
What are you troubleshooting?
</p>
</div>
{/* Onboarding */}
<OnboardingChecklist />
{/* Chat-style input */}
<StartSessionInput />
{/* 1. Start Session Input */}
<div className="fade-in" style={{ animationDelay: '200ms' }}>
<StartSessionInput />
{/* Pending Escalations (auto-hides if none) */}
<div className="mt-6">
<PendingEscalations />
</div>
{/* 2. Pending Escalations (auto-hides if none) */}
<PendingEscalations />
{/* 3. Active Sessions */}
<ActiveFlowPilotSessions />
{/* 4. Performance Stats */}
<PerformanceCards />
{/* 5 + 6. Knowledge Base + Team Summary side by side on desktop */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
<KnowledgeBaseCards />
<TeamSummary />
{/* Active Sessions */}
<div className="mt-4">
<ActiveFlowPilotSessions />
</div>
{/* 7. Recent Sessions */}
<RecentFlowPilotSessions />
{/* Recent Sessions */}
<div className="mt-4">
<RecentFlowPilotSessions />
</div>
{/* Collapsible Dashboard section */}
<div className="mt-8">
<button
type="button"
onClick={() => setDashboardExpanded(!dashboardExpanded)}
className="flex items-center gap-2 text-xs font-sans uppercase tracking-wide text-muted-foreground hover:text-foreground transition-colors w-full"
>
<div className="flex-1 h-px bg-border" />
<span className="flex items-center gap-1.5 px-3">
Dashboard
<ChevronDown size={12} className={cn('transition-transform', dashboardExpanded && 'rotate-180')} />
</span>
<div className="flex-1 h-px bg-border" />
</button>
{dashboardExpanded && (
<div className="mt-4 space-y-4 animate-fade-in">
<PerformanceCards />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<KnowledgeBaseCards />
<TeamSummary />
</div>
</div>
)}
</div>
</div>
</div>
)