diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 8168c24..2944009 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -153,6 +153,39 @@ The `ApiClient` class (`src/api/client.ts`) is a singleton that handles: - **Session recovery** — `onSessionExpired` callback in `App.tsx` automatically reconnects ClickHouse sessions - **Request headers** — Adds `Authorization: Bearer `, `X-Session-Id`, and `X-Requested-With: XMLHttpRequest` to all requests +### Design System ("editorial") + +Since v2.13.0 the entire SPA renders in a single visual language defined in `src/index.css`. Tailwind v4's `@theme` block declares the tokens; shadcn semantic CSS vars (`--background`, `--primary`, `--border`, etc.) are remapped to those tokens so every primitive (Dialog, Select, Tabs, DropdownMenu, Tooltip, Toast) inherits the look without per-component overrides. + +**Token surfaces** + +| Family | Tokens | Use | +|---|---|---| +| `ink-*` (0/50/100/200/300/500/700/800) | dark canvas → elevated surface → border | `bg-ink-50` canvas, `bg-ink-100` card, `bg-ink-200` nested/elevated, `border-ink-500` hairline divider | +| `paper`, `paper-muted`, `paper-dim`, `paper-faint` | text scale (high → low contrast) | `text-paper` headings, `text-paper-muted` body, `text-paper-dim` meta, `text-paper-faint` mono eyebrows | +| `brand`, `brand-soft`, `brand-dim` | ClickHouse-yellow accent | primary CTA, default/active marker, brand tint for selected state — never decoration | +| Semantic palettes | `emerald-*`, `red-*`, `amber-*` (kept from Tailwind) | only when the color carries meaning (success / destructive / warning) — never for chrome | + +**Typography**: Geist Sans + Geist Mono via Google Fonts. Body text is sans; eyebrows, labels, badges, and numeric/code display use mono with `uppercase tracking-[0.14em–0.18em]`. + +**Class recipes** (used across the app — see editorial style guide in user memory for full set): +- **Eyebrow**: `inline-flex items-center gap-2 font-mono text-[10px] uppercase tracking-[0.18em] text-paper-dim` (with optional `` divider) +- **Page header chip+title**: 9×9 hairline icon chip + 18px semibold title + mono eyebrow subtitle +- **Card**: `rounded-xs border border-ink-500 bg-ink-100` for default surface, `bg-ink-200` for nested +- **Primary button**: `h-9 gap-2 rounded-xs bg-brand px-3 font-mono text-[11px] font-semibold uppercase tracking-[0.14em] text-ink-50 hover:bg-brand-soft` +- **Outline button**: `h-9 gap-2 rounded-xs border-ink-500 bg-ink-100 px-3 font-mono text-[11px] uppercase tracking-[0.14em] text-paper hover:border-ink-700 hover:bg-ink-200` +- **Variant pills**: 1.5px-padding chips with mono uppercase 10px — `border-emerald-900/60 bg-emerald-950/40 text-emerald-300` (success), `border-red-900/60 bg-red-950/40 text-red-300` (destructive), `border-brand/40 text-brand` (default/active), `border-ink-500 bg-ink-200 text-paper-faint` (neutral) +- **Stats grid**: hairline `border-l border-t border-ink-500` wrapper with `border-b border-r border-ink-500` per cell, mono label + `font-mono tabular-nums` numeric value + +**Role/category encoding**: per-role color maps (e.g. `ROLE_COLORS`, `ACTION_COLORS` with 11 hues) replaced by 2-letter mono codes (`SA/AD/DV/AN/VW/GS` for RBAC roles) or uniform hairline chips. Identity comes from the label, not the hue. + +**Shared infrastructure that cascades the look app-wide**: +- `ConfirmationDialog` (`src/components/common/`) — one component reused by every delete/logout confirmation +- `Toaster` (`src/components/ui/sonner.tsx`) — sonner without `richColors`, with registered classNames per variant, so every `toast.success/error/warning/info` call inherits the editorial pill +- `AiChatBubble` (`src/components/common/`) — right-anchored side-sheet (compact 420 / standard 560 / wide 760 width cycle, full viewport height, slide-in from right) replacing the previous draggable floating modal. Industry-standard pattern (Cursor / Copilot Chat / JetBrains AI). Mobile keeps full-screen slide-up via FAB. + +**Boundary coercion for ClickHouse numerics**: ClickHouse JSON serializes UInt64 / Float64 fields as strings to preserve precision past 2^53. Hooks that expose these fields (`useQueryLogs`, `useLiveQueriesStats`) run them through a local `toFinite()` / `num()` helper at the API boundary so that downstream reducers can safely use `+` for numeric addition (without the helper, JS coerces to string concatenation, producing the absurd e+58 / e+82 stat displays seen pre-v2.13.0). + --- ## Backend Architecture diff --git a/CHANGELOG.md b/CHANGELOG.md index 09a5a21..7dd5330 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,32 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [v2.13.0] - 2026-05-18 + +### Changed + +- **Editorial redesign of the main app SPA**: migrated every authenticated page from the inherited "AI-template" look (purple→blue gradients, glassmorphism, animated rings, drop-shadow glow, emoji role icons, vivid status pills, drag-to-move floating panels, bright `richColors` toasts) to the editorial style already shipped on the landing page. The new system uses a dark monochrome canvas (`bg-ink-50`), ClickHouse-yellow as the sole brand accent, Geist Sans + Geist Mono typography, hairline `border-ink-500` borders, and mono uppercase eyebrows for chrome. + - **Foundation**: `src/index.css` ports the editorial design tokens (ink-0…ink-700 scale, paper/paper-muted/dim/faint text scale, brand/brand-soft/brand-dim ClickHouse-yellow). Dark shadcn vars are remapped to ink/paper/brand. `src/App.tsx` drops the root radial purple-violet gradient and the two `bg-purple-500/10` / `bg-blue-500/10` blur orbs that bled a violet halo under every authenticated page. + - **Pages**: Login, Home/Overview, Explorer shell, Monitoring wrapper, LiveQueries, Logs, Metrics (StatCard + chart wrappers + 7 sub-tabs; Overview tab progress bars use brand-yellow default with amber 60–80% / red >80% tier), Admin wrapper, Preferences (full rewrite with 4 SettingCards, profile hero with initials chip + emerald online dot + brand role pills, `StatusFooter` helper). + - **Workspace**: WorkspaceTabs, HomeTab empty state, SqlEditor toolbar (header strip, save-status pill, Save/Kill/Shortcuts dialogs), SqlTab Monaco wrapper + result/explain/statistics tabs, EmptyQueryResult, StatisticsDisplay. + - **Sidebar / dock**: FloatingDock, DataExplorer sidebar tree + TreeNode, ConnectionSelector (4 states), UserMenu, Saved Queries connection filter dropdown. + - **Admin sub-components**: UserManagement, RbacRolesTable (unified `ROLE_STYLE` with 2-letter codes SA/AD/DV/AN/VW/GS replacing the per-role color map), ConnectionManagement + ConnectionUserAccess, ClickHouseUsers full 4-step wizard (stepper with brand progress fill, unified DV/AN/VW role cards, editorial database tree, emerald/ink password requirement pills, amber DDL preview warning), AiModels (index + Providers/BaseModels/Configs tabs), CreateUser, EditUser, UserDataAccess, RoleFormDialog, RbacAuditLogs (11-variant `ACTION_COLORS` → single uniform `ACTION_BADGE`), RbacAuditPruneDialog, RbacAuditExportDialog. +- **Shared infrastructure cascading edits**: + - `ConfirmationDialog` (`src/components/common/`) — variant chip (red destructive / amber warning / brand info / emerald success) carries the semantic, title stays neutral. Cascades to every delete/logout confirmation across the app (Preferences logout, Admin → Users/Roles/CH Users/AI Models/Connections/Data access rules). + - Sonner `Toaster` (`src/components/ui/sonner.tsx` + `src/main.tsx`) — dropped `richColors`, registered editorial classNames per variant (success emerald-950/30 + emerald-300 icon, error red-950/30 + red-300, warning amber-950/30 + amber-300, info ink-100). Also fixed a quiet bug where `{...props}` in the wrapper component was overriding `toastOptions` from callers (main.tsx passed `closeButton: true` and accidentally dropped our classNames). +- **`AiChatBubble` converted from draggable modal to side-sheet**: removed the 1340×840 floating modal with zoom system, corner/bottom/left resize handles, and free position state. Replaced with a right-anchored side-sheet (compact 420 / standard 560 / wide 760 width cycle, full viewport height, slide-in from right edge with cubic-out easing). Persistence kept compatible with `getChatPrefsFromWorkspace` / `mergeChatPrefsIntoWorkspace` — position pinned to {0,0}, sheet width stored in `size.width`. Net −97 lines after dropping `useDragControls`, `PanInfo`, and three resize handlers. Mobile FAB behavior unchanged. Industry-standard pattern matching Cursor / Copilot Chat / JetBrains AI. +- **`docker-compose.yml`**: JWT_SECRET / RBAC_ENCRYPTION_KEY / RBAC_ENCRYPTION_SALT switched to `${VAR:-fallback}` substitution. Operators can drop real-length secrets via shell or `.env` without forking the compose file; dev placeholders still allow local boot. + +### Fixed + +- **`/overview` crash after polling tick**: `q.duration.toFixed is not a function` thrown by `Home.tsx:738` when the recent-queries API serialized `duration` as a JSON string (ClickHouse UInt64 / Float64 fields arrive as strings to preserve precision past 2^53). Now defensively coerced through `Number()` + `Number.isFinite` guard. +- **Live Queries header stats showed absurd values (`16.47 EB`, `700.15Q`, `1.879e+82 EB`)**: root cause was `useLiveQueriesStats` doing `acc + q.memory_usage` in a reduce. With a string operand, JS `+` switches from numeric addition to string concatenation — 4 queries of ~2.5 GB became literal "0247423385618345678903142559232367289..." which coerced to ~1e40 bytes. Fixed by introducing a `toFinite()` helper at the boundary and running every summed field through `Number()` before `+` or `Math.max`. Defense-in-depth in the `LiveQueries.tsx` formatters: extended bytes units B → YB so the index always lands in-bounds, all formatters use 2 decimals via a centralized `fixed2()` helper that falls back to compact `.toExponential(2)` for values past 1e15, formatters now accept `unknown` and coerce internally. +- **Query Logs avg duration showed `9.65e+58ms`**: same UInt64-as-string root cause in `useQueryLogs`. Only `event_timestamp` was being run through `Number()`; `query_duration_ms`, `read_rows`, `read_bytes`, `memory_usage` were assigned raw from the API. The `durations.reduce((sum, d) => sum + d, 0)` consumer in `Logs.tsx` was doing string concatenation, then dividing by 50. Fixed at the boundary in `useQueryLogs` — the two later remaps in the same hook read from the now-coerced `logs` array and inherit the fix automatically. +- **AiChatBubble input invisible against new `bg-ink-100` panel**: bumped to `bg-ink-200` for a differentiated surface. +- **AiChatBubble thread-empty welcome state vertically over-centered** post-flatten: reflowed to top-aligned, matching the ChatGPT empty-state pattern. +- **App.tsx dock mode-change race condition**: `FloatingDock` dispatched the event before `App.tsx` had attached its listener (React child effects run before parent effects). Dispatch now goes through `setTimeout(_, 0)` so the listener wins the race. + + ## [v2.12.10] - 2026-04-01 ### Fixed diff --git a/docker-compose.yml b/docker-compose.yml index c05ec90..1144878 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,9 +8,9 @@ services: - "5521:5521" environment: NODE_ENV: production - JWT_SECRET: dev-secret-change-me - RBAC_ENCRYPTION_KEY: dev-key-change-me - RBAC_ENCRYPTION_SALT: dev-salt-change-me + JWT_SECRET: ${JWT_SECRET:-dev-secret-change-me} + RBAC_ENCRYPTION_KEY: ${RBAC_ENCRYPTION_KEY:-dev-key-change-me} + RBAC_ENCRYPTION_SALT: ${RBAC_ENCRYPTION_SALT:-dev-salt-change-me} # Uncomment and provide a config.yaml to use hierarchical configuration # CHOUSE_CONFIG_PATH: /app/config.yaml volumes: diff --git a/package.json b/package.json index 9aa6369..6d47e19 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "chouseui", "private": true, "author": "daun-gatal", - "version": "2.12.9", + "version": "2.13.0", "license": "Apache-2.0", "type": "module", "workspaces": [ diff --git a/packages/server/package.json b/packages/server/package.json index 1636fc3..2eea2f0 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@chouseui/server", - "version": "2.12.9", + "version": "2.13.0", "type": "module", "scripts": { "dev": "bun run --watch src/index.ts", diff --git a/src/App.tsx b/src/App.tsx index e13bd59..f5a8a76 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -56,13 +56,7 @@ const MainLayout = () => { }, []); return ( -
- {/* Background Decor */} -
-
-
-
- +
{/* Spacer for sidebar mode (hidden during fullscreen) */} {isSidebarMode && !isFullscreen &&
} diff --git a/src/components/common/AiChartRenderer.tsx b/src/components/common/AiChartRenderer.tsx index 1112c7b..41af8bb 100644 --- a/src/components/common/AiChartRenderer.tsx +++ b/src/components/common/AiChartRenderer.tsx @@ -174,7 +174,7 @@ export function AiChartRenderer({ spec }: AiChartRendererProps): React.ReactElem if (!rows || rows.length === 0) { return ( -
+
No data to display
); @@ -738,21 +738,21 @@ export function AiChartRenderer({ spec }: AiChartRendererProps): React.ReactElem const chartHeight = CHART_HEIGHT - 20; return ( -
+
{title && ( -
+
{title}
)} -
+
{chartWidth > 0 && ( {chartElement} )}
-
- +
+ {chartType.replace(/_/g, ' ')} {barFamilyTruncated ? ` · Top ${BAR_MAX_CATEGORIES} of ${rows.length.toLocaleString()} rows` @@ -762,23 +762,23 @@ export function AiChartRenderer({ spec }: AiChartRendererProps): React.ReactElem - + - + Download as PNG - + Download data (CSV) - + Download data (JSON) @@ -864,26 +864,26 @@ function HeatmapTable({ spec, palette }: { spec: ChartSpec; palette: string[] }) } return ( -
+
{title && ( -
+
{title}
)} -
- +
+
- + {yAxes.map((col) => ( - + ))} {rows.slice(0, 50).map((r, i) => ( - {yAxes.map((col) => { @@ -891,7 +891,7 @@ function HeatmapTable({ spec, palette }: { spec: ChartSpec; palette: string[] }) return (
{xAxis}{xAxis}{col}{col}
+ {String(r[xAxis] ?? '').slice(0, 20)} {formatAxisValue(val)} @@ -903,31 +903,31 @@ function HeatmapTable({ spec, palette }: { spec: ChartSpec; palette: string[] })
-
- +
+ heatmap · {rows.length.toLocaleString()} rows - + - + Download as PNG - + Download data (CSV) - + Download data (JSON) diff --git a/src/components/common/AiChatBubble.tsx b/src/components/common/AiChatBubble.tsx index c272964..0c52eb6 100644 --- a/src/components/common/AiChatBubble.tsx +++ b/src/components/common/AiChatBubble.tsx @@ -6,7 +6,7 @@ */ import { useState, useEffect, useRef, useCallback, useMemo, type FormEvent, type KeyboardEvent, type MouseEvent as ReactMouseEvent } from 'react'; -import { motion, AnimatePresence, useDragControls, type PanInfo } from 'framer-motion'; +import { motion, AnimatePresence } from 'framer-motion'; import { useWindowSize, type Breakpoint } from '@/hooks/useWindowSize'; import { useDeviceType } from '@/hooks/useDeviceType'; import { @@ -92,12 +92,12 @@ import { // Resize constants // ============================================ -const DEFAULT_DESKTOP_WIDTH = 1340; -const DEFAULT_DESKTOP_HEIGHT = 840; -const MIN_WIDTH = 400; -const MIN_HEIGHT = 360; -const ZOOM_MIN = 0.55; -const ZOOM_MAX = 1.0; +// Right-edge side-sheet widths (cycled by header toggle, resizable from left edge) +const SHEET_WIDTH_COMPACT = 420; +const SHEET_WIDTH_STANDARD = 560; +const SHEET_WIDTH_WIDE = 760; +const MIN_SHEET_WIDTH = 360; +const MAX_SHEET_WIDTH_RATIO = 0.7; // never exceed 70% of viewport width // Register highlight.js languages hljs.registerLanguage('sql', sql); @@ -263,11 +263,11 @@ function SidebarThreadButton({ onLoad(thread.id); } }} - className={`w-full text-left px-3 py-2.5 rounded-xl text-[14px] - flex items-center justify-between group transition-all duration-200 cursor-pointer + className={`w-full text-left px-3 py-2 rounded-xs text-[13px] + flex items-center justify-between group transition-colors duration-200 cursor-pointer border ${activeId === thread.id - ? 'bg-violet-500/10 text-zinc-100 border-l-2 border-l-violet-400 border border-violet-500/10' - : 'text-zinc-400 hover:bg-white/[0.05] hover:text-zinc-200 border border-transparent' + ? 'border-ink-500 bg-ink-200 text-paper border-l-brand border-l-2' + : 'border-transparent text-paper-muted hover:bg-ink-200 hover:text-paper' }`} >
@@ -279,7 +279,7 @@ function SidebarThreadButton({ onBlur={handleSave} onKeyDown={(e) => e.key === 'Escape' && onCancelEdit()} onClick={(e) => e.stopPropagation()} - className="w-full bg-white/10 border border-white/20 rounded px-2 py-1 text-sm text-zinc-100 focus:outline-none focus:ring-1 focus:ring-violet-400" + className="w-full rounded-xs border border-ink-500 bg-ink-100 px-2 py-1 text-[13px] text-paper focus:border-brand focus:outline-none focus:ring-0" placeholder="Thread title" autoFocus aria-label="Edit thread title" @@ -287,7 +287,7 @@ function SidebarThreadButton({ ) : ( <> {thread.title || 'New Thread'} - + {timeAgo(thread.updatedAt)} @@ -302,7 +302,7 @@ function SidebarThreadButton({ onStartEdit(thread.id, e); setEditValue(thread.title || ''); }} - className="p-1 rounded-lg opacity-0 group-hover:opacity-100 hover:bg-white/10 text-zinc-500 hover:text-zinc-300 transition-all" + className="p-1 rounded-xs opacity-0 group-hover:opacity-100 hover:bg-ink-300 text-paper-dim hover:text-paper transition-colors" title="Rename" aria-label="Rename thread" > @@ -313,8 +313,8 @@ function SidebarThreadButton({ e.stopPropagation(); onDelete(thread.id, e); }} - className="p-1 rounded-lg opacity-0 group-hover:opacity-100 - hover:bg-red-500/15 text-zinc-600 hover:text-red-400 transition-all" + className="p-1 rounded-xs opacity-0 group-hover:opacity-100 + hover:bg-red-950/40 text-paper-dim hover:text-red-300 transition-colors" title="Delete chat" > @@ -357,17 +357,17 @@ function CollapsibleThreadGroup({
- {/* Animated shimmer bar while running */} +
+ {/* Subtle pulse bar while running */} {isRunning && ( -
+
)} {expanded && ( -
+
{toolCalls.map((step, i) => (
{step.status === 'running' - ? - : + ? + : }
- {formatToolName(step.tool)} + {formatToolName(step.tool)} {Object.entries(step.args) .filter(([, v]) => v !== undefined && v !== null && v !== '') .map(([k, v]) => { @@ -451,18 +451,18 @@ function ThinkingPanel({ toolCalls, isStreaming }: { toolCalls: ToolCallStep[]; const isMultiLine = strVal.includes('\n') || strVal.length > 80; return (
- {k}: + {k}: {isMultiLine ? ( -
{strVal.trim()}
+
{strVal.trim()}
) : ( - {strVal} + {strVal} )}
); }) } {step.status === 'done' && step.summary && ( -

↳ {step.summary}

+

↳ {step.summary}

)}
@@ -478,18 +478,18 @@ type CodeComponentProps = React.ComponentPropsWithoutRef<'code'> & { className?: const markdownComponents = { table: ({ children, ...props }: React.ComponentPropsWithoutRef<'table'>) => ( -
- {children}
+
+ {children}
), thead: ({ children, ...props }: React.ComponentPropsWithoutRef<'thead'>) => ( - {children} + {children} ), th: ({ children, ...props }: React.ComponentPropsWithoutRef<'th'>) => ( - {children} + {children} ), td: ({ children, ...props }: React.ComponentPropsWithoutRef<'td'>) => ( - {children} + {children} ), code: ({ className, children, ...props }: CodeComponentProps) => { const match = /language-(\w+)/.exec(className || ''); @@ -508,14 +508,14 @@ const markdownComponents = { @@ -523,28 +523,28 @@ const markdownComponents = { ); } // Inline code - return {children}; + return {children}; }, pre: ({ children, ...props }: React.ComponentPropsWithoutRef<'pre'>) => ( -
{children}
+
{children}
), p: ({ children, ...props }: React.ComponentPropsWithoutRef<'p'>) => ( -

{children}

+

{children}

), ul: ({ children, ...props }: React.ComponentPropsWithoutRef<'ul'>) => ( -
    {children}
+
    {children}
), ol: ({ children, ...props }: React.ComponentPropsWithoutRef<'ol'>) => ( -
    {children}
+
    {children}
), - h1: ({ children, ...props }: React.ComponentPropsWithoutRef<'h1'>) =>

{children}

, - h2: ({ children, ...props }: React.ComponentPropsWithoutRef<'h2'>) =>

{children}

, - h3: ({ children, ...props }: React.ComponentPropsWithoutRef<'h3'>) =>

{children}

, + h1: ({ children, ...props }: React.ComponentPropsWithoutRef<'h1'>) =>

{children}

, + h2: ({ children, ...props }: React.ComponentPropsWithoutRef<'h2'>) =>

{children}

, + h3: ({ children, ...props }: React.ComponentPropsWithoutRef<'h3'>) =>

{children}

, a: ({ children, ...props }: React.ComponentPropsWithoutRef<'a'>) => ( - {children} + {children} ), blockquote: ({ children, ...props }: React.ComponentPropsWithoutRef<'blockquote'>) => ( -
{children}
+
{children}
), // Suppress images — the AI sometimes generates ![chart](...) markdown which renders as broken img: () => null, @@ -869,29 +869,42 @@ export default function AiChatBubble() { const [isOpen, setIsOpen] = useState(false); const [showSidebar, setShowSidebar] = useState(false); - // Position and size state (device-aware; defaults applied in load effect) - const [position, setPosition] = useState<{ x: number, y: number }>({ x: 0, y: 0 }); const lastLoadedDeviceRef = useRef(null); - const dragControls = useDragControls(); - const deviceType = useDeviceType(); - const saveTimeoutRef = useRef | null>(null); + // Responsive breakpoint + const { width: viewportWidth, height: viewportHeight, breakpoint } = useWindowSize(); + const isMobile = breakpoint === 'mobile'; + const isTablet = breakpoint === 'tablet'; + const isDesktop = breakpoint === 'desktop'; - const saveChatPrefsDebounced = useCallback((pos: { x: number, y: number }, size: { width: number, height: number }): void => { - try { - localStorage.setItem('chouseui-chat-position', JSON.stringify(pos)); - } catch { /* ignore */ } + // Sheet width state (desktop & tablet only — mobile is always full screen) + const [sheetWidth, setSheetWidth] = useState(SHEET_WIDTH_STANDARD); + const sheetWidthRef = useRef(sheetWidth); + useEffect(() => { + sheetWidthRef.current = sheetWidth; + }, [sheetWidth]); + // Max sheet width based on viewport (prevent the sheet from eating the whole screen) + const maxSheetWidth = Math.max(MIN_SHEET_WIDTH, Math.floor(viewportWidth * MAX_SHEET_WIDTH_RATIO)); + const effectiveSheetWidth = Math.min(Math.max(sheetWidth, MIN_SHEET_WIDTH), maxSheetWidth); + + // Persistence — debounced + const saveTimeoutRef = useRef | null>(null); + const saveChatPrefsDebounced = useCallback((width: number): void => { if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current); } - saveTimeoutRef.current = setTimeout(async () => { try { const current = await rbacUserPreferencesApi.getPreferences(); const workspace = current.workspacePreferences as WorkspacePreferencesMap | undefined; - const merged = mergeChatPrefsIntoWorkspace(workspace, deviceType, { position: pos, size }); + // Keep the prefs API shape (position + size) but pin position to {0,0} and + // store the sheet width in size.width — height fills viewport now. + const merged = mergeChatPrefsIntoWorkspace(workspace, deviceType, { + position: { x: 0, y: 0 }, + size: { width, height: 0 }, + }); await rbacUserPreferencesApi.updatePreferences({ workspacePreferences: merged }); } catch (err) { log.error('[AiChatBubble] Failed to save preferences:', err); @@ -899,114 +912,55 @@ export default function AiChatBubble() { }, 1000); }, [deviceType]); - - // Responsive breakpoint - const { width: viewportWidth, height: viewportHeight, breakpoint } = useWindowSize(); - const isMobile = breakpoint === 'mobile'; - const isTablet = breakpoint === 'tablet'; - const isDesktop = breakpoint === 'desktop'; - - // Resize state (desktop only) - const [windowSize, setWindowSize] = useState({ width: DEFAULT_DESKTOP_WIDTH, height: DEFAULT_DESKTOP_HEIGHT }); - const windowSizeRef = useRef(windowSize); - useEffect(() => { - windowSizeRef.current = windowSize; - }, [windowSize]); - const [isResizing, setIsResizing] = useState(false); - const resizeRef = useRef<{ axis: 'both' | 'x' | 'y'; startX: number; startY: number; startW: number; startH: number } | null>(null); - - // Load preferences (per device type; re-load when device type changes; must run after windowSize is declared) + // Load preferences (per device type) useEffect(() => { if (!hasPermission || lastLoadedDeviceRef.current === deviceType) return; - const loadFromDb = async () => { try { const prefs = await rbacUserPreferencesApi.getPreferences(); const workspace = prefs.workspacePreferences as WorkspacePreferencesMap | undefined; - const { position: loadedPos, size: loadedSize } = getChatPrefsFromWorkspace(workspace, deviceType); - setPosition(loadedPos); - if (deviceType !== 'mobile' && loadedSize.width > 0 && loadedSize.height > 0) { - setWindowSize(loadedSize); + const { size: loadedSize } = getChatPrefsFromWorkspace(workspace, deviceType); + if (deviceType !== 'mobile' && loadedSize.width >= MIN_SHEET_WIDTH) { + setSheetWidth(loadedSize.width); } lastLoadedDeviceRef.current = deviceType; } catch (err) { log.error('[AiChatBubble] Failed to load preferences:', err); - try { - const saved = localStorage.getItem('chouseui-chat-position'); - if (saved) setPosition(JSON.parse(saved)); - } catch { /* ignore */ } } }; loadFromDb(); }, [hasPermission, deviceType]); - // Compute max constraints based on viewport - const maxWidth = Math.min(DEFAULT_DESKTOP_WIDTH, viewportWidth - 40); - const maxHeight = Math.min(900, Math.round(viewportHeight * 0.96)); - - // Effective window dimensions for desktop - const effectiveWidth = Math.min(Math.max(windowSize.width, MIN_WIDTH), maxWidth); - const effectiveHeight = Math.min(Math.max(windowSize.height, MIN_HEIGHT), maxHeight); - - // Compute zoom factor for proportional scaling (desktop resize only) - const zoomFactor = isDesktop - ? Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, Math.min(effectiveWidth / DEFAULT_DESKTOP_WIDTH, effectiveHeight / DEFAULT_DESKTOP_HEIGHT))) - : 1; - - const handleDragEnd = useCallback((_event: MouseEvent | TouchEvent | PointerEvent, info: PanInfo): void => { - const newPos = { - x: position.x + info.offset.x / zoomFactor, - y: position.y + info.offset.y / zoomFactor - }; - setPosition(newPos); - saveChatPrefsDebounced(newPos, windowSizeRef.current); - }, [position, zoomFactor, saveChatPrefsDebounced]); - - // Logical dimensions for internal layout - const logicalWidth = isDesktop ? effectiveWidth / zoomFactor : (isTablet ? 680 : viewportWidth); - const logicalHeight = isDesktop ? effectiveHeight / zoomFactor : (isTablet ? 840 : viewportHeight); + // Logical dimensions for internal layout (no zoom — side-sheet renders at 1:1) + const logicalWidth = isMobile ? viewportWidth : effectiveSheetWidth; + const logicalHeight = viewportHeight; // Adaptive internal layout thresholds - const hideSidebarThreshold = 900; - const singleColPromptThreshold = 600; + const hideSidebarThreshold = 720; + const singleColPromptThreshold = 520; + const shouldHideSidebar = showSidebar && !isMobile && logicalWidth < hideSidebarThreshold; + const useSingleColPrompt = isMobile || logicalWidth < singleColPromptThreshold; - const shouldHideSidebar = showSidebar && isDesktop && logicalWidth < hideSidebarThreshold; - const useSingleColPrompt = isMobile || (isDesktop && logicalWidth < singleColPromptThreshold); - - // Resize handlers (desktop and tablet) - const handleResizeStart = useCallback((axis: 'both' | 'x' | 'y', e: React.PointerEvent) => { + // Width-only resize (drag the left edge of the sheet) + const [isResizing, setIsResizing] = useState(false); + const resizeRef = useRef<{ startX: number; startW: number } | null>(null); + const handleResizeStart = useCallback((e: React.PointerEvent) => { e.preventDefault(); e.stopPropagation(); - // Prevent default touch behaviors to ensure smooth resizing on mobile/tablet - if (e.pointerType === 'touch') { - e.preventDefault(); - } + if (e.pointerType === 'touch') e.preventDefault(); setIsResizing(true); - resizeRef.current = { - axis, - startX: e.clientX, - startY: e.clientY, - startW: effectiveWidth, - startH: effectiveHeight, - }; - }, [effectiveWidth, effectiveHeight]); + resizeRef.current = { startX: e.clientX, startW: effectiveSheetWidth }; + }, [effectiveSheetWidth]); useEffect(() => { if (!isResizing) return; const handlePointerMove = (e: globalThis.PointerEvent) => { if (!resizeRef.current) return; - // Prevent default touch behaviors to ensure smooth resizing on mobile/tablet - if (e.pointerType === 'touch') { - e.preventDefault(); - } - const { axis, startX, startY, startW, startH } = resizeRef.current; - // Coordinate mapping: divide client delta by zoomFactor to get logical delta - const dx = axis !== 'y' ? (startX - e.clientX) / zoomFactor : 0; - const dy = axis !== 'x' ? (e.clientY - startY) / zoomFactor : 0; - setWindowSize({ - width: Math.min(Math.max(startW + dx * zoomFactor, MIN_WIDTH), maxWidth), - height: Math.min(Math.max(startH + dy * zoomFactor, MIN_HEIGHT), maxHeight), - }); + if (e.pointerType === 'touch') e.preventDefault(); + const { startX, startW } = resizeRef.current; + // Dragging left grows the sheet (sheet is anchored to the right edge). + const next = Math.min(Math.max(startW + (startX - e.clientX), MIN_SHEET_WIDTH), maxSheetWidth); + setSheetWidth(next); }; const handlePointerUp = () => { setIsResizing(false); @@ -1018,17 +972,17 @@ export default function AiChatBubble() { window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener('pointerup', handlePointerUp); }; - }, [isResizing, maxWidth, maxHeight, zoomFactor]); + }, [isResizing, maxSheetWidth]); - // Save position + size when resize ends (isResizing goes true -> false) + // Save width when resize ends const prevResizingRef = useRef(false); useEffect(() => { const wasResizing = prevResizingRef.current; prevResizingRef.current = isResizing; if (wasResizing && !isResizing) { - saveChatPrefsDebounced(position, windowSize); + saveChatPrefsDebounced(sheetWidthRef.current); } - }, [isResizing, position, windowSize, saveChatPrefsDebounced]); + }, [isResizing, saveChatPrefsDebounced]); // Auto-close sidebar on smaller breakpoints useEffect(() => { @@ -1401,8 +1355,8 @@ export default function AiChatBubble() { className="ai-chat-fab group" title="Open AI Chat" > - -
+ + ) : (
-
- -
-
+ + + +
-
- CHouse AI - Online +
+ CHouse AI + Online
@@ -1577,64 +1470,69 @@ export default function AiChatBubble() {
- - -
+ +
AI Models
-
- {aiModels.map(m => ( - setSelectedModelId(m.id)} - className={`flex items-start gap-2.5 px-3 py-2 cursor-pointer rounded-lg transition-colors ${selectedModelId === m.id - ? "bg-violet-500/15 text-violet-200" - : "hover:bg-white/5 text-zinc-300" - }`} - > -
-
- {selectedModelId === m.id &&
} +
+ {aiModels.map(m => { + const isCurrent = selectedModelId === m.id; + return ( + setSelectedModelId(m.id)} + className={`flex items-start gap-2.5 rounded-xs px-3 py-2 cursor-pointer transition-colors hover:bg-ink-200 ${isCurrent ? "bg-ink-200" : ""}`} + > +
+
+ {isCurrent &&
} +
-
-
- - {m.name} - - - {m.provider || 'AI Provider'} - -
-
- ))} +
+ + {m.name} + + + {m.provider || 'AI provider'} + +
+ + ); + })}
)} - {isDesktop && ( + {!isMobile && ( )} ))}
) : messages.length === 0 ? ( - /* Thread selected but empty */ -
-
- -
-

- Ask me anything about your ClickHouse databases -

-
+ /* Thread selected but empty — compact top-aligned, leaves space for input below */ +
+ + + New conversation + +

+ Ask anything about your ClickHouse databases. +

+
{visiblePrompts.map((sp) => ( ))}
@@ -1855,23 +1742,23 @@ export default function AiChatBubble() { style={{ animation: `fadeSlideIn 0.3s ease-out ${Math.min(idx * 0.05, 0.3)}s both` }} > {msg.role === 'assistant' && ( -
+ ? 'border-red-900/60 bg-red-950/40' + : 'border-brand/40 bg-brand/5'}`}> {msg.isError - ? - : } + ? + : }
)} -
+
{msg.role === 'assistant' ? ( @@ -1883,21 +1770,21 @@ export default function AiChatBubble() { /> )} {msg.isStreaming && msg.toolStatus && !msg.content && !msg.toolCalls?.length && ( -
+
{msg.toolStatus}
)} {msg.isError ? (
-

{msg.content}

+

{msg.content}

{msg.retryPrompt && (msg.retryable !== false) && (
- {timeAgo(msg.createdAt)} + {timeAgo(msg.createdAt)}
{ msg.role === 'user' && ( -
- +
+
) } @@ -1955,10 +1842,10 @@ export default function AiChatBubble() { )}
- {/* Input Area */} + {/* Input area */} {activeThreadId && ( -
-
+
+