feat(session): impeccable session-screen pass + tasklane keyboard flow
Multi-step UX refactor of the assistant chat session screen, run via the $impeccable skill. Heuristic score moved 24/40 → 33/40 (+9), with the biggest gains on Aesthetic & Minimalist (1→3), Consistency & Standards (1→3), and Recognition Rather Than Recall (2→4). Distill — chat region: - Remove the "Suggested checks" chip strip + selected-chip detail card; the TaskLane is the single canonical home for "what to do next" - Add an inline Next steps · N pending cue above the latest action-bearing AI bubble (anchors attention without duplicating the lane's items) - Link banner ↔ script-panel lifecycle: collapsing or dismissing the ProposalBanner now also hides the InlineNoTemplateDialog / TemplateMatchPanel - Drop backdrop-blur on the handoff-context overlay (DESIGN-SYSTEM hard rule) Quieter — drop decoration overshoot: - Remove 3px side stripes on TaskLane done cards, all 6 ProposalBanner modes, WhatWeKnowItem fact rows - Drop bg-gradient surfaces on WhatWeKnow + every ProposalBanner mode - Drop 2px accent borderTop on the TaskLane header - Replace bordered avatar boxes in banners with inline state-colored icons - Each surface now uses a single decoration channel (top border + inline icon) Layout: - Header consolidates to Resolve + Escalate + ⋯ kebab; Context, New Ticket, Update Ticket, Pause now live behind the kebab on desktop, with feature parity in the existing mobile overflow menu - Messages column anchors to max-w-3xl mx-auto to match the composer - Chat bubbles drop from rounded-2xl to rounded-xl for vocabulary alignment Typeset: - Unify text sizing from 14 distinct sizes (with sub-pixel oddities and rem/px duplicates) to a 5-step scale: 10px / 11px / text-xs / 13px / text-sm WhatWeKnow collapsible: - Header is now a toggle; section body hides when collapsed - Auto-collapses on first render when facts ≥ 5 so Questions / Diagnostic Checks stay above the fold - Engineer's choice persists in sessionStorage per session and beats the auto-collapse heuristic on subsequent renders - key=activeChatId on both render sites resets state cleanly across sessions Polish: - Split MessageCircleQuestion into Pencil (question Answer CTA, write affordance) + HelpCircle (per-check Explain toggle, universal help icon) — same icon for two different jobs was a discoverability bug - Drop redundant text-xs from font-sans text-[0.625rem] / text-[0.6875rem] double-class definitions; the more-specific size always wins TaskLane keyboard flow: - Enter submits and auto-advances to the next pending task; Shift+Enter inserts a newline (consistent across question and action textareas — paste events don't fire keydown, so paste-then-Enter still works as expected) - Esc cancels (same as the Cancel button) - After the last pending task is submitted, focus moves to the Send Responses button so the engineer can fire the whole batch with one more keystroke - Subtle hint row under each open input teaches the shortcut Type-check, lint, and build all clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -74,7 +74,7 @@ export function ActionCardGroup({ actions, onSubmit, disabled, stale }: ActionCa
|
||||
onClick={() => setExpanded(true)}
|
||||
className="w-full rounded-lg border border-default/50 bg-elevated/20 p-2.5 flex items-center justify-between text-left hover:bg-elevated/40 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-[0.75rem] text-muted-foreground">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Terminal size={12} />
|
||||
<span>{pendingCount} diagnostic check{pendingCount !== 1 ? 's' : ''} — not completed</span>
|
||||
</div>
|
||||
@@ -95,7 +95,7 @@ export function ActionCardGroup({ actions, onSubmit, disabled, stale }: ActionCa
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{responses.map((r, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-[0.75rem] text-muted-foreground">
|
||||
<div key={i} className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{r.state === 'done' ? (
|
||||
<Check size={10} className="text-success shrink-0" />
|
||||
) : (
|
||||
@@ -118,7 +118,7 @@ export function ActionCardGroup({ actions, onSubmit, disabled, stale }: ActionCa
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowRunAll(!showRunAll)}
|
||||
className="flex items-center gap-1.5 text-[0.75rem] font-medium text-accent-text hover:text-accent transition-colors"
|
||||
className="flex items-center gap-1.5 text-xs font-medium text-accent-text hover:text-accent transition-colors"
|
||||
>
|
||||
<Terminal size={12} />
|
||||
<span>Run All ({commandActions.length} commands)</span>
|
||||
@@ -128,12 +128,12 @@ export function ActionCardGroup({ actions, onSubmit, disabled, stale }: ActionCa
|
||||
{showRunAll && (
|
||||
<div className="mt-2 rounded-lg border border-default bg-code p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<span className="text-[0.625rem] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Combined diagnostic script
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleCopyCommand(combinedScript)}
|
||||
className="flex items-center gap-1 text-[0.75rem] text-muted-foreground hover:text-heading transition-colors"
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-heading transition-colors"
|
||||
>
|
||||
<Copy size={11} />
|
||||
<span>Copy</span>
|
||||
@@ -167,23 +167,23 @@ export function ActionCardGroup({ actions, onSubmit, disabled, stale }: ActionCa
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[0.8125rem] font-medium text-heading">{action.label}</div>
|
||||
{action.description && (
|
||||
<div className="text-[0.75rem] text-muted-foreground mt-0.5">{action.description}</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">{action.description}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status badge for handled cards */}
|
||||
{response.state === 'done' && (
|
||||
<span className="shrink-0 text-[10px] font-semibold uppercase tracking-wider text-success">Done</span>
|
||||
<span className="shrink-0 text-[0.625rem] font-semibold uppercase tracking-wider text-success">Done</span>
|
||||
)}
|
||||
{response.state === 'skipped' && (
|
||||
<span className="shrink-0 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Skipped</span>
|
||||
<span className="shrink-0 text-[0.625rem] font-semibold uppercase tracking-wider text-muted-foreground">Skipped</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Command with copy button */}
|
||||
{action.command && response.state !== 'skipped' && (
|
||||
<div className="mt-2 flex items-center gap-2 rounded bg-code px-2.5 py-1.5">
|
||||
<code className="flex-1 text-[0.75rem] font-mono text-heading truncate">
|
||||
<code className="flex-1 text-xs font-mono text-heading truncate">
|
||||
{action.command}
|
||||
</code>
|
||||
<button
|
||||
@@ -201,20 +201,20 @@ export function ActionCardGroup({ actions, onSubmit, disabled, stale }: ActionCa
|
||||
<div className="mt-2 flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
|
||||
<button
|
||||
onClick={() => updateCard(idx, { state: 'pasting' })}
|
||||
className="flex items-center justify-center gap-1 rounded-md border border-accent/40 bg-accent-dim/30 px-2.5 py-2 sm:py-1 text-[0.75rem] font-medium text-accent-text hover:bg-accent-dim/50 transition-colors min-h-[44px] sm:min-h-0"
|
||||
className="flex items-center justify-center gap-1 rounded-md border border-accent/40 bg-accent-dim/30 px-2.5 py-2 sm:py-1 text-xs font-medium text-accent-text hover:bg-accent-dim/50 transition-colors min-h-[44px] sm:min-h-0"
|
||||
>
|
||||
<Clipboard size={11} />
|
||||
Paste Result
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateCard(idx, { state: 'typing' })}
|
||||
className="flex items-center justify-center gap-1 rounded-md border border-default bg-elevated/50 px-2.5 py-2 sm:py-1 text-[0.75rem] font-medium text-heading hover:bg-elevated transition-colors min-h-[44px] sm:min-h-0"
|
||||
className="flex items-center justify-center gap-1 rounded-md border border-default bg-elevated/50 px-2.5 py-2 sm:py-1 text-xs font-medium text-heading hover:bg-elevated transition-colors min-h-[44px] sm:min-h-0"
|
||||
>
|
||||
Type Answer
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateCard(idx, { state: 'skipped' })}
|
||||
className="flex items-center justify-center gap-1 rounded-md px-2.5 py-2 sm:py-1 text-[0.75rem] text-muted-foreground hover:text-heading transition-colors min-h-[44px] sm:min-h-0"
|
||||
className="flex items-center justify-center gap-1 rounded-md px-2.5 py-2 sm:py-1 text-xs text-muted-foreground hover:text-heading transition-colors min-h-[44px] sm:min-h-0"
|
||||
>
|
||||
<SkipForward size={11} />
|
||||
Skip
|
||||
@@ -237,14 +237,14 @@ export function ActionCardGroup({ actions, onSubmit, disabled, stale }: ActionCa
|
||||
<button
|
||||
onClick={() => updateCard(idx, { state: 'done' })}
|
||||
disabled={!response.value.trim()}
|
||||
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-[0.75rem] font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors"
|
||||
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-xs font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
<Check size={11} />
|
||||
Done
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateCard(idx, { state: 'pending', value: '' })}
|
||||
className="text-[0.75rem] text-muted-foreground hover:text-heading transition-colors"
|
||||
className="text-xs text-muted-foreground hover:text-heading transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -282,7 +282,7 @@ export function ActionCardGroup({ actions, onSubmit, disabled, stale }: ActionCa
|
||||
</button>
|
||||
|
||||
{submitError && (
|
||||
<div className="flex items-center gap-1.5 text-[0.75rem] text-danger">
|
||||
<div className="flex items-center gap-1.5 text-xs text-danger">
|
||||
<AlertCircle size={12} />
|
||||
<span>Failed to send</span>
|
||||
<button
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Sparkles, User } from 'lucide-react'
|
||||
import { Sparkles, User, ListChecks } from 'lucide-react'
|
||||
import { MarkdownContent } from '@/components/ui/MarkdownContent'
|
||||
import { SuggestedFlowCard } from './SuggestedFlowCard'
|
||||
import type { SuggestedFlow } from '@/types/copilot'
|
||||
@@ -8,9 +8,14 @@ interface ChatMessageProps {
|
||||
content: string
|
||||
suggestedFlows?: SuggestedFlow[]
|
||||
imageUrls?: string[]
|
||||
/** When set on an assistant message, renders a leading "Next steps · N pending"
|
||||
* emphasis above the bubble. Used on the current turn only — the canonical
|
||||
* list of items lives in the TaskLane. */
|
||||
actionCount?: number
|
||||
}
|
||||
|
||||
export function ChatMessage({ role, content, suggestedFlows, imageUrls }: ChatMessageProps) {
|
||||
export function ChatMessage({ role, content, suggestedFlows, imageUrls, actionCount }: ChatMessageProps) {
|
||||
const hasActionEmphasis = role === 'assistant' && actionCount !== undefined && actionCount > 0
|
||||
return (
|
||||
<div className={`flex gap-3 ${role === 'user' ? 'flex-row-reverse' : ''}`}>
|
||||
{/* Avatar */}
|
||||
@@ -41,20 +46,32 @@ export function ChatMessage({ role, content, suggestedFlows, imageUrls }: ChatMe
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasActionEmphasis && (
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-heading">
|
||||
<ListChecks size={12} className="text-primary" />
|
||||
Next steps
|
||||
<span className="text-muted-foreground font-normal">
|
||||
· {actionCount} pending in Tasks
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`rounded-2xl px-4 py-3 text-[0.875rem] leading-relaxed ${
|
||||
className={`rounded-xl px-4 py-3 text-sm leading-relaxed ${
|
||||
role === 'user'
|
||||
? 'bg-primary/15 text-foreground'
|
||||
: 'bg-input text-foreground border border-border'
|
||||
: hasActionEmphasis
|
||||
? 'bg-input text-foreground border border-hover'
|
||||
: 'bg-input text-foreground border border-border'
|
||||
}`}
|
||||
>
|
||||
<MarkdownContent content={content} className="text-[0.875rem] leading-relaxed" />
|
||||
<MarkdownContent content={content} className="text-sm leading-relaxed" />
|
||||
</div>
|
||||
|
||||
{/* Suggested flows (assistant only) */}
|
||||
{role === 'assistant' && suggestedFlows && suggestedFlows.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<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">
|
||||
Related Flows
|
||||
</span>
|
||||
{suggestedFlows.map(flow => (
|
||||
|
||||
@@ -159,7 +159,7 @@ export function ChatSidebarCollapsedBar({
|
||||
<History size={14} />
|
||||
<span>History</span>
|
||||
{chats.length > 0 && (
|
||||
<span className="text-[10px] bg-elevated rounded-full px-1.5 py-0.5 font-medium">{chats.length}</span>
|
||||
<span className="text-[0.625rem] bg-elevated rounded-full px-1.5 py-0.5 font-medium">{chats.length}</span>
|
||||
)}
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
@@ -203,7 +203,7 @@ function ChatItem({
|
||||
<div className="flex-1 min-w-0">
|
||||
{confirming ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[0.75rem] text-danger font-medium">Delete?</span>
|
||||
<span className="text-xs text-danger font-medium">Delete?</span>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onDelete(); setConfirming(false) }}
|
||||
className="text-[0.6875rem] font-medium text-danger hover:text-danger px-1.5 py-0.5 rounded bg-danger/15 hover:bg-danger/25 transition-colors"
|
||||
@@ -222,12 +222,12 @@ function ChatItem({
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<div className="text-[0.8125rem] font-medium truncate">{chat.title}</div>
|
||||
{chat.psa_ticket_id && (
|
||||
<span className="font-mono shrink-0 rounded-md bg-accent-dim px-1.5 py-0.5 text-[0.5625rem] text-accent-text">
|
||||
<span className="font-mono shrink-0 rounded-md bg-accent-dim px-1.5 py-0.5 text-[0.625rem] text-accent-text">
|
||||
#{chat.psa_ticket_id}
|
||||
</span>
|
||||
)}
|
||||
{(chat.status === 'escalated' || chat.status === 'requesting_escalation') && (
|
||||
<span className="font-sans shrink-0 rounded-md bg-warning-dim px-1.5 py-0.5 text-[0.5625rem] uppercase tracking-wider text-warning border border-warning/20">
|
||||
<span className="font-sans shrink-0 rounded-md bg-warning-dim px-1.5 py-0.5 text-[0.625rem] uppercase tracking-wider text-warning border border-warning/20">
|
||||
Escalated
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -268,7 +268,7 @@ export function ConcludeSessionModal({
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'w-6 h-6 rounded-full flex items-center justify-center text-[0.6875rem] font-sans text-xs font-medium transition-colors',
|
||||
'w-6 h-6 rounded-full flex items-center justify-center text-[0.6875rem] font-sans font-medium transition-colors',
|
||||
step === s
|
||||
? 'bg-primary text-white'
|
||||
: (i < ['select-outcome', 'add-notes', 'summary'].indexOf(step))
|
||||
@@ -342,7 +342,7 @@ export function ConcludeSessionModal({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="font-sans text-xs text-[0.625rem] uppercase tracking-widest text-muted-foreground block mb-2">
|
||||
<label className="font-sans text-[0.625rem] uppercase tracking-widest text-muted-foreground block mb-2">
|
||||
Additional Notes (optional)
|
||||
</label>
|
||||
<textarea
|
||||
@@ -396,7 +396,7 @@ export function ConcludeSessionModal({
|
||||
style={{ borderColor: 'var(--color-border-default)' }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="font-sans text-xs text-[0.625rem] uppercase tracking-widest text-muted-foreground flex items-center gap-1.5">
|
||||
<span className="font-sans text-[0.625rem] uppercase tracking-widest text-muted-foreground flex items-center gap-1.5">
|
||||
<Sparkles size={10} className="text-primary" />
|
||||
Ticket Notes
|
||||
</span>
|
||||
@@ -488,7 +488,7 @@ export function ConcludeSessionModal({
|
||||
style={{ borderColor: 'var(--color-border-default)' }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="font-sans text-xs text-[0.625rem] uppercase tracking-widest text-muted-foreground flex items-center gap-1.5">
|
||||
<span className="font-sans text-[0.625rem] uppercase tracking-widest text-muted-foreground flex items-center gap-1.5">
|
||||
<Sparkles size={10} className="text-primary" />
|
||||
Status Update
|
||||
</span>
|
||||
|
||||
@@ -27,11 +27,11 @@ export function SuggestedFlowCard({ flow }: SuggestedFlowCardProps) {
|
||||
<span className="text-[0.8125rem] font-medium text-foreground truncate">
|
||||
{flow.tree_name}
|
||||
</span>
|
||||
<span className="font-sans text-xs text-[0.625rem] uppercase tracking-wider text-muted-foreground">
|
||||
<span className="font-sans text-[0.625rem] uppercase tracking-wider text-muted-foreground">
|
||||
{flow.tree_type}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[0.75rem] text-muted-foreground mt-0.5 line-clamp-2">
|
||||
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
|
||||
{flow.relevance_snippet}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import {
|
||||
Copy, Check, SkipForward, Terminal, ChevronDown, ChevronUp,
|
||||
Send, Clipboard, Loader2, PanelRightClose, MessageCircleQuestion, Eye,
|
||||
Send, Clipboard, Loader2, PanelRightClose, Pencil, HelpCircle, Eye,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
@@ -253,6 +253,24 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
setTasks(prev => prev.map((t, i) => i === idx ? { ...t, ...updates } as TaskResponse : t))
|
||||
}
|
||||
|
||||
// Mark `idx` done and advance focus to the next pending task. If none are
|
||||
// left, focus the Send button so the engineer can fire the batch with one
|
||||
// more keystroke. Powers both keyboard submit (Enter / Cmd+Enter) and the
|
||||
// mouse path on the Answer / Done buttons.
|
||||
const sendButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const submitAndAdvance = (idx: number, value: string) => {
|
||||
if (!value.trim()) return
|
||||
const nextIdx = tasks.findIndex((t, i) => i > idx && t.state === 'pending')
|
||||
setTasks(prev => prev.map((t, i) => {
|
||||
if (i === idx) return { ...t, state: 'done' } as TaskResponse
|
||||
if (nextIdx !== -1 && i === nextIdx) return { ...t, state: 'active' } as TaskResponse
|
||||
return t
|
||||
}))
|
||||
if (nextIdx === -1) {
|
||||
setTimeout(() => sendButtonRef.current?.focus(), 50)
|
||||
}
|
||||
}
|
||||
|
||||
const questionTasks = tasks.filter(t => t.type === 'question')
|
||||
const actionTasks = tasks.filter(t => t.type === 'action') as ActionResponse[]
|
||||
const allHandled = tasks.every(t => t.state === 'done' || t.state === 'skipped')
|
||||
@@ -350,20 +368,21 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
</div>
|
||||
)}
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b border-default flex items-center justify-between shrink-0" style={{ borderTop: '2px solid var(--color-accent)' }}>
|
||||
<div className="px-4 py-3 border-b border-default flex items-center justify-between shrink-0">
|
||||
<h3 className="font-heading text-sm font-bold text-heading flex items-center gap-2">
|
||||
Tasks
|
||||
<span className={cn(
|
||||
'text-[10px] font-semibold px-2 py-0.5 rounded-full',
|
||||
allHandled
|
||||
? 'bg-success-dim text-success'
|
||||
: 'bg-accent-dim text-accent-text'
|
||||
)}>
|
||||
{allHandled ? '✓ Ready' : `${doneCount}/${totalCount}`}
|
||||
</span>
|
||||
{allHandled ? (
|
||||
<span className="flex items-center gap-1 text-[0.625rem] font-semibold uppercase tracking-wider text-success">
|
||||
<Check size={10} /> Ready
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[0.625rem] font-medium tabular-nums text-muted-foreground">
|
||||
{doneCount}/{totalCount}
|
||||
</span>
|
||||
)}
|
||||
{loading && (
|
||||
<span
|
||||
className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground"
|
||||
className="flex items-center gap-1 text-[0.625rem] font-medium text-muted-foreground"
|
||||
title="AI is thinking"
|
||||
>
|
||||
<Loader2 size={10} className="animate-spin" />
|
||||
@@ -386,7 +405,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
{questionTasks.length > 0 && (
|
||||
<section>
|
||||
<div className="pb-2">
|
||||
<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-[0.625rem] font-semibold uppercase tracking-[1.2px] text-muted-foreground pl-0.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
|
||||
Questions
|
||||
{questionTasks.every(q => q.state === 'done' || q.state === 'skipped') && (
|
||||
@@ -401,12 +420,12 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
|
||||
if (q.state === 'done') {
|
||||
return (
|
||||
<div key={idx} id={`task-lane-card-${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 key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border border-default/40 p-3 mb-2 cursor-pointer hover:border-default transition-colors" onClick={() => updateTask(idx, { state: 'active' })}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Check size={12} className="text-success shrink-0" />
|
||||
<span className="text-[0.8125rem] text-foreground">{q.text}</span>
|
||||
<span className="text-[0.8125rem] text-muted-foreground">{q.text}</span>
|
||||
</div>
|
||||
<div className="text-[0.75rem] text-muted-foreground mt-1 pl-5 italic truncate">"{q.value}"</div>
|
||||
<div className="text-xs text-muted-foreground/80 mt-1 pl-5 italic truncate">"{q.value}"</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -416,7 +435,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
<div key={idx} id={`task-lane-card-${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="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-[0.625rem] font-semibold uppercase tracking-wider text-muted-foreground">Skipped</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -434,33 +453,47 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
autoFocus
|
||||
value={q.value}
|
||||
onChange={e => updateTask(idx, { value: e.target.value })}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
submitAndAdvance(idx, q.value)
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
updateTask(idx, { state: 'pending', value: '' })
|
||||
}
|
||||
}}
|
||||
placeholder="Type your answer..."
|
||||
className="w-full rounded-md border border-default bg-input px-3 py-2 text-[0.8125rem] text-heading placeholder:text-muted-foreground resize-y min-h-[48px] max-h-[150px] focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
|
||||
rows={2}
|
||||
/>
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => updateTask(idx, { state: 'done' })}
|
||||
disabled={!q.value.trim()}
|
||||
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-[0.75rem] font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
<Check size={11} /> Answer
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateTask(idx, { state: 'pending', value: '' })}
|
||||
className="text-[0.75rem] text-muted-foreground hover:text-heading"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<div className="mt-1.5 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => submitAndAdvance(idx, q.value)}
|
||||
disabled={!q.value.trim()}
|
||||
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-xs font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
<Check size={11} /> Answer
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateTask(idx, { state: 'pending', value: '' })}
|
||||
className="text-xs text-muted-foreground hover:text-heading"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-[0.625rem] text-muted-foreground/70 tabular-nums">
|
||||
⏎ submit · ⇧⏎ newline
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => updateTask(idx, { state: 'active' })}
|
||||
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"
|
||||
className="flex items-center gap-1 rounded-md border border-accent/40 bg-accent-dim/30 px-2.5 py-1.5 text-xs font-medium text-accent-text hover:bg-accent-dim/50 transition-colors"
|
||||
>
|
||||
<MessageCircleQuestion size={11} /> Answer
|
||||
<Pencil size={11} /> Answer
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateTask(idx, { state: 'skipped' })}
|
||||
@@ -481,7 +514,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
{actionTasks.length > 0 && (
|
||||
<section>
|
||||
<div className="pb-2">
|
||||
<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-[0.625rem] font-semibold uppercase tracking-[1.2px] text-muted-foreground pl-0.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
|
||||
Diagnostic Checks
|
||||
{actionTasks.every(a => a.state === 'done' || a.state === 'skipped') && (
|
||||
@@ -495,7 +528,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
<div className="mb-2">
|
||||
<button
|
||||
onClick={() => setShowRunAll(!showRunAll)}
|
||||
className="flex items-center gap-1.5 text-[0.75rem] font-medium text-accent-text hover:text-accent transition-colors pl-0.5"
|
||||
className="flex items-center gap-1.5 text-xs font-medium text-accent-text hover:text-accent transition-colors pl-0.5"
|
||||
>
|
||||
<Terminal size={12} />
|
||||
Run All ({commandActions.length} commands)
|
||||
@@ -504,16 +537,16 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
{showRunAll && (
|
||||
<div className="mt-2 rounded-lg border border-default bg-code p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Combined script</span>
|
||||
<span className="text-[0.625rem] font-semibold uppercase tracking-wider text-muted-foreground">Combined script</span>
|
||||
<button
|
||||
onClick={() => void handleCopy(combinedScript)}
|
||||
className="flex items-center gap-1 text-[0.75rem] text-muted-foreground hover:text-heading transition-colors"
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-heading transition-colors"
|
||||
>
|
||||
{copiedKey === combinedScript ? <Check size={11} className="text-success" /> : <Copy size={11} />}
|
||||
{copiedKey === combinedScript ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="text-[0.75rem] font-mono text-heading whitespace-pre-wrap overflow-x-auto">{combinedScript}</pre>
|
||||
<pre className="text-xs font-mono text-heading whitespace-pre-wrap overflow-x-auto">{combinedScript}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -525,10 +558,10 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
|
||||
if (a.state === 'done') {
|
||||
return (
|
||||
<div key={idx} id={`task-lane-card-${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 key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border border-default/40 p-3 mb-2 cursor-pointer hover:border-default transition-colors" onClick={() => updateTask(idx, { state: 'active' })}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Check size={12} className="text-success shrink-0" />
|
||||
<span className="text-[0.8125rem] font-medium text-foreground flex-1">{a.label}</span>
|
||||
<span className="text-[0.8125rem] text-muted-foreground flex-1">{a.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -539,7 +572,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
<div key={idx} id={`task-lane-card-${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="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-[0.625rem] font-semibold uppercase tracking-wider text-muted-foreground">Skipped</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -565,7 +598,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
aria-label="Explain this diagnostic check"
|
||||
aria-expanded={expandedHelpKey === `${idx}`}
|
||||
>
|
||||
<MessageCircleQuestion size={13} />
|
||||
<HelpCircle size={13} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -613,31 +646,45 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
autoFocus
|
||||
value={a.value}
|
||||
onChange={e => updateTask(idx, { value: e.target.value })}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
submitAndAdvance(idx, a.value)
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
updateTask(idx, { state: 'pending', value: '' })
|
||||
}
|
||||
}}
|
||||
placeholder="Paste command output here..."
|
||||
className="w-full rounded-md border border-default bg-input px-3 py-2 text-[0.8125rem] text-heading placeholder:text-muted-foreground font-mono resize-y min-h-[60px] max-h-[200px] overflow-y-auto focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
|
||||
rows={3}
|
||||
/>
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => updateTask(idx, { state: 'done' })}
|
||||
disabled={!a.value.trim()}
|
||||
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-[0.75rem] font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
<Check size={11} /> Done
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateTask(idx, { state: 'pending', value: '' })}
|
||||
className="text-[0.75rem] text-muted-foreground hover:text-heading"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<div className="mt-1.5 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => submitAndAdvance(idx, a.value)}
|
||||
disabled={!a.value.trim()}
|
||||
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-xs font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
<Check size={11} /> Done
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateTask(idx, { state: 'pending', value: '' })}
|
||||
className="text-xs text-muted-foreground hover:text-heading"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-[0.625rem] text-muted-foreground/70 tabular-nums">
|
||||
⏎ submit · ⇧⏎ newline
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => updateTask(idx, { state: 'active' })}
|
||||
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"
|
||||
className="flex items-center gap-1 rounded-md border border-accent/40 bg-accent-dim/30 px-2.5 py-1.5 text-xs font-medium text-accent-text hover:bg-accent-dim/50 transition-colors"
|
||||
>
|
||||
<Clipboard size={11} /> {a.command ? 'Paste Output' : 'Answer'}
|
||||
</button>
|
||||
@@ -698,7 +745,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
<div className="mb-2">
|
||||
<button
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
className="flex items-center gap-1.5 text-[0.75rem] font-medium text-muted-foreground hover:text-heading transition-colors mb-1"
|
||||
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-heading transition-colors mb-1"
|
||||
>
|
||||
<Eye size={12} />
|
||||
Preview ({handledCount}/{totalCount} done)
|
||||
@@ -712,6 +759,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
ref={sendButtonRef}
|
||||
onClick={handleSubmit}
|
||||
disabled={!anyHandled || loading || submitting}
|
||||
className={cn(
|
||||
|
||||
@@ -57,7 +57,7 @@ function TabButton({
|
||||
aria-selected={active}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'relative px-3 py-[7px] text-[12.5px] font-medium rounded-t-md transition-colors',
|
||||
'relative px-3 py-[7px] text-xs font-medium rounded-t-md transition-colors',
|
||||
'border-b-2 -mb-px',
|
||||
active
|
||||
? 'text-heading border-accent bg-bg-page'
|
||||
|
||||
@@ -54,27 +54,24 @@ export function ProposalBanner(props: ProposalBannerProps) {
|
||||
|
||||
function ProposedBanner({ fix, onApply, onDismiss, onToggleCollapsed }: ProposalBannerProps) {
|
||||
return (
|
||||
<div className="relative border-t border-warning/30 bg-gradient-to-b from-warning-dim/40 to-warning-dim/20 px-5 py-3 animate-slide-up">
|
||||
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-warning" />
|
||||
<div className="border-t border-warning/30 bg-card px-5 py-3 animate-slide-up">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="shrink-0 mt-0.5 w-7 h-7 rounded-md border border-warning/30 bg-warning-dim flex items-center justify-center text-warning">
|
||||
<Sparkles size={15} />
|
||||
</div>
|
||||
<Sparkles size={16} className="text-warning shrink-0 mt-1" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 font-heading text-[10px] font-semibold uppercase tracking-[1.2px] text-warning">
|
||||
<div className="flex items-center gap-2 font-heading text-[0.625rem] font-semibold uppercase tracking-[1.2px] text-warning">
|
||||
<span>Suggested Fix</span>
|
||||
<span className="tabular-nums px-2 py-[2px] rounded-full bg-warning/20 text-warning text-[10.5px] font-bold">
|
||||
<span className="tabular-nums px-2 py-[2px] rounded-full bg-warning/20 text-warning text-[0.625rem] font-bold">
|
||||
{fix.confidence_pct}% confidence
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[14px] font-semibold text-heading leading-snug">
|
||||
<div className="mt-0.5 text-sm font-semibold text-heading leading-snug">
|
||||
{fix.title}
|
||||
</div>
|
||||
<div className="mt-1 text-[12.5px] text-muted-foreground leading-relaxed">
|
||||
<div className="mt-1 text-xs text-muted-foreground leading-relaxed">
|
||||
{fix.description}
|
||||
</div>
|
||||
{fix.script_template_id && (
|
||||
<div className="mt-1.5 inline-flex items-center gap-1.5 text-[11.5px] text-success">
|
||||
<div className="mt-1.5 inline-flex items-center gap-1.5 text-[0.6875rem] text-success">
|
||||
<Check size={11} />
|
||||
Matches an existing Script Library template — one-click apply
|
||||
</div>
|
||||
@@ -92,13 +89,13 @@ function ProposedBanner({ fix, onApply, onDismiss, onToggleCollapsed }: Proposal
|
||||
)}
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="px-2.5 py-1.5 rounded text-[12.5px] text-muted-foreground hover:bg-white/[0.08] hover:text-primary"
|
||||
className="px-2.5 py-1.5 rounded text-xs text-muted-foreground hover:bg-white/[0.08] hover:text-primary"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
<button
|
||||
onClick={onApply}
|
||||
className="px-3.5 py-[9px] rounded-lg bg-warning text-[#1a1200] font-semibold text-[12.5px] hover:brightness-110 inline-flex items-center gap-1.5"
|
||||
className="px-3.5 py-[9px] rounded-lg bg-warning text-[#1a1200] font-semibold text-xs hover:brightness-110 inline-flex items-center gap-1.5"
|
||||
>
|
||||
Apply fix
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="9 18 15 12 9 6" /></svg>
|
||||
@@ -116,27 +113,23 @@ function VerifyingBanner({ fix, onOutcome }: ProposalBannerProps) {
|
||||
: 'Applied'
|
||||
|
||||
return (
|
||||
<div className="relative border-t border-warning/30 bg-gradient-to-b from-warning-dim/40 to-warning-dim/20 px-5 py-3 animate-slide-up">
|
||||
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-warning" />
|
||||
<div className="border-t border-warning/30 bg-card px-5 py-3 animate-slide-up">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="relative shrink-0 mt-0.5 w-7 h-7 rounded-md border border-warning/30 bg-warning-dim flex items-center justify-center text-warning">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
<span className="absolute inset-[-3px] rounded-lg animate-pulse-amber pointer-events-none" />
|
||||
</div>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-warning shrink-0 mt-1">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 font-heading text-[10px] font-semibold uppercase tracking-[1.2px] text-warning">
|
||||
<div className="flex items-center gap-2 font-heading text-[0.625rem] font-semibold uppercase tracking-[1.2px] text-warning">
|
||||
<span>Verifying</span>
|
||||
<span className="px-2 py-[2px] rounded-full bg-warning/20 text-warning text-[10.5px] font-bold normal-case tracking-normal">
|
||||
<span className="px-2 py-[2px] rounded-full bg-warning/20 text-warning text-[0.625rem] font-bold normal-case tracking-normal">
|
||||
{appliedLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[14px] font-semibold text-heading leading-snug">
|
||||
<div className="mt-0.5 text-sm font-semibold text-heading leading-snug">
|
||||
Did "{fix.title}" work?
|
||||
</div>
|
||||
<div className="mt-1 text-[12.5px] text-muted-foreground leading-relaxed">
|
||||
<div className="mt-1 text-xs text-muted-foreground leading-relaxed">
|
||||
Mark the outcome so the AI can either close the session with this as the resolution, or propose something else.
|
||||
</div>
|
||||
</div>
|
||||
@@ -159,7 +152,7 @@ function VerifyingBanner({ fix, onOutcome }: ProposalBannerProps) {
|
||||
const notes = window.prompt('What did you run / skip?')
|
||||
if (notes && notes.trim()) onOutcome('applied_partial', notes.trim())
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-[12.5px] hover:bg-elevated text-primary"
|
||||
className="w-full text-left px-3 py-2 text-xs hover:bg-elevated text-primary"
|
||||
>
|
||||
Mark partial…
|
||||
</button>
|
||||
@@ -169,7 +162,7 @@ function VerifyingBanner({ fix, onOutcome }: ProposalBannerProps) {
|
||||
const reason = window.prompt('What are you waiting on? (e.g. "client power-cycling router")')
|
||||
if (reason && reason.trim()) onOutcome('applied_pending', reason.trim())
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-[12.5px] hover:bg-elevated text-primary inline-flex items-center gap-2"
|
||||
className="w-full text-left px-3 py-2 text-xs hover:bg-elevated text-primary inline-flex items-center gap-2"
|
||||
>
|
||||
<Clock3 size={12} className="text-info" />
|
||||
Waiting to verify…
|
||||
@@ -181,14 +174,14 @@ function VerifyingBanner({ fix, onOutcome }: ProposalBannerProps) {
|
||||
const reason = window.prompt("Why didn't it work? (optional)")
|
||||
onOutcome('applied_failed', reason?.trim() || undefined)
|
||||
}}
|
||||
className="px-3 py-[9px] rounded-lg border border-danger/30 text-danger text-[12.5px] font-medium hover:bg-danger-dim hover:border-danger inline-flex items-center gap-1.5"
|
||||
className="px-3 py-[9px] rounded-lg border border-danger/30 text-danger text-xs font-medium hover:bg-danger-dim hover:border-danger inline-flex items-center gap-1.5"
|
||||
>
|
||||
<X size={12} strokeWidth={2.5} />
|
||||
Didn't work
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onOutcome('applied_success')}
|
||||
className="px-3 py-[9px] rounded-lg bg-success text-[#0a1a12] font-semibold text-[12.5px] hover:brightness-110 inline-flex items-center gap-1.5"
|
||||
className="px-3 py-[9px] rounded-lg bg-success text-[#0a1a12] font-semibold text-xs hover:brightness-110 inline-flex items-center gap-1.5"
|
||||
>
|
||||
<Check size={12} strokeWidth={2.5} />
|
||||
It worked
|
||||
@@ -209,25 +202,22 @@ function formatRelativeMinutes(iso: string): string {
|
||||
|
||||
function PartialBanner({ fix, onOutcome, onApply }: ProposalBannerProps) {
|
||||
return (
|
||||
<div className="relative border-t border-info/30 bg-gradient-to-b from-info-dim/40 to-info-dim/20 px-5 py-3 animate-slide-up">
|
||||
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-info" />
|
||||
<div className="border-t border-info/30 bg-card px-5 py-3 animate-slide-up">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="shrink-0 mt-0.5 w-7 h-7 rounded-md border border-info/30 bg-info-dim flex items-center justify-center text-info">
|
||||
<Info size={15} />
|
||||
</div>
|
||||
<Info size={16} className="text-info shrink-0 mt-1" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 font-heading text-[10px] font-semibold uppercase tracking-[1.2px] text-info">
|
||||
<div className="flex items-center gap-2 font-heading text-[0.625rem] font-semibold uppercase tracking-[1.2px] text-info">
|
||||
<span>Partially applied</span>
|
||||
<span className="px-2 py-[2px] rounded-full bg-info/20 text-info text-[10.5px] font-bold normal-case tracking-normal">
|
||||
<span className="px-2 py-[2px] rounded-full bg-info/20 text-info text-[0.625rem] font-bold normal-case tracking-normal">
|
||||
Parked
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[14px] font-semibold text-heading leading-snug">
|
||||
<div className="mt-0.5 text-sm font-semibold text-heading leading-snug">
|
||||
{fix.title}
|
||||
</div>
|
||||
{fix.partial_notes && (
|
||||
<div className="mt-1.5 flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-info/[0.08] border border-info/30 text-[12px] italic text-primary">
|
||||
<span className="not-italic font-bold text-info text-[10.5px] uppercase tracking-[0.6px]">Note</span>
|
||||
<div className="mt-1.5 flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-info/[0.08] border border-info/30 text-xs italic text-primary">
|
||||
<span className="not-italic font-bold text-info text-[0.625rem] uppercase tracking-[0.6px]">Note</span>
|
||||
<span>{fix.partial_notes}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -238,19 +228,19 @@ function PartialBanner({ fix, onOutcome, onApply }: ProposalBannerProps) {
|
||||
const reason = window.prompt("Why didn't it work? (optional)")
|
||||
onOutcome('applied_failed', reason?.trim() || undefined)
|
||||
}}
|
||||
className="px-3 py-[9px] rounded-lg border border-danger/30 text-danger text-[12.5px] font-medium hover:bg-danger-dim hover:border-danger"
|
||||
className="px-3 py-[9px] rounded-lg border border-danger/30 text-danger text-xs font-medium hover:bg-danger-dim hover:border-danger"
|
||||
>
|
||||
Didn't work
|
||||
</button>
|
||||
<button
|
||||
onClick={onApply}
|
||||
className="px-3 py-[9px] rounded-lg bg-card border border-white/10 text-primary text-[12.5px] font-medium hover:bg-elevated"
|
||||
className="px-3 py-[9px] rounded-lg bg-card border border-white/10 text-primary text-xs font-medium hover:bg-elevated"
|
||||
>
|
||||
Finish it ›
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onOutcome('applied_success')}
|
||||
className="px-3 py-[9px] rounded-lg bg-success text-[#0a1a12] font-semibold text-[12.5px] hover:brightness-110"
|
||||
className="px-3 py-[9px] rounded-lg bg-success text-[#0a1a12] font-semibold text-xs hover:brightness-110"
|
||||
>
|
||||
It worked
|
||||
</button>
|
||||
@@ -262,25 +252,22 @@ function PartialBanner({ fix, onOutcome, onApply }: ProposalBannerProps) {
|
||||
|
||||
function PendingBanner({ fix, onOutcome, onDismiss }: ProposalBannerProps) {
|
||||
return (
|
||||
<div className="relative border-t border-info/30 bg-gradient-to-b from-info-dim/40 to-info-dim/20 px-5 py-3 animate-slide-up">
|
||||
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-info" />
|
||||
<div className="border-t border-info/30 bg-card px-5 py-3 animate-slide-up">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="shrink-0 mt-0.5 w-7 h-7 rounded-md border border-info/30 bg-info-dim flex items-center justify-center text-info">
|
||||
<Clock3 size={15} />
|
||||
</div>
|
||||
<Clock3 size={16} className="text-info shrink-0 mt-1" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 font-heading text-[10px] font-semibold uppercase tracking-[1.2px] text-info">
|
||||
<div className="flex items-center gap-2 font-heading text-[0.625rem] font-semibold uppercase tracking-[1.2px] text-info">
|
||||
<span>Awaiting verification</span>
|
||||
<span className="px-2 py-[2px] rounded-full bg-info/20 text-info text-[10.5px] font-bold normal-case tracking-normal">
|
||||
<span className="px-2 py-[2px] rounded-full bg-info/20 text-info text-[0.625rem] font-bold normal-case tracking-normal">
|
||||
Parked
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[14px] font-semibold text-heading leading-snug">
|
||||
<div className="mt-0.5 text-sm font-semibold text-heading leading-snug">
|
||||
{fix.title}
|
||||
</div>
|
||||
{fix.pending_reason && (
|
||||
<div className="mt-1.5 flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-info/[0.08] border border-info/30 text-[12px] italic text-primary">
|
||||
<span className="not-italic font-bold text-info text-[10.5px] uppercase tracking-[0.6px]">Waiting on</span>
|
||||
<div className="mt-1.5 flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-info/[0.08] border border-info/30 text-xs italic text-primary">
|
||||
<span className="not-italic font-bold text-info text-[0.625rem] uppercase tracking-[0.6px]">Waiting on</span>
|
||||
<span>{fix.pending_reason}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -288,7 +275,7 @@ function PendingBanner({ fix, onOutcome, onDismiss }: ProposalBannerProps) {
|
||||
<div className="flex items-center gap-2 shrink-0 pt-0.5">
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="px-3 py-[9px] rounded-lg text-muted-foreground text-[12.5px] hover:bg-white/[0.08] hover:text-primary"
|
||||
className="px-3 py-[9px] rounded-lg text-muted-foreground text-xs hover:bg-white/[0.08] hover:text-primary"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
@@ -300,7 +287,7 @@ function PendingBanner({ fix, onOutcome, onDismiss }: ProposalBannerProps) {
|
||||
)
|
||||
if (reason && reason.trim()) onOutcome('applied_pending', reason.trim())
|
||||
}}
|
||||
className="px-3 py-[9px] rounded-lg text-muted-foreground text-[12.5px] hover:bg-white/[0.08] hover:text-primary"
|
||||
className="px-3 py-[9px] rounded-lg text-muted-foreground text-xs hover:bg-white/[0.08] hover:text-primary"
|
||||
>
|
||||
Update reason
|
||||
</button>
|
||||
@@ -309,13 +296,13 @@ function PendingBanner({ fix, onOutcome, onDismiss }: ProposalBannerProps) {
|
||||
const reason = window.prompt("Why didn't it work? (optional)")
|
||||
onOutcome('applied_failed', reason?.trim() || undefined)
|
||||
}}
|
||||
className="px-3 py-[9px] rounded-lg border border-danger/30 text-danger text-[12.5px] font-medium hover:bg-danger-dim hover:border-danger"
|
||||
className="px-3 py-[9px] rounded-lg border border-danger/30 text-danger text-xs font-medium hover:bg-danger-dim hover:border-danger"
|
||||
>
|
||||
Didn't work
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onOutcome('applied_success')}
|
||||
className="px-3 py-[9px] rounded-lg bg-success text-[#0a1a12] font-semibold text-[12.5px] hover:brightness-110 inline-flex items-center gap-1.5"
|
||||
className="px-3 py-[9px] rounded-lg bg-success text-[#0a1a12] font-semibold text-xs hover:brightness-110 inline-flex items-center gap-1.5"
|
||||
>
|
||||
<Check size={12} strokeWidth={2.5} />
|
||||
It worked
|
||||
@@ -339,37 +326,34 @@ function AIConfirmingBanner({ fix, onAcceptAIProposal, onRejectAIProposal }: Pro
|
||||
: 'was partially applied'
|
||||
|
||||
return (
|
||||
<div className="relative border-t border-accent/30 bg-gradient-to-b from-accent-dim/40 to-accent-dim/20 px-5 py-3 animate-slide-up">
|
||||
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-accent" />
|
||||
<div className="border-t border-accent/30 bg-card px-5 py-3 animate-slide-up">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="shrink-0 mt-0.5 w-7 h-7 rounded-md border border-accent/30 bg-accent-dim flex items-center justify-center text-accent">
|
||||
<Sparkles size={15} />
|
||||
</div>
|
||||
<Sparkles size={16} className="text-accent shrink-0 mt-1" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 font-heading text-[10px] font-semibold uppercase tracking-[1.2px] text-accent">
|
||||
<div className="flex items-center gap-2 font-heading text-[0.625rem] font-semibold uppercase tracking-[1.2px] text-accent">
|
||||
<span>AI detected outcome</span>
|
||||
<span className="px-2 py-[2px] rounded-full bg-accent/20 text-accent text-[10.5px] font-bold normal-case tracking-normal">
|
||||
<span className="px-2 py-[2px] rounded-full bg-accent/20 text-accent text-[0.625rem] font-bold normal-case tracking-normal">
|
||||
{isSuccess ? 'Success' : isFailure ? 'Failure' : 'Partial'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[14px] font-semibold text-heading leading-snug">
|
||||
<div className="mt-0.5 text-sm font-semibold text-heading leading-snug">
|
||||
AI thinks the fix {headlineVerb} — confirm?
|
||||
</div>
|
||||
<div className="mt-1 text-[12.5px] text-muted-foreground leading-relaxed">
|
||||
<div className="mt-1 text-xs text-muted-foreground leading-relaxed">
|
||||
{proposal.reason || 'Based on the recent chat. One click either confirms or corrects.'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 pt-0.5">
|
||||
<button
|
||||
onClick={onRejectAIProposal}
|
||||
className="px-3 py-[9px] rounded-lg text-muted-foreground text-[12.5px] hover:bg-white/[0.08] hover:text-primary"
|
||||
className="px-3 py-[9px] rounded-lg text-muted-foreground text-xs hover:bg-white/[0.08] hover:text-primary"
|
||||
>
|
||||
Not yet
|
||||
</button>
|
||||
<button
|
||||
onClick={onAcceptAIProposal}
|
||||
className={cn(
|
||||
'px-3 py-[9px] rounded-lg font-semibold text-[12.5px] inline-flex items-center gap-1.5 hover:brightness-110',
|
||||
'px-3 py-[9px] rounded-lg font-semibold text-xs inline-flex items-center gap-1.5 hover:brightness-110',
|
||||
isSuccess
|
||||
? 'bg-success text-[#0a1a12]'
|
||||
: 'bg-danger text-[#180808]',
|
||||
@@ -386,14 +370,13 @@ function AIConfirmingBanner({ fix, onAcceptAIProposal, onRejectAIProposal }: Pro
|
||||
|
||||
function NudgeBanner({ fix, onOutcome, onSilenceNudge }: ProposalBannerProps) {
|
||||
return (
|
||||
<div className="relative border-t border-warning/30 bg-warning-dim/60 px-5 py-2 flex items-center gap-3">
|
||||
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-warning" />
|
||||
<div className="border-t border-warning/30 bg-card px-5 py-2 flex items-center gap-3">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-warning shrink-0">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 8v4" />
|
||||
<path d="M12 16h.01" />
|
||||
</svg>
|
||||
<span className="flex-1 text-[12.5px] text-primary">
|
||||
<span className="flex-1 text-xs text-primary">
|
||||
Did <strong className="text-heading">"{fix.title}"</strong> work?
|
||||
</span>
|
||||
<button
|
||||
@@ -407,7 +390,7 @@ function NudgeBanner({ fix, onOutcome, onSilenceNudge }: ProposalBannerProps) {
|
||||
onSilenceNudge()
|
||||
}
|
||||
}}
|
||||
className="px-2.5 py-1 rounded text-[12px] text-muted-foreground hover:bg-white/[0.08] hover:text-primary inline-flex items-center gap-1"
|
||||
className="px-2.5 py-1 rounded text-xs text-muted-foreground hover:bg-white/[0.08] hover:text-primary inline-flex items-center gap-1"
|
||||
>
|
||||
<Clock3 size={11} />
|
||||
Still checking
|
||||
@@ -417,13 +400,13 @@ function NudgeBanner({ fix, onOutcome, onSilenceNudge }: ProposalBannerProps) {
|
||||
const reason = window.prompt("Why didn't it work? (optional)")
|
||||
onOutcome('applied_failed', reason?.trim() || undefined)
|
||||
}}
|
||||
className="px-2.5 py-1 rounded border border-danger/30 text-danger text-[12px] hover:bg-danger-dim"
|
||||
className="px-2.5 py-1 rounded border border-danger/30 text-danger text-xs hover:bg-danger-dim"
|
||||
>
|
||||
No
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onOutcome('applied_success')}
|
||||
className="px-2.5 py-1 rounded bg-success text-[#0a1a12] font-semibold text-[12px] hover:brightness-110"
|
||||
className="px-2.5 py-1 rounded bg-success text-[#0a1a12] font-semibold text-xs hover:brightness-110"
|
||||
>
|
||||
Yes
|
||||
</button>
|
||||
@@ -435,15 +418,14 @@ function CollapsedBanner({ fix, onToggleCollapsed }: ProposalBannerProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onToggleCollapsed}
|
||||
className="relative w-full border-t border-warning/30 bg-warning-dim/40 px-5 py-2 flex items-center gap-2.5 hover:bg-warning-dim/60 transition-colors text-left"
|
||||
className="w-full border-t border-warning/30 bg-card px-5 py-2 flex items-center gap-2.5 hover:bg-[var(--color-bg-card-hover)] transition-colors text-left"
|
||||
>
|
||||
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-warning" />
|
||||
<Sparkles size={12} className="text-warning shrink-0" />
|
||||
<span className="flex-1 text-[12px] font-medium text-heading truncate">{fix.title}</span>
|
||||
<span className="px-1.5 py-[1px] rounded-full bg-warning/20 text-warning text-[10.5px] font-bold tabular-nums">
|
||||
<span className="flex-1 text-xs font-medium text-heading truncate">{fix.title}</span>
|
||||
<span className="px-1.5 py-[1px] rounded-full bg-warning/20 text-warning text-[0.625rem] font-bold tabular-nums">
|
||||
{fix.confidence_pct}%
|
||||
</span>
|
||||
<span className="text-muted-foreground text-[11px]">▸ expand</span>
|
||||
<span className="text-muted-foreground text-[0.6875rem]">▸ expand</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export function AddNoteButton({ onAdd }: AddNoteButtonProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="flex items-center gap-1.5 text-[0.75rem] text-muted-foreground hover:text-accent-text transition-colors pl-1 mt-1"
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-accent-text transition-colors pl-1 mt-1"
|
||||
>
|
||||
<Plus size={12} />
|
||||
Add a note
|
||||
@@ -62,20 +62,20 @@ export function AddNoteButton({ onAdd }: AddNoteButtonProps) {
|
||||
value={summary}
|
||||
onChange={(e) => setSummary(e.target.value)}
|
||||
placeholder="Short label (optional)"
|
||||
className="mt-1.5 w-full rounded-md border border-default bg-input px-2.5 py-1 text-[0.75rem] text-heading placeholder:text-muted-foreground focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
|
||||
className="mt-1.5 w-full rounded-md border border-default bg-input px-2.5 py-1 text-xs text-heading placeholder:text-muted-foreground focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
|
||||
/>
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={busy || !text.trim()}
|
||||
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-[0.75rem] font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors"
|
||||
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-xs font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
<Check size={11} /> Add
|
||||
</button>
|
||||
<button
|
||||
onClick={reset}
|
||||
disabled={busy}
|
||||
className="text-[0.75rem] text-muted-foreground hover:text-heading"
|
||||
className="text-xs text-muted-foreground hover:text-heading"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
* and renders the section. Loading/refresh logic lives in the parent
|
||||
* (AssistantChatPage) so it can coordinate with the chat send cycle.
|
||||
*/
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useState } from 'react'
|
||||
import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react'
|
||||
import type { SessionFact } from '@/api/sessionFacts'
|
||||
import { WhatWeKnowItem } from './WhatWeKnowItem'
|
||||
import { AddNoteButton } from './AddNoteButton'
|
||||
@@ -23,32 +23,65 @@ interface WhatWeKnowProps {
|
||||
onUpdateFact: (factId: string, text: string, summary: string | null) => Promise<void> | void
|
||||
onDeleteFact: (factId: string) => Promise<void> | void
|
||||
loading?: boolean
|
||||
/** Used as the sessionStorage key for the engineer's collapse preference.
|
||||
* When the parent re-keys this component on session change, the lazy
|
||||
* initializer reads fresh state for the new session. */
|
||||
sessionId?: string | null
|
||||
}
|
||||
|
||||
const COLLAPSE_STORAGE_KEY = 'rf-whatweknow-collapsed'
|
||||
// First-render auto-collapse threshold. Past this, the section is hidden by
|
||||
// default so Questions / Diagnostic Checks stay above the fold. The engineer's
|
||||
// explicit toggle (stored per-session) always wins over this heuristic.
|
||||
const AUTO_COLLAPSE_THRESHOLD = 5
|
||||
|
||||
export function WhatWeKnow({
|
||||
facts,
|
||||
onAddNote,
|
||||
onUpdateFact,
|
||||
onDeleteFact,
|
||||
loading,
|
||||
sessionId,
|
||||
}: WhatWeKnowProps) {
|
||||
const count = facts.length
|
||||
|
||||
const [collapsed, setCollapsed] = useState<boolean>(() => {
|
||||
if (sessionId) {
|
||||
try {
|
||||
const stored = sessionStorage.getItem(`${COLLAPSE_STORAGE_KEY}:${sessionId}`)
|
||||
if (stored !== null) return stored === '1'
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
return count >= AUTO_COLLAPSE_THRESHOLD
|
||||
})
|
||||
|
||||
const toggle = () => {
|
||||
setCollapsed(prev => {
|
||||
const next = !prev
|
||||
if (sessionId) {
|
||||
try { sessionStorage.setItem(`${COLLAPSE_STORAGE_KEY}:${sessionId}`, next ? '1' : '0') } catch { /* ignore */ }
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
'rounded-lg p-3 -mx-1 mb-1',
|
||||
// Subtle green-to-transparent gradient distinguishes this section
|
||||
// from the rest of the lane (mockup 01-session-primary.png).
|
||||
'bg-gradient-to-b from-success/[0.05] to-transparent',
|
||||
)}
|
||||
>
|
||||
<div className="pb-2">
|
||||
<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-success" />
|
||||
What we know
|
||||
<span className="text-muted-foreground">·</span>
|
||||
<span className="tabular-nums">{count}</span>
|
||||
<section className="rounded-lg p-3 -mx-1 mb-1">
|
||||
<div className={collapsed ? '' : 'pb-2'}>
|
||||
<div className="flex items-center gap-2 pl-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-expanded={!collapsed}
|
||||
aria-label={collapsed ? 'Expand What we know' : 'Collapse What we know'}
|
||||
className="flex items-center gap-2 text-[0.625rem] font-semibold uppercase tracking-[1.2px] text-muted-foreground hover:text-heading transition-colors"
|
||||
>
|
||||
{collapsed ? <ChevronRight size={10} /> : <ChevronDown size={10} />}
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-success" />
|
||||
What we know
|
||||
<span className="text-muted-foreground">·</span>
|
||||
<span className="tabular-nums">{count}</span>
|
||||
</button>
|
||||
{loading && (
|
||||
<span
|
||||
className="ml-auto flex items-center gap-1 text-[0.625rem] font-medium normal-case tracking-normal text-muted-foreground"
|
||||
@@ -61,29 +94,33 @@ export function WhatWeKnow({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{count === 0 && loading && (
|
||||
<div className="space-y-2 px-1 py-2">
|
||||
<div className="h-3 w-3/4 rounded bg-elevated/60 animate-pulse" />
|
||||
<div className="h-3 w-1/2 rounded bg-elevated/60 animate-pulse" />
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<>
|
||||
{count === 0 && loading && (
|
||||
<div className="space-y-2 px-1 py-2">
|
||||
<div className="h-3 w-3/4 rounded bg-elevated/60 animate-pulse" />
|
||||
<div className="h-3 w-1/2 rounded bg-elevated/60 animate-pulse" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{count === 0 && !loading && (
|
||||
<div className="text-xs text-muted-foreground italic px-1 py-2">
|
||||
Nothing confirmed yet — facts appear here as the engineer answers questions and runs checks.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{facts.map((fact) => (
|
||||
<WhatWeKnowItem
|
||||
key={fact.id}
|
||||
fact={fact}
|
||||
onSave={(text, summary) => onUpdateFact(fact.id, text, summary)}
|
||||
onDelete={() => onDeleteFact(fact.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<AddNoteButton onAdd={onAddNote} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{count === 0 && !loading && (
|
||||
<div className="text-[0.75rem] text-muted-foreground italic px-1 py-2">
|
||||
Nothing confirmed yet — facts appear here as the engineer answers questions and runs checks.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{facts.map((fact) => (
|
||||
<WhatWeKnowItem
|
||||
key={fact.id}
|
||||
fact={fact}
|
||||
onSave={(text, summary) => onUpdateFact(fact.id, text, summary)}
|
||||
onDelete={() => onDeleteFact(fact.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<AddNoteButton onAdd={onAddNote} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -79,20 +79,20 @@ export function WhatWeKnowItem({ fact, onSave, onDelete }: WhatWeKnowItemProps)
|
||||
value={draftSummary}
|
||||
onChange={(e) => setDraftSummary(e.target.value)}
|
||||
placeholder="Short label (e.g. 'rules out tenant/license')"
|
||||
className="mt-1.5 w-full rounded-md border border-default bg-input px-2.5 py-1 text-[0.75rem] text-heading placeholder:text-muted-foreground focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
|
||||
className="mt-1.5 w-full rounded-md border border-default bg-input px-2.5 py-1 text-xs text-heading placeholder:text-muted-foreground focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
|
||||
/>
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={busy || !draftText.trim()}
|
||||
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-[0.75rem] font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors"
|
||||
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-xs font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
<Check size={11} /> Save
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
disabled={busy}
|
||||
className="text-[0.75rem] text-muted-foreground hover:text-heading"
|
||||
className="text-xs text-muted-foreground hover:text-heading"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -104,7 +104,7 @@ export function WhatWeKnowItem({ fact, onSave, onDelete }: WhatWeKnowItemProps)
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group rounded-lg border-l-[3px] border-l-success border border-success/25 bg-success-dim/20 p-3 mb-2',
|
||||
'group rounded-lg border border-default/40 p-3 mb-2',
|
||||
busy && 'opacity-60',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { handoffsApi } from '@/api/handoffs'
|
||||
import { timeAgo } from '@/lib/timeAgo'
|
||||
import type { HandoffResponse } from '@/types/branching'
|
||||
import { HandoffContextScreen } from '@/components/flowpilot/HandoffContextScreen'
|
||||
import { Sparkles, Send, Loader2, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus, ListChecks, FileText, CheckCircle2, ArrowUpRight, ArrowRight, MoreHorizontal, Pause, Plus, Copy, Check } from 'lucide-react'
|
||||
import { Sparkles, Send, Loader2, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus, ListChecks, FileText, CheckCircle2, ArrowUpRight, MoreHorizontal, Pause, Plus } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { uploadsApi } from '@/api/uploads'
|
||||
import type { PendingUpload } from '@/types/upload'
|
||||
@@ -88,9 +88,6 @@ export default function AssistantChatPage() {
|
||||
// composer. Click prefills the input; first send hides the strip; explicit
|
||||
// X also hides. Per-session lifetime — a refresh wipes the state, which is
|
||||
// fine because the senior can re-open the Context overlay.
|
||||
const [chipsHidden, setChipsHidden] = useState(false)
|
||||
const [selectedChipCardIdx, setSelectedChipCardIdx] = useState<number | null>(null)
|
||||
const [copiedChipCmd, setCopiedChipCmd] = useState(false)
|
||||
const [chats, setChats] = useState<ChatListItem[]>([])
|
||||
const [activeChatId, setActiveChatId] = useState<string | null>(() => {
|
||||
if (urlSessionId) return urlSessionId
|
||||
@@ -912,6 +909,10 @@ export default function AssistantChatPage() {
|
||||
try {
|
||||
const updated = await sessionSuggestedFixesApi.patchOutcome(activeChatId, activeFix.id, outcome, notes)
|
||||
setActiveFix(updated)
|
||||
// Banner and script panel are linked surfaces: once an outcome is
|
||||
// recorded, the script-execution affordance has done its job, so close
|
||||
// it alongside the banner state transition.
|
||||
setScriptPanelOpen(false)
|
||||
// Reset apply tracking state since we now have a terminal outcome.
|
||||
setPostApplyMsgCount(0)
|
||||
setNudgeSilenced(false)
|
||||
@@ -1304,7 +1305,6 @@ export default function AssistantChatPage() {
|
||||
.map((u) => u.preview)
|
||||
setInput('')
|
||||
setPendingUploads([])
|
||||
setChipsHidden(true)
|
||||
setMessages(prev => [...prev, { role: 'user', content: userMessage, imageUrls: imageUrls.length > 0 ? imageUrls : undefined }])
|
||||
setLoading(true)
|
||||
|
||||
@@ -1769,27 +1769,10 @@ export default function AssistantChatPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop actions — shown when session is active and has messages */}
|
||||
{/* Desktop actions — Resolve + Escalate stay first-class; everything
|
||||
else (Context / New Ticket / Update Ticket / Pause) folds behind
|
||||
a single kebab to keep the header to two visible primary actions. */}
|
||||
<div className="hidden sm:flex items-center gap-1.5">
|
||||
{magicHandoff && (
|
||||
<button
|
||||
onClick={openHandoffContextOverlay}
|
||||
disabled={overlayLoading}
|
||||
title="Show the handoff context the original engineer sent"
|
||||
className="flex items-center gap-1.5 rounded-lg border border-default px-3 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground hover:border-hover disabled:opacity-40 transition-colors"
|
||||
>
|
||||
<Sparkles size={13} />
|
||||
Context
|
||||
</button>
|
||||
)}
|
||||
{activePsaTicketId && (
|
||||
<button
|
||||
onClick={() => { setSpinOffHint(undefined); setShowNewTicket(true) }}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground border border-default rounded-[5px] hover:border-hover hover:text-primary transition-colors"
|
||||
>
|
||||
<Plus className="w-3 h-3" /> New Ticket
|
||||
</button>
|
||||
)}
|
||||
{isActive && (
|
||||
<>
|
||||
<button
|
||||
@@ -1802,55 +1785,76 @@ export default function AssistantChatPage() {
|
||||
Resolve
|
||||
</button>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={handleEscalateClick}
|
||||
disabled={!canAct}
|
||||
data-conclude-outcome="escalated"
|
||||
className="flex items-center gap-1.5 rounded-lg bg-warning-dim border border-warning/20 px-3 py-1.5 text-xs font-medium text-warning hover:bg-warning/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
|
||||
>
|
||||
<ArrowUpRight size={13} />
|
||||
Escalate
|
||||
</button>
|
||||
{escalateIntercept && (
|
||||
<EscalateInterceptDialog
|
||||
fixTitle={escalateIntercept.fixTitle}
|
||||
onChoose={handleInterceptChoice}
|
||||
onClose={() => setEscalateIntercept(null)}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={handleEscalateClick}
|
||||
disabled={!canAct}
|
||||
data-conclude-outcome="escalated"
|
||||
className="flex items-center gap-1.5 rounded-lg bg-warning-dim border border-warning/20 px-3 py-1.5 text-xs font-medium text-warning hover:bg-warning/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
|
||||
>
|
||||
<ArrowUpRight size={13} />
|
||||
Escalate
|
||||
</button>
|
||||
{escalateIntercept && (
|
||||
<EscalateInterceptDialog
|
||||
fixTitle={escalateIntercept.fixTitle}
|
||||
onChoose={handleInterceptChoice}
|
||||
onClose={() => setEscalateIntercept(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{messages.length >= 2 && (
|
||||
<button
|
||||
onClick={() => setShowStatusUpdate(true)}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-accent-dim border border-accent/20 px-3 py-1.5 text-xs font-medium text-accent hover:bg-accent/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
|
||||
>
|
||||
<FileText size={13} />
|
||||
{updateLabel}
|
||||
</button>
|
||||
)}
|
||||
{/* Overflow: Pause / — */}
|
||||
{isActive && messages.length >= 2 && (
|
||||
{(magicHandoff || activePsaTicketId || messages.length >= 2) && (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowOverflow(!showOverflow)}
|
||||
className="flex items-center justify-center rounded-lg px-2 py-1.5 text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||||
aria-label="More session actions"
|
||||
>
|
||||
<MoreHorizontal size={16} />
|
||||
</button>
|
||||
{showOverflow && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowOverflow(false)} />
|
||||
<div className="absolute right-0 top-full mt-1 z-50 w-36 rounded-lg border border-border bg-card py-1 shadow-lg">
|
||||
<button
|
||||
onClick={() => { setShowOverflow(false); aiSessionsApi.pauseSession(activeChatId).then(() => setActiveSessionStatus('paused')).catch(() => toast.error('Failed to pause')) }}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||||
>
|
||||
<Pause size={13} />
|
||||
Pause
|
||||
</button>
|
||||
<div className="absolute right-0 top-full mt-1 z-50 w-44 rounded-lg border border-border bg-card py-1 shadow-lg">
|
||||
{magicHandoff && (
|
||||
<button
|
||||
onClick={() => { setShowOverflow(false); openHandoffContextOverlay() }}
|
||||
disabled={overlayLoading}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors disabled:opacity-40"
|
||||
>
|
||||
<Sparkles size={13} />
|
||||
Context
|
||||
</button>
|
||||
)}
|
||||
{activePsaTicketId && (
|
||||
<button
|
||||
onClick={() => { setShowOverflow(false); setSpinOffHint(undefined); setShowNewTicket(true) }}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||||
>
|
||||
<Plus size={13} />
|
||||
New Ticket
|
||||
</button>
|
||||
)}
|
||||
{messages.length >= 2 && (
|
||||
<button
|
||||
onClick={() => { setShowOverflow(false); setShowStatusUpdate(true) }}
|
||||
disabled={loading}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors disabled:opacity-40"
|
||||
>
|
||||
<FileText size={13} />
|
||||
{updateLabel}
|
||||
</button>
|
||||
)}
|
||||
{isActive && messages.length >= 2 && (
|
||||
<button
|
||||
onClick={() => { setShowOverflow(false); aiSessionsApi.pauseSession(activeChatId).then(() => setActiveSessionStatus('paused')).catch(() => toast.error('Failed to pause')) }}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||||
>
|
||||
<Pause size={13} />
|
||||
Pause
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -1858,12 +1862,14 @@ export default function AssistantChatPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile: single overflow menu */}
|
||||
{messages.length >= 2 && (
|
||||
{/* Mobile: single overflow menu — same items as desktop kebab plus
|
||||
Resolve/Escalate (which live in the visible row on desktop). */}
|
||||
{(magicHandoff || activePsaTicketId || messages.length >= 2) && (
|
||||
<div className="sm:hidden relative">
|
||||
<button
|
||||
onClick={() => setShowOverflow(!showOverflow)}
|
||||
className="flex items-center justify-center rounded-lg px-2 py-1.5 text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||||
aria-label="Session actions"
|
||||
>
|
||||
<MoreHorizontal size={18} />
|
||||
</button>
|
||||
@@ -1871,7 +1877,7 @@ export default function AssistantChatPage() {
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowOverflow(false)} />
|
||||
<div className="absolute right-0 top-full mt-1 z-50 w-44 rounded-lg border border-border bg-card py-1 shadow-lg">
|
||||
{isActive && (
|
||||
{isActive && messages.length >= 2 && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => { setShowOverflow(false); handleResolveClick() }}
|
||||
@@ -1902,15 +1908,36 @@ export default function AssistantChatPage() {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setShowOverflow(false); setShowStatusUpdate(true) }}
|
||||
disabled={loading}
|
||||
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-accent hover:bg-accent-dim transition-colors disabled:opacity-40"
|
||||
>
|
||||
<FileText size={14} />
|
||||
{updateLabel}
|
||||
</button>
|
||||
{isActive && (
|
||||
{magicHandoff && (
|
||||
<button
|
||||
onClick={() => { setShowOverflow(false); openHandoffContextOverlay() }}
|
||||
disabled={overlayLoading}
|
||||
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors disabled:opacity-40"
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
Context
|
||||
</button>
|
||||
)}
|
||||
{activePsaTicketId && (
|
||||
<button
|
||||
onClick={() => { setShowOverflow(false); setSpinOffHint(undefined); setShowNewTicket(true) }}
|
||||
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
New Ticket
|
||||
</button>
|
||||
)}
|
||||
{messages.length >= 2 && (
|
||||
<button
|
||||
onClick={() => { setShowOverflow(false); setShowStatusUpdate(true) }}
|
||||
disabled={loading}
|
||||
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-accent hover:bg-accent-dim transition-colors disabled:opacity-40"
|
||||
>
|
||||
<FileText size={14} />
|
||||
{updateLabel}
|
||||
</button>
|
||||
)}
|
||||
{isActive && messages.length >= 2 && (
|
||||
<button
|
||||
onClick={() => { setShowOverflow(false); aiSessionsApi.pauseSession(activeChatId).then(() => setActiveSessionStatus('paused')).catch(() => toast.error('Failed to pause')) }}
|
||||
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||||
@@ -1941,8 +1968,11 @@ export default function AssistantChatPage() {
|
||||
Hidden (not unmounted) when Script Builder tab is active so
|
||||
scroll position and input state are preserved. */}
|
||||
<div className={cn('flex-1 min-h-0 flex flex-col', chatTab !== 'chat' && 'hidden')}>
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto px-4 sm:px-6 py-4 space-y-4">
|
||||
{/* Messages — scroll container is full width (so the scrollbar lives at
|
||||
the chat-column edge) but content is centered to max-w-3xl to match
|
||||
the composer below, giving the column a single anchor. */}
|
||||
<div className="flex-1 overflow-y-auto px-4 sm:px-6 py-4">
|
||||
<div className="max-w-3xl mx-auto space-y-4">
|
||||
{messages.length === 0 && !loading && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-accent-dim flex items-center justify-center mb-4">
|
||||
@@ -1957,26 +1987,41 @@ export default function AssistantChatPage() {
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg, i) => (
|
||||
<ChatMessage
|
||||
key={i}
|
||||
role={msg.role}
|
||||
content={msg.content}
|
||||
suggestedFlows={msg.suggestedFlows}
|
||||
imageUrls={msg.imageUrls}
|
||||
/>
|
||||
))}
|
||||
{(() => {
|
||||
// Action emphasis is shown on the *current* turn only — i.e. the
|
||||
// latest assistant message when active items are pending and the
|
||||
// magic-moment hero has dismissed. The TaskLane remains the
|
||||
// canonical list; this is just an inline cue.
|
||||
let lastAssistantIdx = -1
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role === 'assistant') { lastAssistantIdx = i; break }
|
||||
}
|
||||
const showActionEmphasis = magicState === 'dismissed'
|
||||
&& (activeQuestions.length + activeActions.length) > 0
|
||||
const turnActionCount = activeQuestions.length + activeActions.length
|
||||
return messages.map((msg, i) => (
|
||||
<ChatMessage
|
||||
key={i}
|
||||
role={msg.role}
|
||||
content={msg.content}
|
||||
suggestedFlows={msg.suggestedFlows}
|
||||
imageUrls={msg.imageUrls}
|
||||
actionCount={i === lastAssistantIdx && showActionEmphasis ? turnActionCount : undefined}
|
||||
/>
|
||||
))
|
||||
})()}
|
||||
{loading && (
|
||||
<div className="flex gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-primary/15 flex items-center justify-center">
|
||||
<Sparkles size={14} className="text-primary" />
|
||||
</div>
|
||||
<div className="bg-input border border-border rounded-2xl px-4 py-3">
|
||||
<div className="bg-input border border-border rounded-xl px-4 py-3">
|
||||
<Loader2 size={16} className="animate-spin text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phase 8: ProposalBanner — mounted above the composer */}
|
||||
@@ -1997,8 +2042,9 @@ export default function AssistantChatPage() {
|
||||
|
||||
{/* Phase 9: InlineNoTemplateDialog — drafted-script evaluation case,
|
||||
rendered in the chat region above the composer so all three
|
||||
option cards fit side-by-side without the TaskLane's narrow width. */}
|
||||
{scriptPanelOpen && activeFix && activeChatId && !activeFix.script_template_id && activeFix.ai_drafted_script && (
|
||||
option cards fit side-by-side without the TaskLane's narrow width.
|
||||
Hidden when the banner is collapsed: the two surfaces are linked. */}
|
||||
{scriptPanelOpen && !bannerCollapsed && activeFix && activeChatId && !activeFix.script_template_id && activeFix.ai_drafted_script && (
|
||||
<InlineNoTemplateDialog
|
||||
fix={activeFix}
|
||||
onClose={() => setScriptPanelOpen(false)}
|
||||
@@ -2007,143 +2053,6 @@ export default function AssistantChatPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Task-lane shortcut chips: visible after the magic-moment
|
||||
dissolves when the task lane has loaded items. Each card
|
||||
links directly to the corresponding diagnostic card in the
|
||||
task lane — clicking opens the lane (if closed) and scrolls
|
||||
to that card. Sourced from actual task lane items, not the
|
||||
AI's free-text suggested_steps, so the card the user lands
|
||||
on has full detail (description, command, etc.). */}
|
||||
{!chipsHidden &&
|
||||
(activeActions.length > 0 || activeQuestions.length > 0) &&
|
||||
magicState === 'dismissed' && (() => {
|
||||
const chipItems = [
|
||||
...activeActions.slice(0, 4).map((a, ai) => ({
|
||||
label: a.label,
|
||||
cardIdx: activeQuestions.length + ai,
|
||||
description: a.description,
|
||||
command: a.command ?? null,
|
||||
type: 'action' as const,
|
||||
})),
|
||||
...activeQuestions.slice(0, Math.max(0, 4 - Math.min(activeActions.length, 4))).map((q, qi) => ({
|
||||
label: q.text,
|
||||
cardIdx: qi,
|
||||
description: q.context ?? null,
|
||||
command: null,
|
||||
type: 'question' as const,
|
||||
})),
|
||||
]
|
||||
const selectedChip = chipItems.find(c => c.cardIdx === selectedChipCardIdx) ?? null
|
||||
return (
|
||||
<div className="px-3 sm:px-6 pt-2 pb-0.5 shrink-0">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<p className="font-sans text-[0.625rem] uppercase tracking-wider text-muted-foreground">
|
||||
Suggested checks
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setChipsHidden(true); setSelectedChipCardIdx(null) }}
|
||||
aria-label="Hide suggestions"
|
||||
className="p-0.5 rounded text-muted-foreground hover:text-foreground hover:bg-elevated transition-colors"
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Inline detail card — shown when a chip is selected */}
|
||||
{selectedChip && (
|
||||
<div className="mb-2 rounded-lg border border-default bg-card p-3 animate-fade-in">
|
||||
<div className="flex items-start justify-between gap-2 mb-1.5">
|
||||
<span className="text-[0.8125rem] font-medium text-heading leading-snug">{selectedChip.label}</span>
|
||||
<button
|
||||
onClick={() => setSelectedChipCardIdx(null)}
|
||||
className="shrink-0 p-0.5 rounded text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label="Close detail"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
{selectedChip.description && (
|
||||
<p className="text-[0.6875rem] text-muted-foreground mb-2 leading-relaxed">{selectedChip.description}</p>
|
||||
)}
|
||||
{selectedChip.command && (
|
||||
<div className="rounded-md bg-code border border-default/50 px-3 py-2 flex items-start gap-2 mb-2.5">
|
||||
<code className="flex-1 text-[0.75rem] font-mono text-heading whitespace-pre-wrap break-all leading-relaxed">{selectedChip.command}</code>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(selectedChip.command!)
|
||||
} catch {
|
||||
try {
|
||||
const el = document.createElement('textarea')
|
||||
el.value = selectedChip.command!
|
||||
el.style.cssText = 'position:fixed;opacity:0;pointer-events:none'
|
||||
document.body.appendChild(el)
|
||||
el.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(el)
|
||||
} catch { return }
|
||||
}
|
||||
setCopiedChipCmd(true)
|
||||
setTimeout(() => setCopiedChipCmd(false), 1500)
|
||||
}}
|
||||
className="shrink-0 p-1 rounded text-muted-foreground hover:text-heading hover:bg-elevated transition-colors mt-0.5"
|
||||
title={copiedChipCmd ? 'Copied!' : 'Copy command'}
|
||||
>
|
||||
{copiedChipCmd
|
||||
? <Check size={13} className="text-success" />
|
||||
: <Copy size={13} />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedChipCardIdx(null)
|
||||
if (!showTaskLane) setShowTaskLane(true)
|
||||
const el = document.getElementById(`task-lane-card-${selectedChip.cardIdx}`)
|
||||
if (el) {
|
||||
setTimeout(() => el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }), showTaskLane ? 0 : 200)
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-1 text-[0.75rem] font-medium text-accent-text hover:text-accent transition-colors"
|
||||
>
|
||||
<ArrowRight size={11} />
|
||||
Open in Tasks panel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 overflow-x-auto pb-1" style={{ scrollbarWidth: 'none' }}>
|
||||
{chipItems.map((item) => {
|
||||
const isSelected = item.cardIdx === selectedChipCardIdx
|
||||
return (
|
||||
<button
|
||||
key={item.cardIdx}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCopiedChipCmd(false)
|
||||
setSelectedChipCardIdx(isSelected ? null : item.cardIdx)
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-start gap-2 rounded-lg border px-3 py-2.5 text-left transition-colors shrink-0 w-[172px]',
|
||||
isSelected
|
||||
? 'border-accent/50 bg-accent-dim'
|
||||
: 'border-default bg-card hover:bg-accent-dim hover:border-accent/30',
|
||||
)}
|
||||
>
|
||||
<ArrowRight size={12} className="text-accent-text shrink-0 mt-0.5" />
|
||||
<span className="text-xs text-foreground line-clamp-2 leading-snug">{item.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Rich Input */}
|
||||
<div className="px-3 sm:px-6 py-3 shrink-0">
|
||||
<div
|
||||
@@ -2191,7 +2100,7 @@ export default function AssistantChatPage() {
|
||||
{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">
|
||||
<div className="w-full h-full flex items-center justify-center text-[0.625rem] text-muted-foreground px-1 text-center">
|
||||
{upload.file.name.split('.').pop()?.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
@@ -2359,6 +2268,8 @@ export default function AssistantChatPage() {
|
||||
loading={loading}
|
||||
whatWeKnowSlot={
|
||||
<WhatWeKnow
|
||||
key={activeChatId ?? 'no-session'}
|
||||
sessionId={activeChatId}
|
||||
facts={facts}
|
||||
onAddNote={handleAddNote}
|
||||
onUpdateFact={handleUpdateFact}
|
||||
@@ -2368,7 +2279,7 @@ export default function AssistantChatPage() {
|
||||
}
|
||||
bottomSlot={
|
||||
<>
|
||||
{scriptPanelOpen && activeFix && activeChatId && activeFix.script_template_id && (
|
||||
{scriptPanelOpen && !bannerCollapsed && activeFix && activeChatId && activeFix.script_template_id && (
|
||||
<TemplateMatchPanel
|
||||
fix={activeFix}
|
||||
sessionId={activeChatId}
|
||||
@@ -2380,7 +2291,7 @@ export default function AssistantChatPage() {
|
||||
<button
|
||||
onClick={() => handleOpenPreview('resolve')}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 text-[0.75rem] font-medium transition-colors',
|
||||
'flex items-center gap-1.5 text-xs font-medium transition-colors',
|
||||
previewKind === 'resolve'
|
||||
? 'text-success'
|
||||
: 'text-accent-text hover:text-heading',
|
||||
@@ -2392,7 +2303,7 @@ export default function AssistantChatPage() {
|
||||
<button
|
||||
onClick={() => handleOpenPreview('escalate')}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 text-[0.75rem] font-medium transition-colors',
|
||||
'flex items-center gap-1.5 text-xs font-medium transition-colors',
|
||||
previewKind === 'escalate'
|
||||
? 'text-warning'
|
||||
: 'text-muted-foreground hover:text-heading',
|
||||
@@ -2430,6 +2341,8 @@ export default function AssistantChatPage() {
|
||||
loading={loading}
|
||||
whatWeKnowSlot={
|
||||
<WhatWeKnow
|
||||
key={activeChatId ?? 'no-session'}
|
||||
sessionId={activeChatId}
|
||||
facts={facts}
|
||||
onAddNote={handleAddNote}
|
||||
onUpdateFact={handleUpdateFact}
|
||||
@@ -2439,7 +2352,7 @@ export default function AssistantChatPage() {
|
||||
}
|
||||
bottomSlot={
|
||||
<>
|
||||
{scriptPanelOpen && activeFix && activeChatId && activeFix.script_template_id && (
|
||||
{scriptPanelOpen && !bannerCollapsed && activeFix && activeChatId && activeFix.script_template_id && (
|
||||
<TemplateMatchPanel
|
||||
fix={activeFix}
|
||||
sessionId={activeChatId}
|
||||
@@ -2451,7 +2364,7 @@ export default function AssistantChatPage() {
|
||||
<button
|
||||
onClick={() => handleOpenPreview('resolve')}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 text-[0.75rem] font-medium transition-colors',
|
||||
'flex items-center gap-1.5 text-xs font-medium transition-colors',
|
||||
previewKind === 'resolve'
|
||||
? 'text-success'
|
||||
: 'text-accent-text hover:text-heading',
|
||||
@@ -2463,7 +2376,7 @@ export default function AssistantChatPage() {
|
||||
<button
|
||||
onClick={() => handleOpenPreview('escalate')}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 text-[0.75rem] font-medium transition-colors',
|
||||
'flex items-center gap-1.5 text-xs font-medium transition-colors',
|
||||
previewKind === 'escalate'
|
||||
? 'text-warning'
|
||||
: 'text-muted-foreground hover:text-heading',
|
||||
@@ -2561,7 +2474,7 @@ export default function AssistantChatPage() {
|
||||
{/* Handoff context overlay — re-opened from the toolbar */}
|
||||
{overlayHandoff && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/60 backdrop-blur-sm p-4 sm:p-8 animate-fade-in"
|
||||
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/70 p-4 sm:p-8 animate-fade-in"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) setOverlayHandoff(null)
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user