feat(pilot): Phase 7 — polish (loading/empty states, shortcuts, responsive drawer)
All checks were successful
Mirror to GitHub / mirror (push) Successful in 4s

- WhatWeKnow shows a "synthesizing" indicator + skeleton pulse while the
  chat cycle is in-flight; task-lane header mirrors the signal with a
  "thinking" pip so engineers know the AI is still working.
- Quiet-state hint when the lane is open (facts exist) but no open
  questions, checks, or active fix — keeps the surface from looking
  "finished" when the AI is about to follow up.
- Keyboard shortcuts: ⌘↵/Ctrl+↵ send in the composer (plain Enter still
  sends), ⌘G toggles the Script Generator panel for the active fix,
  `?` opens a new ShortcutsHelpOverlay listing all bindings. ⌘K palette
  was already wired in TopBar.
- Responsive: below 1200px the task lane collapses to a bottom drawer
  with a backdrop + a floating "Tasks ●" toggle button. TaskLane now
  takes a `variant: 'side' | 'drawer'` prop; drawer variant drops the
  resize handle and uses the shared slide-in-bottom animation.
- Build hygiene: fixed a pre-existing TS error in confirm-post error
  handling (duplicate `response` type keys) and an unused-import warning
  in TemplatizePrompt.

Verified: `npx tsc -b` and `npm run build` both clean against the dev
stack; Vite HMR applied each change without errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-22 14:19:44 -04:00
parent 4aaf57adb5
commit 8a242f5db9
7 changed files with 339 additions and 19 deletions

View File

@@ -2,7 +2,7 @@
> **Target:** Transform `/assistant` (ResolutionAssist) into the new unified `/pilot` (FlowPilot) surface.
> **Audience:** Claude Code (implementation) and Codex (review) reviewed by Michael (owner).
> **Status:** Phases 06 implemented and verified end-to-end against the dev stack. Phase 7 (polish) next.
> **Status:** Phases 07 implemented. Phase 7 delivered polish: fact-synthesis loading indicator in `WhatWeKnow`, "thinking" pip in the task-lane header, quiet-state hint when questions/checks/fix are all absent, keyboard shortcuts (`⌘K` palette already present, `⌘↵` send, `⌘G` toggle script panel, `?` help overlay), and responsive bottom-drawer lane on viewports <1200px with a floating "Tasks" toggle. `tsc -b` and `npm run build` both clean.
> **Last updated:** April 22, 2026 (Phase 6 — post-resolve TemplatizePrompt — committed; draft accept → script_templates promotion with provenance verified live)
---

View File

