From c7e5de2dfa9491350e4c5e2b0a0eb82cab228832 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 31 Jul 2026 19:51:27 -0700 Subject: [PATCH 1/8] [eric] canvas: Tidy layout left you at 41% with a 2-wide ribbon, it now grids to the screen and frames it readable --- .../hooks/interaction/useCanvasControls.ts | 41 +++++++++++----- .../lifecycle/useDashboardCardActions.ts | 8 ++- .../src/shared/state/dashboardLayoutSlice.ts | 49 +++++++++++++++---- 3 files changed, 75 insertions(+), 23 deletions(-) diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts index b61c88a0..5d661e6a 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts @@ -7,7 +7,7 @@ import { getScrollFocusedCard } from '@/shared/cardScrollFocus'; import { getWebview } from '@/shared/browserRegistry'; import { applyBrowserZoom } from '@/shared/browserZoom'; import { syncTiledGeometry } from '../../canvas/tiledGeometry'; -import { revealZoom } from '../../canvas/revealZoom'; +import { revealZoom, REVEAL_MIN_ZOOM } from '../../canvas/revealZoom'; const MIN_ZOOM = 0.15; // The floor for AUTOMATIC reveals only. revealCards takes min(current, fit), which can only ever go @@ -19,6 +19,10 @@ const MAX_ZOOM = 3.0; const ZOOM_IN_FACTOR = 1.1; const ZOOM_OUT_FACTOR = 1 / ZOOM_IN_FACTOR; const FIT_PADDING = 200; +// Tidy frames everything at once, so it gets its own tighter margin than a single-card fit. The wider +// x inset is the left dock, which floats over the canvas and would otherwise sit on the first column. +const TIDY_PADDING = { x: 120, y: 56 }; +const TIDY_MIN_ZOOM = REVEAL_MIN_ZOOM; // Card-framing (spawn, click-to-focus, arrow-nav) snaps as fast as the zoom buttons so a new card lands under you now, not after a lazy glide. const FIT_DURATION = 150; // Must outlast FIT_DURATION so the drift re-snap lands after the glide, never mid-flight. @@ -691,6 +695,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: maxZoom?: number, minZoom?: number, centered?: boolean, + padding?: { x: number; y: number }, ): { panX: number; panY: number; zoom: number } | null => { const viewport = viewportRef.current; if (!viewport || cardRects.length === 0) return null; @@ -711,8 +716,10 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: const contentWidth = maxX - minX; const contentHeight = maxY - minY; - const availW = vRect.width - FIT_PADDING * 2; - const availH = vRect.height - FIT_PADDING * 2; + const padX = padding?.x ?? FIT_PADDING; + const padY = padding?.y ?? FIT_PADDING; + const availW = vRect.width - padX * 2; + const availH = vRect.height - padY * 2; const ceiling = maxZoom ?? MAX_ZOOM; const floor = minZoom ?? MIN_ZOOM; const targetZoom = clamp( @@ -720,12 +727,14 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: floor, ceiling, ); - const targetPanX = - (vRect.width - contentWidth * targetZoom) / 2 - minX * targetZoom; + // Content wider/taller than the viewport only happens when the zoom floor bit; centering then hides both ends, so anchor the start of it instead. + const targetPanX = contentWidth * targetZoom > vRect.width + ? padX - minX * targetZoom + : (vRect.width - contentWidth * targetZoom) / 2 - minX * targetZoom; // A single card normally top-biases (header up top, no dead space below). On creation we want the opposite: the new card dead-centered "in front of you", so `centered` forces true vertical centering. const topBiased = cardRects.length === 1 && !centered; - const targetPanY = topBiased - ? FIT_PADDING * 0.4 - minY * targetZoom + const targetPanY = topBiased || contentHeight * targetZoom > vRect.height + ? padY * 0.4 - minY * targetZoom : (vRect.height - contentHeight * targetZoom) / 2 - minY * targetZoom; return { panX: targetPanX, panY: targetPanY, zoom: targetZoom }; @@ -740,10 +749,11 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: animate?: boolean, minZoom?: number, centered?: boolean, + padding?: { x: number; y: number }, ) => { cancelAnimation(); - const target = computeFitTarget(cardRects, maxZoom, minZoom, centered); + const target = computeFitTarget(cardRects, maxZoom, minZoom, centered, padding); if (!target) { // Keep current camera; snapping to (0,0,1) used to desync the minimap. if (cardRects.length === 0 || !viewportRef.current) { @@ -761,7 +771,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: // Settle pass: cancelAnimation() must be able to cancel it, else back-to-back fitToCards races and the first settle overwrites the second target. settleTimerRef.current = window.setTimeout(() => { settleTimerRef.current = null; - const fresh = computeFitTarget(cardRects, maxZoom, minZoom, centered); + const fresh = computeFitTarget(cardRects, maxZoom, minZoom, centered, padding); if (!fresh) return; const cur2 = stateRef.current; const drift = @@ -777,6 +787,15 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: [cancelAnimation, animateTo, computeFitTarget, setCanvasState], ); + // The camera half of Tidy: frame the freshly gridded cards close in (the 200px fit padding was + // eating a third of the viewport), never below readable, and never magnified past life size. + const fitTidy = useCallback( + (cardRects: Array<{ x: number; y: number; width: number; height: number }>) => { + fitToCards(cardRects, 1, true, TIDY_MIN_ZOOM, false, TIDY_PADDING); + }, + [fitToCards], + ); + // Figma-style spawn camera: never zoom IN, never move if the cards are already on screen; otherwise the minimal pan that reveals them, zooming out only when they cannot fit at the current zoom. const revealCards = useCallback( (cardRects: Array<{ x: number; y: number; width: number; height: number }>) => { @@ -833,9 +852,9 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: const getLiveState = useCallback((): CanvasState => stateRef.current, []); const actions = useMemo(() => ({ - zoomIn, zoomOut, resetZoom, fitToView, fitToCards, revealCards, animateTo, cancelAnimation, + zoomIn, zoomOut, resetZoom, fitToView, fitToCards, fitTidy, revealCards, animateTo, cancelAnimation, setState: setCanvasState, panBy, commit: commitLive, syncTransform: applyLiveToDom, getLiveState, - }), [zoomIn, zoomOut, resetZoom, fitToView, fitToCards, revealCards, animateTo, cancelAnimation, setCanvasState, panBy, commitLive, applyLiveToDom, getLiveState]); + }), [zoomIn, zoomOut, resetZoom, fitToView, fitToCards, fitTidy, revealCards, animateTo, cancelAnimation, setCanvasState, panBy, commitLive, applyLiveToDom, getLiveState]); return { ...state, diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardCardActions.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardCardActions.ts index 88c25191..750cd57e 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardCardActions.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardCardActions.ts @@ -111,6 +111,7 @@ export function useDashboardCardActions({ const { cards: tidied, viewCards: tidiedViews, browserCards: tidiedBrowsers, workflowCards: tidiedWorkflows, workflowsHub: tidiedHub, + workflowsMonitorCard: tidiedMonitor, settingsCard: tidiedSettings, } = store.getState().dashboardLayout; const allRects = [ ...Object.values(tidied).map((c) => ({ @@ -120,9 +121,12 @@ export function useDashboardCardActions({ ...Object.values(tidiedViews).map((c) => ({ x: c.x, y: c.y, width: c.width, height: c.height })), ...Object.values(tidiedBrowsers).map((c) => ({ x: c.x, y: c.y, width: c.width, height: c.height })), ...Object.values(tidiedWorkflows).map((c) => ({ x: c.x, y: c.y, width: c.width, height: c.height })), - ...(tidiedHub ? [{ x: tidiedHub.x, y: tidiedHub.y, width: tidiedHub.width, height: tidiedHub.height }] : []), + // The hub, the monitor and Settings get tidied into the grid too, so the camera has to know about them or it frames a stale box. + ...[tidiedHub, tidiedMonitor, tidiedSettings] + .filter((c): c is NonNullable => !!c) + .map((c) => ({ x: c.x, y: c.y, width: c.width, height: c.height })), ]; - canvasActions.fitToCards(allRects); + canvasActions.fitTidy(allRects); }, [dispatch, canvasActions]); return { diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index b835e99b..85d970d1 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -345,10 +345,11 @@ export function findOpenGridCell( occupiedRects: Rect[], newW: number, newH: number, + colLimit?: number, ): { x: number; y: number } { const cellW = DEFAULT_CARD_W + GRID_GAP; const cellH = DEFAULT_CARD_H + GRID_GAP; - const maxCols = Math.max( + const maxCols = colLimit ?? Math.max( 1, Math.floor((window.innerWidth - GRID_ORIGIN.x) / cellW) || GRID_COLS_FALLBACK, ); @@ -365,6 +366,34 @@ export function findOpenGridCell( } } +// Tidy packs into the grid shape that fills the SCREEN best. The default column count is derived from +// window.innerWidth, which is screen pixels pretending to be world units: it laid 8 cards out as a +// 2-wide, 4-tall ribbon that the camera then had to pull back to 41% to show. +export function tidyColumnCount(itemSizes: Array<{ w: number; h: number }>): number { + const cellW = DEFAULT_CARD_W + GRID_GAP; + const cellH = DEFAULT_CARD_H + GRID_GAP; + let cells = 0; + let widest = 1; + for (const s of itemSizes) { + const cols = Math.max(1, Math.ceil(s.w / cellW)); + cells += cols * Math.max(1, Math.ceil(s.h / cellH)); + widest = Math.max(widest, cols); + } + const vw = window.innerWidth || 1440; + const vh = window.innerHeight || 900; + let best = widest; + let bestZoom = 0; + for (let cols = widest; cols <= Math.max(widest, cells); cols++) { + const rows = Math.ceil(cells / cols); + const zoom = Math.min(vw / (cols * cellW), vh / (rows * cellH)); + if (zoom > bestZoom) { + bestZoom = zoom; + best = cols; + } + } + return best; +} + // Like findOpenGridCell but biased to stay near a proposed (x,y) anchor. Used when the backend hands us a card with a position that's already occupied (sub-agent or sub-browser spawning on top of its parent or a sibling). Spirals outward from the anchor on a grid, snapping to cell-aligned positions so the result still looks intentional, not dropped from orbit. Caps the spiral search at ~1000 cells to avoid pathological work in adversarial layouts, falls back to findOpenGridCell after that. Cost: O(rects × cells_scanned). Spawn events are rare (not per-frame), so this only runs when a new card appears. Typical scan resolves in <10 cells, well below the cap. No perf impact on steady-state UI. export function findOpenSpotNear( anchorX: number, @@ -768,19 +797,19 @@ const dashboardLayoutSlice = createSlice({ ]; allItems.sort((a, b) => a.y - b.y || a.x - b.x); + const sizeOf = (item: typeof allItems[number]): { w: number; h: number } => ({ + w: item.storedW, + h: item.kind === 'agent' && expanded.has(item.id) + ? Math.max(EXPANDED_CARD_MIN_H, item.storedH) + : item.storedH, + }); + const cols = tidyColumnCount(allItems.map(sizeOf)); const placedRects: Rect[] = []; for (const item of allItems) { - let w: number, h: number; - if (item.kind === 'agent') { - w = item.storedW; - h = expanded.has(item.id) ? Math.max(EXPANDED_CARD_MIN_H, item.storedH) : item.storedH; - } else { - w = item.storedW; - h = item.storedH; - } + const { w, h } = sizeOf(item); - const pos = findOpenGridCell(placedRects, w, h); + const pos = findOpenGridCell(placedRects, w, h, cols); placedRects.push({ x: pos.x, y: pos.y, w, h }); if (item.kind === 'agent') { From 83408a1c09a15784207d95311843dce53f504bba Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 31 Jul 2026 19:56:15 -0700 Subject: [PATCH 2/8] [eric] dashboard: the hero prompt box was a one-line input, a 280-char prompt showed its last 65 characters --- .../Dashboard/canvas/DashboardEmptyState.tsx | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/frontend/src/app/pages/Dashboard/canvas/DashboardEmptyState.tsx b/frontend/src/app/pages/Dashboard/canvas/DashboardEmptyState.tsx index 93bf2374..fcdcfd84 100644 --- a/frontend/src/app/pages/Dashboard/canvas/DashboardEmptyState.tsx +++ b/frontend/src/app/pages/Dashboard/canvas/DashboardEmptyState.tsx @@ -30,6 +30,9 @@ function iconForStarter(text: string): LucideIcon { // The placeholder cycles agentic invitations with a typewriter feel (only while the field is empty), // so the hero reads like an agent offering to go DO things, not a search box waiting for keywords. +// Seven lines of prompt, then it scrolls: enough to see a whole paragraph without the hero eating the starters below it. +const COMPOSER_MAX_H = 176; + const GHOST_DEFAULTS = [ 'Send an agent to find me something great...', 'Build me a tool I can use right now...', @@ -71,6 +74,7 @@ const DashboardEmptyState: React.FC<{ const userName = useAppSelector((s) => s.settings.data.user_name ?? null); const [text, setText] = React.useState(''); const [launching, setLaunching] = React.useState(false); + const fieldRef = React.useRef(null); const [openCat, setOpenCat] = React.useState(null); const menu = React.useMemo(() => heroMenuFor(personalizedMenu, personalized), [personalizedMenu, personalized]); const firstName = (userName ?? '').trim().split(/\s+/)[0] || null; @@ -80,6 +84,14 @@ const DashboardEmptyState: React.FC<{ [personalized], ); const ghost = useTypedGhost(ghostLines, text.length === 0 && canRun); + // Height follows the value, so a long prompt wraps into view instead of scrolling sideways, and + // clearing on send snaps it back to one line. Past the cap it scrolls, like every other composer. + React.useLayoutEffect(() => { + const el = fieldRef.current; + if (!el) return; + el.style.height = 'auto'; + el.style.height = `${Math.min(el.scrollHeight, COMPOSER_MAX_H)}px`; + }, [text]); const launch = (prompt: string) => { const p = prompt.trim(); @@ -115,7 +127,7 @@ const DashboardEmptyState: React.FC<{ floating chrome (sidebar, pills, chat cards), not a stark white box that fights the canvas. */} ) => setText(e.target.value)} - onKeyDown={(e: React.KeyboardEvent) => { + onChange={(e: React.ChangeEvent) => setText(e.target.value)} + onKeyDown={(e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); launch(text); setText(''); } }} placeholder={ghost || "Ask me anything..."} disabled={launching} sx={{ - flex: 1, border: 'none', outline: 'none', bgcolor: 'transparent', + flex: 1, border: 'none', outline: 'none', bgcolor: 'transparent', resize: 'none', color: 'rgba(255,255,255,0.92)', fontFamily: 'inherit', fontSize: c.font.size.md, + lineHeight: '24px', py: '4px', maxHeight: `${COMPOSER_MAX_H}px`, overflowY: 'auto', '&::placeholder': { color: 'rgba(255,255,255,0.45)' }, }} /> From de926fd16f3287a3a6f5800bcd8bdf6131f20679 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 31 Jul 2026 20:02:50 -0700 Subject: [PATCH 3/8] [eric] agents: a finished run said so by DELETING the word working and turning the cost orange --- .../app/components/overlays/DynamicIsland.tsx | 6 ++-- .../app/pages/Dashboard/cards/AgentCard.tsx | 30 +++++++++++++++---- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/frontend/src/app/components/overlays/DynamicIsland.tsx b/frontend/src/app/components/overlays/DynamicIsland.tsx index 9922f92e..e4d5015b 100644 --- a/frontend/src/app/components/overlays/DynamicIsland.tsx +++ b/frontend/src/app/components/overlays/DynamicIsland.tsx @@ -570,12 +570,14 @@ const CompactPill: React.FC<{ userSelect: 'none', }} > - + {activeCount > 0 + ? + : } 0 ? c.text.tertiary : c.status.success, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index 0e9f9989..b881350a 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -1141,8 +1141,7 @@ const AgentCard: React.FC = ({ )} - {/* Status speaks only when it needs the user; finished work sits quiet. The welcome - chat hides its 'draft' label so the title reads clean. */} + {/* The welcome chat hides its 'draft' label so the title reads clean. */} {session.status !== 'completed' && session.status !== 'stopped' && !session.is_welcome_draft && ( @@ -1150,6 +1149,24 @@ const AgentCard: React.FC = ({ )} + {/* Finishing used to be signalled by the word 'working' DISAPPEARING, which is not a signal. */} + + } + label="Done" + size="small" + sx={{ + bgcolor: c.status.successBg, + color: c.status.success, + border: `1px solid ${c.status.success}33`, + fontWeight: 600, + fontSize: '0.6875rem', + height: 22, + flexShrink: 0, + '& .MuiChip-icon': { ml: '4px' }, + }} + /> + {/* Calm, zero-click signal: the agent recalled or built up memory of this site, so the user feels it getting smarter on its own. */} @@ -1189,9 +1206,12 @@ const AgentCard: React.FC = ({ > {session.cost_usd > 0 && hasApiKey && ( - - ${session.cost_usd.toFixed(4)} - + // Accent orange on a bare number read as a warning; it is just what the run cost. + + + ${session.cost_usd.toFixed(4)} + + )} From 5a66b708b840eee1debb0cb78b6f9ffc18ab3c5f Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 31 Jul 2026 20:05:51 -0700 Subject: [PATCH 4/8] [eric] dock: agent tiles had no accessible name and a browser tile's hover card was an unlabeled screenshot --- .../pages/Dashboard/desktop/DesktopDock.tsx | 3 +++ .../Dashboard/desktop/DockHoverPreview.tsx | 24 +++++++++---------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx b/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx index 35ccfcdd..5b696dc1 100644 --- a/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx +++ b/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx @@ -196,6 +196,9 @@ function DesktopDock({ beginHover(entry, e.currentTarget as HTMLElement)} onClick={() => { endHover(); diff --git a/frontend/src/app/pages/Dashboard/desktop/DockHoverPreview.tsx b/frontend/src/app/pages/Dashboard/desktop/DockHoverPreview.tsx index 0eb31f99..e1c70104 100644 --- a/frontend/src/app/pages/Dashboard/desktop/DockHoverPreview.tsx +++ b/frontend/src/app/pages/Dashboard/desktop/DockHoverPreview.tsx @@ -27,20 +27,18 @@ function DockHoverPreview({ entry, top, image }: DockHoverPreviewProps): React.R pointerEvents: 'none', }} > - {image ? ( - - ) : ( - - - {entry.label} + {image && } + {/* The name rides along even under a live shot: a thumbnail of a page is not its title, and three identical glyphs need one. */} + + + {entry.label} + + {!image && entry.snippet && ( + + {entry.snippet} - {entry.snippet && ( - - {entry.snippet} - - )} - - )} + )} + ); } From 1426f6e409c42b4dd71a6320b12d2980115ecff5 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 31 Jul 2026 20:16:02 -0700 Subject: [PATCH 5/8] [eric] ui: four hand-rolled empty states now use the shared EmptyState, which had zero callers --- .../app/pages/Dashboard/DashboardToolbar.tsx | 7 ++----- .../Dashboard/desktop/ApplicationsWindow.tsx | 19 ++++++++++++++----- .../pages/Skills/CommunitySkillsDialog.tsx | 5 ++--- frontend/src/app/pages/Views/HistoryPanel.tsx | 12 ++++++------ 4 files changed, 24 insertions(+), 19 deletions(-) diff --git a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx index 857314f8..61b45fee 100644 --- a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx +++ b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx @@ -10,6 +10,7 @@ import DesktopSpawnPill from './desktop/DesktopSpawnPill'; import SearchIcon from '@mui/icons-material/Search'; import { motion } from 'framer-motion'; import ChatInput from '@/app/pages/AgentChat/ChatInput'; +import { EmptyState } from '@/app/components/feedback/Loading'; import type { ContextPath } from '@/app/components/editor/DirectoryBrowser'; import SchedulePopover from '@/app/pages/Workflows/SchedulePopover'; import { openWorkflowCard, fetchAllRuns, upsertRun } from '@/shared/state/workflowsSlice'; @@ -537,11 +538,7 @@ const DashboardToolbar = React.forwardRef( }} > {filteredOutputs.length === 0 ? ( - - - {outputList.length === 0 ? 'No apps created yet' : 'No matching apps'} - - + ) : ( filteredOutputs.map((output) => ( {apps.length === 0 && ( - - {Object.keys(outputs).length === 0 - ? 'No apps yet. Ask an agent to build one and it lands here.' - : 'No apps match that search.'} - + // The window is glass over the canvas, so the shared empty state needs dark-surface tokens to be readable. + + {Object.keys(outputs).length === 0 ? ( + } + title="No apps yet" + hint="Ask an agent to build one and it lands here." + /> + ) : ( + + )} + )} {apps.length > 0 && ( diff --git a/frontend/src/app/pages/Skills/CommunitySkillsDialog.tsx b/frontend/src/app/pages/Skills/CommunitySkillsDialog.tsx index 22f6fc49..cb4398a5 100644 --- a/frontend/src/app/pages/Skills/CommunitySkillsDialog.tsx +++ b/frontend/src/app/pages/Skills/CommunitySkillsDialog.tsx @@ -12,6 +12,7 @@ import CircularProgress from '@mui/material/CircularProgress'; import Alert from '@mui/material/Alert'; import InputAdornment from '@mui/material/InputAdornment'; import SearchIcon from '@mui/icons-material/Search'; +import { EmptyState } from '@/app/components/feedback/Loading'; import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import WarningAmberIcon from '@mui/icons-material/WarningAmber'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; @@ -132,9 +133,7 @@ const CommunitySkillsDialog: React.FC = ({ open, onClose, onInstalled }) /> {loading && } {!loading && results.length === 0 && ( - - {query.trim() ? 'No matching skills.' : 'Type to search the community registry.'} - + )} {!loading && results.map((s) => ( = ({ outputId, isAgentActive, saveLabel, onB ) : versions.length === 0 ? ( - - - - No history yet. Every time you change your app, we'll save a snapshot here so you can go back. - - + } + title="No history yet" + hint="Every time you change your app, we'll save a snapshot here so you can go back." + /> ) : ( <> From 392e1f0330834da8b24082ab803244ebccfef9de Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 31 Jul 2026 20:22:12 -0700 Subject: [PATCH 6/8] [eric] dock: split the four fixed action tiles out, DesktopDock had crossed the 300-line cap --- .../pages/Dashboard/desktop/DesktopDock.tsx | 42 +------------ .../Dashboard/desktop/DockActionTiles.tsx | 62 +++++++++++++++++++ 2 files changed, 65 insertions(+), 39 deletions(-) create mode 100644 frontend/src/app/pages/Dashboard/desktop/DockActionTiles.tsx diff --git a/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx b/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx index 5b696dc1..9e466d69 100644 --- a/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx +++ b/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx @@ -1,13 +1,7 @@ import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; import Box from '@mui/material/Box'; -import Tooltip from '@mui/material/Tooltip'; -import LanguageIcon from '@mui/icons-material/Language'; -import EventRepeatIcon from '@mui/icons-material/EventRepeat'; import KeyboardArrowUpRoundedIcon from '@mui/icons-material/KeyboardArrowUpRounded'; import KeyboardArrowDownRoundedIcon from '@mui/icons-material/KeyboardArrowDownRounded'; -import { openSettingsCard, openWorkflowsApp } from '@/shared/state/dashboardLayoutSlice'; -import SettingsIcon from '@mui/icons-material/Settings'; -import AppsRoundedIcon from '@mui/icons-material/AppsRounded'; import { useAppDispatch } from '@/shared/hooks'; import { useClaudeTokens } from '@/shared/styles/ThemeContext'; import { getWebview } from '@/shared/browserRegistry'; @@ -16,6 +10,7 @@ import { openCardContextMenu } from './openCardContextMenu'; import { dockTileMenuRows } from './dockTileMenuRows'; import { useDockLayout } from './useDockLayout'; import { DockTileIcon } from './DockTileIcon'; +import DockActionTiles, { DOCK_ACTION_COUNT } from './DockActionTiles'; import DockHoverPreview from './DockHoverPreview'; import type { AgentSession } from '@/shared/state/agentsSlice'; import type { @@ -39,7 +34,6 @@ interface DesktopDockProps { onAddBrowser: () => void; } -const ACTION_COUNT = 4; const CARET_H = 13; /** Left-edge desktop dock: one tile per open card, hover previews, click focuses the window. */ @@ -69,7 +63,7 @@ function DesktopDock({ const { dockRef, scrollRef, tile, gap, iconSize, scrolls, scrollHeight, bleed, applyMagnify } = useDockLayout({ cardCount: entries.length, - actionCount: ACTION_COUNT, + actionCount: DOCK_ACTION_COUNT, dividerCount: entries.length > 0 ? 2 : 1, }); @@ -240,37 +234,7 @@ function DesktopDock({ {entries.length > 0 && ( )} - {/* The og toolbar's actions, dock-resident: browser, workflow, then settings + apps below their own divider. New-chat lives in the spawn pill, history on the top island. */} - {([ - { label: 'New browser', icon: , act: onAddBrowser }, - { label: 'Workflows', icon: , act: () => dispatch(openWorkflowsApp()) }, - { label: 'Settings', icon: , act: () => dispatch(openSettingsCard()), divider: true }, - { label: 'Applications', icon: , act: onApplications, bg: 'linear-gradient(135deg, #3d3d46, #232329)' }, - ] as { label: string; icon: React.ReactNode; act: () => void; divider?: boolean; bg?: string }[]).map((a) => ( - - {a.divider && } - - - {a.icon} - - - - ))} + {/* Anchored to the root's padding box, whose top edge IS the scroll box's top edge. */} {carets.map((c) => ( diff --git a/frontend/src/app/pages/Dashboard/desktop/DockActionTiles.tsx b/frontend/src/app/pages/Dashboard/desktop/DockActionTiles.tsx new file mode 100644 index 00000000..c553a23d --- /dev/null +++ b/frontend/src/app/pages/Dashboard/desktop/DockActionTiles.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import Box from '@mui/material/Box'; +import Tooltip from '@mui/material/Tooltip'; +import LanguageIcon from '@mui/icons-material/Language'; +import EventRepeatIcon from '@mui/icons-material/EventRepeat'; +import SettingsIcon from '@mui/icons-material/Settings'; +import AppsRoundedIcon from '@mui/icons-material/AppsRounded'; +import { useAppDispatch } from '@/shared/hooks'; +import { openSettingsCard, openWorkflowsApp } from '@/shared/state/dashboardLayoutSlice'; + +// The dock reserves room for these before it knows what they are, so the count lives with the list. +export const DOCK_ACTION_COUNT = 4; + +interface DockActionTilesProps { + tile: number; + onAddBrowser: () => void; + onApplications: () => void; + onHoverAway: () => void; +} + +/** The dock's fixed group: browser, workflows, then settings + applications under their own divider. */ +function DockActionTiles({ tile, onAddBrowser, onApplications, onHoverAway }: DockActionTilesProps): React.ReactElement { + const dispatch = useAppDispatch(); + const actions: { label: string; icon: React.ReactNode; act: () => void; divider?: boolean; bg?: string }[] = [ + { label: 'New browser', icon: , act: onAddBrowser }, + { label: 'Workflows', icon: , act: () => dispatch(openWorkflowsApp()) }, + { label: 'Settings', icon: , act: () => dispatch(openSettingsCard()), divider: true }, + { label: 'Applications', icon: , act: onApplications, bg: 'linear-gradient(135deg, #3d3d46, #232329)' }, + ]; + + return ( + <> + {actions.map((a) => ( + + {a.divider && } + + + {a.icon} + + + + ))} + + ); +} + +export default DockActionTiles; From 888bbb5cf207e5edbc7e212ca2d2e96d7dc2700f Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 31 Jul 2026 20:24:27 -0700 Subject: [PATCH 7/8] [eric] canvas: keep the tidy column helper file-local, nothing outside imports it --- frontend/src/shared/state/dashboardLayoutSlice.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index 85d970d1..f459634c 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -369,7 +369,7 @@ export function findOpenGridCell( // Tidy packs into the grid shape that fills the SCREEN best. The default column count is derived from // window.innerWidth, which is screen pixels pretending to be world units: it laid 8 cards out as a // 2-wide, 4-tall ribbon that the camera then had to pull back to 41% to show. -export function tidyColumnCount(itemSizes: Array<{ w: number; h: number }>): number { +function tidyColumnCount(itemSizes: Array<{ w: number; h: number }>): number { const cellW = DEFAULT_CARD_W + GRID_GAP; const cellH = DEFAULT_CARD_H + GRID_GAP; let cells = 0; From fa62c4c53645603f0a9a9c3380fb9058709cee8f Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 31 Jul 2026 20:28:40 -0700 Subject: [PATCH 8/8] [eric] canvas: Tidy was beheading expanded cards and sliding the first column under the dock --- .../hooks/interaction/useCanvasControls.ts | 10 ++++++---- .../hooks/lifecycle/useDashboardCardActions.ts | 15 +++++++++++---- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts index 5d661e6a..edacea0b 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts @@ -727,10 +727,12 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: floor, ceiling, ); - // Content wider/taller than the viewport only happens when the zoom floor bit; centering then hides both ends, so anchor the start of it instead. - const targetPanX = contentWidth * targetZoom > vRect.width - ? padX - minX * targetZoom - : (vRect.width - contentWidth * targetZoom) / 2 - minX * targetZoom; + // Centering is right until the zoom floor bites and the content outgrows its margins: then the left + // edge slides under the dock (or off screen), so never let it start left of the inset. + const targetPanX = Math.max( + (vRect.width - contentWidth * targetZoom) / 2 - minX * targetZoom, + padX - minX * targetZoom, + ); // A single card normally top-biases (header up top, no dead space below). On creation we want the opposite: the new card dead-centered "in front of you", so `centered` forces true vertical centering. const topBiased = cardRects.length === 1 && !centered; const targetPanY = topBiased || contentHeight * targetZoom > vRect.height diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardCardActions.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardCardActions.ts index 750cd57e..a9482142 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardCardActions.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardCardActions.ts @@ -17,6 +17,9 @@ import type { CardType, useDashboardSelection } from '../state/useDashboardSelec import type { CanvasActions } from '../interaction/useCanvasControls'; import { useSpawnPlacement } from './useSpawnPlacement'; +// Title bubble + cost line, the strip an expanded card floats above itself. +const EXPANDED_HEADER_H = 64; + type Selection = ReturnType; interface UseDashboardCardActionsArgs { @@ -114,10 +117,14 @@ export function useDashboardCardActions({ workflowsMonitorCard: tidiedMonitor, settingsCard: tidiedSettings, } = store.getState().dashboardLayout; const allRects = [ - ...Object.values(tidied).map((c) => ({ - x: c.x, y: c.y, width: c.width, - height: expandedSet.has(c.session_id) ? Math.max(EXPANDED_CARD_MIN_H, c.height) : c.height, - })), + ...Object.values(tidied).map((c) => { + // An expanded card wears its title bubble ABOVE its rect, so the camera has to be told about that strip or Tidy frames the card and beheads it. + const isExpanded = expandedSet.has(c.session_id); + const height = isExpanded ? Math.max(EXPANDED_CARD_MIN_H, c.height) : c.height; + return isExpanded + ? { x: c.x, y: c.y - EXPANDED_HEADER_H, width: c.width, height: height + EXPANDED_HEADER_H } + : { x: c.x, y: c.y, width: c.width, height }; + }), ...Object.values(tidiedViews).map((c) => ({ x: c.x, y: c.y, width: c.width, height: c.height })), ...Object.values(tidiedBrowsers).map((c) => ({ x: c.x, y: c.y, width: c.width, height: c.height })), ...Object.values(tidiedWorkflows).map((c) => ({ x: c.x, y: c.y, width: c.width, height: c.height })),