mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-29 11:19:44 +02:00
[eric] merge eric/quality-fixes: tidy actually tidies, the hero box wraps, finishing looks finished
This commit is contained in:
@@ -570,12 +570,14 @@ const CompactPill: React.FC<{
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<ActivityIndicator c={c} />
|
||||
{activeCount > 0
|
||||
? <ActivityIndicator c={c} />
|
||||
: <CheckIcon sx={{ fontSize: 13, color: c.status.success, flexShrink: 0 }} />}
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.6875rem',
|
||||
fontWeight: 500,
|
||||
color: c.text.tertiary,
|
||||
color: activeCount > 0 ? c.text.tertiary : c.status.success,
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
|
||||
@@ -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<HTMLDivElement, Props>(
|
||||
}}
|
||||
>
|
||||
{filteredOutputs.length === 0 ? (
|
||||
<Box sx={{ px: 2, py: 3, textAlign: 'center' }}>
|
||||
<Typography sx={{ fontSize: '0.8125rem', color: c.text.muted }}>
|
||||
{outputList.length === 0 ? 'No apps created yet' : 'No matching apps'}
|
||||
</Typography>
|
||||
</Box>
|
||||
<EmptyState title={outputList.length === 0 ? 'No apps created yet' : 'No matching apps'} />
|
||||
) : (
|
||||
filteredOutputs.map((output) => (
|
||||
<Box
|
||||
|
||||
@@ -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<HTMLTextAreaElement>(null);
|
||||
const [openCat, setOpenCat] = React.useState<HeroCategoryId | null>(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. */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'center', gap: 1,
|
||||
display: 'flex', alignItems: 'flex-end', gap: 1,
|
||||
background: 'rgba(22,12,34,0.72)',
|
||||
backdropFilter: 'blur(20px) saturate(160%)',
|
||||
WebkitBackdropFilter: 'blur(20px) saturate(160%)',
|
||||
@@ -126,18 +138,21 @@ const DashboardEmptyState: React.FC<{
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="input"
|
||||
component="textarea"
|
||||
ref={fieldRef}
|
||||
rows={1}
|
||||
value={text}
|
||||
autoFocus
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setText(e.target.value)}
|
||||
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setText(e.target.value)}
|
||||
onKeyDown={(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
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)' },
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1141,8 +1141,7 @@ const AgentCard: React.FC<Props> = ({
|
||||
)}
|
||||
</Typewriter>
|
||||
</InlineEditableTitle>
|
||||
{/* 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 && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', flexShrink: 0 }}>
|
||||
<Typography sx={{ fontSize: '0.75rem', fontWeight: 500, color: c.text.tertiary, whiteSpace: 'nowrap' }}>
|
||||
@@ -1150,6 +1149,24 @@ const AgentCard: React.FC<Props> = ({
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{/* Finishing used to be signalled by the word 'working' DISAPPEARING, which is not a signal. */}
|
||||
<Fade in={session.status === 'completed' && !session.is_welcome_draft} timeout={{ enter: 260, exit: 160 }} unmountOnExit>
|
||||
<Chip
|
||||
icon={<CheckIcon sx={{ fontSize: 13, color: `${c.status.success} !important` }} />}
|
||||
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' },
|
||||
}}
|
||||
/>
|
||||
</Fade>
|
||||
{/* Calm, zero-click signal: the agent recalled or built up memory of
|
||||
this site, so the user feels it getting smarter on its own. */}
|
||||
<Fade in={session.memory_recalled || session.memory_learned} timeout={{ enter: 200, exit: 220 }} unmountOnExit>
|
||||
@@ -1189,9 +1206,12 @@ const AgentCard: React.FC<Props> = ({
|
||||
>
|
||||
<Box sx={{ display: 'flex', gap: 1.5, minWidth: 0, overflow: 'hidden' }}>
|
||||
{session.cost_usd > 0 && hasApiKey && (
|
||||
<Typography variant="caption" sx={{ color: c.accent.primary, whiteSpace: 'nowrap' }}>
|
||||
${session.cost_usd.toFixed(4)}
|
||||
</Typography>
|
||||
// Accent orange on a bare number read as a warning; it is just what the run cost.
|
||||
<Tooltip title="What this run has cost so far" placement="bottom-start">
|
||||
<Typography variant="caption" sx={{ color: c.text.tertiary, whiteSpace: 'nowrap' }}>
|
||||
${session.cost_usd.toFixed(4)}
|
||||
</Typography>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -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
|
||||
|
||||
<Box sx={{ overflowY: 'auto', flex: 1, minHeight: 120 }}>
|
||||
{apps.length === 0 && (
|
||||
<Typography sx={{ color: 'rgba(255,255,255,0.55)', fontSize: '0.875rem', textAlign: 'center', py: 5 }}>
|
||||
{Object.keys(outputs).length === 0
|
||||
? 'No apps yet. Ask an agent to build one and it lands here.'
|
||||
: 'No apps match that search.'}
|
||||
</Typography>
|
||||
// The window is glass over the canvas, so the shared empty state needs dark-surface tokens to be readable.
|
||||
<DarkTokensScope>
|
||||
{Object.keys(outputs).length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<GridViewRoundedIcon sx={{ fontSize: 32 }} />}
|
||||
title="No apps yet"
|
||||
hint="Ask an agent to build one and it lands here."
|
||||
/>
|
||||
) : (
|
||||
<EmptyState title="No apps match that search." />
|
||||
)}
|
||||
</DarkTokensScope>
|
||||
)}
|
||||
{apps.length > 0 && (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(78px, 1fr))', gap: 1.5 }}>
|
||||
|
||||
@@ -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({
|
||||
<Box
|
||||
key={entry.id}
|
||||
className="osw-dock-tile"
|
||||
role="button"
|
||||
// The hover card carries the name for the eye; this carries it for everything else (screen readers, tests).
|
||||
aria-label={entry.label}
|
||||
onMouseEnter={(e) => beginHover(entry, e.currentTarget as HTMLElement)}
|
||||
onClick={() => {
|
||||
endHover();
|
||||
@@ -237,37 +234,7 @@ function DesktopDock({
|
||||
{entries.length > 0 && (
|
||||
<Box sx={{ width: tile - 8, height: '1px', background: 'rgba(255,255,255,0.14)' }} />
|
||||
)}
|
||||
{/* 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: <LanguageIcon sx={{ color: '#e8e8ee' }} />, act: onAddBrowser },
|
||||
{ label: 'Workflows', icon: <EventRepeatIcon sx={{ color: '#e8e8ee' }} />, act: () => dispatch(openWorkflowsApp()) },
|
||||
{ label: 'Settings', icon: <SettingsIcon sx={{ color: '#e8e8ee' }} />, act: () => dispatch(openSettingsCard()), divider: true },
|
||||
{ label: 'Applications', icon: <AppsRoundedIcon sx={{ color: '#e8e8ee' }} />, act: onApplications, bg: 'linear-gradient(135deg, #3d3d46, #232329)' },
|
||||
] as { label: string; icon: React.ReactNode; act: () => void; divider?: boolean; bg?: string }[]).map((a) => (
|
||||
<React.Fragment key={a.label}>
|
||||
{a.divider && <Box sx={{ width: tile - 8, height: '1px', background: 'rgba(255,255,255,0.14)' }} />}
|
||||
<Tooltip title={a.label} placement="right">
|
||||
<Box
|
||||
className="osw-dock-tile"
|
||||
onClick={a.act}
|
||||
onMouseEnter={endHover}
|
||||
sx={{
|
||||
width: tile,
|
||||
height: tile,
|
||||
borderRadius: '12px',
|
||||
background: a.bg ?? 'linear-gradient(135deg, #5a5a62, #34343c)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{a.icon}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</React.Fragment>
|
||||
))}
|
||||
<DockActionTiles tile={tile} onAddBrowser={onAddBrowser} onApplications={onApplications} onHoverAway={endHover} />
|
||||
|
||||
{/* Anchored to the root's padding box, whose top edge IS the scroll box's top edge. */}
|
||||
{carets.map((c) => (
|
||||
|
||||
@@ -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: <LanguageIcon sx={{ color: '#e8e8ee' }} />, act: onAddBrowser },
|
||||
{ label: 'Workflows', icon: <EventRepeatIcon sx={{ color: '#e8e8ee' }} />, act: () => dispatch(openWorkflowsApp()) },
|
||||
{ label: 'Settings', icon: <SettingsIcon sx={{ color: '#e8e8ee' }} />, act: () => dispatch(openSettingsCard()), divider: true },
|
||||
{ label: 'Applications', icon: <AppsRoundedIcon sx={{ color: '#e8e8ee' }} />, act: onApplications, bg: 'linear-gradient(135deg, #3d3d46, #232329)' },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{actions.map((a) => (
|
||||
<React.Fragment key={a.label}>
|
||||
{a.divider && <Box sx={{ width: tile - 8, height: '1px', background: 'rgba(255,255,255,0.14)' }} />}
|
||||
<Tooltip title={a.label} placement="right">
|
||||
<Box
|
||||
className="osw-dock-tile"
|
||||
onClick={a.act}
|
||||
onMouseEnter={onHoverAway}
|
||||
sx={{
|
||||
width: tile,
|
||||
height: tile,
|
||||
borderRadius: '12px',
|
||||
background: a.bg ?? 'linear-gradient(135deg, #5a5a62, #34343c)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{a.icon}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default DockActionTiles;
|
||||
@@ -27,20 +27,18 @@ function DockHoverPreview({ entry, top, image }: DockHoverPreviewProps): React.R
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{image ? (
|
||||
<Box component="img" src={image} alt="" sx={{ width: '100%', display: 'block' }} />
|
||||
) : (
|
||||
<Box sx={{ p: 1.25 }}>
|
||||
<Typography sx={{ color: '#fff', fontSize: '0.75rem', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{entry.label}
|
||||
{image && <Box component="img" src={image} alt="" sx={{ width: '100%', display: 'block' }} />}
|
||||
{/* The name rides along even under a live shot: a thumbnail of a page is not its title, and three identical glyphs need one. */}
|
||||
<Box sx={{ p: 1.25, ...(image && { background: 'rgba(22,12,34,0.9)' }) }}>
|
||||
<Typography sx={{ color: '#fff', fontSize: '0.75rem', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{entry.label}
|
||||
</Typography>
|
||||
{!image && entry.snippet && (
|
||||
<Typography sx={{ color: 'rgba(255,255,255,0.6)', fontSize: '0.6875rem', mt: 0.25, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{entry.snippet}
|
||||
</Typography>
|
||||
{entry.snippet && (
|
||||
<Typography sx={{ color: 'rgba(255,255,255,0.6)', fontSize: '0.6875rem', mt: 0.25, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{entry.snippet}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<typeof useDashboardSelection>;
|
||||
|
||||
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<typeof c> => !!c)
|
||||
.map((c) => ({ x: c.x, y: c.y, width: c.width, height: c.height })),
|
||||
];
|
||||
canvasActions.fitToCards(allRects);
|
||||
canvasActions.fitTidy(allRects);
|
||||
}, [dispatch, canvasActions]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -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<Props> = ({ open, onClose, onInstalled })
|
||||
/>
|
||||
{loading && <Box sx={{ display: 'flex', justifyContent: 'center', py: 3 }}><CircularProgress size={22} /></Box>}
|
||||
{!loading && results.length === 0 && (
|
||||
<Typography sx={{ fontSize: '0.8125rem', color: c.text.tertiary, textAlign: 'center', py: 3 }}>
|
||||
{query.trim() ? 'No matching skills.' : 'Type to search the community registry.'}
|
||||
</Typography>
|
||||
<EmptyState title={query.trim() ? 'No matching skills.' : 'Type to search the community registry.'} />
|
||||
)}
|
||||
{!loading && results.map((s) => (
|
||||
<Box key={`${s.source}/${s.skillId}`}
|
||||
|
||||
@@ -8,6 +8,7 @@ import Fade from '@mui/material/Fade';
|
||||
import RestoreIcon from '@mui/icons-material/Restore';
|
||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||
import HistoryIcon from '@mui/icons-material/History';
|
||||
import { EmptyState } from '@/app/components/feedback/Loading';
|
||||
import BookmarkAddOutlinedIcon from '@mui/icons-material/BookmarkAddOutlined';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
@@ -162,12 +163,11 @@ const HistoryPanel: React.FC<Props> = ({ outputId, isAgentActive, saveLabel, onB
|
||||
<CircularProgress size={22} sx={{ color: c.text.tertiary }} />
|
||||
</Box>
|
||||
) : versions.length === 0 ? (
|
||||
<Box sx={{ textAlign: 'center', pt: 6, px: 2 }}>
|
||||
<HistoryIcon sx={{ fontSize: 34, color: c.text.tertiary, opacity: 0.5, mb: 1 }} />
|
||||
<Typography sx={{ fontSize: '0.875rem', color: c.text.muted, lineHeight: 1.5 }}>
|
||||
No history yet. Every time you change your app, we'll save a snapshot here so you can go back.
|
||||
</Typography>
|
||||
</Box>
|
||||
<EmptyState
|
||||
icon={<HistoryIcon sx={{ fontSize: 32 }} />}
|
||||
title="No history yet"
|
||||
hint="Every time you change your app, we'll save a snapshot here so you can go back."
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 1 }}>
|
||||
|
||||
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user