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:
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import {
|
||||
Copy, Check, SkipForward, Terminal, ChevronDown, ChevronUp,
|
||||
Send, Clipboard, Loader2, PanelRightClose, MessageCircleQuestion, Eye,
|
||||
Send, Clipboard, Loader2, PanelRightClose, Pencil, HelpCircle, Eye,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
@@ -253,6 +253,24 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
setTasks(prev => prev.map((t, i) => i === idx ? { ...t, ...updates } as TaskResponse : t))
|
||||
}
|
||||
|
||||
// Mark `idx` done and advance focus to the next pending task. If none are
|
||||
// left, focus the Send button so the engineer can fire the batch with one
|
||||
// more keystroke. Powers both keyboard submit (Enter / Cmd+Enter) and the
|
||||
// mouse path on the Answer / Done buttons.
|
||||
const sendButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const submitAndAdvance = (idx: number, value: string) => {
|
||||
if (!value.trim()) return
|
||||
const nextIdx = tasks.findIndex((t, i) => i > idx && t.state === 'pending')
|
||||
setTasks(prev => prev.map((t, i) => {
|
||||
if (i === idx) return { ...t, state: 'done' } as TaskResponse
|
||||
if (nextIdx !== -1 && i === nextIdx) return { ...t, state: 'active' } as TaskResponse
|
||||
return t
|
||||
}))
|
||||
if (nextIdx === -1) {
|
||||
setTimeout(() => sendButtonRef.current?.focus(), 50)
|
||||
}
|
||||
}
|
||||
|
||||
const questionTasks = tasks.filter(t => t.type === 'question')
|
||||
const actionTasks = tasks.filter(t => t.type === 'action') as ActionResponse[]
|
||||
const allHandled = tasks.every(t => t.state === 'done' || t.state === 'skipped')
|
||||
@@ -350,20 +368,21 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
</div>
|
||||
)}
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b border-default flex items-center justify-between shrink-0" style={{ borderTop: '2px solid var(--color-accent)' }}>
|
||||
<div className="px-4 py-3 border-b border-default flex items-center justify-between shrink-0">
|
||||
<h3 className="font-heading text-sm font-bold text-heading flex items-center gap-2">
|
||||
Tasks
|
||||
<span className={cn(
|
||||
'text-[10px] font-semibold px-2 py-0.5 rounded-full',
|
||||
allHandled
|
||||
? 'bg-success-dim text-success'
|
||||
: 'bg-accent-dim text-accent-text'
|
||||
)}>
|
||||
{allHandled ? '✓ Ready' : `${doneCount}/${totalCount}`}
|
||||
</span>
|
||||
{allHandled ? (
|
||||
<span className="flex items-center gap-1 text-[0.625rem] font-semibold uppercase tracking-wider text-success">
|
||||
<Check size={10} /> Ready
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[0.625rem] font-medium tabular-nums text-muted-foreground">
|
||||
{doneCount}/{totalCount}
|
||||
</span>
|
||||
)}
|
||||
{loading && (
|
||||
<span
|
||||
className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground"
|
||||
className="flex items-center gap-1 text-[0.625rem] font-medium text-muted-foreground"
|
||||
title="AI is thinking"
|
||||
>
|
||||
<Loader2 size={10} className="animate-spin" />
|
||||
@@ -386,7 +405,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
{questionTasks.length > 0 && (
|
||||
<section>
|
||||
<div className="pb-2">
|
||||
<div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[1.2px] text-muted-foreground pl-0.5">
|
||||
<div className="flex items-center gap-2 text-[0.625rem] font-semibold uppercase tracking-[1.2px] text-muted-foreground pl-0.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
|
||||
Questions
|
||||
{questionTasks.every(q => q.state === 'done' || q.state === 'skipped') && (
|
||||
@@ -401,12 +420,12 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
|
||||
if (q.state === 'done') {
|
||||
return (
|
||||
<div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border-l-[3px] border-l-success border border-success/25 bg-success-dim/30 p-3 mb-2 cursor-pointer hover:border-success/40 transition-colors" onClick={() => updateTask(idx, { state: 'active' })}>
|
||||
<div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border border-default/40 p-3 mb-2 cursor-pointer hover:border-default transition-colors" onClick={() => updateTask(idx, { state: 'active' })}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Check size={12} className="text-success shrink-0" />
|
||||
<span className="text-[0.8125rem] text-foreground">{q.text}</span>
|
||||
<span className="text-[0.8125rem] text-muted-foreground">{q.text}</span>
|
||||
</div>
|
||||
<div className="text-[0.75rem] text-muted-foreground mt-1 pl-5 italic truncate">"{q.value}"</div>
|
||||
<div className="text-xs text-muted-foreground/80 mt-1 pl-5 italic truncate">"{q.value}"</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -416,7 +435,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
<div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border border-default/50 bg-elevated/20 p-3 mb-2 opacity-60 cursor-pointer hover:opacity-80 hover:border-default transition-all" onClick={() => updateTask(idx, { state: 'pending' })} title="Click to restore">
|
||||
<div className="flex justify-between">
|
||||
<div className="text-[0.8125rem] text-muted-foreground line-through">{q.text}</div>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Skipped</span>
|
||||
<span className="text-[0.625rem] font-semibold uppercase tracking-wider text-muted-foreground">Skipped</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -434,33 +453,47 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
autoFocus
|
||||
value={q.value}
|
||||
onChange={e => updateTask(idx, { value: e.target.value })}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
submitAndAdvance(idx, q.value)
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
updateTask(idx, { state: 'pending', value: '' })
|
||||
}
|
||||
}}
|
||||
placeholder="Type your answer..."
|
||||
className="w-full rounded-md border border-default bg-input px-3 py-2 text-[0.8125rem] text-heading placeholder:text-muted-foreground resize-y min-h-[48px] max-h-[150px] focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
|
||||
rows={2}
|
||||
/>
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => updateTask(idx, { state: 'done' })}
|
||||
disabled={!q.value.trim()}
|
||||
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-[0.75rem] font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
<Check size={11} /> Answer
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateTask(idx, { state: 'pending', value: '' })}
|
||||
className="text-[0.75rem] text-muted-foreground hover:text-heading"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<div className="mt-1.5 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => submitAndAdvance(idx, q.value)}
|
||||
disabled={!q.value.trim()}
|
||||
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-xs font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
<Check size={11} /> Answer
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateTask(idx, { state: 'pending', value: '' })}
|
||||
className="text-xs text-muted-foreground hover:text-heading"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-[0.625rem] text-muted-foreground/70 tabular-nums">
|
||||
⏎ submit · ⇧⏎ newline
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => updateTask(idx, { state: 'active' })}
|
||||
className="flex items-center gap-1 rounded-md border border-accent/40 bg-accent-dim/30 px-2.5 py-1.5 text-[0.75rem] font-medium text-accent-text hover:bg-accent-dim/50 transition-colors"
|
||||
className="flex items-center gap-1 rounded-md border border-accent/40 bg-accent-dim/30 px-2.5 py-1.5 text-xs font-medium text-accent-text hover:bg-accent-dim/50 transition-colors"
|
||||
>
|
||||
<MessageCircleQuestion size={11} /> Answer
|
||||
<Pencil size={11} /> Answer
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateTask(idx, { state: 'skipped' })}
|
||||
@@ -481,7 +514,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
{actionTasks.length > 0 && (
|
||||
<section>
|
||||
<div className="pb-2">
|
||||
<div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[1.2px] text-muted-foreground pl-0.5">
|
||||
<div className="flex items-center gap-2 text-[0.625rem] font-semibold uppercase tracking-[1.2px] text-muted-foreground pl-0.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
|
||||
Diagnostic Checks
|
||||
{actionTasks.every(a => a.state === 'done' || a.state === 'skipped') && (
|
||||
@@ -495,7 +528,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
<div className="mb-2">
|
||||
<button
|
||||
onClick={() => setShowRunAll(!showRunAll)}
|
||||
className="flex items-center gap-1.5 text-[0.75rem] font-medium text-accent-text hover:text-accent transition-colors pl-0.5"
|
||||
className="flex items-center gap-1.5 text-xs font-medium text-accent-text hover:text-accent transition-colors pl-0.5"
|
||||
>
|
||||
<Terminal size={12} />
|
||||
Run All ({commandActions.length} commands)
|
||||
@@ -504,16 +537,16 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
{showRunAll && (
|
||||
<div className="mt-2 rounded-lg border border-default bg-code p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Combined script</span>
|
||||
<span className="text-[0.625rem] font-semibold uppercase tracking-wider text-muted-foreground">Combined script</span>
|
||||
<button
|
||||
onClick={() => void handleCopy(combinedScript)}
|
||||
className="flex items-center gap-1 text-[0.75rem] text-muted-foreground hover:text-heading transition-colors"
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-heading transition-colors"
|
||||
>
|
||||
{copiedKey === combinedScript ? <Check size={11} className="text-success" /> : <Copy size={11} />}
|
||||
{copiedKey === combinedScript ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="text-[0.75rem] font-mono text-heading whitespace-pre-wrap overflow-x-auto">{combinedScript}</pre>
|
||||
<pre className="text-xs font-mono text-heading whitespace-pre-wrap overflow-x-auto">{combinedScript}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -525,10 +558,10 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
|
||||
if (a.state === 'done') {
|
||||
return (
|
||||
<div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border-l-[3px] border-l-success border border-success/25 bg-success-dim/30 p-3 mb-2 cursor-pointer hover:border-success/40 transition-colors" onClick={() => updateTask(idx, { state: 'active' })}>
|
||||
<div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border border-default/40 p-3 mb-2 cursor-pointer hover:border-default transition-colors" onClick={() => updateTask(idx, { state: 'active' })}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Check size={12} className="text-success shrink-0" />
|
||||
<span className="text-[0.8125rem] font-medium text-foreground flex-1">{a.label}</span>
|
||||
<span className="text-[0.8125rem] text-muted-foreground flex-1">{a.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -539,7 +572,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
<div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border border-default/50 bg-elevated/20 p-3 mb-2 opacity-60 cursor-pointer hover:opacity-80 hover:border-default transition-all" onClick={() => updateTask(idx, { state: 'pending' })} title="Click to restore">
|
||||
<div className="flex justify-between">
|
||||
<div className="text-[0.8125rem] text-muted-foreground line-through">{a.label}</div>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Skipped</span>
|
||||
<span className="text-[0.625rem] font-semibold uppercase tracking-wider text-muted-foreground">Skipped</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -565,7 +598,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
aria-label="Explain this diagnostic check"
|
||||
aria-expanded={expandedHelpKey === `${idx}`}
|
||||
>
|
||||
<MessageCircleQuestion size={13} />
|
||||
<HelpCircle size={13} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -613,31 +646,45 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
autoFocus
|
||||
value={a.value}
|
||||
onChange={e => updateTask(idx, { value: e.target.value })}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
submitAndAdvance(idx, a.value)
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
updateTask(idx, { state: 'pending', value: '' })
|
||||
}
|
||||
}}
|
||||
placeholder="Paste command output here..."
|
||||
className="w-full rounded-md border border-default bg-input px-3 py-2 text-[0.8125rem] text-heading placeholder:text-muted-foreground font-mono resize-y min-h-[60px] max-h-[200px] overflow-y-auto focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
|
||||
rows={3}
|
||||
/>
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => updateTask(idx, { state: 'done' })}
|
||||
disabled={!a.value.trim()}
|
||||
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-[0.75rem] font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
<Check size={11} /> Done
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateTask(idx, { state: 'pending', value: '' })}
|
||||
className="text-[0.75rem] text-muted-foreground hover:text-heading"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<div className="mt-1.5 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => submitAndAdvance(idx, a.value)}
|
||||
disabled={!a.value.trim()}
|
||||
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-xs font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
<Check size={11} /> Done
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateTask(idx, { state: 'pending', value: '' })}
|
||||
className="text-xs text-muted-foreground hover:text-heading"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-[0.625rem] text-muted-foreground/70 tabular-nums">
|
||||
⏎ submit · ⇧⏎ newline
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => updateTask(idx, { state: 'active' })}
|
||||
className="flex items-center gap-1 rounded-md border border-accent/40 bg-accent-dim/30 px-2.5 py-1.5 text-[0.75rem] font-medium text-accent-text hover:bg-accent-dim/50 transition-colors"
|
||||
className="flex items-center gap-1 rounded-md border border-accent/40 bg-accent-dim/30 px-2.5 py-1.5 text-xs font-medium text-accent-text hover:bg-accent-dim/50 transition-colors"
|
||||
>
|
||||
<Clipboard size={11} /> {a.command ? 'Paste Output' : 'Answer'}
|
||||
</button>
|
||||
@@ -698,7 +745,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
<div className="mb-2">
|
||||
<button
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
className="flex items-center gap-1.5 text-[0.75rem] font-medium text-muted-foreground hover:text-heading transition-colors mb-1"
|
||||
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-heading transition-colors mb-1"
|
||||
>
|
||||
<Eye size={12} />
|
||||
Preview ({handledCount}/{totalCount} done)
|
||||
@@ -712,6 +759,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
ref={sendButtonRef}
|
||||
onClick={handleSubmit}
|
||||
disabled={!anyHandled || loading || submitting}
|
||||
className={cn(
|
||||
|
||||
Reference in New Issue
Block a user