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)} 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" 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} /> <Terminal size={12} />
<span>{pendingCount} diagnostic check{pendingCount !== 1 ? 's' : ''} not completed</span> <span>{pendingCount} diagnostic check{pendingCount !== 1 ? 's' : ''} not completed</span>
</div> </div>
@@ -95,7 +95,7 @@ export function ActionCardGroup({ actions, onSubmit, disabled, stale }: ActionCa
</div> </div>
<div className="space-y-0.5"> <div className="space-y-0.5">
{responses.map((r, i) => ( {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' ? ( {r.state === 'done' ? (
<Check size={10} className="text-success shrink-0" /> <Check size={10} className="text-success shrink-0" />
) : ( ) : (
@@ -118,7 +118,7 @@ export function ActionCardGroup({ actions, onSubmit, disabled, stale }: ActionCa
<div> <div>
<button <button
onClick={() => setShowRunAll(!showRunAll)} 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} /> <Terminal size={12} />
<span>Run All ({commandActions.length} commands)</span> <span>Run All ({commandActions.length} commands)</span>
@@ -128,12 +128,12 @@ export function ActionCardGroup({ actions, onSubmit, disabled, stale }: ActionCa
{showRunAll && ( {showRunAll && (
<div className="mt-2 rounded-lg border border-default bg-code p-3"> <div className="mt-2 rounded-lg border border-default bg-code p-3">
<div className="flex items-center justify-between mb-2"> <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 Combined diagnostic script
</span> </span>
<button <button
onClick={() => handleCopyCommand(combinedScript)} 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} /> <Copy size={11} />
<span>Copy</span> <span>Copy</span>
@@ -167,23 +167,23 @@ export function ActionCardGroup({ actions, onSubmit, disabled, stale }: ActionCa
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="text-[0.8125rem] font-medium text-heading">{action.label}</div> <div className="text-[0.8125rem] font-medium text-heading">{action.label}</div>
{action.description && ( {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> </div>
{/* Status badge for handled cards */} {/* Status badge for handled cards */}
{response.state === 'done' && ( {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' && ( {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> </div>
{/* Command with copy button */} {/* Command with copy button */}
{action.command && response.state !== 'skipped' && ( {action.command && response.state !== 'skipped' && (
<div className="mt-2 flex items-center gap-2 rounded bg-code px-2.5 py-1.5"> <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} {action.command}
</code> </code>
<button <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"> <div className="mt-2 flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
<button <button
onClick={() => updateCard(idx, { state: 'pasting' })} 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} /> <Clipboard size={11} />
Paste Result Paste Result
</button> </button>
<button <button
onClick={() => updateCard(idx, { state: 'typing' })} 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 Type Answer
</button> </button>
<button <button
onClick={() => updateCard(idx, { state: 'skipped' })} 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} /> <SkipForward size={11} />
Skip Skip
@@ -237,14 +237,14 @@ export function ActionCardGroup({ actions, onSubmit, disabled, stale }: ActionCa
<button <button
onClick={() => updateCard(idx, { state: 'done' })} onClick={() => updateCard(idx, { state: 'done' })}
disabled={!response.value.trim()} 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} /> <Check size={11} />
Done Done
</button> </button>
<button <button
onClick={() => updateCard(idx, { state: 'pending', value: '' })} 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 Cancel
</button> </button>
@@ -282,7 +282,7 @@ export function ActionCardGroup({ actions, onSubmit, disabled, stale }: ActionCa
</button> </button>
{submitError && ( {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} /> <AlertCircle size={12} />
<span>Failed to send</span> <span>Failed to send</span>
<button <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 { MarkdownContent } from '@/components/ui/MarkdownContent'
import { SuggestedFlowCard } from './SuggestedFlowCard' import { SuggestedFlowCard } from './SuggestedFlowCard'
import type { SuggestedFlow } from '@/types/copilot' import type { SuggestedFlow } from '@/types/copilot'
@@ -8,9 +8,14 @@ interface ChatMessageProps {
content: string content: string
suggestedFlows?: SuggestedFlow[] suggestedFlows?: SuggestedFlow[]
imageUrls?: string[] 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 ( return (
<div className={`flex gap-3 ${role === 'user' ? 'flex-row-reverse' : ''}`}> <div className={`flex gap-3 ${role === 'user' ? 'flex-row-reverse' : ''}`}>
{/* Avatar */} {/* Avatar */}
@@ -41,20 +46,32 @@ export function ChatMessage({ role, content, suggestedFlows, imageUrls }: ChatMe
</div> </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 <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' role === 'user'
? 'bg-primary/15 text-foreground' ? '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> </div>
{/* Suggested flows (assistant only) */} {/* Suggested flows (assistant only) */}
{role === 'assistant' && suggestedFlows && suggestedFlows.length > 0 && ( {role === 'assistant' && suggestedFlows && suggestedFlows.length > 0 && (
<div className="space-y-1.5"> <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 Related Flows
</span> </span>
{suggestedFlows.map(flow => ( {suggestedFlows.map(flow => (

View File

@@ -159,7 +159,7 @@ export function ChatSidebarCollapsedBar({
<History size={14} /> <History size={14} />
<span>History</span> <span>History</span>
{chats.length > 0 && ( {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> </button>
<div className="flex-1" /> <div className="flex-1" />
@@ -203,7 +203,7 @@ function ChatItem({
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
{confirming ? ( {confirming ? (
<div className="flex items-center gap-2"> <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 <button
onClick={e => { e.stopPropagation(); onDelete(); setConfirming(false) }} 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" 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="flex items-center gap-1.5 min-w-0">
<div className="text-[0.8125rem] font-medium truncate">{chat.title}</div> <div className="text-[0.8125rem] font-medium truncate">{chat.title}</div>
{chat.psa_ticket_id && ( {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} #{chat.psa_ticket_id}
</span> </span>
)} )}
{(chat.status === 'escalated' || chat.status === 'requesting_escalation') && ( {(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 Escalated
</span> </span>
)} )}

View File

@@ -268,7 +268,7 @@ export function ConcludeSessionModal({
)} )}
<div <div
className={cn( 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 step === s
? 'bg-primary text-white' ? 'bg-primary text-white'
: (i < ['select-outcome', 'add-notes', 'summary'].indexOf(step)) : (i < ['select-outcome', 'add-notes', 'summary'].indexOf(step))
@@ -342,7 +342,7 @@ export function ConcludeSessionModal({
</div> </div>
<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) Additional Notes (optional)
</label> </label>
<textarea <textarea
@@ -396,7 +396,7 @@ export function ConcludeSessionModal({
style={{ borderColor: 'var(--color-border-default)' }} style={{ borderColor: 'var(--color-border-default)' }}
> >
<div className="flex items-center justify-between mb-3"> <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" /> <Sparkles size={10} className="text-primary" />
Ticket Notes Ticket Notes
</span> </span>
@@ -488,7 +488,7 @@ export function ConcludeSessionModal({
style={{ borderColor: 'var(--color-border-default)' }} style={{ borderColor: 'var(--color-border-default)' }}
> >
<div className="flex items-center justify-between mb-3"> <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" /> <Sparkles size={10} className="text-primary" />
Status Update Status Update
</span> </span>

View File

@@ -27,11 +27,11 @@ export function SuggestedFlowCard({ flow }: SuggestedFlowCardProps) {
<span className="text-[0.8125rem] font-medium text-foreground truncate"> <span className="text-[0.8125rem] font-medium text-foreground truncate">
{flow.tree_name} {flow.tree_name}
</span> </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} {flow.tree_type}
</span> </span>
</div> </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} {flow.relevance_snippet}
</p> </p>
</div> </div>

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useRef, useCallback } from 'react' import { useState, useEffect, useRef, useCallback } from 'react'
import { import {
Copy, Check, SkipForward, Terminal, ChevronDown, ChevronUp, Copy, Check, SkipForward, Terminal, ChevronDown, ChevronUp,
Send, Clipboard, Loader2, PanelRightClose, MessageCircleQuestion, Eye, Send, Clipboard, Loader2, PanelRightClose, Pencil, HelpCircle, Eye,
} from 'lucide-react' } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast' 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)) 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 questionTasks = tasks.filter(t => t.type === 'question')
const actionTasks = tasks.filter(t => t.type === 'action') as ActionResponse[] const actionTasks = tasks.filter(t => t.type === 'action') as ActionResponse[]
const allHandled = tasks.every(t => t.state === 'done' || t.state === 'skipped') 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> </div>
)} )}
{/* Header */} {/* 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"> <h3 className="font-heading text-sm font-bold text-heading flex items-center gap-2">
Tasks Tasks
<span className={cn( {allHandled ? (
'text-[10px] font-semibold px-2 py-0.5 rounded-full', <span className="flex items-center gap-1 text-[0.625rem] font-semibold uppercase tracking-wider text-success">
allHandled <Check size={10} /> Ready
? 'bg-success-dim text-success' </span>
: 'bg-accent-dim text-accent-text' ) : (
)}> <span className="text-[0.625rem] font-medium tabular-nums text-muted-foreground">
{allHandled ? '✓ Ready' : `${doneCount}/${totalCount}`} {doneCount}/{totalCount}
</span> </span>
)}
{loading && ( {loading && (
<span <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" title="AI is thinking"
> >
<Loader2 size={10} className="animate-spin" /> <Loader2 size={10} className="animate-spin" />
@@ -386,7 +405,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
{questionTasks.length > 0 && ( {questionTasks.length > 0 && (
<section> <section>
<div className="pb-2"> <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" /> <span className="w-1.5 h-1.5 rounded-full bg-accent" />
Questions Questions
{questionTasks.every(q => q.state === 'done' || q.state === 'skipped') && ( {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') { if (q.state === 'done') {
return ( 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"> <div className="flex items-center gap-1.5">
<Check size={12} className="text-success shrink-0" /> <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>
<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> </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 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="flex justify-between">
<div className="text-[0.8125rem] text-muted-foreground line-through">{q.text}</div> <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>
</div> </div>
) )
@@ -434,33 +453,47 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
autoFocus autoFocus
value={q.value} value={q.value}
onChange={e => updateTask(idx, { value: e.target.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..." 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" 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} rows={2}
/> />
<div className="mt-1.5 flex items-center gap-2"> <div className="mt-1.5 flex items-center justify-between gap-2">
<button <div className="flex items-center gap-2">
onClick={() => updateTask(idx, { state: 'done' })} <button
disabled={!q.value.trim()} onClick={() => submitAndAdvance(idx, q.value)}
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" 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> <Check size={11} /> Answer
<button </button>
onClick={() => updateTask(idx, { state: 'pending', value: '' })} <button
className="text-[0.75rem] text-muted-foreground hover:text-heading" onClick={() => updateTask(idx, { state: 'pending', value: '' })}
> className="text-xs text-muted-foreground hover:text-heading"
Cancel >
</button> Cancel
</button>
</div>
<span className="text-[0.625rem] text-muted-foreground/70 tabular-nums">
submit · newline
</span>
</div> </div>
</div> </div>
) : ( ) : (
<div className="mt-2 flex items-center gap-2"> <div className="mt-2 flex items-center gap-2">
<button <button
onClick={() => updateTask(idx, { state: 'active' })} 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>
<button <button
onClick={() => updateTask(idx, { state: 'skipped' })} onClick={() => updateTask(idx, { state: 'skipped' })}
@@ -481,7 +514,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
{actionTasks.length > 0 && ( {actionTasks.length > 0 && (
<section> <section>
<div className="pb-2"> <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" /> <span className="w-1.5 h-1.5 rounded-full bg-accent" />
Diagnostic Checks Diagnostic Checks
{actionTasks.every(a => a.state === 'done' || a.state === 'skipped') && ( {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"> <div className="mb-2">
<button <button
onClick={() => setShowRunAll(!showRunAll)} 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} /> <Terminal size={12} />
Run All ({commandActions.length} commands) Run All ({commandActions.length} commands)
@@ -504,16 +537,16 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
{showRunAll && ( {showRunAll && (
<div className="mt-2 rounded-lg border border-default bg-code p-3"> <div className="mt-2 rounded-lg border border-default bg-code p-3">
<div className="flex items-center justify-between mb-2"> <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 <button
onClick={() => void handleCopy(combinedScript)} 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 ? <Check size={11} className="text-success" /> : <Copy size={11} />}
{copiedKey === combinedScript ? 'Copied' : 'Copy'} {copiedKey === combinedScript ? 'Copied' : 'Copy'}
</button> </button>
</div> </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>
)} )}
</div> </div>
@@ -525,10 +558,10 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
if (a.state === 'done') { if (a.state === 'done') {
return ( 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"> <div className="flex items-center gap-1.5">
<Check size={12} className="text-success shrink-0" /> <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>
</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 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="flex justify-between">
<div className="text-[0.8125rem] text-muted-foreground line-through">{a.label}</div> <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>
</div> </div>
) )
@@ -565,7 +598,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
aria-label="Explain this diagnostic check" aria-label="Explain this diagnostic check"
aria-expanded={expandedHelpKey === `${idx}`} aria-expanded={expandedHelpKey === `${idx}`}
> >
<MessageCircleQuestion size={13} /> <HelpCircle size={13} />
</button> </button>
</div> </div>
@@ -613,31 +646,45 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
autoFocus autoFocus
value={a.value} value={a.value}
onChange={e => updateTask(idx, { value: e.target.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..." 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" 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} rows={3}
/> />
<div className="mt-1.5 flex items-center gap-2"> <div className="mt-1.5 flex items-center justify-between gap-2">
<button <div className="flex items-center gap-2">
onClick={() => updateTask(idx, { state: 'done' })} <button
disabled={!a.value.trim()} onClick={() => submitAndAdvance(idx, a.value)}
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" 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> <Check size={11} /> Done
<button </button>
onClick={() => updateTask(idx, { state: 'pending', value: '' })} <button
className="text-[0.75rem] text-muted-foreground hover:text-heading" onClick={() => updateTask(idx, { state: 'pending', value: '' })}
> className="text-xs text-muted-foreground hover:text-heading"
Cancel >
</button> Cancel
</button>
</div>
<span className="text-[0.625rem] text-muted-foreground/70 tabular-nums">
submit · newline
</span>
</div> </div>
</div> </div>
) : ( ) : (
<div className="mt-2 flex items-center gap-2"> <div className="mt-2 flex items-center gap-2">
<button <button
onClick={() => updateTask(idx, { state: 'active' })} 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'} <Clipboard size={11} /> {a.command ? 'Paste Output' : 'Answer'}
</button> </button>
@@ -698,7 +745,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
<div className="mb-2"> <div className="mb-2">
<button <button
onClick={() => setShowPreview(!showPreview)} 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} /> <Eye size={12} />
Preview ({handledCount}/{totalCount} done) Preview ({handledCount}/{totalCount} done)
@@ -712,6 +759,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
</div> </div>
)} )}
<button <button
ref={sendButtonRef}
onClick={handleSubmit} onClick={handleSubmit}
disabled={!anyHandled || loading || submitting} disabled={!anyHandled || loading || submitting}
className={cn( className={cn(

View File

@@ -57,7 +57,7 @@ function TabButton({
aria-selected={active} aria-selected={active}
onClick={onClick} onClick={onClick}
className={cn( 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', 'border-b-2 -mb-px',
active active
? 'text-heading border-accent bg-bg-page' ? '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) { function ProposedBanner({ fix, onApply, onDismiss, onToggleCollapsed }: ProposalBannerProps) {
return ( 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="border-t border-warning/30 bg-card px-5 py-3 animate-slide-up">
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-warning" />
<div className="flex items-start gap-3"> <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={16} className="text-warning shrink-0 mt-1" />
<Sparkles size={15} />
</div>
<div className="flex-1 min-w-0"> <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>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 {fix.confidence_pct}% confidence
</span> </span>
</div> </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} {fix.title}
</div> </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} {fix.description}
</div> </div>
{fix.script_template_id && ( {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} /> <Check size={11} />
Matches an existing Script Library template one-click apply Matches an existing Script Library template one-click apply
</div> </div>
@@ -92,13 +89,13 @@ function ProposedBanner({ fix, onApply, onDismiss, onToggleCollapsed }: Proposal
)} )}
<button <button
onClick={onDismiss} 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 Dismiss
</button> </button>
<button <button
onClick={onApply} 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 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> <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' : 'Applied'
return ( 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="border-t border-warning/30 bg-card px-5 py-3 animate-slide-up">
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-warning" />
<div className="flex items-start gap-3"> <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="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">
<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" />
<circle cx="12" cy="12" r="10" /> <polyline points="12 6 12 12 16 14" />
<polyline points="12 6 12 12 16 14" /> </svg>
</svg>
<span className="absolute inset-[-3px] rounded-lg animate-pulse-amber pointer-events-none" />
</div>
<div className="flex-1 min-w-0"> <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>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} {appliedLabel}
</span> </span>
</div> </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? Did "{fix.title}" work?
</div> </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. Mark the outcome so the AI can either close the session with this as the resolution, or propose something else.
</div> </div>
</div> </div>
@@ -159,7 +152,7 @@ function VerifyingBanner({ fix, onOutcome }: ProposalBannerProps) {
const notes = window.prompt('What did you run / skip?') const notes = window.prompt('What did you run / skip?')
if (notes && notes.trim()) onOutcome('applied_partial', notes.trim()) 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 Mark partial
</button> </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")') const reason = window.prompt('What are you waiting on? (e.g. "client power-cycling router")')
if (reason && reason.trim()) onOutcome('applied_pending', reason.trim()) 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" /> <Clock3 size={12} className="text-info" />
Waiting to verify Waiting to verify
@@ -181,14 +174,14 @@ function VerifyingBanner({ fix, onOutcome }: ProposalBannerProps) {
const reason = window.prompt("Why didn't it work? (optional)") const reason = window.prompt("Why didn't it work? (optional)")
onOutcome('applied_failed', reason?.trim() || undefined) 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} /> <X size={12} strokeWidth={2.5} />
Didn't work Didn't work
</button> </button>
<button <button
onClick={() => onOutcome('applied_success')} 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} /> <Check size={12} strokeWidth={2.5} />
It worked It worked
@@ -209,25 +202,22 @@ function formatRelativeMinutes(iso: string): string {
function PartialBanner({ fix, onOutcome, onApply }: ProposalBannerProps) { function PartialBanner({ fix, onOutcome, onApply }: ProposalBannerProps) {
return ( 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="border-t border-info/30 bg-card px-5 py-3 animate-slide-up">
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-info" />
<div className="flex items-start gap-3"> <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={16} className="text-info shrink-0 mt-1" />
<Info size={15} />
</div>
<div className="flex-1 min-w-0"> <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>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 Parked
</span> </span>
</div> </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} {fix.title}
</div> </div>
{fix.partial_notes && ( {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"> <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-[10.5px] uppercase tracking-[0.6px]">Note</span> <span className="not-italic font-bold text-info text-[0.625rem] uppercase tracking-[0.6px]">Note</span>
<span>{fix.partial_notes}</span> <span>{fix.partial_notes}</span>
</div> </div>
)} )}
@@ -238,19 +228,19 @@ function PartialBanner({ fix, onOutcome, onApply }: ProposalBannerProps) {
const reason = window.prompt("Why didn't it work? (optional)") const reason = window.prompt("Why didn't it work? (optional)")
onOutcome('applied_failed', reason?.trim() || undefined) 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 Didn't work
</button> </button>
<button <button
onClick={onApply} 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 Finish it
</button> </button>
<button <button
onClick={() => onOutcome('applied_success')} 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 It worked
</button> </button>
@@ -262,25 +252,22 @@ function PartialBanner({ fix, onOutcome, onApply }: ProposalBannerProps) {
function PendingBanner({ fix, onOutcome, onDismiss }: ProposalBannerProps) { function PendingBanner({ fix, onOutcome, onDismiss }: ProposalBannerProps) {
return ( 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="border-t border-info/30 bg-card px-5 py-3 animate-slide-up">
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-info" />
<div className="flex items-start gap-3"> <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={16} className="text-info shrink-0 mt-1" />
<Clock3 size={15} />
</div>
<div className="flex-1 min-w-0"> <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>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 Parked
</span> </span>
</div> </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} {fix.title}
</div> </div>
{fix.pending_reason && ( {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"> <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-[10.5px] uppercase tracking-[0.6px]">Waiting on</span> <span className="not-italic font-bold text-info text-[0.625rem] uppercase tracking-[0.6px]">Waiting on</span>
<span>{fix.pending_reason}</span> <span>{fix.pending_reason}</span>
</div> </div>
)} )}
@@ -288,7 +275,7 @@ function PendingBanner({ fix, onOutcome, onDismiss }: ProposalBannerProps) {
<div className="flex items-center gap-2 shrink-0 pt-0.5"> <div className="flex items-center gap-2 shrink-0 pt-0.5">
<button <button
onClick={onDismiss} 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 Dismiss
</button> </button>
@@ -300,7 +287,7 @@ function PendingBanner({ fix, onOutcome, onDismiss }: ProposalBannerProps) {
) )
if (reason && reason.trim()) onOutcome('applied_pending', reason.trim()) 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 Update reason
</button> </button>
@@ -309,13 +296,13 @@ function PendingBanner({ fix, onOutcome, onDismiss }: ProposalBannerProps) {
const reason = window.prompt("Why didn't it work? (optional)") const reason = window.prompt("Why didn't it work? (optional)")
onOutcome('applied_failed', reason?.trim() || undefined) 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 Didn't work
</button> </button>
<button <button
onClick={() => onOutcome('applied_success')} 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} /> <Check size={12} strokeWidth={2.5} />
It worked It worked
@@ -339,37 +326,34 @@ function AIConfirmingBanner({ fix, onAcceptAIProposal, onRejectAIProposal }: Pro
: 'was partially applied' : 'was partially applied'
return ( 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="border-t border-accent/30 bg-card px-5 py-3 animate-slide-up">
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-accent" />
<div className="flex items-start gap-3"> <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={16} className="text-accent shrink-0 mt-1" />
<Sparkles size={15} />
</div>
<div className="flex-1 min-w-0"> <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>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'} {isSuccess ? 'Success' : isFailure ? 'Failure' : 'Partial'}
</span> </span>
</div> </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? AI thinks the fix {headlineVerb} confirm?
</div> </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.'} {proposal.reason || 'Based on the recent chat. One click either confirms or corrects.'}
</div> </div>
</div> </div>
<div className="flex items-center gap-2 shrink-0 pt-0.5"> <div className="flex items-center gap-2 shrink-0 pt-0.5">
<button <button
onClick={onRejectAIProposal} 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 Not yet
</button> </button>
<button <button
onClick={onAcceptAIProposal} onClick={onAcceptAIProposal}
className={cn( 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 isSuccess
? 'bg-success text-[#0a1a12]' ? 'bg-success text-[#0a1a12]'
: 'bg-danger text-[#180808]', : 'bg-danger text-[#180808]',
@@ -386,14 +370,13 @@ function AIConfirmingBanner({ fix, onAcceptAIProposal, onRejectAIProposal }: Pro
function NudgeBanner({ fix, onOutcome, onSilenceNudge }: ProposalBannerProps) { function NudgeBanner({ fix, onOutcome, onSilenceNudge }: ProposalBannerProps) {
return ( return (
<div className="relative border-t border-warning/30 bg-warning-dim/60 px-5 py-2 flex items-center gap-3"> <div className="border-t border-warning/30 bg-card px-5 py-2 flex items-center gap-3">
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-warning" />
<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"> <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" /> <circle cx="12" cy="12" r="10" />
<path d="M12 8v4" /> <path d="M12 8v4" />
<path d="M12 16h.01" /> <path d="M12 16h.01" />
</svg> </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? Did <strong className="text-heading">"{fix.title}"</strong> work?
</span> </span>
<button <button
@@ -407,7 +390,7 @@ function NudgeBanner({ fix, onOutcome, onSilenceNudge }: ProposalBannerProps) {
onSilenceNudge() 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} /> <Clock3 size={11} />
Still checking Still checking
@@ -417,13 +400,13 @@ function NudgeBanner({ fix, onOutcome, onSilenceNudge }: ProposalBannerProps) {
const reason = window.prompt("Why didn't it work? (optional)") const reason = window.prompt("Why didn't it work? (optional)")
onOutcome('applied_failed', reason?.trim() || undefined) 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 No
</button> </button>
<button <button
onClick={() => onOutcome('applied_success')} 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 Yes
</button> </button>
@@ -435,15 +418,14 @@ function CollapsedBanner({ fix, onToggleCollapsed }: ProposalBannerProps) {
return ( return (
<button <button
onClick={onToggleCollapsed} 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" /> <Sparkles size={12} className="text-warning shrink-0" />
<span className="flex-1 text-[12px] font-medium text-heading truncate">{fix.title}</span> <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-[10.5px] font-bold tabular-nums"> <span className="px-1.5 py-[1px] rounded-full bg-warning/20 text-warning text-[0.625rem] font-bold tabular-nums">
{fix.confidence_pct}% {fix.confidence_pct}%
</span> </span>
<span className="text-muted-foreground text-[11px]"> expand</span> <span className="text-muted-foreground text-[0.6875rem]"> expand</span>
</button> </button>
) )
} }

View File

@@ -39,7 +39,7 @@ export function AddNoteButton({ onAdd }: AddNoteButtonProps) {
return ( return (
<button <button
onClick={() => setOpen(true)} 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} /> <Plus size={12} />
Add a note Add a note
@@ -62,20 +62,20 @@ export function AddNoteButton({ onAdd }: AddNoteButtonProps) {
value={summary} value={summary}
onChange={(e) => setSummary(e.target.value)} onChange={(e) => setSummary(e.target.value)}
placeholder="Short label (optional)" 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"> <div className="mt-1.5 flex items-center gap-2">
<button <button
onClick={handleSubmit} onClick={handleSubmit}
disabled={busy || !text.trim()} 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 <Check size={11} /> Add
</button> </button>
<button <button
onClick={reset} onClick={reset}
disabled={busy} disabled={busy}
className="text-[0.75rem] text-muted-foreground hover:text-heading" className="text-xs text-muted-foreground hover:text-heading"
> >
Cancel Cancel
</button> </button>

View File

@@ -11,8 +11,8 @@
* and renders the section. Loading/refresh logic lives in the parent * and renders the section. Loading/refresh logic lives in the parent
* (AssistantChatPage) so it can coordinate with the chat send cycle. * (AssistantChatPage) so it can coordinate with the chat send cycle.
*/ */
import { Loader2 } from 'lucide-react' import { useState } from 'react'
import { cn } from '@/lib/utils' import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react'
import type { SessionFact } from '@/api/sessionFacts' import type { SessionFact } from '@/api/sessionFacts'
import { WhatWeKnowItem } from './WhatWeKnowItem' import { WhatWeKnowItem } from './WhatWeKnowItem'
import { AddNoteButton } from './AddNoteButton' import { AddNoteButton } from './AddNoteButton'
@@ -23,32 +23,65 @@ interface WhatWeKnowProps {
onUpdateFact: (factId: string, text: string, summary: string | null) => Promise<void> | void onUpdateFact: (factId: string, text: string, summary: string | null) => Promise<void> | void
onDeleteFact: (factId: string) => Promise<void> | void onDeleteFact: (factId: string) => Promise<void> | void
loading?: boolean 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({ export function WhatWeKnow({
facts, facts,
onAddNote, onAddNote,
onUpdateFact, onUpdateFact,
onDeleteFact, onDeleteFact,
loading, loading,
sessionId,
}: WhatWeKnowProps) { }: WhatWeKnowProps) {
const count = facts.length 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 ( return (
<section <section className="rounded-lg p-3 -mx-1 mb-1">
className={cn( <div className={collapsed ? '' : 'pb-2'}>
'rounded-lg p-3 -mx-1 mb-1', <div className="flex items-center gap-2 pl-0.5">
// Subtle green-to-transparent gradient distinguishes this section <button
// from the rest of the lane (mockup 01-session-primary.png). type="button"
'bg-gradient-to-b from-success/[0.05] to-transparent', onClick={toggle}
)} aria-expanded={!collapsed}
> aria-label={collapsed ? 'Expand What we know' : 'Collapse What we know'}
<div className="pb-2"> className="flex items-center gap-2 text-[0.625rem] font-semibold uppercase tracking-[1.2px] text-muted-foreground hover:text-heading transition-colors"
<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" /> {collapsed ? <ChevronRight size={10} /> : <ChevronDown size={10} />}
What we know <span className="w-1.5 h-1.5 rounded-full bg-success" />
<span className="text-muted-foreground">·</span> What we know
<span className="tabular-nums">{count}</span> <span className="text-muted-foreground">·</span>
<span className="tabular-nums">{count}</span>
</button>
{loading && ( {loading && (
<span <span
className="ml-auto flex items-center gap-1 text-[0.625rem] font-medium normal-case tracking-normal text-muted-foreground" 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>
</div> </div>
{count === 0 && loading && ( {!collapsed && (
<div className="space-y-2 px-1 py-2"> <>
<div className="h-3 w-3/4 rounded bg-elevated/60 animate-pulse" /> {count === 0 && loading && (
<div className="h-3 w-1/2 rounded bg-elevated/60 animate-pulse" /> <div className="space-y-2 px-1 py-2">
</div> <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> </section>
) )
} }

View File

@@ -79,20 +79,20 @@ export function WhatWeKnowItem({ fact, onSave, onDelete }: WhatWeKnowItemProps)
value={draftSummary} value={draftSummary}
onChange={(e) => setDraftSummary(e.target.value)} onChange={(e) => setDraftSummary(e.target.value)}
placeholder="Short label (e.g. 'rules out tenant/license')" 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"> <div className="mt-1.5 flex items-center gap-2">
<button <button
onClick={handleSave} onClick={handleSave}
disabled={busy || !draftText.trim()} 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 <Check size={11} /> Save
</button> </button>
<button <button
onClick={handleCancel} onClick={handleCancel}
disabled={busy} disabled={busy}
className="text-[0.75rem] text-muted-foreground hover:text-heading" className="text-xs text-muted-foreground hover:text-heading"
> >
Cancel Cancel
</button> </button>
@@ -104,7 +104,7 @@ export function WhatWeKnowItem({ fact, onSave, onDelete }: WhatWeKnowItemProps)
return ( return (
<div <div
className={cn( 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', busy && 'opacity-60',
)} )}
> >

View File

@@ -5,7 +5,7 @@ import { handoffsApi } from '@/api/handoffs'
import { timeAgo } from '@/lib/timeAgo' import { timeAgo } from '@/lib/timeAgo'
import type { HandoffResponse } from '@/types/branching' import type { HandoffResponse } from '@/types/branching'
import { HandoffContextScreen } from '@/components/flowpilot/HandoffContextScreen' 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 { cn } from '@/lib/utils'
import { uploadsApi } from '@/api/uploads' import { uploadsApi } from '@/api/uploads'
import type { PendingUpload } from '@/types/upload' 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 // composer. Click prefills the input; first send hides the strip; explicit
// X also hides. Per-session lifetime — a refresh wipes the state, which is // X also hides. Per-session lifetime — a refresh wipes the state, which is
// fine because the senior can re-open the Context overlay. // 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 [chats, setChats] = useState<ChatListItem[]>([])
const [activeChatId, setActiveChatId] = useState<string | null>(() => { const [activeChatId, setActiveChatId] = useState<string | null>(() => {
if (urlSessionId) return urlSessionId if (urlSessionId) return urlSessionId
@@ -912,6 +909,10 @@ export default function AssistantChatPage() {
try { try {
const updated = await sessionSuggestedFixesApi.patchOutcome(activeChatId, activeFix.id, outcome, notes) const updated = await sessionSuggestedFixesApi.patchOutcome(activeChatId, activeFix.id, outcome, notes)
setActiveFix(updated) 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. // Reset apply tracking state since we now have a terminal outcome.
setPostApplyMsgCount(0) setPostApplyMsgCount(0)
setNudgeSilenced(false) setNudgeSilenced(false)
@@ -1304,7 +1305,6 @@ export default function AssistantChatPage() {
.map((u) => u.preview) .map((u) => u.preview)
setInput('') setInput('')
setPendingUploads([]) setPendingUploads([])
setChipsHidden(true)
setMessages(prev => [...prev, { role: 'user', content: userMessage, imageUrls: imageUrls.length > 0 ? imageUrls : undefined }]) setMessages(prev => [...prev, { role: 'user', content: userMessage, imageUrls: imageUrls.length > 0 ? imageUrls : undefined }])
setLoading(true) setLoading(true)
@@ -1769,27 +1769,10 @@ export default function AssistantChatPage() {
)} )}
</div> </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"> <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 && ( {isActive && (
<> <>
<button <button
@@ -1802,55 +1785,76 @@ export default function AssistantChatPage() {
Resolve Resolve
</button> </button>
<div className="relative"> <div className="relative">
<button <button
onClick={handleEscalateClick} onClick={handleEscalateClick}
disabled={!canAct} disabled={!canAct}
data-conclude-outcome="escalated" 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" 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} /> <ArrowUpRight size={13} />
Escalate Escalate
</button> </button>
{escalateIntercept && ( {escalateIntercept && (
<EscalateInterceptDialog <EscalateInterceptDialog
fixTitle={escalateIntercept.fixTitle} fixTitle={escalateIntercept.fixTitle}
onChoose={handleInterceptChoice} onChoose={handleInterceptChoice}
onClose={() => setEscalateIntercept(null)} onClose={() => setEscalateIntercept(null)}
/> />
)} )}
</div> </div>
</> </>
)} )}
{messages.length >= 2 && ( {(magicHandoff || activePsaTicketId || 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 && (
<div className="relative"> <div className="relative">
<button <button
onClick={() => setShowOverflow(!showOverflow)} 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" 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} /> <MoreHorizontal size={16} />
</button> </button>
{showOverflow && ( {showOverflow && (
<> <>
<div className="fixed inset-0 z-40" onClick={() => setShowOverflow(false)} /> <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"> <div className="absolute right-0 top-full mt-1 z-50 w-44 rounded-lg border border-border bg-card py-1 shadow-lg">
<button {magicHandoff && (
onClick={() => { setShowOverflow(false); aiSessionsApi.pauseSession(activeChatId).then(() => setActiveSessionStatus('paused')).catch(() => toast.error('Failed to pause')) }} <button
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" onClick={() => { setShowOverflow(false); openHandoffContextOverlay() }}
> disabled={overlayLoading}
<Pause size={13} /> 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"
Pause >
</button> <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> </div>
</> </>
)} )}
@@ -1858,12 +1862,14 @@ export default function AssistantChatPage() {
)} )}
</div> </div>
{/* Mobile: single overflow menu */} {/* Mobile: single overflow menu — same items as desktop kebab plus
{messages.length >= 2 && ( Resolve/Escalate (which live in the visible row on desktop). */}
{(magicHandoff || activePsaTicketId || messages.length >= 2) && (
<div className="sm:hidden relative"> <div className="sm:hidden relative">
<button <button
onClick={() => setShowOverflow(!showOverflow)} 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" 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} /> <MoreHorizontal size={18} />
</button> </button>
@@ -1871,7 +1877,7 @@ export default function AssistantChatPage() {
<> <>
<div className="fixed inset-0 z-40" onClick={() => setShowOverflow(false)} /> <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"> <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 <button
onClick={() => { setShowOverflow(false); handleResolveClick() }} onClick={() => { setShowOverflow(false); handleResolveClick() }}
@@ -1902,15 +1908,36 @@ export default function AssistantChatPage() {
</div> </div>
</> </>
)} )}
<button {magicHandoff && (
onClick={() => { setShowOverflow(false); setShowStatusUpdate(true) }} <button
disabled={loading} onClick={() => { setShowOverflow(false); openHandoffContextOverlay() }}
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" 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"
<FileText size={14} /> >
{updateLabel} <Sparkles size={14} />
</button> Context
{isActive && ( </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 <button
onClick={() => { setShowOverflow(false); aiSessionsApi.pauseSession(activeChatId).then(() => setActiveSessionStatus('paused')).catch(() => toast.error('Failed to pause')) }} 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" 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 Hidden (not unmounted) when Script Builder tab is active so
scroll position and input state are preserved. */} scroll position and input state are preserved. */}
<div className={cn('flex-1 min-h-0 flex flex-col', chatTab !== 'chat' && 'hidden')}> <div className={cn('flex-1 min-h-0 flex flex-col', chatTab !== 'chat' && 'hidden')}>
{/* Messages */} {/* Messages — scroll container is full width (so the scrollbar lives at
<div className="flex-1 overflow-y-auto px-4 sm:px-6 py-4 space-y-4"> 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 && ( {messages.length === 0 && !loading && (
<div className="flex flex-col items-center justify-center h-full text-center"> <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"> <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> </p>
</div> </div>
)} )}
{messages.map((msg, i) => ( {(() => {
<ChatMessage // Action emphasis is shown on the *current* turn only — i.e. the
key={i} // latest assistant message when active items are pending and the
role={msg.role} // magic-moment hero has dismissed. The TaskLane remains the
content={msg.content} // canonical list; this is just an inline cue.
suggestedFlows={msg.suggestedFlows} let lastAssistantIdx = -1
imageUrls={msg.imageUrls} 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 && ( {loading && (
<div className="flex gap-3"> <div className="flex gap-3">
<div className="w-8 h-8 rounded-full bg-primary/15 flex items-center justify-center"> <div className="w-8 h-8 rounded-full bg-primary/15 flex items-center justify-center">
<Sparkles size={14} className="text-primary" /> <Sparkles size={14} className="text-primary" />
</div> </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" /> <Loader2 size={16} className="animate-spin text-primary" />
</div> </div>
</div> </div>
)} )}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</div>
</div> </div>
{/* Phase 8: ProposalBanner — mounted above the composer */} {/* Phase 8: ProposalBanner — mounted above the composer */}
@@ -1997,8 +2042,9 @@ export default function AssistantChatPage() {
{/* Phase 9: InlineNoTemplateDialog — drafted-script evaluation case, {/* Phase 9: InlineNoTemplateDialog — drafted-script evaluation case,
rendered in the chat region above the composer so all three rendered in the chat region above the composer so all three
option cards fit side-by-side without the TaskLane's narrow width. */} option cards fit side-by-side without the TaskLane's narrow width.
{scriptPanelOpen && activeFix && activeChatId && !activeFix.script_template_id && activeFix.ai_drafted_script && ( Hidden when the banner is collapsed: the two surfaces are linked. */}
{scriptPanelOpen && !bannerCollapsed && activeFix && activeChatId && !activeFix.script_template_id && activeFix.ai_drafted_script && (
<InlineNoTemplateDialog <InlineNoTemplateDialog
fix={activeFix} fix={activeFix}
onClose={() => setScriptPanelOpen(false)} 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 */} {/* Rich Input */}
<div className="px-3 sm:px-6 py-3 shrink-0"> <div className="px-3 sm:px-6 py-3 shrink-0">
<div <div
@@ -2191,7 +2100,7 @@ export default function AssistantChatPage() {
{upload.preview ? ( {upload.preview ? (
<img src={upload.preview} alt="" className="w-full h-full object-cover" /> <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()} {upload.file.name.split('.').pop()?.toUpperCase()}
</div> </div>
)} )}
@@ -2359,6 +2268,8 @@ export default function AssistantChatPage() {
loading={loading} loading={loading}
whatWeKnowSlot={ whatWeKnowSlot={
<WhatWeKnow <WhatWeKnow
key={activeChatId ?? 'no-session'}
sessionId={activeChatId}
facts={facts} facts={facts}
onAddNote={handleAddNote} onAddNote={handleAddNote}
onUpdateFact={handleUpdateFact} onUpdateFact={handleUpdateFact}
@@ -2368,7 +2279,7 @@ export default function AssistantChatPage() {
} }
bottomSlot={ bottomSlot={
<> <>
{scriptPanelOpen && activeFix && activeChatId && activeFix.script_template_id && ( {scriptPanelOpen && !bannerCollapsed && activeFix && activeChatId && activeFix.script_template_id && (
<TemplateMatchPanel <TemplateMatchPanel
fix={activeFix} fix={activeFix}
sessionId={activeChatId} sessionId={activeChatId}
@@ -2380,7 +2291,7 @@ export default function AssistantChatPage() {
<button <button
onClick={() => handleOpenPreview('resolve')} onClick={() => handleOpenPreview('resolve')}
className={cn( 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' previewKind === 'resolve'
? 'text-success' ? 'text-success'
: 'text-accent-text hover:text-heading', : 'text-accent-text hover:text-heading',
@@ -2392,7 +2303,7 @@ export default function AssistantChatPage() {
<button <button
onClick={() => handleOpenPreview('escalate')} onClick={() => handleOpenPreview('escalate')}
className={cn( 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' previewKind === 'escalate'
? 'text-warning' ? 'text-warning'
: 'text-muted-foreground hover:text-heading', : 'text-muted-foreground hover:text-heading',
@@ -2430,6 +2341,8 @@ export default function AssistantChatPage() {
loading={loading} loading={loading}
whatWeKnowSlot={ whatWeKnowSlot={
<WhatWeKnow <WhatWeKnow
key={activeChatId ?? 'no-session'}
sessionId={activeChatId}
facts={facts} facts={facts}
onAddNote={handleAddNote} onAddNote={handleAddNote}
onUpdateFact={handleUpdateFact} onUpdateFact={handleUpdateFact}
@@ -2439,7 +2352,7 @@ export default function AssistantChatPage() {
} }
bottomSlot={ bottomSlot={
<> <>
{scriptPanelOpen && activeFix && activeChatId && activeFix.script_template_id && ( {scriptPanelOpen && !bannerCollapsed && activeFix && activeChatId && activeFix.script_template_id && (
<TemplateMatchPanel <TemplateMatchPanel
fix={activeFix} fix={activeFix}
sessionId={activeChatId} sessionId={activeChatId}
@@ -2451,7 +2364,7 @@ export default function AssistantChatPage() {
<button <button
onClick={() => handleOpenPreview('resolve')} onClick={() => handleOpenPreview('resolve')}
className={cn( 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' previewKind === 'resolve'
? 'text-success' ? 'text-success'
: 'text-accent-text hover:text-heading', : 'text-accent-text hover:text-heading',
@@ -2463,7 +2376,7 @@ export default function AssistantChatPage() {
<button <button
onClick={() => handleOpenPreview('escalate')} onClick={() => handleOpenPreview('escalate')}
className={cn( 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' previewKind === 'escalate'
? 'text-warning' ? 'text-warning'
: 'text-muted-foreground hover:text-heading', : 'text-muted-foreground hover:text-heading',
@@ -2561,7 +2474,7 @@ export default function AssistantChatPage() {
{/* Handoff context overlay — re-opened from the toolbar */} {/* Handoff context overlay — re-opened from the toolbar */}
{overlayHandoff && ( {overlayHandoff && (
<div <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) => { onClick={(e) => {
if (e.target === e.currentTarget) setOverlayHandoff(null) if (e.target === e.currentTarget) setOverlayHandoff(null)
}} }}