@@ -49,6 +49,10 @@ interface TaskLaneProps {
// (parent owns state). Renders inside the scrollable body so the popover
// stays anchored as the lane scrolls.
bottomSlot?: React.ReactNode
// Phase 7: `'drawer'` renders the lane as a full-width, bottom-anchored
// sheet (no resize handle, no left border) — used on viewports <1200px.
// Default `'side'` keeps the existing resizable right-side panel.
variant?: 'side' | 'drawer'
}
// ── Storage helpers ──
@@ -75,7 +79,8 @@ export function clearTaskState(sessionId: string) {
// ── Component ──
export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loading, whatWeKnowSlot, suggestedFixSlot, bottomSlot }: TaskLaneProps) {
export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loading, whatWeKnowSlot, suggestedFixSlot, bottomSlot, variant = 'side' }: TaskLaneProps) {
const isDrawer = variant === 'drawer'
const [tasks, setTasks] = useState<TaskResponse[]>(() => {
// Try to restore saved state for this session (preserves user's in-progress answers)
if (sessionId) {
@@ -245,20 +250,31 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
return (
<div
className="relative border-l border-default flex flex-col shrink-0 animate-slide-in-right"
style={{ background: 'var(--color-bg-page)', width: panelWidth }}
className={cn(
'relative flex flex-col',
isDrawer
? 'w-full h-full border-t border-default animate-slide-in-bottom'
: 'border-l border-default shrink-0 animate-slide-in-right',
)}
style={{
background: 'var(--color-bg-page)',
...(isDrawer ? {} : { width: panelWidth }),
}}
>
{/* Resize grip handle */}
<div
onMouseDown={handleMouseDown}
className="absolute left-0 top-0 bottom-0 w-[6px] cursor-col-resize z-20 flex items-center justify-center group hover:bg-accent/10 transition-colors"
>
<div className="flex flex-col gap-[3px] opacity-0 group-hover:opacity-100 transition-opacity">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="w-[3px] h-[3px] rounded-full bg-muted-foreground" />
))}
{/* Resize grip handle — side variant only. Drawer variant has no
horizontal neighbor to resize against. */}
{!isDrawer && (
<div
onMouseDown={handleMouseDown}
className="absolute left-0 top-0 bottom-0 w-[6px] cursor-col-resize z-20 flex items-center justify-center group hover:bg-accent/10 transition-colors"
>
<div className="flex flex-col gap-[3px] opacity-0 group-hover:opacity-100 transition-opacity">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="w-[3px] h-[3px] rounded-full bg-muted-foreground" />
))}
</div>
</div>
</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)' }}>
<h3 className="font-heading text-sm font-bold text-heading flex items-center gap-2">
@@ -271,6 +287,15 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
)}>
{allHandled ? '✓ Ready' : `${doneCount}/${totalCount}`}
</span>
{loading && (
<span
className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground"
title="AI is thinking"
>
<Loader2 size={10} className="animate-spin" />
thinking
</span>
)}
</h3>
<button onClick={onClose} className="text-muted-foreground hover:text-heading transition-colors p-1" title="Collapse tasks">
<PanelRightClose size={16} />
@@ -513,6 +538,18 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
{/* ── Suggested fix (Phase 3) ── */}
{suggestedFixSlot}
{/* Quiet-state hint: lane is open (facts exist), but AI hasn't
proposed a next step yet. Keeps the lane from feeling "finished"
when the engineer still expects a question / fix to arrive. */}
{questionTasks.length === 0
&& actionTasks.length === 0
&& !suggestedFixSlot
&& !loading && (
<div className="text-[0.6875rem] italic text-muted-foreground px-1 py-2">
No open questions send a message or add a note; the AI will follow up.
</div>
)}
{/* ── Resolve action bar + preview popover (Phase 3) ── */}
{bottomSlot}
</div>

View File

@@ -0,0 +1,78 @@
/**
* ShortcutsHelpOverlay — Phase 7 keyboard-shortcut reference modal.
*
* Opened by `?` from anywhere inside /pilot (the global useKeyboardShortcuts
* hook skips keypresses inside inputs, so `?` is safe from composer collisions).
*/
import { X } from 'lucide-react'
interface ShortcutsHelpOverlayProps {
open: boolean
onClose: () => void
}
interface Row {
keys: string[]
label: string
}
const ROWS: Row[] = [
{ keys: ['⌘', 'K'], label: 'Open command palette' },
{ keys: ['⌘', '↵'], label: 'Send the current message' },
{ keys: ['⌘', 'G'], label: 'Toggle Script Generator for the active fix' },
{ keys: ['?'], label: 'Show this shortcut reference' },
{ keys: ['Esc'], label: 'Close modal / cancel edit' },
]
export function ShortcutsHelpOverlay({ open, onClose }: ShortcutsHelpOverlayProps) {
if (!open) return null
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
onClick={onClose}
role="dialog"
aria-modal="true"
aria-label="Keyboard shortcuts"
>
<div
className="relative w-full max-w-md rounded-xl border border-default bg-bg-page shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-4 py-3 border-b border-default">
<h2 className="font-heading text-sm font-semibold text-heading">
Keyboard shortcuts
</h2>
<button
onClick={onClose}
className="p-1 rounded text-muted-foreground hover:text-heading hover:bg-elevated/40 transition-colors"
aria-label="Close shortcuts"
>
<X size={14} />
</button>
</div>
<div className="px-4 py-3 space-y-2">
{ROWS.map((row) => (
<div
key={row.label}
className="flex items-center justify-between gap-3 text-[0.8125rem]"
>
<span className="text-muted-foreground">{row.label}</span>
<span className="flex items-center gap-1">
{row.keys.map((k, i) => (
<kbd
key={i}
className="inline-flex items-center justify-center rounded border border-white/[0.06] bg-white/[0.08] px-1.5 py-0.5 text-[0.6875rem] font-mono text-heading min-w-[20px]"
>
{k}
</kbd>
))}
</span>
</div>
))}
</div>
</div>
</div>
)
}
export default ShortcutsHelpOverlay

