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:
@@ -5,7 +5,7 @@ import { handoffsApi } from '@/api/handoffs'
|
||||
import { timeAgo } from '@/lib/timeAgo'
|
||||
import type { HandoffResponse } from '@/types/branching'
|
||||
import { HandoffContextScreen } from '@/components/flowpilot/HandoffContextScreen'
|
||||
import { Sparkles, Send, Loader2, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus, ListChecks, FileText, CheckCircle2, ArrowUpRight, ArrowRight, MoreHorizontal, Pause, Plus, Copy, Check } from 'lucide-react'
|
||||
import { Sparkles, Send, Loader2, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus, ListChecks, FileText, CheckCircle2, ArrowUpRight, MoreHorizontal, Pause, Plus } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { uploadsApi } from '@/api/uploads'
|
||||
import type { PendingUpload } from '@/types/upload'
|
||||
@@ -88,9 +88,6 @@ export default function AssistantChatPage() {
|
||||
// composer. Click prefills the input; first send hides the strip; explicit
|
||||
// X also hides. Per-session lifetime — a refresh wipes the state, which is
|
||||
// fine because the senior can re-open the Context overlay.
|
||||
const [chipsHidden, setChipsHidden] = useState(false)
|
||||
const [selectedChipCardIdx, setSelectedChipCardIdx] = useState<number | null>(null)
|
||||
const [copiedChipCmd, setCopiedChipCmd] = useState(false)
|
||||
const [chats, setChats] = useState<ChatListItem[]>([])
|
||||
const [activeChatId, setActiveChatId] = useState<string | null>(() => {
|
||||
if (urlSessionId) return urlSessionId
|
||||
@@ -912,6 +909,10 @@ export default function AssistantChatPage() {
|
||||
try {
|
||||
const updated = await sessionSuggestedFixesApi.patchOutcome(activeChatId, activeFix.id, outcome, notes)
|
||||
setActiveFix(updated)
|
||||
// Banner and script panel are linked surfaces: once an outcome is
|
||||
// recorded, the script-execution affordance has done its job, so close
|
||||
// it alongside the banner state transition.
|
||||
setScriptPanelOpen(false)
|
||||
// Reset apply tracking state since we now have a terminal outcome.
|
||||
setPostApplyMsgCount(0)
|
||||
setNudgeSilenced(false)
|
||||
@@ -1304,7 +1305,6 @@ export default function AssistantChatPage() {
|
||||
.map((u) => u.preview)
|
||||
setInput('')
|
||||
setPendingUploads([])
|
||||
setChipsHidden(true)
|
||||
setMessages(prev => [...prev, { role: 'user', content: userMessage, imageUrls: imageUrls.length > 0 ? imageUrls : undefined }])
|
||||
setLoading(true)
|
||||
|
||||
@@ -1769,27 +1769,10 @@ export default function AssistantChatPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop actions — shown when session is active and has messages */}
|
||||
{/* Desktop actions — Resolve + Escalate stay first-class; everything
|
||||
else (Context / New Ticket / Update Ticket / Pause) folds behind
|
||||
a single kebab to keep the header to two visible primary actions. */}
|
||||
<div className="hidden sm:flex items-center gap-1.5">
|
||||
{magicHandoff && (
|
||||
<button
|
||||
onClick={openHandoffContextOverlay}
|
||||
disabled={overlayLoading}
|
||||
title="Show the handoff context the original engineer sent"
|
||||
className="flex items-center gap-1.5 rounded-lg border border-default px-3 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground hover:border-hover disabled:opacity-40 transition-colors"
|
||||
>
|
||||
<Sparkles size={13} />
|
||||
Context
|
||||
</button>
|
||||
)}
|
||||
{activePsaTicketId && (
|
||||
<button
|
||||
onClick={() => { setSpinOffHint(undefined); setShowNewTicket(true) }}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground border border-default rounded-[5px] hover:border-hover hover:text-primary transition-colors"
|
||||
>
|
||||
<Plus className="w-3 h-3" /> New Ticket
|
||||
</button>
|
||||
)}
|
||||
{isActive && (
|
||||
<>
|
||||
<button
|
||||
@@ -1802,55 +1785,76 @@ export default function AssistantChatPage() {
|
||||
Resolve
|
||||
</button>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={handleEscalateClick}
|
||||
disabled={!canAct}
|
||||
data-conclude-outcome="escalated"
|
||||
className="flex items-center gap-1.5 rounded-lg bg-warning-dim border border-warning/20 px-3 py-1.5 text-xs font-medium text-warning hover:bg-warning/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
|
||||
>
|
||||
<ArrowUpRight size={13} />
|
||||
Escalate
|
||||
</button>
|
||||
{escalateIntercept && (
|
||||
<EscalateInterceptDialog
|
||||
fixTitle={escalateIntercept.fixTitle}
|
||||
onChoose={handleInterceptChoice}
|
||||
onClose={() => setEscalateIntercept(null)}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={handleEscalateClick}
|
||||
disabled={!canAct}
|
||||
data-conclude-outcome="escalated"
|
||||
className="flex items-center gap-1.5 rounded-lg bg-warning-dim border border-warning/20 px-3 py-1.5 text-xs font-medium text-warning hover:bg-warning/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
|
||||
>
|
||||
<ArrowUpRight size={13} />
|
||||
Escalate
|
||||
</button>
|
||||
{escalateIntercept && (
|
||||
<EscalateInterceptDialog
|
||||
fixTitle={escalateIntercept.fixTitle}
|
||||
onChoose={handleInterceptChoice}
|
||||
onClose={() => setEscalateIntercept(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{messages.length >= 2 && (
|
||||
<button
|
||||
onClick={() => setShowStatusUpdate(true)}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-accent-dim border border-accent/20 px-3 py-1.5 text-xs font-medium text-accent hover:bg-accent/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
|
||||
>
|
||||
<FileText size={13} />
|
||||
{updateLabel}
|
||||
</button>
|
||||
)}
|
||||
{/* Overflow: Pause / — */}
|
||||
{isActive && messages.length >= 2 && (
|
||||
{(magicHandoff || activePsaTicketId || messages.length >= 2) && (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowOverflow(!showOverflow)}
|
||||
className="flex items-center justify-center rounded-lg px-2 py-1.5 text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||||
aria-label="More session actions"
|
||||
>
|
||||
<MoreHorizontal size={16} />
|
||||
</button>
|
||||
{showOverflow && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowOverflow(false)} />
|
||||
<div className="absolute right-0 top-full mt-1 z-50 w-36 rounded-lg border border-border bg-card py-1 shadow-lg">
|
||||
<button
|
||||
onClick={() => { setShowOverflow(false); aiSessionsApi.pauseSession(activeChatId).then(() => setActiveSessionStatus('paused')).catch(() => toast.error('Failed to pause')) }}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||||
>
|
||||
<Pause size={13} />
|
||||
Pause
|
||||
</button>
|
||||
<div className="absolute right-0 top-full mt-1 z-50 w-44 rounded-lg border border-border bg-card py-1 shadow-lg">
|
||||
{magicHandoff && (
|
||||
<button
|
||||
onClick={() => { setShowOverflow(false); openHandoffContextOverlay() }}
|
||||
disabled={overlayLoading}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors disabled:opacity-40"
|
||||
>
|
||||
<Sparkles size={13} />
|
||||
Context
|
||||
</button>
|
||||
)}
|
||||
{activePsaTicketId && (
|
||||
<button
|
||||
onClick={() => { setShowOverflow(false); setSpinOffHint(undefined); setShowNewTicket(true) }}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||||
>
|
||||
<Plus size={13} />
|
||||
New Ticket
|
||||
</button>
|
||||
)}
|
||||
{messages.length >= 2 && (
|
||||
<button
|
||||
onClick={() => { setShowOverflow(false); setShowStatusUpdate(true) }}
|
||||
disabled={loading}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors disabled:opacity-40"
|
||||
>
|
||||
<FileText size={13} />
|
||||
{updateLabel}
|
||||
</button>
|
||||
)}
|
||||
{isActive && messages.length >= 2 && (
|
||||
<button
|
||||
onClick={() => { setShowOverflow(false); aiSessionsApi.pauseSession(activeChatId).then(() => setActiveSessionStatus('paused')).catch(() => toast.error('Failed to pause')) }}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||||
>
|
||||
<Pause size={13} />
|
||||
Pause
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -1858,12 +1862,14 @@ export default function AssistantChatPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile: single overflow menu */}
|
||||
{messages.length >= 2 && (
|
||||
{/* Mobile: single overflow menu — same items as desktop kebab plus
|
||||
Resolve/Escalate (which live in the visible row on desktop). */}
|
||||
{(magicHandoff || activePsaTicketId || messages.length >= 2) && (
|
||||
<div className="sm:hidden relative">
|
||||
<button
|
||||
onClick={() => setShowOverflow(!showOverflow)}
|
||||
className="flex items-center justify-center rounded-lg px-2 py-1.5 text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||||
aria-label="Session actions"
|
||||
>
|
||||
<MoreHorizontal size={18} />
|
||||
</button>
|
||||
@@ -1871,7 +1877,7 @@ export default function AssistantChatPage() {
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowOverflow(false)} />
|
||||
<div className="absolute right-0 top-full mt-1 z-50 w-44 rounded-lg border border-border bg-card py-1 shadow-lg">
|
||||
{isActive && (
|
||||
{isActive && messages.length >= 2 && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => { setShowOverflow(false); handleResolveClick() }}
|
||||
@@ -1902,15 +1908,36 @@ export default function AssistantChatPage() {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setShowOverflow(false); setShowStatusUpdate(true) }}
|
||||
disabled={loading}
|
||||
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-accent hover:bg-accent-dim transition-colors disabled:opacity-40"
|
||||
>
|
||||
<FileText size={14} />
|
||||
{updateLabel}
|
||||
</button>
|
||||
{isActive && (
|
||||
{magicHandoff && (
|
||||
<button
|
||||
onClick={() => { setShowOverflow(false); openHandoffContextOverlay() }}
|
||||
disabled={overlayLoading}
|
||||
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors disabled:opacity-40"
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
Context
|
||||
</button>
|
||||
)}
|
||||
{activePsaTicketId && (
|
||||
<button
|
||||
onClick={() => { setShowOverflow(false); setSpinOffHint(undefined); setShowNewTicket(true) }}
|
||||
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||||
>
|
||||
<Plus size={14} />
|
||||
New Ticket
|
||||
</button>
|
||||
)}
|
||||
{messages.length >= 2 && (
|
||||
<button
|
||||
onClick={() => { setShowOverflow(false); setShowStatusUpdate(true) }}
|
||||
disabled={loading}
|
||||
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-accent hover:bg-accent-dim transition-colors disabled:opacity-40"
|
||||
>
|
||||
<FileText size={14} />
|
||||
{updateLabel}
|
||||
</button>
|
||||
)}
|
||||
{isActive && messages.length >= 2 && (
|
||||
<button
|
||||
onClick={() => { setShowOverflow(false); aiSessionsApi.pauseSession(activeChatId).then(() => setActiveSessionStatus('paused')).catch(() => toast.error('Failed to pause')) }}
|
||||
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||||
@@ -1941,8 +1968,11 @@ export default function AssistantChatPage() {
|
||||
Hidden (not unmounted) when Script Builder tab is active so
|
||||
scroll position and input state are preserved. */}
|
||||
<div className={cn('flex-1 min-h-0 flex flex-col', chatTab !== 'chat' && 'hidden')}>
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto px-4 sm:px-6 py-4 space-y-4">
|
||||
{/* Messages — scroll container is full width (so the scrollbar lives at
|
||||
the chat-column edge) but content is centered to max-w-3xl to match
|
||||
the composer below, giving the column a single anchor. */}
|
||||
<div className="flex-1 overflow-y-auto px-4 sm:px-6 py-4">
|
||||
<div className="max-w-3xl mx-auto space-y-4">
|
||||
{messages.length === 0 && !loading && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-accent-dim flex items-center justify-center mb-4">
|
||||
@@ -1957,26 +1987,41 @@ export default function AssistantChatPage() {
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg, i) => (
|
||||
<ChatMessage
|
||||
key={i}
|
||||
role={msg.role}
|
||||
content={msg.content}
|
||||
suggestedFlows={msg.suggestedFlows}
|
||||
imageUrls={msg.imageUrls}
|
||||
/>
|
||||
))}
|
||||
{(() => {
|
||||
// Action emphasis is shown on the *current* turn only — i.e. the
|
||||
// latest assistant message when active items are pending and the
|
||||
// magic-moment hero has dismissed. The TaskLane remains the
|
||||
// canonical list; this is just an inline cue.
|
||||
let lastAssistantIdx = -1
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role === 'assistant') { lastAssistantIdx = i; break }
|
||||
}
|
||||
const showActionEmphasis = magicState === 'dismissed'
|
||||
&& (activeQuestions.length + activeActions.length) > 0
|
||||
const turnActionCount = activeQuestions.length + activeActions.length
|
||||
return messages.map((msg, i) => (
|
||||
<ChatMessage
|
||||
key={i}
|
||||
role={msg.role}
|
||||
content={msg.content}
|
||||
suggestedFlows={msg.suggestedFlows}
|
||||
imageUrls={msg.imageUrls}
|
||||
actionCount={i === lastAssistantIdx && showActionEmphasis ? turnActionCount : undefined}
|
||||
/>
|
||||
))
|
||||
})()}
|
||||
{loading && (
|
||||
<div className="flex gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-primary/15 flex items-center justify-center">
|
||||
<Sparkles size={14} className="text-primary" />
|
||||
</div>
|
||||
<div className="bg-input border border-border rounded-2xl px-4 py-3">
|
||||
<div className="bg-input border border-border rounded-xl px-4 py-3">
|
||||
<Loader2 size={16} className="animate-spin text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phase 8: ProposalBanner — mounted above the composer */}
|
||||
@@ -1997,8 +2042,9 @@ export default function AssistantChatPage() {
|
||||
|
||||
{/* Phase 9: InlineNoTemplateDialog — drafted-script evaluation case,
|
||||
rendered in the chat region above the composer so all three
|
||||
option cards fit side-by-side without the TaskLane's narrow width. */}
|
||||
{scriptPanelOpen && activeFix && activeChatId && !activeFix.script_template_id && activeFix.ai_drafted_script && (
|
||||
option cards fit side-by-side without the TaskLane's narrow width.
|
||||
Hidden when the banner is collapsed: the two surfaces are linked. */}
|
||||
{scriptPanelOpen && !bannerCollapsed && activeFix && activeChatId && !activeFix.script_template_id && activeFix.ai_drafted_script && (
|
||||
<InlineNoTemplateDialog
|
||||
fix={activeFix}
|
||||
onClose={() => setScriptPanelOpen(false)}
|
||||
@@ -2007,143 +2053,6 @@ export default function AssistantChatPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Task-lane shortcut chips: visible after the magic-moment
|
||||
dissolves when the task lane has loaded items. Each card
|
||||
links directly to the corresponding diagnostic card in the
|
||||
task lane — clicking opens the lane (if closed) and scrolls
|
||||
to that card. Sourced from actual task lane items, not the
|
||||
AI's free-text suggested_steps, so the card the user lands
|
||||
on has full detail (description, command, etc.). */}
|
||||
{!chipsHidden &&
|
||||
(activeActions.length > 0 || activeQuestions.length > 0) &&
|
||||
magicState === 'dismissed' && (() => {
|
||||
const chipItems = [
|
||||
...activeActions.slice(0, 4).map((a, ai) => ({
|
||||
label: a.label,
|
||||
cardIdx: activeQuestions.length + ai,
|
||||
description: a.description,
|
||||
command: a.command ?? null,
|
||||
type: 'action' as const,
|
||||
})),
|
||||
...activeQuestions.slice(0, Math.max(0, 4 - Math.min(activeActions.length, 4))).map((q, qi) => ({
|
||||
label: q.text,
|
||||
cardIdx: qi,
|
||||
description: q.context ?? null,
|
||||
command: null,
|
||||
type: 'question' as const,
|
||||
})),
|
||||
]
|
||||
const selectedChip = chipItems.find(c => c.cardIdx === selectedChipCardIdx) ?? null
|
||||
return (
|
||||
<div className="px-3 sm:px-6 pt-2 pb-0.5 shrink-0">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<p className="font-sans text-[0.625rem] uppercase tracking-wider text-muted-foreground">
|
||||
Suggested checks
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setChipsHidden(true); setSelectedChipCardIdx(null) }}
|
||||
aria-label="Hide suggestions"
|
||||
className="p-0.5 rounded text-muted-foreground hover:text-foreground hover:bg-elevated transition-colors"
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Inline detail card — shown when a chip is selected */}
|
||||
{selectedChip && (
|
||||
<div className="mb-2 rounded-lg border border-default bg-card p-3 animate-fade-in">
|
||||
<div className="flex items-start justify-between gap-2 mb-1.5">
|
||||
<span className="text-[0.8125rem] font-medium text-heading leading-snug">{selectedChip.label}</span>
|
||||
<button
|
||||
onClick={() => setSelectedChipCardIdx(null)}
|
||||
className="shrink-0 p-0.5 rounded text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label="Close detail"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
{selectedChip.description && (
|
||||
<p className="text-[0.6875rem] text-muted-foreground mb-2 leading-relaxed">{selectedChip.description}</p>
|
||||
)}
|
||||
{selectedChip.command && (
|
||||
<div className="rounded-md bg-code border border-default/50 px-3 py-2 flex items-start gap-2 mb-2.5">
|
||||
<code className="flex-1 text-[0.75rem] font-mono text-heading whitespace-pre-wrap break-all leading-relaxed">{selectedChip.command}</code>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(selectedChip.command!)
|
||||
} catch {
|
||||
try {
|
||||
const el = document.createElement('textarea')
|
||||
el.value = selectedChip.command!
|
||||
el.style.cssText = 'position:fixed;opacity:0;pointer-events:none'
|
||||
document.body.appendChild(el)
|
||||
el.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(el)
|
||||
} catch { return }
|
||||
}
|
||||
setCopiedChipCmd(true)
|
||||
setTimeout(() => setCopiedChipCmd(false), 1500)
|
||||
}}
|
||||
className="shrink-0 p-1 rounded text-muted-foreground hover:text-heading hover:bg-elevated transition-colors mt-0.5"
|
||||
title={copiedChipCmd ? 'Copied!' : 'Copy command'}
|
||||
>
|
||||
{copiedChipCmd
|
||||
? <Check size={13} className="text-success" />
|
||||
: <Copy size={13} />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedChipCardIdx(null)
|
||||
if (!showTaskLane) setShowTaskLane(true)
|
||||
const el = document.getElementById(`task-lane-card-${selectedChip.cardIdx}`)
|
||||
if (el) {
|
||||
setTimeout(() => el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }), showTaskLane ? 0 : 200)
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-1 text-[0.75rem] font-medium text-accent-text hover:text-accent transition-colors"
|
||||
>
|
||||
<ArrowRight size={11} />
|
||||
Open in Tasks panel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 overflow-x-auto pb-1" style={{ scrollbarWidth: 'none' }}>
|
||||
{chipItems.map((item) => {
|
||||
const isSelected = item.cardIdx === selectedChipCardIdx
|
||||
return (
|
||||
<button
|
||||
key={item.cardIdx}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCopiedChipCmd(false)
|
||||
setSelectedChipCardIdx(isSelected ? null : item.cardIdx)
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-start gap-2 rounded-lg border px-3 py-2.5 text-left transition-colors shrink-0 w-[172px]',
|
||||
isSelected
|
||||
? 'border-accent/50 bg-accent-dim'
|
||||
: 'border-default bg-card hover:bg-accent-dim hover:border-accent/30',
|
||||
)}
|
||||
>
|
||||
<ArrowRight size={12} className="text-accent-text shrink-0 mt-0.5" />
|
||||
<span className="text-xs text-foreground line-clamp-2 leading-snug">{item.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Rich Input */}
|
||||
<div className="px-3 sm:px-6 py-3 shrink-0">
|
||||
<div
|
||||
@@ -2191,7 +2100,7 @@ export default function AssistantChatPage() {
|
||||
{upload.preview ? (
|
||||
<img src={upload.preview} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-[0.5rem] text-muted-foreground px-1 text-center">
|
||||
<div className="w-full h-full flex items-center justify-center text-[0.625rem] text-muted-foreground px-1 text-center">
|
||||
{upload.file.name.split('.').pop()?.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
@@ -2359,6 +2268,8 @@ export default function AssistantChatPage() {
|
||||
loading={loading}
|
||||
whatWeKnowSlot={
|
||||
<WhatWeKnow
|
||||
key={activeChatId ?? 'no-session'}
|
||||
sessionId={activeChatId}
|
||||
facts={facts}
|
||||
onAddNote={handleAddNote}
|
||||
onUpdateFact={handleUpdateFact}
|
||||
@@ -2368,7 +2279,7 @@ export default function AssistantChatPage() {
|
||||
}
|
||||
bottomSlot={
|
||||
<>
|
||||
{scriptPanelOpen && activeFix && activeChatId && activeFix.script_template_id && (
|
||||
{scriptPanelOpen && !bannerCollapsed && activeFix && activeChatId && activeFix.script_template_id && (
|
||||
<TemplateMatchPanel
|
||||
fix={activeFix}
|
||||
sessionId={activeChatId}
|
||||
@@ -2380,7 +2291,7 @@ export default function AssistantChatPage() {
|
||||
<button
|
||||
onClick={() => handleOpenPreview('resolve')}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 text-[0.75rem] font-medium transition-colors',
|
||||
'flex items-center gap-1.5 text-xs font-medium transition-colors',
|
||||
previewKind === 'resolve'
|
||||
? 'text-success'
|
||||
: 'text-accent-text hover:text-heading',
|
||||
@@ -2392,7 +2303,7 @@ export default function AssistantChatPage() {
|
||||
<button
|
||||
onClick={() => handleOpenPreview('escalate')}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 text-[0.75rem] font-medium transition-colors',
|
||||
'flex items-center gap-1.5 text-xs font-medium transition-colors',
|
||||
previewKind === 'escalate'
|
||||
? 'text-warning'
|
||||
: 'text-muted-foreground hover:text-heading',
|
||||
@@ -2430,6 +2341,8 @@ export default function AssistantChatPage() {
|
||||
loading={loading}
|
||||
whatWeKnowSlot={
|
||||
<WhatWeKnow
|
||||
key={activeChatId ?? 'no-session'}
|
||||
sessionId={activeChatId}
|
||||
facts={facts}
|
||||
onAddNote={handleAddNote}
|
||||
onUpdateFact={handleUpdateFact}
|
||||
@@ -2439,7 +2352,7 @@ export default function AssistantChatPage() {
|
||||
}
|
||||
bottomSlot={
|
||||
<>
|
||||
{scriptPanelOpen && activeFix && activeChatId && activeFix.script_template_id && (
|
||||
{scriptPanelOpen && !bannerCollapsed && activeFix && activeChatId && activeFix.script_template_id && (
|
||||
<TemplateMatchPanel
|
||||
fix={activeFix}
|
||||
sessionId={activeChatId}
|
||||
@@ -2451,7 +2364,7 @@ export default function AssistantChatPage() {
|
||||
<button
|
||||
onClick={() => handleOpenPreview('resolve')}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 text-[0.75rem] font-medium transition-colors',
|
||||
'flex items-center gap-1.5 text-xs font-medium transition-colors',
|
||||
previewKind === 'resolve'
|
||||
? 'text-success'
|
||||
: 'text-accent-text hover:text-heading',
|
||||
@@ -2463,7 +2376,7 @@ export default function AssistantChatPage() {
|
||||
<button
|
||||
onClick={() => handleOpenPreview('escalate')}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 text-[0.75rem] font-medium transition-colors',
|
||||
'flex items-center gap-1.5 text-xs font-medium transition-colors',
|
||||
previewKind === 'escalate'
|
||||
? 'text-warning'
|
||||
: 'text-muted-foreground hover:text-heading',
|
||||
@@ -2561,7 +2474,7 @@ export default function AssistantChatPage() {
|
||||
{/* Handoff context overlay — re-opened from the toolbar */}
|
||||
{overlayHandoff && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/60 backdrop-blur-sm p-4 sm:p-8 animate-fade-in"
|
||||
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/70 p-4 sm:p-8 animate-fade-in"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) setOverlayHandoff(null)
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user