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>
301 lines
12 KiB
TypeScript
301 lines
12 KiB
TypeScript
import { useState } from 'react'
|
|
import { Copy, Check, SkipForward, Terminal, ChevronDown, ChevronUp, Send, Clipboard, Loader2, AlertCircle } from 'lucide-react'
|
|
import { cn } from '@/lib/utils'
|
|
import { toast } from '@/lib/toast'
|
|
import type { ActionItem } from '@/types/ai-session'
|
|
|
|
type CardState = 'pending' | 'pasting' | 'typing' | 'skipped' | 'done'
|
|
|
|
interface CardResponse {
|
|
label: string
|
|
state: CardState
|
|
value: string
|
|
}
|
|
|
|
interface ActionCardGroupProps {
|
|
actions: ActionItem[]
|
|
onSubmit: (responses: CardResponse[]) => void
|
|
disabled?: boolean
|
|
stale?: boolean
|
|
}
|
|
|
|
export function ActionCardGroup({ actions, onSubmit, disabled, stale }: ActionCardGroupProps) {
|
|
const [responses, setResponses] = useState<CardResponse[]>(
|
|
actions.map(a => ({ label: a.label, state: 'pending', value: '' }))
|
|
)
|
|
const [showRunAll, setShowRunAll] = useState(false)
|
|
const [submitting, setSubmitting] = useState(false)
|
|
const [submitted, setSubmitted] = useState(false)
|
|
const [submitError, setSubmitError] = useState(false)
|
|
const [expanded, setExpanded] = useState(false)
|
|
|
|
const anyPending = responses.some(r => r.state === 'pending')
|
|
const isCollapsed = stale && anyPending && !expanded
|
|
|
|
const updateCard = (idx: number, updates: Partial<CardResponse>) => {
|
|
setResponses(prev => prev.map((r, i) => i === idx ? { ...r, ...updates } : r))
|
|
}
|
|
|
|
const allHandled = responses.every(r => r.state !== 'pending' && r.state !== 'pasting' && r.state !== 'typing')
|
|
const anyInteracted = responses.some(r => r.state !== 'pending')
|
|
|
|
const handleSubmit = async () => {
|
|
setSubmitting(true)
|
|
setSubmitError(false)
|
|
try {
|
|
onSubmit(responses)
|
|
setSubmitted(true)
|
|
} catch {
|
|
setSubmitError(true)
|
|
} finally {
|
|
setSubmitting(false)
|
|
}
|
|
}
|
|
|
|
const handleCopyCommand = (command: string) => {
|
|
navigator.clipboard.writeText(command)
|
|
toast.success('Copied to clipboard')
|
|
}
|
|
|
|
// Build combined script for "Run All"
|
|
const commandActions = actions.filter(a => a.command)
|
|
const combinedScript = commandActions.map((a, i) => (
|
|
`# ── ${i + 1}. ${a.label} ──\n${a.command}`
|
|
)).join('\n\n')
|
|
|
|
const doneCount = responses.filter(r => r.state === 'done').length
|
|
const skippedCount = responses.filter(r => r.state === 'skipped').length
|
|
|
|
// ── Collapsed state (stale cards from earlier in conversation) ──
|
|
if (isCollapsed) {
|
|
const pendingCount = responses.filter(r => r.state === 'pending').length
|
|
return (
|
|
<button
|
|
onClick={() => setExpanded(true)}
|
|
className="w-full rounded-lg border border-default/50 bg-elevated/20 p-2.5 flex items-center justify-between text-left hover:bg-elevated/40 transition-colors group"
|
|
>
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<Terminal size={12} />
|
|
<span>{pendingCount} diagnostic check{pendingCount !== 1 ? 's' : ''} — not completed</span>
|
|
</div>
|
|
<span className="text-[0.6875rem] text-accent-text opacity-0 group-hover:opacity-100 transition-opacity">
|
|
Expand
|
|
</span>
|
|
</button>
|
|
)
|
|
}
|
|
|
|
// ── Submitted state ──
|
|
if (submitted) {
|
|
return (
|
|
<div className="rounded-lg border border-success/20 bg-success-dim/20 p-3 space-y-1.5">
|
|
<div className="flex items-center gap-2 text-[0.8125rem] font-medium text-success">
|
|
<Check size={14} />
|
|
<span>{doneCount} checked, {skippedCount} skipped</span>
|
|
</div>
|
|
<div className="space-y-0.5">
|
|
{responses.map((r, i) => (
|
|
<div key={i} className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
{r.state === 'done' ? (
|
|
<Check size={10} className="text-success shrink-0" />
|
|
) : (
|
|
<SkipForward size={10} className="text-muted-foreground shrink-0" />
|
|
)}
|
|
<span className={r.state === 'skipped' ? 'line-through opacity-60' : ''}>
|
|
{r.label}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
{/* Run All button — only if multiple commands exist */}
|
|
{commandActions.length > 1 && (
|
|
<div>
|
|
<button
|
|
onClick={() => setShowRunAll(!showRunAll)}
|
|
className="flex items-center gap-1.5 text-xs font-medium text-accent-text hover:text-accent transition-colors"
|
|
>
|
|
<Terminal size={12} />
|
|
<span>Run All ({commandActions.length} commands)</span>
|
|
{showRunAll ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
|
</button>
|
|
|
|
{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-[0.625rem] font-semibold uppercase tracking-wider text-muted-foreground">
|
|
Combined diagnostic script
|
|
</span>
|
|
<button
|
|
onClick={() => handleCopyCommand(combinedScript)}
|
|
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-heading transition-colors"
|
|
>
|
|
<Copy size={11} />
|
|
<span>Copy</span>
|
|
</button>
|
|
</div>
|
|
<pre className="text-[0.8125rem] font-mono text-heading whitespace-pre-wrap overflow-x-auto">
|
|
{combinedScript}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Individual action cards */}
|
|
{actions.map((action, idx) => {
|
|
const response = responses[idx]
|
|
const isExpanded = response.state === 'pasting' || response.state === 'typing'
|
|
|
|
return (
|
|
<div
|
|
key={idx}
|
|
className={cn(
|
|
'rounded-lg border p-3 transition-all',
|
|
response.state === 'done' ? 'border-success/30 bg-success-dim/30' :
|
|
response.state === 'skipped' ? 'border-default/50 bg-elevated/20 opacity-60' :
|
|
'border-default bg-card hover:border-hover'
|
|
)}
|
|
>
|
|
{/* Card header */}
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-[0.8125rem] font-medium text-heading">{action.label}</div>
|
|
{action.description && (
|
|
<div className="text-xs text-muted-foreground mt-0.5">{action.description}</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Status badge for handled cards */}
|
|
{response.state === 'done' && (
|
|
<span className="shrink-0 text-[0.625rem] font-semibold uppercase tracking-wider text-success">Done</span>
|
|
)}
|
|
{response.state === 'skipped' && (
|
|
<span className="shrink-0 text-[0.625rem] font-semibold uppercase tracking-wider text-muted-foreground">Skipped</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Command with copy button */}
|
|
{action.command && response.state !== 'skipped' && (
|
|
<div className="mt-2 flex items-center gap-2 rounded bg-code px-2.5 py-1.5">
|
|
<code className="flex-1 text-xs font-mono text-heading truncate">
|
|
{action.command}
|
|
</code>
|
|
<button
|
|
onClick={() => handleCopyCommand(action.command!)}
|
|
className="shrink-0 text-muted-foreground hover:text-heading transition-colors"
|
|
title="Copy command"
|
|
>
|
|
<Copy size={12} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Action buttons — only for pending cards */}
|
|
{response.state === 'pending' && !disabled && (
|
|
<div className="mt-2 flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
|
|
<button
|
|
onClick={() => updateCard(idx, { state: 'pasting' })}
|
|
className="flex items-center justify-center gap-1 rounded-md border border-accent/40 bg-accent-dim/30 px-2.5 py-2 sm:py-1 text-xs font-medium text-accent-text hover:bg-accent-dim/50 transition-colors min-h-[44px] sm:min-h-0"
|
|
>
|
|
<Clipboard size={11} />
|
|
Paste Result
|
|
</button>
|
|
<button
|
|
onClick={() => updateCard(idx, { state: 'typing' })}
|
|
className="flex items-center justify-center gap-1 rounded-md border border-default bg-elevated/50 px-2.5 py-2 sm:py-1 text-xs font-medium text-heading hover:bg-elevated transition-colors min-h-[44px] sm:min-h-0"
|
|
>
|
|
Type Answer
|
|
</button>
|
|
<button
|
|
onClick={() => updateCard(idx, { state: 'skipped' })}
|
|
className="flex items-center justify-center gap-1 rounded-md px-2.5 py-2 sm:py-1 text-xs text-muted-foreground hover:text-heading transition-colors min-h-[44px] sm:min-h-0"
|
|
>
|
|
<SkipForward size={11} />
|
|
Skip
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Expanded input area */}
|
|
{isExpanded && (
|
|
<div className="mt-2">
|
|
<textarea
|
|
autoFocus
|
|
value={response.value}
|
|
onChange={e => updateCard(idx, { value: e.target.value })}
|
|
placeholder={response.state === 'pasting' ? 'Paste command output here...' : '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 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={() => updateCard(idx, { state: 'done' })}
|
|
disabled={!response.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={() => updateCard(idx, { state: 'pending', value: '' })}
|
|
className="text-xs text-muted-foreground hover:text-heading transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
|
|
{/* Submit / Error / Loading */}
|
|
{anyInteracted && (
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
onClick={handleSubmit}
|
|
disabled={!allHandled || disabled || submitting}
|
|
className={cn(
|
|
'flex items-center gap-1.5 rounded-lg px-4 py-2 text-[0.8125rem] font-medium transition-colors',
|
|
allHandled && !submitting
|
|
? 'bg-accent text-white hover:bg-accent-hover'
|
|
: 'bg-elevated text-muted-foreground cursor-not-allowed'
|
|
)}
|
|
>
|
|
{submitting ? (
|
|
<>
|
|
<Loader2 size={13} className="animate-spin" />
|
|
Sending...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Send size={13} />
|
|
Send Responses
|
|
</>
|
|
)}
|
|
</button>
|
|
|
|
{submitError && (
|
|
<div className="flex items-center gap-1.5 text-xs text-danger">
|
|
<AlertCircle size={12} />
|
|
<span>Failed to send</span>
|
|
<button
|
|
onClick={handleSubmit}
|
|
className="underline hover:no-underline"
|
|
>
|
|
Retry
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|