-
-
Notifications
You must be signed in to change notification settings - Fork 0
Improve spreadsheet-first workspace and docked chat UX #13
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
d6aa540
7c545e9
c41bf4c
a273fc4
e7c3320
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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]) | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+50
to
+56
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Validate persisted panel widths before arithmetic.
Proposed fix- const storedWidth = panelWidths[panelId] ?? def.defaultWidth
+ const rawStoredWidth = panelWidths[panelId]
+ const storedWidth =
+ typeof rawStoredWidth === 'number' && Number.isFinite(rawStoredWidth)
+ ? rawStoredWidth
+ : def.defaultWidth📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| 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<HTMLDivElement>) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| 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<HTMLDivElement>) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| 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<HTMLDivElement>) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| resizeStartRef.current = null | ||||||||||||||||||||||||||||||||||||||||||||||||||
| if (e.currentTarget.hasPointerCapture(e.pointerId)) { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| e.currentTarget.releasePointerCapture(e.pointerId) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| document.body.style.cursor = '' | ||||||||||||||||||||||||||||||||||||||||||||||||||
| document.body.style.userSelect = '' | ||||||||||||||||||||||||||||||||||||||||||||||||||
| }, []) | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+80
to
+87
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win Clean up global drag styles on unmount. If the panel unmounts during an active drag, Proposed fix+ useEffect(() => {
+ return () => {
+ resizeStartRef.current = null
+ document.body.style.cursor = ''
+ document.body.style.userSelect = ''
+ }
+ }, [])📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| document.addEventListener('mousemove', onMove) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| document.addEventListener('mouseup', onUp) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| }, [width, panelId, def.maxWidth, def.minWidth, setPanelWidth]) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| const handleResizeKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| 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 ( | ||||||||||||||||||||||||||||||||||||||||||||||||||
| <div | ||||||||||||||||||||||||||||||||||||||||||||||||||
| className="relative flex flex-col bg-white border-l border-gray-200 shrink-0 h-full" | ||||||||||||||||||||||||||||||||||||||||||||||||||
| style={{ width, minWidth: def.minWidth, maxWidth: def.maxWidth }} | ||||||||||||||||||||||||||||||||||||||||||||||||||
| className="dock-panel relative flex flex-col bg-white border-l border-gray-200 shrink-0 h-full max-md:fixed max-md:inset-0 max-md:z-40" | ||||||||||||||||||||||||||||||||||||||||||||||||||
| style={{ width, minWidth: def.minWidth, maxWidth: effectiveMaxWidth }} | ||||||||||||||||||||||||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||||||||||||||||||||||||
| {/* Resize handle (left edge) */} | ||||||||||||||||||||||||||||||||||||||||||||||||||
| <div | ||||||||||||||||||||||||||||||||||||||||||||||||||
| role="separator" | ||||||||||||||||||||||||||||||||||||||||||||||||||
| role="slider" | ||||||||||||||||||||||||||||||||||||||||||||||||||
| aria-orientation="vertical" | ||||||||||||||||||||||||||||||||||||||||||||||||||
| aria-label={`Resize ${def.label} panel`} | ||||||||||||||||||||||||||||||||||||||||||||||||||
| onMouseDown={handleResizeStart} | ||||||||||||||||||||||||||||||||||||||||||||||||||
| className="absolute top-0 left-0 w-1.5 h-full cursor-col-resize z-10 group hover:bg-blue-400/30 active:bg-blue-500/40" | ||||||||||||||||||||||||||||||||||||||||||||||||||
| aria-valuemin={def.minWidth} | ||||||||||||||||||||||||||||||||||||||||||||||||||
| aria-valuemax={effectiveMaxWidth} | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+114
to
+118
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
# Locate and inspect the relevant component and its keyboard handling semantics.
if [ -f src/components/panels/DockPanel.tsx ]; then
printf '--- DockPanel outline ---\n'
ast-grep outline src/components/panels/DockPanel.tsx --view compact 2>/dev/null || true
printf '\n--- DockPanel relevant lines 1-180 ---\n'
sed -n '1,180p' src/components/panels/DockPanel.tsx
else
fd -a DockPanel.tsx .
fi
printf '\n--- searches for DockPanel resizing controls ---\n'
rg -n "role=\"slider\"|aria-orientation|KeyDown|ArrowLeft|ArrowRight|separator|resize" src/components/panels/DockPanel.tsx src || trueRepository: Ocean82/smartshit Length of output: 246 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "pwd=$(pwd)"
git ls-files | grep -F 'src/components/panels/DockPanel.tsx' || true
ls -la src/components/panels/DockPanel.tsx 2>/dev/null || true
if [ -f src/components/panels/DockPanel.tsx ]; then
echo "--- DockPanel outline ---"
ast-grep outline src/components/panels/DockPanel.tsx --view compact 2>/dev/null || true
echo
echo "--- DockPanel relevant lines 1-180 ---"
sed -n '1,180p' src/components/panels/DockPanel.tsx
else
echo "--- fallback location search ---"
fd -a DockPanel.tsx . 2>/dev/null || true
fi
echo
echo "--- searches for DockPanel resizing controls ---"
rg -n 'role="slider"|aria-orientation|onKeyDown|KeyboardEvent|ArrowLeft|ArrowRight|separator|resize' src/components/panels/DockPanel.tsx src || trueRepository: Ocean82/smartshit Length of output: 15132 Make the resize slider’s aria orientation match the horizontal resize behavior. The vertical divider control changes 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||
| aria-valuenow={width} | ||||||||||||||||||||||||||||||||||||||||||||||||||
| tabIndex={0} | ||||||||||||||||||||||||||||||||||||||||||||||||||
| onPointerDown={handleResizeStart} | ||||||||||||||||||||||||||||||||||||||||||||||||||
| onPointerMove={handleResizeMove} | ||||||||||||||||||||||||||||||||||||||||||||||||||
| onPointerUp={handleResizeEnd} | ||||||||||||||||||||||||||||||||||||||||||||||||||
| onPointerCancel={handleResizeEnd} | ||||||||||||||||||||||||||||||||||||||||||||||||||
| onKeyDown={handleResizeKeyDown} | ||||||||||||||||||||||||||||||||||||||||||||||||||
| className="absolute top-0 left-0 w-2 h-full cursor-col-resize z-10 group hover:bg-blue-400/30 active:bg-blue-500/40 touch-none max-md:hidden" | ||||||||||||||||||||||||||||||||||||||||||||||||||
| > | ||||||||||||||||||||||||||||||||||||||||||||||||||
| <div className="absolute top-1/2 left-0 -translate-y-1/2 w-1 h-8 rounded-full bg-gray-300 group-hover:bg-blue-500 transition-colors" /> | ||||||||||||||||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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: <MessageSquare size={15} strokeWidth={1.8} />, label: 'Chat', defaultWidth: 360, minWidth: 280, maxWidth: 500 }, | ||
| { id: 'insights', icon: <BarChart3 size={15} strokeWidth={1.8} />, label: 'Insights', defaultWidth: 320, minWidth: 260, maxWidth: 480 }, | ||
| { id: 'auditor', icon: <Shield size={15} strokeWidth={1.8} />, label: 'Auditor', defaultWidth: 300, minWidth: 260, maxWidth: 440 }, | ||
| { id: 'inspector', icon: <Microscope size={15} strokeWidth={1.8} />, 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)! | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.