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:
2026-05-01 16:22:50 -04:00
parent 4d8b107121
commit 0156aae684
12 changed files with 442 additions and 445 deletions

View File

@@ -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

View File

@@ -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 => (

View File

@@ -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>
)}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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(

View File

@@ -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'

View File

@@ -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>
)
}

View File

@@ -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>

View File

@@ -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>
)
}

View File

@@ -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',
)}
>

View File

@@ -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)
}}