From e4a391c8a4bbdbcf7776996096562aee38a69a78 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 16 Jul 2026 07:50:24 +0200 Subject: [PATCH] feat: make the assistant panel fully placeable: drag the inner edge to resize (360-720px, persisted), dock to the left or right screen edge from the header (persisted, dock bar follows), and panel-scoped shortcuts (Cmd/Ctrl+]/[ switch tabs, Alt+N new chat, Alt+W close tab, Alt+M minimize) listed in the shortcuts dialog --- web/src/components/app/agent/AgentPanel.tsx | 120 +++++++++++++++++-- web/src/components/shared/ShortcutsModal.tsx | 1 + web/src/hooks/useKeyboardShortcuts.ts | 10 ++ web/src/stores/slices/agentSlice.ts | 20 +++- web/src/stores/useAppStore.ts | 3 + 5 files changed, 141 insertions(+), 13 deletions(-) diff --git a/web/src/components/app/agent/AgentPanel.tsx b/web/src/components/app/agent/AgentPanel.tsx index 853f89b6..f958eb49 100644 --- a/web/src/components/app/agent/AgentPanel.tsx +++ b/web/src/components/app/agent/AgentPanel.tsx @@ -30,6 +30,8 @@ import { MinusIcon, ChevronUpIcon, ClockIcon, + PanelLeftIcon, + PanelRightIcon, } from "lucide-react"; import { cn } from "@/lib/utils"; import { useAppStore } from "@/stores"; @@ -70,6 +72,9 @@ export default function AgentPanel() { const setExpanded = useAppStore((s) => s.setAgentExpanded); const minimized = useAppStore((s) => s.agentMinimized); const setMinimized = useAppStore((s) => s.setAgentMinimized); + const side = useAppStore((s) => s.agentSide); + const setSide = useAppStore((s) => s.setAgentSide); + const width = useAppStore((s) => s.agentWidth); const tabs = useAppStore((s) => s.agentTabs); const activeKey = useAppStore((s) => s.agentActiveKey); const visible = open && !minimized; @@ -351,6 +356,63 @@ export default function AgentPanel() { setMinimized(false); } + function cycleTab(dir: number) { + const idx = tabs.findIndex((t) => t.key === activeKey); + if (idx === -1 || tabs.length < 2) return; + const next = tabs[(idx + dir + tabs.length) % tabs.length]; + useAppStore.getState().agentSetActive(next.key); + } + + // Panel-scoped shortcuts (fire only while focus is inside the panel). + // Alt combos match on e.code because macOS Option remaps e.key to symbols. + function onPanelKeyDown(e: React.KeyboardEvent) { + if (e.key === "Escape") { + e.stopPropagation(); + closePanel(); + return; + } + const mod = e.metaKey || e.ctrlKey; + if (mod && !e.altKey && (e.key === "]" || e.key === "[")) { + e.preventDefault(); + e.stopPropagation(); + cycleTab(e.key === "]" ? 1 : -1); + return; + } + if (e.altKey && !mod) { + switch (e.code) { + case "KeyN": + e.preventDefault(); + useAppStore.getState().agentNewTab(); + return; + case "KeyW": + e.preventDefault(); + if (activeTab) closeTab(activeTab.key); + return; + case "KeyM": + e.preventDefault(); + setMinimized(true); + return; + } + } + } + + // Drag the panel's inner edge to resize (persisted via the store clamp). + function startResize(e: React.PointerEvent) { + if (expanded) return; + e.preventDefault(); + const onMove = (ev: PointerEvent) => { + const w = + side === "right" ? window.innerWidth - ev.clientX : ev.clientX; + useAppStore.getState().setAgentWidth(w); + }; + const onUp = () => { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + }; + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + } + return ( <> {/* Mobile backdrop. */} @@ -365,6 +427,7 @@ export default function AgentPanel() { { if (key) useAppStore.getState().agentSetActive(key); setMinimized(false); @@ -374,22 +437,34 @@ export default function AgentPanel() { )} { - if (e.key === "Escape") { - e.stopPropagation(); - closePanel(); - } - }} + onKeyDown={onPanelKeyDown} + style={{ "--agent-w": `${width}px` } as React.CSSProperties} className={cn( - "fixed right-0 top-0 z-50 h-full bg-white border-l border-slate-200 shadow-[0_0_60px_-12px_rgba(15,23,42,0.3)] flex", - expanded ? "w-full sm:w-[min(1080px,94vw)]" : "w-full sm:w-[440px]", + "fixed top-0 z-50 h-full bg-white shadow-[0_0_60px_-12px_rgba(15,23,42,0.3)] flex", + side === "right" + ? "right-0 border-l border-slate-200" + : "left-0 border-r border-slate-200", + expanded + ? "w-full sm:w-[min(1080px,94vw)]" + : "w-full sm:w-[min(var(--agent-w),94vw)]", )} aria-hidden={!visible} > + {/* Drag handle on the inner edge (desktop, normal mode). */} + {!expanded && ( +
+ )} {/* History rail (expanded only). */} {expanded && (
+