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

@@ -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>
</>
)