diff --git a/package-lock.json b/package-lock.json index 53cdacd..0ec6783 100644 --- a/package-lock.json +++ b/package-lock.json @@ -74,7 +74,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -1974,7 +1973,6 @@ "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1984,7 +1982,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2056,7 +2053,6 @@ "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.65.0", "@typescript-eslint/types": "8.65.0", @@ -2403,7 +2399,6 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2536,7 +2531,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -2926,7 +2920,6 @@ "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", "dev": true, "license": "MIT", - "peer": true, "workspaces": [ "packages/*" ], @@ -3400,7 +3393,6 @@ "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz", "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/immer" @@ -4991,7 +4983,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5088,7 +5079,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5098,7 +5088,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5221,7 +5210,6 @@ "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.9" }, @@ -5574,7 +5562,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5789,7 +5776,6 @@ "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", @@ -6080,7 +6066,6 @@ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx index df5b0c7..df3bc2a 100644 --- a/src/components/ChatPanel.tsx +++ b/src/components/ChatPanel.tsx @@ -22,6 +22,14 @@ function healthFooterMessage(health: ServerHealth | null): string { return 'Instant analysis active ยท Skills work without AI' } +/** + * Renders the assistant chat panel with messaging, spreadsheet actions, attachments, feedback, and usage controls. + * + * @param isMobileOpen - Whether the standalone panel is open on mobile. + * @param onCloseMobile - Callback invoked when the mobile panel is closed. + * @param embedded - Whether to render the panel without standalone header and visibility controls. + * @returns The assistant chat panel. + */ export function ChatPanel({ isMobileOpen, onCloseMobile, embedded }: { isMobileOpen?: boolean; onCloseMobile?: () => void; embedded?: boolean }) { const { messages, @@ -186,7 +194,7 @@ export function ChatPanel({ isMobileOpen, onCloseMobile, embedded }: { isMobileO

smartsh!t assistant

-

Describe what you need โ€” I handle the rest

+

Ask about this sheet or make a change

@@ -229,9 +237,10 @@ export function ChatPanel({ isMobileOpen, onCloseMobile, embedded }: { isMobileO )}
diff --git a/src/components/panels/DockPanel.tsx b/src/components/panels/DockPanel.tsx index 1ccd22a..8a4a60f 100644 --- a/src/components/panels/DockPanel.tsx +++ b/src/components/panels/DockPanel.tsx @@ -3,7 +3,7 @@ * Provides: header with title + close button, resize handle, content slot. */ -import { useCallback, useRef } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { useStore } from '@/store/useStore' import { getPanelDef, type PanelId } from './panelTypes' import { X } from 'lucide-react' @@ -17,62 +17,113 @@ interface DockPanelProps { headerActions?: React.ReactNode } +/** + * Renders an active dock panel with resizable content and header controls. + * + * @param panelId - Identifier of the panel to render and resize + * @param title - Optional title displayed in the panel header + * @param headerActions - Optional controls displayed before the close button + * @returns The dock panel when active, or `null` otherwise + */ export function DockPanel({ panelId, children, title, headerActions }: DockPanelProps) { const activePanel = useStore((s) => s.activePanel) const setActivePanel = useStore((s) => s.setActivePanel) const panelWidths = useStore((s) => s.panelWidths) const setPanelWidth = useStore((s) => s.setPanelWidth) - const resizingRef = useRef(false) + const resizeStartRef = useRef<{ x: number; width: number } | null>(null) const def = getPanelDef(panelId) + const [viewportWidth, setViewportWidth] = useState(() => + typeof window === 'undefined' ? def.maxWidth + 360 : window.innerWidth, + ) + + useEffect(() => { + const handleViewportResize = () => setViewportWidth(window.innerWidth) + window.addEventListener('resize', handleViewportResize) + return () => window.removeEventListener('resize', handleViewportResize) + }, []) - const width = panelWidths[panelId] ?? def.defaultWidth + const viewportMaxWidth = Math.max(0, viewportWidth - 360) + // Keep the existing minimum width as the lower bound. On mobile the panel + // becomes full-screen, so this constraint only applies to the desktop dock. + const effectiveMaxWidth = Math.max(def.minWidth, Math.min(def.maxWidth, viewportMaxWidth)) + const storedWidth = panelWidths[panelId] ?? def.defaultWidth + const width = Math.min(effectiveMaxWidth, Math.max(def.minWidth, storedWidth)) const isOpen = activePanel === panelId + useEffect(() => { + if (storedWidth !== width) setPanelWidth(panelId, width) + }, [panelId, setPanelWidth, storedWidth, width]) + const handleClose = () => setActivePanel(null) - const handleResizeStart = useCallback((e: React.MouseEvent) => { + const resizeBy = useCallback((delta: number) => { + setPanelWidth(panelId, Math.min(effectiveMaxWidth, Math.max(def.minWidth, width + delta))) + }, [width, panelId, effectiveMaxWidth, def.minWidth, setPanelWidth]) + + const handleResizeStart = useCallback((e: React.PointerEvent) => { e.preventDefault() - resizingRef.current = true - const startX = e.clientX - const startWidth = width + e.currentTarget.setPointerCapture(e.pointerId) + resizeStartRef.current = { x: e.clientX, width } document.body.style.cursor = 'col-resize' document.body.style.userSelect = 'none' + }, [width]) - const onMove = (ev: MouseEvent) => { - if (!resizingRef.current) return - // Dragging left edge โ†’ moving left means bigger panel - const delta = startX - ev.clientX - const newWidth = Math.min(def.maxWidth, Math.max(def.minWidth, startWidth + delta)) - setPanelWidth(panelId, newWidth) - } + const handleResizeMove = useCallback((e: React.PointerEvent) => { + const start = resizeStartRef.current + if (!start) return + // The handle is on the left edge: moving left makes the panel wider. + const delta = start.x - e.clientX + setPanelWidth(panelId, Math.min(effectiveMaxWidth, Math.max(def.minWidth, start.width + delta))) + }, [panelId, effectiveMaxWidth, def.minWidth, setPanelWidth]) - const onUp = () => { - resizingRef.current = false - document.body.style.cursor = '' - document.body.style.userSelect = '' - document.removeEventListener('mousemove', onMove) - document.removeEventListener('mouseup', onUp) + const handleResizeEnd = useCallback((e: React.PointerEvent) => { + resizeStartRef.current = null + if (e.currentTarget.hasPointerCapture(e.pointerId)) { + e.currentTarget.releasePointerCapture(e.pointerId) } + document.body.style.cursor = '' + document.body.style.userSelect = '' + }, []) - document.addEventListener('mousemove', onMove) - document.addEventListener('mouseup', onUp) - }, [width, panelId, def.maxWidth, def.minWidth, setPanelWidth]) + const handleResizeKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowLeft') { + e.preventDefault() + resizeBy(20) + } else if (e.key === 'ArrowRight') { + e.preventDefault() + resizeBy(-20) + } else if (e.key === 'Home') { + e.preventDefault() + setPanelWidth(panelId, def.minWidth) + } else if (e.key === 'End') { + e.preventDefault() + setPanelWidth(panelId, effectiveMaxWidth) + } + } if (!isOpen) return null return (
{/* Resize handle (left edge) */}
diff --git a/src/components/panels/panelTypes.ts b/src/components/panels/panelTypes.ts deleted file mode 100644 index 096802a..0000000 --- a/src/components/panels/panelTypes.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Panel system type definitions. - * Each panel has an ID, icon, label, and default width. - */ - -export type PanelId = 'chat' | 'insights' | 'auditor' | 'inspector' - -export interface PanelDef { - id: PanelId - icon: string - label: string - defaultWidth: number - minWidth: number - maxWidth: number -} - -export const PANELS: PanelDef[] = [ - { id: 'chat', icon: '๐Ÿ’ฌ', label: 'Chat', defaultWidth: 360, minWidth: 280, maxWidth: 500 }, - { id: 'insights', icon: '๐Ÿ“Š', label: 'Insights', defaultWidth: 320, minWidth: 260, maxWidth: 480 }, - { id: 'auditor', icon: '๐Ÿ›ก๏ธ', label: 'Auditor', defaultWidth: 300, minWidth: 260, maxWidth: 440 }, - { id: 'inspector', icon: '๐Ÿ”ฌ', label: 'Inspector', defaultWidth: 300, minWidth: 260, maxWidth: 440 }, -] - -export function getPanelDef(id: PanelId): PanelDef { - return PANELS.find((p) => p.id === id)! -} diff --git a/src/components/panels/panelTypes.tsx b/src/components/panels/panelTypes.tsx new file mode 100644 index 0000000..4f29ea0 --- /dev/null +++ b/src/components/panels/panelTypes.tsx @@ -0,0 +1,35 @@ +/** + * Panel system type definitions. + * Each panel has an ID, icon, label, and default width. + */ + +import type { ReactNode } from 'react' +import { BarChart3, Microscope, MessageSquare, Shield } from 'lucide-react' + +export type PanelId = 'chat' | 'insights' | 'auditor' | 'inspector' + +export interface PanelDef { + id: PanelId + icon: ReactNode + label: string + defaultWidth: number + minWidth: number + maxWidth: number +} + +export const PANELS: PanelDef[] = [ + { id: 'chat', icon: , label: 'Chat', defaultWidth: 360, minWidth: 280, maxWidth: 500 }, + { id: 'insights', icon: , label: 'Insights', defaultWidth: 320, minWidth: 260, maxWidth: 480 }, + { id: 'auditor', icon: , label: 'Auditor', defaultWidth: 300, minWidth: 260, maxWidth: 440 }, + { id: 'inspector', icon: , label: 'Inspector', defaultWidth: 300, minWidth: 260, maxWidth: 440 }, +] + +/** + * Retrieves the definition for a panel identifier. + * + * @param id - The identifier of the panel to retrieve + * @returns The matching panel definition + */ +export function getPanelDef(id: PanelId): PanelDef { + return PANELS.find((p) => p.id === id)! +} diff --git a/src/index.css b/src/index.css index 1ca8fac..4c9f5fe 100644 --- a/src/index.css +++ b/src/index.css @@ -215,3 +215,35 @@ body { transform: scale(0.90); } } + +/* Spreadsheet-first workspace: docked tools never float over the grid. */ +#spreadsheet-main { + min-width: 320px; + background: var(--surface-panel); +} + +@media (max-width: 900px) and (min-width: 769px) { + #spreadsheet-main { + min-width: 240px; + } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } +} + +/* On phones the active tool becomes a deliberate full-screen workspace, rather + than squeezing the sheet into an unusable column. The close button returns + users to the sheet. */ +@media (max-width: 767px) { + .dock-panel { + width: 100% !important; + min-width: 0 !important; + max-width: none !important; + } +}