Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 0 additions & 15 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 11 additions & 2 deletions src/components/ChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -186,7 +194,7 @@ export function ChatPanel({ isMobileOpen, onCloseMobile, embedded }: { isMobileO
<Sparkles size={18} className="text-amber-300 shrink-0" />
<div className="min-w-0">
<h2 className="text-sm font-bold text-white truncate">smartsh!t assistant</h2>
<p className="text-[10px] truncate" style={{ color: 'var(--accent-300)' }}>Describe what you need — I handle the rest</p>
<p className="text-[10px] truncate" style={{ color: 'var(--accent-300)' }}>Ask about this sheet or make a change</p>
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
Expand Down Expand Up @@ -229,9 +237,10 @@ export function ChatPanel({ isMobileOpen, onCloseMobile, embedded }: { isMobileO
<button
type="button"
onClick={onCloseMobile}
aria-label="Close assistant"
className="md:hidden p-1.5 rounded-lg text-white hover:bg-white/20"
>
<X size={16} aria-hidden="true" />
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
</button>
)}
</div>
Expand Down
107 changes: 79 additions & 28 deletions src/components/panels/DockPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Validate persisted panel widths before arithmetic.

src/store/slices/uiSlice.ts deserializes panelWidths without validating each value. A stale or corrupted entry can make width become NaN; this also makes storedWidth !== width permanently true because NaN !== NaN, potentially causing repeated setPanelWidth calls. Normalize values to finite numbers with a default fallback before clamping.

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 rawStoredWidth = panelWidths[panelId]
const storedWidth =
typeof rawStoredWidth === 'number' && Number.isFinite(rawStoredWidth)
? rawStoredWidth
: 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])
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/panels/DockPanel.tsx` around lines 42 - 48, Validate and
normalize the persisted value used by storedWidth in DockPanel before passing it
to Math.min/Math.max: accept only finite numeric widths and fall back to
def.defaultWidth otherwise. Keep the existing clamping and useEffect persistence
flow, ensuring corrupted values cannot produce NaN or repeated setPanelWidth
calls.


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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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, document.body retains cursor: col-resize and user-select: none because cleanup only runs on pointer-up/cancel. Add an unmount cleanup that clears resizeStartRef and restores these styles.

Proposed fix
+  useEffect(() => {
+    return () => {
+      resizeStartRef.current = null
+      document.body.style.cursor = ''
+      document.body.style.userSelect = ''
+    }
+  }, [])
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 = ''
}, [])
useEffect(() => {
return () => {
resizeStartRef.current = null
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
}, [])
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 = ''
}, [])
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/panels/DockPanel.tsx` around lines 72 - 79, Add unmount
cleanup for the resize drag state in DockPanel’s handleResizeEnd-related effect
or component cleanup: clear resizeStartRef and reset document.body.style.cursor
and document.body.style.userSelect to empty values when the panel unmounts,
while preserving the existing pointer-up/cancel cleanup.


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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 || true

Repository: 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 || true

Repository: Ocean82/smartshit

Length of output: 15132


Make the resize slider’s aria orientation match the horizontal resize behavior.

The vertical divider control changes width and responds to Left/Right keys, but exposes aria-orientation="vertical". Use aria-orientation="horizontal" to match the change direction, or replace the slider role with role="separator" if vertical-divider semantics are intended.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/panels/DockPanel.tsx` around lines 106 - 110, Update the
resize control’s accessibility semantics in the DockPanel slider markup: change
aria-orientation to horizontal so it matches the width-changing, Left/Right
keyboard behavior, while preserving the existing slider role and value
attributes.

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>
Expand Down
26 changes: 0 additions & 26 deletions src/components/panels/panelTypes.ts

This file was deleted.

35 changes: 35 additions & 0 deletions src/components/panels/panelTypes.tsx
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)!
}
Loading
Loading