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;
+ }
+}