mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-09 00:03:10 +00:00
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
This commit is contained in:
@@ -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() {
|
||||
<DockBar
|
||||
tabs={tabs}
|
||||
activeKey={activeKey}
|
||||
side={side}
|
||||
onRestore={(key) => {
|
||||
if (key) useAppStore.getState().agentSetActive(key);
|
||||
setMinimized(false);
|
||||
@@ -374,22 +437,34 @@ export default function AgentPanel() {
|
||||
)}
|
||||
<motion.aside
|
||||
initial={false}
|
||||
animate={{ x: visible ? 0 : "101%" }}
|
||||
animate={{ x: visible ? 0 : side === "right" ? "101%" : "-101%" }}
|
||||
transition={{ type: "spring", stiffness: 380, damping: 40 }}
|
||||
// inert keeps the off-screen panel out of the tab order.
|
||||
inert={!visible}
|
||||
onKeyDown={(e) => {
|
||||
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 && (
|
||||
<div
|
||||
onPointerDown={startResize}
|
||||
title="Drag to resize"
|
||||
className={cn(
|
||||
"hidden sm:block absolute top-0 h-full w-1.5 cursor-col-resize touch-none z-10 hover:bg-sky-400/40 active:bg-sky-500/50 transition-colors",
|
||||
side === "right" ? "left-0" : "right-0",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{/* History rail (expanded only). */}
|
||||
{expanded && (
|
||||
<SessionSidebar
|
||||
@@ -408,9 +483,27 @@ export default function AgentPanel() {
|
||||
Assistant
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<button
|
||||
onClick={() =>
|
||||
setSide(side === "right" ? "left" : "right")
|
||||
}
|
||||
title={
|
||||
side === "right"
|
||||
? "Move to the left edge"
|
||||
: "Move to the right edge"
|
||||
}
|
||||
aria-label="Switch panel side"
|
||||
className="size-7 rounded-md text-slate-500 hover:text-slate-900 hover:bg-slate-100 hidden sm:inline-flex items-center justify-center transition-colors"
|
||||
>
|
||||
{side === "right" ? (
|
||||
<PanelLeftIcon className="w-4 h-4" />
|
||||
) : (
|
||||
<PanelRightIcon className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMinimized(true)}
|
||||
title="Minimize to dock"
|
||||
title="Minimize to dock (⌥M)"
|
||||
aria-label="Minimize to dock"
|
||||
className="size-7 rounded-md text-slate-500 hover:text-slate-900 hover:bg-slate-100 inline-flex items-center justify-center transition-colors"
|
||||
>
|
||||
@@ -709,11 +802,13 @@ function TabBar({
|
||||
function DockBar({
|
||||
tabs,
|
||||
activeKey,
|
||||
side,
|
||||
onRestore,
|
||||
onClose,
|
||||
}: {
|
||||
tabs: AgentTab[];
|
||||
activeKey: string | null;
|
||||
side: "left" | "right";
|
||||
onRestore: (key: string | null) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
@@ -766,7 +861,10 @@ function DockBar({
|
||||
initial={{ y: 16, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ type: "spring", stiffness: 420, damping: 34 }}
|
||||
className="fixed bottom-4 right-4 z-50"
|
||||
className={cn(
|
||||
"fixed bottom-4 z-50",
|
||||
side === "right" ? "right-4" : "left-4",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
role="button"
|
||||
|
||||
@@ -66,6 +66,7 @@ export function ShortcutsModal() {
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-6">
|
||||
<ShortcutGroup title="Navigation" shortcuts={shortcutDefinitions.navigation} />
|
||||
<ShortcutGroup title="Assistant" shortcuts={shortcutDefinitions.assistant} />
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<ShortcutGroup title="List Navigation" shortcuts={shortcutDefinitions.list} />
|
||||
|
||||
@@ -190,4 +190,14 @@ export const shortcutDefinitions = {
|
||||
{ keys: ['?'], description: 'Show shortcuts' },
|
||||
{ keys: ['Ctrl', 'k'], description: 'Command palette' },
|
||||
],
|
||||
// Panel-scoped shortcuts fire while focus is inside the assistant panel.
|
||||
assistant: [
|
||||
{ keys: ['Ctrl', 'i'], description: 'Open / close the assistant' },
|
||||
{ keys: ['Ctrl', ']'], description: 'Next conversation tab' },
|
||||
{ keys: ['Ctrl', '['], description: 'Previous conversation tab' },
|
||||
{ keys: ['Alt', 'n'], description: 'New chat' },
|
||||
{ keys: ['Alt', 'w'], description: 'Close tab' },
|
||||
{ keys: ['Alt', 'm'], description: 'Minimize to dock' },
|
||||
{ keys: ['Esc'], description: 'Close the panel' },
|
||||
],
|
||||
}
|
||||
|
||||
@@ -62,6 +62,10 @@ export type AgentTab = {
|
||||
let tabSeq = 0
|
||||
const newTabKey = () => `tab_${++tabSeq}`
|
||||
|
||||
export const AGENT_MIN_WIDTH = 360
|
||||
export const AGENT_MAX_WIDTH = 720
|
||||
export const AGENT_DEFAULT_WIDTH = 440
|
||||
|
||||
function makeTab(partial?: Partial<AgentTab>): AgentTab {
|
||||
return {
|
||||
key: newTabKey(),
|
||||
@@ -85,12 +89,19 @@ export interface AgentSlice {
|
||||
agentTabs: AgentTab[]
|
||||
agentActiveKey: string | null
|
||||
agentExpanded: boolean
|
||||
// Minimized docks the open panel into a compact bottom-right status bar;
|
||||
// runs keep streaming and the dock mirrors their state.
|
||||
// Minimized docks the open panel into a compact status bar; runs keep
|
||||
// streaming and the dock mirrors their state.
|
||||
agentMinimized: boolean
|
||||
// Layout preferences, persisted: which screen edge the panel docks to and
|
||||
// its width in the normal (non-expanded) mode.
|
||||
agentSide: 'left' | 'right'
|
||||
agentWidth: number
|
||||
|
||||
setAgentExpanded: (v: boolean) => void
|
||||
setAgentMinimized: (v: boolean) => void
|
||||
setAgentSide: (v: 'left' | 'right') => void
|
||||
// Clamped to [AGENT_MIN_WIDTH, AGENT_MAX_WIDTH].
|
||||
setAgentWidth: (v: number) => void
|
||||
// Ensure at least one tab exists (called when the panel first opens).
|
||||
agentEnsureTab: () => void
|
||||
// Open a brand-new empty conversation tab and focus it.
|
||||
@@ -111,9 +122,14 @@ export const createAgentSlice: StateCreator<AgentSlice, [], [], AgentSlice> = (s
|
||||
agentActiveKey: null,
|
||||
agentExpanded: false,
|
||||
agentMinimized: false,
|
||||
agentSide: 'right',
|
||||
agentWidth: AGENT_DEFAULT_WIDTH,
|
||||
|
||||
setAgentExpanded: (agentExpanded) => set({ agentExpanded }),
|
||||
setAgentMinimized: (agentMinimized) => set({ agentMinimized }),
|
||||
setAgentSide: (agentSide) => set({ agentSide }),
|
||||
setAgentWidth: (v) =>
|
||||
set({ agentWidth: Math.min(AGENT_MAX_WIDTH, Math.max(AGENT_MIN_WIDTH, Math.round(v))) }),
|
||||
|
||||
agentEnsureTab: () => {
|
||||
if (get().agentTabs.length > 0) return
|
||||
|
||||
@@ -42,6 +42,9 @@ export const useAppStore = create<AppStore>()(
|
||||
// Only persist UI preferences
|
||||
theme: state.theme,
|
||||
sidebarCollapsed: state.sidebarCollapsed,
|
||||
// Assistant panel layout (edge + width)
|
||||
agentSide: state.agentSide,
|
||||
agentWidth: state.agentWidth,
|
||||
// Persist current organization selection
|
||||
currentOrganization: state.currentOrganization,
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user