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/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) => (
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)' },
}}
/>
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)}
+
+
)}
diff --git a/frontend/src/app/pages/Dashboard/desktop/ApplicationsWindow.tsx b/frontend/src/app/pages/Dashboard/desktop/ApplicationsWindow.tsx
index ee691fb2..758f29bb 100644
--- a/frontend/src/app/pages/Dashboard/desktop/ApplicationsWindow.tsx
+++ b/frontend/src/app/pages/Dashboard/desktop/ApplicationsWindow.tsx
@@ -2,6 +2,8 @@ import React, { useMemo, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import GridViewRoundedIcon from '@mui/icons-material/GridViewRounded';
+import { EmptyState } from '@/app/components/feedback/Loading';
+import { DarkTokensScope } from '@/shared/styles/ThemeContext';
import type { Output } from '@/shared/state/outputsSlice';
interface ApplicationsWindowProps {
@@ -100,11 +102,18 @@ function ApplicationsWindow({ outputs, onOpenApp, onClose }: ApplicationsWindowP
{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/Dashboard/desktop/DesktopDock.tsx b/frontend/src/app/pages/Dashboard/desktop/DesktopDock.tsx
index 35ccfcdd..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,
});
@@ -196,6 +190,9 @@ function DesktopDock({
beginHover(entry, e.currentTarget as HTMLElement)}
onClick={() => {
endHover();
@@ -237,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;
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}
-
- )}
-
- )}
+ )}
+
);
}
diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts
index b61c88a0..edacea0b 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,16 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
floor,
ceiling,
);
- const targetPanX =
- (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
- ? 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 +751,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 +773,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 +789,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 +854,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..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 {
@@ -111,18 +114,26 @@ 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) => ({
- 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 })),
- ...(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/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."
+ />
) : (
<>
diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts
index b835e99b..f459634c 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.
+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') {