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

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