View File

@@ -20,7 +20,7 @@
* modal for the next one after save/skip.
*/
import { useEffect, useMemo, useState } from 'react'
import { Loader2, Check, X, Trash2, Sparkles } from 'lucide-react'
import { Loader2, Check, Trash2, Sparkles } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Modal } from '@/components/common/Modal'
import { toast } from '@/lib/toast'

View File

@@ -11,6 +11,7 @@
* and renders the section. Loading/refresh logic lives in the parent
* (AssistantChatPage) so it can coordinate with the chat send cycle.
*/
import { Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { SessionFact } from '@/api/sessionFacts'
import { WhatWeKnowItem } from './WhatWeKnowItem'
@@ -48,9 +49,25 @@ export function WhatWeKnow({
What we know
<span className="text-muted-foreground">·</span>
<span className="tabular-nums">{count}</span>
{loading && (
<span
className="ml-auto flex items-center gap-1 text-[0.625rem] font-medium normal-case tracking-normal text-muted-foreground"
title="AI may be synthesizing new facts from this turn"
>
<Loader2 size={10} className="animate-spin" />
synthesizing
</span>
)}
</div>
</div>
{count === 0 && loading && (
<div className="space-y-2 px-1 py-2">
<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-[0.75rem] text-muted-foreground italic px-1 py-2">
Nothing confirmed yet facts appear here as the engineer answers questions and runs checks.

View File

@@ -0,0 +1,27 @@
import { useEffect, useState } from 'react'
/**
* SSR-safe CSS media-query hook. Returns the current match boolean and
* re-renders on viewport changes. Used by /pilot to swap the task lane
* between side panel (≥1200px) and bottom drawer (<1200px) per Phase 7.
*/
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState<boolean>(() => {
if (typeof window === 'undefined') return false
return window.matchMedia(query).matches
})
useEffect(() => {
if (typeof window === 'undefined') return
const mql = window.matchMedia(query)
const handler = (e: MediaQueryListEvent) => setMatches(e.matches)
// Sync once on mount in case state drifted between render and effect.
setMatches(mql.matches)
mql.addEventListener('change', handler)
return () => mql.removeEventListener('change', handler)
}, [query])
return matches
}
export default useMediaQuery

View File

@@ -19,6 +19,9 @@ import { ResolutionNotePreview as ResolutionNotePreviewPopover } from '@/compone
import { TemplateMatchPanel } from '@/components/pilot/script/TemplateMatchPanel'
import { NoTemplateDialog } from '@/components/pilot/script/NoTemplateDialog'
import { TemplatizePrompt } from '@/components/pilot/script/TemplatizePrompt'
import { ShortcutsHelpOverlay } from '@/components/pilot/ShortcutsHelpOverlay'
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'
import { useMediaQuery } from '@/hooks/useMediaQuery'
import {
draftTemplatesApi,
accountPreferencesApi,
@@ -121,6 +124,11 @@ export default function AssistantChatPage() {
// advance to the next pending draft.
const [templatizeQueue, setTemplatizeQueue] = useState<DraftTemplate[]>([])
const [showOverflow, setShowOverflow] = useState(false)
// Phase 7: keyboard-shortcut help overlay.
const [shortcutsHelpOpen, setShortcutsHelpOpen] = useState(false)
// Phase 7: below 1200px the task lane collapses to a bottom drawer per the
// migration spec. Above, it's the standard right-side panel.
const isNarrow = useMediaQuery('(max-width: 1199px)')
const toggleSidebarCollapse = () => {
const next = !sidebarCollapsed
setSidebarCollapsed(next)
@@ -539,8 +547,9 @@ export default function AssistantChatPage() {
}
} catch (err: unknown) {
console.error('[AssistantChat] confirm post failed:', err)
const status = (err as { response?: { status?: number }; response?: { data?: { detail?: string } } })?.response?.status
const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail
const errResp = (err as { response?: { status?: number; data?: { detail?: string } } })?.response
const status = errResp?.status
const detail = errResp?.data?.detail
if (status === 502) {
toast.error(detail || 'PSA posted partially — see server logs.')
} else if (status === 409) {
@@ -864,6 +873,14 @@ export default function AssistantChatPage() {
}
const handleKeyDown = (e: React.KeyboardEvent) => {
// ⌘↵ / Ctrl+↵ is the FlowPilot-wide "send" shortcut (Phase 7 spec).
// Plain Enter (without modifiers, without shift) also sends to preserve
// existing composer ergonomics. Shift+Enter keeps the newline behavior.
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
handleSend()
return
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
@@ -878,6 +895,25 @@ export default function AssistantChatPage() {
el.style.height = `${Math.min(el.scrollHeight, 150)}px`
}, [input])
// Phase 7: global keyboard shortcuts. The hook auto-skips keypresses that
// originate inside inputs/textareas, so `?` and ⌘G won't fight the composer.
// ⌘K is handled by the TopBar (opens the global command palette).
useKeyboardShortcuts([
{
key: '?',
shift: true,
handler: () => setShortcutsHelpOpen((v) => !v),
},
{
key: 'g',
ctrl: true,
handler: () => {
if (activeFix) setScriptPanelOpen((v) => !v)
},
enabled: activeFix !== null,
},
])
// ── File handling ──────────────────────────────
const ACCEPTED_FILE_TYPES = 'image/png,image/jpeg,image/gif,image/webp,.txt,.log,.csv,.pdf,.docx'
@@ -1344,11 +1380,128 @@ export default function AssistantChatPage() {
)}
</div>
{/* Phase 7: on narrow viewports (<1200px) the lane is a bottom drawer
that's hidden by default; a floating "Tasks" button toggles it.
Shows a count pill when new items are present while closed. */}
{isNarrow
&& !showTaskLane
&& (activeQuestions.length > 0 || activeActions.length > 0 || facts.length > 0 || activeFix !== null) && (
<button
onClick={() => setShowTaskLane(true)}
className="fixed bottom-4 right-4 z-40 flex items-center gap-2 rounded-full bg-accent text-white px-4 py-2.5 shadow-lg hover:bg-accent-hover transition-colors"
title="Open tasks drawer"
>
<ListChecks size={14} />
<span className="text-[0.8125rem] font-semibold">Tasks</span>
{(activeQuestions.length + activeActions.length) > 0 && (
<span className="rounded-full bg-white/20 px-1.5 py-0.5 text-[0.625rem] font-bold tabular-nums">
{activeQuestions.length + activeActions.length}
</span>
)}
</button>
)}
{/* Task lane — slides in when AI sends questions/actions OR when the
session has any "What we know" facts OR an active suggested fix.
Phase 2/3 make the lane the structural home of session diagnostic
state, not a transient questions panel. */}
state, not a transient questions panel.
Narrow viewport: the lane renders as a bottom drawer with backdrop. */}
{showTaskLane && (activeQuestions.length > 0 || activeActions.length > 0 || facts.length > 0 || activeFix !== null) && (
isNarrow ? (
<div className="fixed inset-0 z-50 flex flex-col" role="dialog" aria-modal="true">
<div
className="flex-1 bg-black/50"
onClick={() => setShowTaskLane(false)}
/>
<div className="h-[80vh] bg-bg-page">
<TaskLane
variant="drawer"
questions={activeQuestions}
actions={activeActions}
sessionId={activeChatId}
onSubmit={handleTaskSubmit}
onClose={() => setShowTaskLane(false)}
loading={loading}
whatWeKnowSlot={
<WhatWeKnow
facts={facts}
onAddNote={handleAddNote}
onUpdateFact={handleUpdateFact}
onDeleteFact={handleDeleteFact}
loading={loading}
/>
}
suggestedFixSlot={
activeFix && (
<SuggestedFix
fix={activeFix}
onDismiss={handleDismissFix}
onActivate={() => setScriptPanelOpen((prev) => !prev)}
panelOpen={scriptPanelOpen}
/>
)
}
bottomSlot={
<>
{scriptPanelOpen && activeFix && activeChatId && (
activeFix.script_template_id ? (
<TemplateMatchPanel
fix={activeFix}
sessionId={activeChatId}
onClose={() => setScriptPanelOpen(false)}
/>
) : (
<NoTemplateDialog
fix={activeFix}
onClose={() => setScriptPanelOpen(false)}
onDecide={handleScriptDecision}
busy={scriptDecisionBusy}
/>
)
)}
<div className="flex items-center gap-3 px-3 mt-1">
<button
onClick={() => handleOpenPreview('resolve')}
className={cn(
'flex items-center gap-1.5 text-[0.75rem] font-medium transition-colors',
previewKind === 'resolve'
? 'text-success'
: 'text-accent-text hover:text-heading',
)}
>
<FileText size={12} />
{previewKind === 'resolve' ? 'Showing' : 'Preview'} Resolve note
</button>
<button
onClick={() => handleOpenPreview('escalate')}
className={cn(
'flex items-center gap-1.5 text-[0.75rem] font-medium transition-colors',
previewKind === 'escalate'
? 'text-warning'
: 'text-muted-foreground hover:text-heading',
)}
>
<ArrowUpRight size={12} />
{previewKind === 'escalate' ? 'Showing' : 'Escalate instead'}
</button>
</div>
<ResolutionNotePreviewPopover
kind={previewKind ?? 'resolve'}
open={previewOpen}
loading={previewLoading}
preview={previewData}
error={previewError}
onClose={handleClosePreview}
onRefresh={() => { if (activeChatId) return refreshPreview(activeChatId) }}
onConfirm={handleConfirmPost}
posting={previewPosting}
/>
</>
}
/>
</div>
</div>
) : (
<TaskLane
questions={activeQuestions}
actions={activeActions}
@@ -1364,6 +1517,7 @@ export default function AssistantChatPage() {
onAddNote={handleAddNote}
onUpdateFact={handleUpdateFact}
onDeleteFact={handleDeleteFact}
loading={loading}
/>
}
suggestedFixSlot={
@@ -1427,13 +1581,14 @@ export default function AssistantChatPage() {
preview={previewData}
error={previewError}
onClose={handleClosePreview}
onRefresh={() => activeChatId && refreshPreview(activeChatId)}
onRefresh={() => { if (activeChatId) return refreshPreview(activeChatId) }}
onConfirm={handleConfirmPost}
posting={previewPosting}
/>
</>
}
/>
)
)}
{/* Branch map hidden — branching is now silent/background only.
@@ -1473,6 +1628,12 @@ export default function AssistantChatPage() {
onResolved={() => setTemplatizeQueue((q) => q.slice(1))}
/>
)}
{/* Phase 7: keyboard-shortcut help. `?` from anywhere inside /pilot. */}
<ShortcutsHelpOverlay
open={shortcutsHelpOpen}
onClose={() => setShortcutsHelpOpen(false)}
/>
</div>
</>
)