From 8a242f5db906ab9872473d8cbc5eb2b1a383fe3c Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Wed, 22 Apr 2026 14:19:44 -0400 Subject: [PATCH] =?UTF-8?q?feat(pilot):=20Phase=207=20=E2=80=94=20polish?= =?UTF-8?q?=20(loading/empty=20states,=20shortcuts,=20responsive=20drawer)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .../FLOWPILOT-MIGRATION.md | 2 +- .../src/components/assistant/TaskLane.tsx | 63 +++++-- .../components/pilot/ShortcutsHelpOverlay.tsx | 78 ++++++++ .../pilot/script/TemplatizePrompt.tsx | 2 +- .../components/pilot/sections/WhatWeKnow.tsx | 17 ++ frontend/src/hooks/useMediaQuery.ts | 27 +++ frontend/src/pages/AssistantChatPage.tsx | 169 +++++++++++++++++- 7 files changed, 339 insertions(+), 19 deletions(-) create mode 100644 frontend/src/components/pilot/ShortcutsHelpOverlay.tsx create mode 100644 frontend/src/hooks/useMediaQuery.ts diff --git a/docs/FlowAssist_Migration/FLOWPILOT-MIGRATION.md b/docs/FlowAssist_Migration/FLOWPILOT-MIGRATION.md index 4ea082a4..525b91ea 100644 --- a/docs/FlowAssist_Migration/FLOWPILOT-MIGRATION.md +++ b/docs/FlowAssist_Migration/FLOWPILOT-MIGRATION.md @@ -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 0–6 implemented and verified end-to-end against the dev stack. Phase 7 (polish) next. +> **Status:** Phases 0–7 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) --- diff --git a/frontend/src/components/assistant/TaskLane.tsx b/frontend/src/components/assistant/TaskLane.tsx index 1c700009..ff744565 100644 --- a/frontend/src/components/assistant/TaskLane.tsx +++ b/frontend/src/components/assistant/TaskLane.tsx @@ -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(() => { // 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 (
- {/* Resize grip handle */} -
-
- {Array.from({ length: 6 }).map((_, i) => ( -
- ))} + {/* Resize grip handle — side variant only. Drawer variant has no + horizontal neighbor to resize against. */} + {!isDrawer && ( +
+
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} +
-
+ )} {/* Header */}

@@ -271,6 +287,15 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa )}> {allHandled ? '✓ Ready' : `${doneCount}/${totalCount}`} + {loading && ( + + + thinking + + )}

diff --git a/frontend/src/components/pilot/ShortcutsHelpOverlay.tsx b/frontend/src/components/pilot/ShortcutsHelpOverlay.tsx new file mode 100644 index 00000000..17e19fdd --- /dev/null +++ b/frontend/src/components/pilot/ShortcutsHelpOverlay.tsx @@ -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 ( +
+
e.stopPropagation()} + > +
+

+ Keyboard shortcuts +

+ +
+
+ {ROWS.map((row) => ( +
+ {row.label} + + {row.keys.map((k, i) => ( + + {k} + + ))} + +
+ ))} +
+
+
+ ) +} + +export default ShortcutsHelpOverlay diff --git a/frontend/src/components/pilot/script/TemplatizePrompt.tsx b/frontend/src/components/pilot/script/TemplatizePrompt.tsx index fbeb5760..d40c86ed 100644 --- a/frontend/src/components/pilot/script/TemplatizePrompt.tsx +++ b/frontend/src/components/pilot/script/TemplatizePrompt.tsx @@ -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' diff --git a/frontend/src/components/pilot/sections/WhatWeKnow.tsx b/frontend/src/components/pilot/sections/WhatWeKnow.tsx index f49707ac..644d6ef2 100644 --- a/frontend/src/components/pilot/sections/WhatWeKnow.tsx +++ b/frontend/src/components/pilot/sections/WhatWeKnow.tsx @@ -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 · {count} + {loading && ( + + + synthesizing + + )}
+ {count === 0 && loading && ( +
+
+
+
+ )} + {count === 0 && !loading && (
Nothing confirmed yet — facts appear here as the engineer answers questions and runs checks. diff --git a/frontend/src/hooks/useMediaQuery.ts b/frontend/src/hooks/useMediaQuery.ts new file mode 100644 index 00000000..7e56c0ca --- /dev/null +++ b/frontend/src/hooks/useMediaQuery.ts @@ -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(() => { + 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 diff --git a/frontend/src/pages/AssistantChatPage.tsx b/frontend/src/pages/AssistantChatPage.tsx index c88bfdb0..0ae41ec4 100644 --- a/frontend/src/pages/AssistantChatPage.tsx +++ b/frontend/src/pages/AssistantChatPage.tsx @@ -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([]) 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() { )}
+ {/* 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) && ( + + )} + {/* 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 ? ( +
+
setShowTaskLane(false)} + /> +
+ setShowTaskLane(false)} + loading={loading} + whatWeKnowSlot={ + + } + suggestedFixSlot={ + activeFix && ( + setScriptPanelOpen((prev) => !prev)} + panelOpen={scriptPanelOpen} + /> + ) + } + bottomSlot={ + <> + {scriptPanelOpen && activeFix && activeChatId && ( + activeFix.script_template_id ? ( + setScriptPanelOpen(false)} + /> + ) : ( + setScriptPanelOpen(false)} + onDecide={handleScriptDecision} + busy={scriptDecisionBusy} + /> + ) + )} +
+ + +
+ { if (activeChatId) return refreshPreview(activeChatId) }} + onConfirm={handleConfirmPost} + posting={previewPosting} + /> + + } + /> +
+
+ ) : ( } 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. */} + setShortcutsHelpOpen(false)} + />
)