Merge branch 'eric/dev' into eric/redesign

# Conflicts:
#	backend/apps/agents/manager/run/RunOptions.py
#	electron/main.js
#	frontend/src/app/pages/Dashboard/canvas/DashboardCanvas.tsx
#	frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx
#	frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx
#	frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts
This commit is contained in:
ciregenz
2026-07-19 21:40:18 -07:00
99 changed files with 2835 additions and 689 deletions
@@ -272,7 +272,9 @@ const AppShell: React.FC = () => {
dispatch(setPendingBrowserUrl(url));
const lastId = (window as any).__openswarm_last_dashboard_id as string | undefined;
const firstDashboard = dashboardList[0];
const targetId = lastId || firstDashboard?.id;
// Only navigate to lastId if it's a REAL dashboard: a stale localStorage id for a deleted dashboard used to route to /dashboard/<phantom>, which 404s and re-fires the layout wipe (drops your cards / breaks a drag).
const lastIsReal = !!lastId && dashboardList.some((d) => d.id === lastId);
const targetId = (lastIsReal ? lastId : undefined) || firstDashboard?.id;
if (targetId) {
navigate(`/dashboard/${targetId}`);
} else {
@@ -9,6 +9,8 @@ import FileDownloadIcon from '@mui/icons-material/FileDownload';
import { useNavigate } from 'react-router-dom';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { fetchWorkflows } from '@/shared/state/workflowsSlice';
import ImportDigest, { DigestHandle } from './ImportDigest';
import ImportModal from './ImportModal';
@@ -43,6 +45,8 @@ const delay = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
const ImportEntryPoint: React.FC = () => {
const c = useClaudeTokens();
const navigate = useNavigate();
const dispatch = useAppDispatch();
const dashboardId = useAppSelector((s) => s.tempState.lastDashboardId) || undefined;
const inputRef = useRef<HTMLInputElement | null>(null);
const digestRef = useRef<DigestHandle | null>(null);
const depth = useRef(0);
@@ -56,10 +60,12 @@ const ImportEntryPoint: React.FC = () => {
(rootType: string, rootId: string, name: string) => {
const msg = rootType === 'app' ? `Added ${name} to your Apps` : `Added ${name}`;
setToast({ msg, sev: 'success' });
// A workflow has no route of its own, so nothing would pull it in: an open Workflows hub only fetches on mount and would keep showing a stale list. Import drops dashboard_id, and /list keeps unassigned workflows for every dashboard, so this surfaces it wherever the user is.
if (rootType === 'workflow') dispatch(fetchWorkflows(dashboardId));
const to = DEST[rootType]?.(rootId);
if (to) navigate(to);
},
[navigate],
[navigate, dispatch, dashboardId],
);
const commitAndFinish = useCallback(
@@ -55,11 +55,11 @@ const ShareModal: React.FC<Props> = ({ target, open, onClose }) => {
return load();
}, [open, load]);
const handleDownload = async () => {
const handleDownload = async (allowSecrets = false) => {
if (!preflight) return;
setDownloading(true);
try {
await downloadSwarm(target, preflight.filename);
await downloadSwarm(target, preflight.filename, allowSecrets);
setToast(`Saved ${preflight.filename}`);
onClose();
} catch (e: any) {
@@ -68,6 +68,8 @@ const ShareModal: React.FC<Props> = ({ target, open, onClose }) => {
setDownloading(false);
}
};
// The file-content secret heuristic is overridable (download goes to people you trust); our own credential fields ("secret-shaped field(s)") are not.
const secretOverridable = error.includes('secret-shaped value');
const optionRow = (
selected: boolean,
@@ -150,6 +152,16 @@ const ShareModal: React.FC<Props> = ({ target, open, onClose }) => {
<Button size="small" onClick={load} sx={{ textTransform: 'none', color: c.accent.primary }}>
Try again
</Button>
{secretOverridable && (
<Button
size="small"
onClick={() => { setError(''); handleDownload(true); }}
disabled={downloading}
sx={{ textTransform: 'none', color: c.status.error, ml: 1 }}
>
Export anyway (includes the flagged value; only send to people you trust)
</Button>
)}
</Box>
) : preflight ? (
<IncludesList summary={preflight.summary} />
@@ -179,7 +191,7 @@ const ShareModal: React.FC<Props> = ({ target, open, onClose }) => {
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 1 }}>
<Button
variant="contained"
onClick={handleDownload}
onClick={() => handleDownload()}
disabled={!preflight || downloading}
startIcon={
downloading ? (
@@ -28,11 +28,11 @@ export async function exportPreflight(target: ShareTarget): Promise<ExportPrefli
return res.json();
}
export async function downloadSwarm(target: ShareTarget, filename: string): Promise<void> {
export async function downloadSwarm(target: ShareTarget, filename: string, allowSecrets = false): Promise<void> {
const res = await fetch(`${API_BASE}/swarm/export`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: target.kind, id: target.id }),
body: JSON.stringify({ type: target.kind, id: target.id, allow_secrets: allowSecrets }),
});
if (!res.ok) throw new Error(await _detail(res, "We couldn't build the file."));
const blob = await res.blob();
@@ -47,7 +47,7 @@ import { displayChatTitle, isLegacyAutoName } from '@/shared/state/sessionDispla
import { Typewriter } from '@/app/components/feedback/Animated';
import { store } from '@/shared/state/store';
import { fetchModes } from '@/shared/state/modesSlice';
import { createSessionWs, acquireSessionWs, releaseSessionWs } from '@/shared/ws/WebSocketManager';
import { createSessionWs, acquireSessionWs, releaseSessionWs, seedSessionSeq } from '@/shared/ws/WebSocketManager';
import StreamingBubble from './bubbles/StreamingBubble';
import WelcomeQuickReplies from './WelcomeQuickReplies';
import { useWelcomeGreeting } from './useWelcomeGreeting';
@@ -376,7 +376,12 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
dispatch(fetchSession(id));
} else {
try {
await dispatch(fetchSession(id));
const action = await dispatch(fetchSession(id));
// Seed the resume cursor from the snapshot's seq so the connect below doesn't replay the whole ring buffer we just hydrated over REST.
if (fetchSession.fulfilled.match(action)) {
const seq = (action.payload as { event_seq?: number }).event_seq;
if (typeof seq === 'number') seedSessionSeq(id, seq);
}
} catch {
// Even if the REST hydrate fails, still connect, the WS resume protocol can hydrate from buffered events as a fallback.
}
@@ -92,7 +92,7 @@ function modelFamilyKey(label: string): string {
.trim();
}
/** Sort: intelligence desc, family asc, version desc, label asc. */
/** Sort: intelligence desc, version desc (newest first within a tier), family asc, label asc. */
export function sortModelsForPicker<T extends { label: string }>(models: T[]): T[] {
const intelOf = (opt: any): number => {
if (Array.isArray(opt.tiers) && opt.tiers.length === 3) return opt.tiers[0];
@@ -102,12 +102,13 @@ export function sortModelsForPicker<T extends { label: string }>(models: T[]): T
const intelA = intelOf(a);
const intelB = intelOf(b);
if (intelA !== intelB) return intelB - intelA;
const famA = modelFamilyKey(a.label);
const famB = modelFamilyKey(b.label);
if (famA !== famB) return famA.localeCompare(famB);
// Version before family: among models of similar capability, the NEWEST goes on top (Sonnet 5 above Opus 4.6, not buried under the alphabetically-earlier "opus" family).
const verA = modelVersion(a.label);
const verB = modelVersion(b.label);
if (verA !== verB) return verB - verA;
const famA = modelFamilyKey(a.label);
const famB = modelFamilyKey(b.label);
if (famA !== famB) return famA.localeCompare(famB);
return a.label.localeCompare(b.label);
});
}
@@ -13,7 +13,9 @@ export function isInvokeAgentTool(name: string): boolean {
}
export function isCreateAgentTool(name: string): boolean {
return name === 'Agent';
if (name === 'Agent') return true;
const mcp = parseMcpToolName(name);
return mcp.isMcp && mcp.serverSlug === 'openswarm-spawn-agent';
}
export function parseInvokedSessionId(rawText: string): string | null {
@@ -155,7 +155,8 @@ const lightFeedColors: FeedColors = {
// Stable ref keeps shallowEqual happy when there are no browser sessions yet.
const EMPTY_STREAMING: Record<string, StreamingMessage> = Object.freeze({}) as Record<string, StreamingMessage>;
const selectBrowserSessions = createSelector(
// Factory, one selector PER FEED: a module-level createSelector has a cache of 1 shared by every mounted feed, so two feeds with different args thrash it and every render recomputes (and returns a fresh array identity, which defeats all downstream memoization).
const makeSelectBrowserSessions = () => createSelector(
[(state: RootState) => state.agents.sessions,
(_: RootState, parentSessionId: string) => parentSessionId,
(_: RootState, __: string, browserId?: string) => browserId],
@@ -166,6 +167,8 @@ const selectBrowserSessions = createSelector(
s.parent_session_id === parentSessionId &&
(!browserId || s.browser_id === browserId),
),
// Same members = same array identity: ANY session update rebuilds the sessions dict, and without this every unrelated agent:status re-ran formatMessage over the whole feed history.
{ memoizeOptions: { resultEqualityCheck: shallowEqual } },
);
const BrowserAgentInlineFeed: React.FC<Props> = ({ parentSessionId, browserId }) => {
@@ -176,6 +179,7 @@ const BrowserAgentInlineFeed: React.FC<Props> = ({ parentSessionId, browserId })
const scrollRef = useRef<HTMLDivElement>(null);
const fetchedForSession = useRef<string | null>(null);
const selectBrowserSessions = useMemo(makeSelectBrowserSessions, []);
const browserSessions = useAppSelector((state) =>
selectBrowserSessions(state, parentSessionId, browserId),
);
@@ -197,29 +201,37 @@ const BrowserAgentInlineFeed: React.FC<Props> = ({ parentSessionId, browserId })
shallowEqual,
);
// A child that arrived only through the trimmed session-list poll carries its message_count but no messages; fetch the full children so its history renders instead of showing a blank feed. Keyed by the unhydrated-children set (not one-shot per parent) so a NEW child appearing mid-run still hydrates, while the same set never refetches (no loop).
const unhydratedKey = browserSessions.length === 0
? `${parentSessionId}:empty`
: browserSessions.filter((s) => (s.message_count ?? 0) > 0 && s.messages.length === 0).map((s) => s.id).sort().join(',');
useEffect(() => {
if (browserSessions.length === 0 && fetchedForSession.current !== parentSessionId) {
fetchedForSession.current = parentSessionId;
dispatch(fetchBrowserAgentChildren(parentSessionId))
.unwrap()
.catch(() => { fetchedForSession.current = null; });
}
}, [browserSessions.length, parentSessionId, dispatch]);
if (!unhydratedKey.endsWith(':empty') && unhydratedKey === '') return;
if (fetchedForSession.current === unhydratedKey) return;
fetchedForSession.current = unhydratedKey;
dispatch(fetchBrowserAgentChildren(parentSessionId))
.unwrap()
.catch(() => { fetchedForSession.current = null; });
}, [unhydratedKey, parentSessionId, dispatch]);
const sessionsWithEntries = useMemo(() => {
const sessionsWithHistoricalEntries = useMemo(() => {
return browserSessions.map((session) => {
const entries: FeedEntry[] = [];
for (const msg of session.messages) {
const entry = formatMessage(msg);
if (entry) entries.push(entry);
}
const stream: StreamingMessage | undefined = streamingBySession[session.id];
if (stream?.role === 'assistant' && stream.content) {
entries.push({ type: 'thought', text: stream.content });
}
return { session, entries };
});
}, [browserSessions, streamingBySession]);
}, [browserSessions]);
const sessionsWithEntries = sessionsWithHistoricalEntries.map(({ session, entries }) => {
const stream: StreamingMessage | undefined = streamingBySession[session.id];
if (stream?.role === 'assistant' && stream.content) {
return { session, entries: [...entries, { type: 'thought' as const, text: stream.content }] };
}
return { session, entries };
});
const totalMessages = browserSessions.reduce(
(n, s) => n + s.messages.length + (streamingBySession[s.id] ? 1 : 0),
@@ -404,7 +416,8 @@ const BrowserAgentInlineFeed: React.FC<Props> = ({ parentSessionId, browserId })
);
};
const EntryRow: React.FC<{ entry: FeedEntry; accentColor: string; fc: FeedColors }> = ({ entry, accentColor, fc }) => {
// Memoized: the feed re-renders on every streamed token, and un-memoized rows re-render the ENTIRE lazy-loaded history per token (the "browser use = hella lag" bug).
const EntryRow = React.memo<{ entry: FeedEntry; accentColor: string; fc: FeedColors }>(({ entry, accentColor, fc }) => {
const c = useClaudeTokens();
if (entry.type === 'thought') {
@@ -485,7 +498,7 @@ const EntryRow: React.FC<{ entry: FeedEntry; accentColor: string; fc: FeedColors
}
return null;
};
});
const SessionStatusChip: React.FC<{ status: string }> = ({ status }) => {
const c = useClaudeTokens();
@@ -75,7 +75,7 @@ interface DashboardCanvasProps {
onViewportMouseMove: (e: React.MouseEvent) => void;
onViewportMouseUp: (e: React.MouseEvent) => void;
onViewportDoubleClick: (e: React.MouseEvent) => void;
onCardSelect: (id: string, type: CardType, shiftKey: boolean) => void;
onCardSelect: (id: string, type: CardType, shiftKey: boolean, originTarget?: EventTarget | null) => void;
onDragStart: (id: string, type: CardType) => void;
onDragMove: (dx: number, dy: number, mouseX?: number, mouseY?: number) => void;
onDragEnd: (dx: number, dy: number, didDrag: boolean) => void;
@@ -185,6 +185,11 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
return () => window.removeEventListener('keydown', onKey, true);
}, [fullscreenCardId, dispatch]);
// Gestures write the transform imperatively (no React commit per frame), so a foreign render mid-gesture would paint the stale committed transform for a frame. Re-applying live after EVERY render seals that; do not remove.
React.useLayoutEffect(() => {
canvas.actions.syncTransform();
});
return (
<>
<Box sx={{ position: 'relative', height: '100%', overflow: 'hidden' }}>
@@ -311,8 +316,9 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
/>
)}
{/* Dot grid background */}
{/* Dot grid background; gestures move it imperatively via gridRef (phase + scale), commits re-render it here (dot radius included) */}
<Box
ref={canvas.gridRef}
sx={{
position: 'absolute',
inset: 0,
@@ -348,9 +354,6 @@ const DashboardCanvas: React.FC<DashboardCanvasProps> = ({
outputs={outputs}
glowingAgentCards={glowingAgentCards}
expandedSessionIds={expandedSessionIds}
zoom={canvas.zoom}
panX={canvas.panX}
panY={canvas.panY}
cmdHeld={canvas.cmdHeld}
selection={selection}
highlightedCardId={highlightedCardId}
@@ -39,9 +39,6 @@ interface DashboardCardLayerProps {
outputs: Record<string, Output>;
glowingAgentCards: Record<string, GlowingAgentCard>;
expandedSessionIds: string[];
zoom: number;
panX: number;
panY: number;
cmdHeld: boolean;
selection: Selection;
highlightedCardId: string | null;
@@ -54,7 +51,7 @@ interface DashboardCardLayerProps {
revealSpawnedRef: RefObject<Set<string>>;
measuredHeightsRef: RefObject<Record<string, number>>;
getCanvasState: () => { panX: number; panY: number; zoom: number };
onCardSelect: (id: string, type: CardType, shiftKey: boolean) => void;
onCardSelect: (id: string, type: CardType, shiftKey: boolean, originTarget?: EventTarget | null) => void;
onDragStart: (id: string, type: CardType) => void;
onDragMove: (dx: number, dy: number, mouseX?: number, mouseY?: number) => void;
onDragEnd: (dx: number, dy: number, didDrag: boolean) => void;
@@ -76,9 +73,6 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
outputs,
glowingAgentCards,
expandedSessionIds,
zoom,
panX,
panY,
cmdHeld,
selection,
highlightedCardId,
@@ -205,9 +199,7 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
cardWidth={vc.width}
cardHeight={vc.height}
cardZOrder={vc.zOrder ?? 0}
zoom={zoom}
panX={panX}
panY={panY}
getCanvasState={getCanvasState}
cmdHeld={cmdHeld}
isSelected={selection.isSelected(cardKey)}
isHighlighted={highlightedCardId === cardKey}
@@ -234,9 +226,7 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
cardWidth={bc.width}
cardHeight={bc.height}
cardZOrder={bc.zOrder ?? 0}
zoom={zoom}
panX={panX}
panY={panY}
getCanvasState={getCanvasState}
cmdHeld={cmdHeld}
isSelected={selection.isSelected(bc.browser_id)}
isHighlighted={highlightedCardId === bc.browser_id}
@@ -258,9 +248,7 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
cardWidth={n.width}
cardHeight={n.height}
cardZOrder={n.zOrder ?? 0}
zoom={zoom}
panX={panX}
panY={panY}
getCanvasState={getCanvasState}
cmdHeld={cmdHeld}
content={n.content}
color={n.color}
@@ -282,9 +270,7 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
cardWidth={workflowsHub.width}
cardHeight={workflowsHub.height}
cardZOrder={workflowsHub.zOrder ?? 0}
zoom={zoom}
panX={panX}
panY={panY}
getCanvasState={getCanvasState}
isSelected={selection.isSelected('workflows-hub')}
isHighlighted={highlightedCardId === 'workflows-hub'}
multiDragDelta={selection.isSelected('workflows-hub') ? multiDragDelta : null}
@@ -303,9 +289,7 @@ const DashboardCardLayer: React.FC<DashboardCardLayerProps> = ({
cardWidth={monitorCard.width}
cardHeight={monitorCard.height}
cardZOrder={monitorCard.zOrder ?? 0}
zoom={zoom}
panX={panX}
panY={panY}
getCanvasState={getCanvasState}
onDragStart={onDragStart}
onDragMove={onDragMove}
onDragEnd={onDragEnd}
@@ -21,7 +21,8 @@ const TetherLayer: React.FC<TetherLayerProps> = ({ tethers, c }) => {
height: 1,
overflow: 'visible',
pointerEvents: 'none',
zIndex: 10,
// Behind every card (cards use zOrder 1..N as their z-index): connector lines tuck UNDER the cards like a node graph, visible only in the gaps between them. At zIndex 10 the line drew OVER any card with zOrder < 10, so it cut through the chat and the browsers.
zIndex: 0,
}}
>
<defs>
@@ -344,15 +344,20 @@ const AgentCard: React.FC<Props> = ({
return Boolean(sourceWorkflow);
}, [workflowRunsMap, sourceWorkflow, session.id, session.workflow_test_state]);
const hasUserPrompt = useMemo(
() => (session.messages || []).some((m) => m.role === 'user' && !m.hidden),
[session.messages],
() => session.messages.length > 0
? session.messages.some((m) => m.role === 'user' && !m.hidden)
: !!session.first_user_message,
[session.messages, session.first_user_message],
);
const messageCount = session.messages.length > 0
? session.messages.length
: session.message_count ?? 0;
const isConvertBlockedByTurn = session.status !== 'completed' && session.status !== 'stopped';
const showConvertToWorkflow =
!session.is_welcome_draft &&
!isWorkflowRunnerSession &&
hasUserPrompt &&
(session.messages.length >= 2 || isConvertBlockedByTurn || !!workflowSuggestion);
(messageCount >= 2 || isConvertBlockedByTurn || !!workflowSuggestion);
const canConvertToWorkflow = showConvertToWorkflow && !isConvertBlockedByTurn;
// Curated picker label with a tidy fallback for unknowns.
const friendlyModelLabel = useMemo(() => {
@@ -368,7 +373,7 @@ const AgentCard: React.FC<Props> = ({
if (s.includes('/')) s = s.split('/').pop() || s;
return s;
}, [session.model, modelsByProvider]);
const scrollOverlayRef = useOverlayScrollPassthrough(isSelected);
const scrollOverlayRef = useOverlayScrollPassthrough(isSelected && !expanded);
const suggestionPulseRef = useRef('');
const readyPulseRef = useRef('');
@@ -679,7 +684,7 @@ const AgentCard: React.FC<Props> = ({
).slice(0, 120)
: lastMessage && typeof lastMessage.content === 'string'
? lastMessage.content.slice(0, 120)
: '';
: session.last_message_preview ?? '';
const hasPending = session.pending_approvals.length > 0;
const pendingReq = session.pending_approvals[0];
@@ -889,8 +894,8 @@ const AgentCard: React.FC<Props> = ({
/>
))}
{/* Selection overlay , blocks click interaction while selected, enabling drag from anywhere */}
{isSelected && (
{/* Selection overlay , drag-from-anywhere for a COLLAPSED selected card. Never over an expanded chat: it would sit on the composer/transcript so you couldn't type or click (that was "chat opens stuck in drag mode"). Expanded chats drag via the header zone below (zIndex 16). */}
{isSelected && !expanded && (
<Box
ref={scrollOverlayRef}
onPointerDown={handleDragPointerDown}
@@ -50,6 +50,8 @@ import {
registerWebview,
unregisterWebview,
setActiveTab as setRegistryActiveTab,
registerPendingLoad,
wakePendingLoad,
type BrowserWebview,
} from '@/shared/browserRegistry';
import { setLastInteractedBrowser } from '@/shared/browserFocus';
@@ -179,16 +181,14 @@ interface Props {
cardY: number;
cardWidth: number;
cardHeight: number;
zoom?: number;
panX?: number;
panY?: number;
getCanvasState: () => { panX: number; panY: number; zoom: number };
cmdHeld?: boolean;
isSelected?: boolean;
isHighlighted?: boolean;
multiDragDelta?: { dx: number; dy: number } | null;
// Belongs to a non-active dashboard but kept mounted-hidden so its webContents + sessionStorage survive the switch.
keepAliveHidden?: boolean;
onCardSelect?: (id: string, type: 'agent' | 'view' | 'browser', shiftKey: boolean) => void;
onCardSelect?: (id: string, type: 'agent' | 'view' | 'browser', shiftKey: boolean, originTarget?: EventTarget | null) => void;
onDragStart?: (id: string, type: 'agent' | 'view' | 'browser') => void;
onDragMove?: (dx: number, dy: number, mouseX?: number, mouseY?: number) => void;
onDragEnd?: (dx: number, dy: number, didDrag: boolean) => void;
@@ -199,7 +199,7 @@ interface Props {
const BrowserCard: React.FC<Props> = ({
browserId, tabs, activeTabId, cardX, cardY, cardWidth, cardHeight, zoom = 1, panX = 0, panY = 0, cmdHeld = false,
browserId, tabs, activeTabId, cardX, cardY, cardWidth, cardHeight, getCanvasState, cmdHeld = false,
isSelected = false, isHighlighted = false, keepAliveHidden = false, multiDragDelta, onCardSelect, onDragStart, onDragMove, onDragEnd,
cardZOrder = 0, onDoubleClick, onBringToFront,
}) => {
@@ -290,8 +290,14 @@ const BrowserCard: React.FC<Props> = ({
}
}, []);
// Kept current so the mount-time load decision (eager vs deferred) reads the live active tab, not a stale closure (the load effect keys on the tab SET, not activeTabId).
const activeTabIdRef = useRef(activeTabId);
useEffect(() => {
activeTabIdRef.current = activeTabId;
setRegistryActiveTab(browserId, activeTabId);
// Switching to a deferred background tab loads it now; no-op if it already loaded or hasn't reached dom-ready yet (onReady then loads it eagerly because it's the active tab).
const wv = webviewMap.current.get(activeTabId);
if (wv) wakePendingLoad(wv);
}, [browserId, activeTabId]);
// Open the find bar when AppShell routes a Ctrl/Cmd+F to this browser; re-trigger re-focuses the input.
@@ -345,8 +351,15 @@ const BrowserCard: React.FC<Props> = ({
(wv as any).setZoomFactor?.(1);
} catch (_) {}
};
wv.addEventListener('dom-ready', doLoad, { once: true });
cleanups.push(() => wv.removeEventListener('dom-ready', doLoad));
// Lazy tabs: only the VISIBLE tab loads its page on mount. A background tab stays at
// about:blank (deferred) so a many-tab card doesn't load every page at once; it's woken
// the instant it becomes active OR an agent command resolves it (browserRegistry wake).
const onReady = () => {
if (tabId === activeTabIdRef.current) doLoad();
else registerPendingLoad(wv, targetUrl, doLoad);
};
wv.addEventListener('dom-ready', onReady, { once: true });
cleanups.push(() => wv.removeEventListener('dom-ready', onReady));
}
const mirrorUrl = () => dispatch(updateBrowserTabUrl({ browserId, tabId, url: wv.getURL() }));
@@ -427,6 +440,13 @@ const BrowserCard: React.FC<Props> = ({
});
};
// A failed/aborted main-frame load never fires did-stop-loading, and initializedTabs is already set so doLoad won't re-arm: without this the card sits blank with the spinner running forever. errorCode -3 is ERR_ABORTED (a superseded nav), not a failure.
const onDidFailLoad = (e: any) => {
if (!e || e.isMainFrame === false) return;
updateTabLocal(tabId, { loading: false });
if (e.errorCode && e.errorCode !== -3) onProcessGone();
};
const onFaviconUpdate = (e: any) => {
const favicons = e.favicons || (e.detail && e.detail.favicons);
if (favicons?.[0]) {
@@ -451,6 +471,7 @@ const BrowserCard: React.FC<Props> = ({
wv.addEventListener('new-window', onNewWindow as any);
wv.addEventListener('render-process-gone', onProcessGone as any);
wv.addEventListener('crashed', onProcessGone as any);
wv.addEventListener('did-fail-load', onDidFailLoad as any);
cleanups.push(() => {
unregisterWebview(browserId, tabId);
@@ -464,6 +485,7 @@ const BrowserCard: React.FC<Props> = ({
wv.removeEventListener('new-window', onNewWindow as any);
wv.removeEventListener('render-process-gone', onProcessGone as any);
wv.removeEventListener('crashed', onProcessGone as any);
wv.removeEventListener('did-fail-load', onDidFailLoad as any);
const churn = urlChurnThrottle.current.get(tabId);
if (churn?.timer) { clearTimeout(churn.timer); churn.timer = null; }
});
@@ -654,8 +676,9 @@ const BrowserCard: React.FC<Props> = ({
// Screen -> canvas: derive the transform origin from this card's own strip (screenX = originX + canvasX * zoom).
const barRect = tabBarRef.current?.getBoundingClientRect();
if (barRect) {
const dropX = (e.clientX - (barRect.left - cardX * zoomRef.current)) / zoomRef.current - 40;
const dropY = (e.clientY - (barRect.top - cardY * zoomRef.current)) / zoomRef.current - 16;
const z = getCanvasState().zoom;
const dropX = (e.clientX - (barRect.left - cardX * z)) / z - 40;
const dropY = (e.clientY - (barRect.top - cardY * z)) / z - 16;
dispatch(moveBrowserTab({ fromBrowserId: browserId, tabId: drag.tabId, x: dropX, y: dropY }));
}
}
@@ -665,7 +688,7 @@ const BrowserCard: React.FC<Props> = ({
setDragTabOffset(0);
setDetachGhost(null);
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
}, [handleSwitchTab, dispatch, browserId, cardX, cardY]);
}, [handleSwitchTab, dispatch, browserId, cardX, cardY, getCanvasState]);
const DRAG_THRESHOLD = 3;
const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number; startPanX: number; startPanY: number } | null>(null);
@@ -675,22 +698,18 @@ const BrowserCard: React.FC<Props> = ({
const justDraggedRef = useRef(false);
const lastPointerRef = useRef<{ clientX: number; clientY: number }>({ clientX: 0, clientY: 0 });
const panRef = useRef({ panX, panY });
panRef.current = { panX, panY };
const zoomRef = useRef(zoom);
zoomRef.current = zoom;
const handleDragPointerDown = useCallback((e: React.PointerEvent) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
dragState.current = { startX: e.clientX, startY: e.clientY, origX: cardX, origY: cardY, startPanX: panRef.current.panX, startPanY: panRef.current.panY };
const cs = getCanvasState();
dragState.current = { startX: e.clientX, startY: e.clientY, origX: cardX, origY: cardY, startPanX: cs.panX, startPanY: cs.panY };
lastPointerRef.current = { clientX: e.clientX, clientY: e.clientY };
didDrag.current = false;
setIsDragging(true);
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
onDragStart?.(browserId, 'browser');
}, [cardX, cardY, onDragStart, browserId]);
}, [cardX, cardY, onDragStart, browserId, getCanvasState]);
const recomputeDragPos = useCallback(() => {
const ds = dragState.current;
@@ -698,18 +717,25 @@ const BrowserCard: React.FC<Props> = ({
const { clientX, clientY } = lastPointerRef.current;
const rawDx = clientX - ds.startX;
const rawDy = clientY - ds.startY;
const z = zoomRef.current;
const panDx = (panRef.current.panX - ds.startPanX) / z;
const panDy = (panRef.current.panY - ds.startPanY) / z;
const cs = getCanvasState();
const z = cs.zoom;
const panDx = (cs.panX - ds.startPanX) / z;
const panDy = (cs.panY - ds.startPanY) / z;
const dx = rawDx / z - panDx;
const dy = rawDy / z - panDy;
setLocalDragPos({ x: ds.origX + dx, y: ds.origY + dy });
onDragMove?.(dx, dy, clientX, clientY);
}, [onDragMove]);
}, [onDragMove, getCanvasState]);
// Edge-pan/wheel-zoom moves the camera without a React commit; the pan-changed event is the live signal to re-pin the card to the cursor.
useEffect(() => {
if (isDragging && didDrag.current) recomputeDragPos();
}, [panX, panY, isDragging, recomputeDragPos]);
if (!isDragging) return;
const onPanChange = () => {
if (didDrag.current) recomputeDragPos();
};
window.addEventListener('openswarm:canvas-pan-changed', onPanChange);
return () => window.removeEventListener('openswarm:canvas-pan-changed', onPanChange);
}, [isDragging, recomputeDragPos]);
const handleDragPointerMove = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
@@ -723,9 +749,10 @@ const BrowserCard: React.FC<Props> = ({
const handleDragPointerUp = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
const z = zoomRef.current;
const panDx = (panRef.current.panX - dragState.current.startPanX) / z;
const panDy = (panRef.current.panY - dragState.current.startPanY) / z;
const cs = getCanvasState();
const z = cs.zoom;
const panDx = (cs.panX - dragState.current.startPanX) / z;
const panDy = (cs.panY - dragState.current.startPanY) / z;
const dx = (e.clientX - dragState.current.startX) / z - panDx;
const dy = (e.clientY - dragState.current.startY) / z - panDy;
if (didDrag.current) {
@@ -750,7 +777,7 @@ const BrowserCard: React.FC<Props> = ({
setLocalDragPos(null);
setIsDragging(false);
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
}, [dispatch, browserId, onDragEnd]);
}, [dispatch, browserId, onDragEnd, getCanvasState]);
const resizeRef = useRef<{
dir: ResizeDir; startX: number; startY: number;
@@ -778,6 +805,7 @@ const BrowserCard: React.FC<Props> = ({
(e: React.PointerEvent) => {
if (!resizeRef.current) return null;
const { dir, startX, startY, origX, origY, origW, origH } = resizeRef.current;
const zoom = getCanvasState().zoom;
const dx = (e.clientX - startX) / zoom;
const dy = (e.clientY - startY) / zoom;
let newX = origX, newY = origY, newW = origW, newH = origH;
@@ -789,7 +817,7 @@ const BrowserCard: React.FC<Props> = ({
if (newH < MIN_H) { if (dir.includes('n')) newY = origY + origH - MIN_H; newH = MIN_H; }
return { x: newX, y: newY, w: newW, h: newH };
},
[zoom],
[getCanvasState],
);
const handleResizeMove = useCallback(
@@ -820,6 +848,10 @@ const BrowserCard: React.FC<Props> = ({
const displayW = localResize?.w ?? cardWidth;
const displayH = localResize?.h ?? cardHeight;
const noTransition = isDragging || isResizing || (isSelected && !!multiDragDelta);
// During a drag, move the card by a COMPOSITOR transform, not left/top layout: while edge-panning, the canvas transform and the card's left/top update land a frame apart, and the webview's guest surface follows the transform immediately while left/top relayouts late, so the browser visibly shimmers back and forth. A transform for the drag delta rides the same compositor path as the canvas pan, so they move together in one frame.
const dragging = isDragging && !!localDragPos && !localResize;
const dragTx = dragging ? displayX - cardX : 0;
const dragTy = dragging ? displayY - cardY : 0;
const isSecure = activeUrl.startsWith('https://');
const isSearch = isGoogleSearch(activeUrl);
@@ -866,8 +898,8 @@ const BrowserCard: React.FC<Props> = ({
data-keepalive-hidden={keepAliveHidden || isMinimized ? '1' : undefined}
onPointerDownCapture={(e: React.PointerEvent) => {
onBringToFront?.(browserId, 'browser');
// Capture-phase so chrome clicks (tab strip, URL bar) the children swallow still select the card; clicks inside the guest page never reach the host at all. Shift keeps the bubbled toggle path.
if (e.button === 0 && !e.shiftKey) onCardSelect?.(browserId, 'browser', false);
// Capture-phase so chrome clicks (tab strip, URL bar) the children swallow still select the card; clicks inside the guest page never reach the host at all. Shift keeps the bubbled toggle path. Pass the target so URL-bar/tab presses select without yanking the camera.
if (e.button === 0 && !e.shiftKey) onCardSelect?.(browserId, 'browser', false, e.target);
}}
onClick={(e: React.MouseEvent) => {
if (justDraggedRef.current) return;
@@ -885,8 +917,9 @@ const BrowserCard: React.FC<Props> = ({
contain: 'layout style',
// Own compositor layer so hover/paint invalidations stay contained to this card. See AgentCard for full rationale.
willChange: 'transform',
left: keepAliveHidden || isMinimized ? -100000 : displayX,
top: displayY,
left: keepAliveHidden || isMinimized ? -100000 : (dragging ? cardX : displayX),
top: dragging ? cardY : displayY,
transform: dragging ? `translate3d(${dragTx}px, ${dragTy}px, 0)` : undefined,
width: displayW,
height: displayH,
borderRadius: `${c.radius.lg}px`,
@@ -73,9 +73,7 @@ interface Props {
cardY: number;
cardWidth: number;
cardHeight: number;
zoom?: number;
panX?: number;
panY?: number;
getCanvasState: () => { panX: number; panY: number; zoom: number };
cmdHeld?: boolean;
isSelected?: boolean;
isHighlighted?: boolean;
@@ -125,7 +123,7 @@ const BootingBody: React.FC = () => {
};
const DashboardViewCard: React.FC<Props> = ({
output, cardKey: cardKeyProp, instance = 1, cardX, cardY, cardWidth, cardHeight, zoom = 1, panX = 0, panY = 0, cmdHeld = false,
output, cardKey: cardKeyProp, instance = 1, cardX, cardY, cardWidth, cardHeight, getCanvasState, cmdHeld = false,
isSelected = false, isHighlighted = false, multiDragDelta, onCardSelect, onDragStart, onDragMove, onDragEnd,
cardZOrder = 0, onDoubleClick, onBringToFront,
}) => {
@@ -135,10 +133,23 @@ const DashboardViewCard: React.FC<Props> = ({
const scrollOverlayRef = useOverlayScrollPassthrough(isSelected);
const previewRef = useRef<ViewPreviewHandle>(null);
const activeViewCardId = useAppSelector((s) => s.dashboardLayout.activeViewCardId);
// Agent-driving glow, same treatment as browser cards: an AppAgent session carries browser_id "app:<output_id>", which keys glowingBrowserCards.
const appGlow = useAppSelector((s) => s.dashboardLayout.glowingBrowserCards[`app:${cardKeyProp ?? output.id}`]);
const showAgentGlow = !!appGlow && !appGlow.fading;
const interactive = activeViewCardId === cardKey;
const tileZone = useAppSelector((s) => s.dashboardLayout.tiledCards[cardKey]);
const isMinimized = useAppSelector((s) => !!s.dashboardLayout.minimizedCards[cardKey]);
const tiledStyle = useTiledStyle(tileZone, panX, panY, zoom);
// Tiled geometry must track pan/zoom, but the camera lives outside React now; subscribe to the pan event ONLY while tiled and read the live getter.
const [tileTick, setTileTick] = useState(0);
useEffect(() => {
if (!tileZone) return undefined;
const onPan = (): void => setTileTick((t) => t + 1);
window.addEventListener('openswarm:canvas-pan-changed', onPan);
return () => window.removeEventListener('openswarm:canvas-pan-changed', onPan);
}, [tileZone]);
void tileTick;
const cam = getCanvasState();
const tiledStyle = useTiledStyle(tileZone, cam.panX, cam.panY, cam.zoom);
const isFullscreen = tileZone === 'fullscreen';
// Deselecting the card exits interact mode (click anywhere else on canvas).
@@ -228,22 +239,19 @@ const DashboardViewCard: React.FC<Props> = ({
const justDraggedRef = useRef(false);
const lastPointerRef = useRef<{ clientX: number; clientY: number }>({ clientX: 0, clientY: 0 });
const panRef = useRef({ panX, panY });
panRef.current = { panX, panY };
const zoomRef = useRef(zoom);
zoomRef.current = zoom;
const handleDragPointerDown = useCallback((e: React.PointerEvent) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
dragState.current = { startX: e.clientX, startY: e.clientY, origX: cardX, origY: cardY, startPanX: panRef.current.panX, startPanY: panRef.current.panY };
const cs = getCanvasState();
dragState.current = { startX: e.clientX, startY: e.clientY, origX: cardX, origY: cardY, startPanX: cs.panX, startPanY: cs.panY };
lastPointerRef.current = { clientX: e.clientX, clientY: e.clientY };
didDrag.current = false;
setIsDragging(true);
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
onDragStart?.(cardKey, 'view');
}, [cardX, cardY, onDragStart, cardKey]);
}, [cardX, cardY, onDragStart, cardKey, getCanvasState]);
const recomputeDragPos = useCallback(() => {
const ds = dragState.current;
@@ -251,18 +259,25 @@ const DashboardViewCard: React.FC<Props> = ({
const { clientX, clientY } = lastPointerRef.current;
const rawDx = clientX - ds.startX;
const rawDy = clientY - ds.startY;
const z = zoomRef.current;
const panDx = (panRef.current.panX - ds.startPanX) / z;
const panDy = (panRef.current.panY - ds.startPanY) / z;
const cs = getCanvasState();
const z = cs.zoom;
const panDx = (cs.panX - ds.startPanX) / z;
const panDy = (cs.panY - ds.startPanY) / z;
const dx = rawDx / z - panDx;
const dy = rawDy / z - panDy;
setLocalDragPos({ x: ds.origX + dx, y: ds.origY + dy });
onDragMove?.(dx, dy, clientX, clientY);
}, [onDragMove]);
}, [onDragMove, getCanvasState]);
// Edge-pan/wheel-zoom moves the camera without a React commit; the pan-changed event is the live signal to re-pin the card to the cursor.
useEffect(() => {
if (isDragging && didDrag.current) recomputeDragPos();
}, [panX, panY, isDragging, recomputeDragPos]);
if (!isDragging) return;
const onPanChange = () => {
if (didDrag.current) recomputeDragPos();
};
window.addEventListener('openswarm:canvas-pan-changed', onPanChange);
return () => window.removeEventListener('openswarm:canvas-pan-changed', onPanChange);
}, [isDragging, recomputeDragPos]);
const handleDragPointerMove = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
@@ -276,9 +291,10 @@ const DashboardViewCard: React.FC<Props> = ({
const handleDragPointerUp = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
const z = zoomRef.current;
const panDx = (panRef.current.panX - dragState.current.startPanX) / z;
const panDy = (panRef.current.panY - dragState.current.startPanY) / z;
const cs = getCanvasState();
const z = cs.zoom;
const panDx = (cs.panX - dragState.current.startPanX) / z;
const panDy = (cs.panY - dragState.current.startPanY) / z;
const dx = (e.clientX - dragState.current.startX) / z - panDx;
const dy = (e.clientY - dragState.current.startY) / z - panDy;
if (didDrag.current) {
@@ -303,7 +319,7 @@ const DashboardViewCard: React.FC<Props> = ({
setLocalDragPos(null);
setIsDragging(false);
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
}, [dispatch, cardKey, onDragEnd]);
}, [dispatch, cardKey, onDragEnd, getCanvasState]);
const resizeRef = useRef<{
dir: ResizeDir; startX: number; startY: number;
@@ -331,6 +347,7 @@ const DashboardViewCard: React.FC<Props> = ({
(e: React.PointerEvent) => {
if (!resizeRef.current) return null;
const { dir, startX, startY, origX, origY, origW, origH } = resizeRef.current;
const zoom = getCanvasState().zoom;
const dx = (e.clientX - startX) / zoom;
const dy = (e.clientY - startY) / zoom;
let newX = origX, newY = origY, newW = origW, newH = origH;
@@ -342,7 +359,7 @@ const DashboardViewCard: React.FC<Props> = ({
if (newH < MIN_H) { if (dir.includes('n')) newY = origY + origH - MIN_H; newH = MIN_H; }
return { x: newX, y: newY, w: newW, h: newH };
},
[zoom],
[getCanvasState],
);
const handleResizeMove = useCallback(
@@ -419,6 +436,10 @@ const DashboardViewCard: React.FC<Props> = ({
const displayW = localResize?.w ?? cardWidth;
const displayH = localResize?.h ?? cardHeight;
const noTransition = isDragging || isResizing || (isSelected && !!multiDragDelta);
// Drag via a compositor transform, not left/top: an app card's webview surface shimmers back and forth while edge-panning otherwise (the transform and the late left/top relayout desync a frame). Same fix as BrowserCard.
const dragging = isDragging && !!localDragPos && !localResize;
const dragTx = dragging ? displayX - cardX : 0;
const dragTy = dragging ? displayY - cardY : 0;
return (
<Box
@@ -440,31 +461,35 @@ const DashboardViewCard: React.FC<Props> = ({
// contain + willChange: own compositor layer so paint stays scoped (see AgentCard for full rationale).
contain: 'layout style',
willChange: 'transform',
left: tiledStyle ? tiledStyle.left : displayX,
top: tiledStyle ? tiledStyle.top : displayY,
left: tiledStyle ? tiledStyle.left : (dragging ? cardX : displayX),
top: tiledStyle ? tiledStyle.top : (dragging ? cardY : displayY),
width: tiledStyle ? tiledStyle.width : (isMinimized ? 220 : displayW),
height: tiledStyle ? tiledStyle.height : (isMinimized ? 44 : displayH),
transform: tiledStyle ? tiledStyle.transform : undefined,
transform: tiledStyle ? tiledStyle.transform : (dragging ? `translate3d(${dragTx}px, ${dragTy}px, 0)` : undefined),
transformOrigin: tiledStyle ? tiledStyle.transformOrigin : undefined,
borderRadius: isFullscreen ? '12px' : `${c.radius.lg}px`,
border: isHighlighted
? `2px solid ${c.accent.primary}`
: interactive
: showAgentGlow
? `2px solid ${c.accent.primary}`
: isSelected ? '2px solid #3b82f6' : `1px solid ${c.border.medium}`,
: interactive
? `2px solid ${c.accent.primary}`
: isSelected ? '2px solid #3b82f6' : `1px solid ${c.border.medium}`,
bgcolor: c.bg.surface,
boxShadow: isHighlighted
? `0 0 0 3px ${c.accent.primary}50, 0 0 20px ${c.accent.primary}35, 0 0 40px ${c.accent.primary}15`
: isDragging || isResizing
? c.shadow.lg
: isSelected
? `0 0 0 1px #3b82f6, ${c.shadow.md}`
: c.shadow.md,
: showAgentGlow
? `0 0 0 2px ${c.accent.primary}40, 0 0 18px ${c.accent.primary}30, 0 0 40px ${c.accent.primary}15, inset 0 0 30px ${c.accent.primary}25`
: isDragging || isResizing
? c.shadow.lg
: isSelected
? `0 0 0 1px #3b82f6, ${c.shadow.md}`
: c.shadow.md,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
zIndex: tiledStyle ? 999990 : (isDragging || isResizing) ? 999999 : cardZOrder,
transition: noTransition ? 'none' : 'box-shadow 0.2s',
transition: noTransition ? 'none' : 'box-shadow 0.4s ease, border 0.3s ease',
'&:hover .resize-handle': { opacity: 1 },
...(isHighlighted && {
animation: 'card-highlight-pulse 2s ease-out forwards',
@@ -60,9 +60,7 @@ interface Props {
cardY: number;
cardWidth: number;
cardHeight: number;
zoom?: number;
panX?: number;
panY?: number;
getCanvasState: () => { panX: number; panY: number; zoom: number };
cmdHeld?: boolean;
isSelected?: boolean;
isHighlighted?: boolean;
@@ -71,7 +69,7 @@ interface Props {
color: NoteColor;
cardZOrder?: number;
autoFocus?: boolean;
onCardSelect?: (id: string, type: 'agent' | 'view' | 'browser' | 'note', shiftKey: boolean) => void;
onCardSelect?: (id: string, type: 'agent' | 'view' | 'browser' | 'note', shiftKey: boolean, originTarget?: EventTarget | null) => void;
onDragStart?: (id: string, type: 'agent' | 'view' | 'browser' | 'note') => void;
onDragMove?: (dx: number, dy: number, mouseX?: number, mouseY?: number) => void;
onDragEnd?: (dx: number, dy: number, didDrag: boolean) => void;
@@ -79,7 +77,7 @@ interface Props {
}
const NoteCard: React.FC<Props> = ({
noteId, cardX, cardY, cardWidth, cardHeight, zoom = 1, panX = 0, panY = 0,
noteId, cardX, cardY, cardWidth, cardHeight, getCanvasState,
isSelected = false, isHighlighted = false, multiDragDelta, content, color,
cardZOrder = 0, autoFocus, onCardSelect, onDragStart, onDragMove, onDragEnd, onBringToFront,
}) => {
@@ -96,10 +94,6 @@ const NoteCard: React.FC<Props> = ({
const didDrag = useRef(false);
const justDraggedRef = useRef(false);
const lastPointerRef = useRef<{ clientX: number; clientY: number }>({ clientX: 0, clientY: 0 });
const panRef = useRef({ panX, panY });
panRef.current = { panX, panY };
const zoomRef = useRef(zoom);
zoomRef.current = zoom;
const [showColorPicker, setShowColorPicker] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -116,17 +110,18 @@ const NoteCard: React.FC<Props> = ({
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
const cs = getCanvasState();
dragState.current = {
startX: e.clientX, startY: e.clientY,
origX: cardX, origY: cardY,
startPanX: panRef.current.panX, startPanY: panRef.current.panY,
startPanX: cs.panX, startPanY: cs.panY,
};
lastPointerRef.current = { clientX: e.clientX, clientY: e.clientY };
didDrag.current = false;
setIsDragging(true);
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
onDragStart?.(noteId, 'note');
}, [cardX, cardY, noteId, onDragStart]);
}, [cardX, cardY, noteId, onDragStart, getCanvasState]);
const recomputeDragPos = useCallback(() => {
const ds = dragState.current;
@@ -134,18 +129,25 @@ const NoteCard: React.FC<Props> = ({
const { clientX, clientY } = lastPointerRef.current;
const rawDx = clientX - ds.startX;
const rawDy = clientY - ds.startY;
const z = zoomRef.current;
const panDx = (panRef.current.panX - ds.startPanX) / z;
const panDy = (panRef.current.panY - ds.startPanY) / z;
const cs = getCanvasState();
const z = cs.zoom;
const panDx = (cs.panX - ds.startPanX) / z;
const panDy = (cs.panY - ds.startPanY) / z;
const dx = rawDx / z - panDx;
const dy = rawDy / z - panDy;
setLocalDragPos({ x: ds.origX + dx, y: ds.origY + dy });
onDragMove?.(dx, dy, clientX, clientY);
}, [onDragMove]);
}, [onDragMove, getCanvasState]);
// Edge-pan/wheel-zoom moves the camera without a React commit; the pan-changed event is the live signal to re-pin the card to the cursor.
useEffect(() => {
if (isDragging && didDrag.current) recomputeDragPos();
}, [panX, panY, isDragging, recomputeDragPos]);
if (!isDragging) return;
const onPanChange = () => {
if (didDrag.current) recomputeDragPos();
};
window.addEventListener('openswarm:canvas-pan-changed', onPanChange);
return () => window.removeEventListener('openswarm:canvas-pan-changed', onPanChange);
}, [isDragging, recomputeDragPos]);
const handleDragPointerMove = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
@@ -159,9 +161,10 @@ const NoteCard: React.FC<Props> = ({
const handleDragPointerUp = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
const z = zoomRef.current;
const panDx = (panRef.current.panX - dragState.current.startPanX) / z;
const panDy = (panRef.current.panY - dragState.current.startPanY) / z;
const cs = getCanvasState();
const z = cs.zoom;
const panDx = (cs.panX - dragState.current.startPanX) / z;
const panDy = (cs.panY - dragState.current.startPanY) / z;
const dx = (e.clientX - dragState.current.startX) / z - panDx;
const dy = (e.clientY - dragState.current.startY) / z - panDy;
if (didDrag.current) {
@@ -181,7 +184,7 @@ const NoteCard: React.FC<Props> = ({
setLocalDragPos(null);
setIsDragging(false);
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
}, [dispatch, noteId, onDragEnd]);
}, [dispatch, noteId, onDragEnd, getCanvasState]);
const resizeRef = useRef<{
dir: ResizeDir; startX: number; startY: number;
@@ -209,6 +212,7 @@ const NoteCard: React.FC<Props> = ({
(e: React.PointerEvent) => {
if (!resizeRef.current) return null;
const { dir, startX, startY, origX, origY, origW, origH } = resizeRef.current;
const zoom = getCanvasState().zoom;
const dx = (e.clientX - startX) / zoom;
const dy = (e.clientY - startY) / zoom;
let newX = origX, newY = origY, newW = origW, newH = origH;
@@ -220,7 +224,7 @@ const NoteCard: React.FC<Props> = ({
if (newH < MIN_H) { if (dir.includes('n')) newY = origY + origH - MIN_H; newH = MIN_H; }
return { x: newX, y: newY, w: newW, h: newH };
},
[zoom],
[getCanvasState],
);
const handleResizeMove = useCallback(
@@ -255,7 +259,17 @@ const NoteCard: React.FC<Props> = ({
if (zone === 'restore') dispatch(clearTiledCard(noteId));
else dispatch(setTiledCard({ cardId: noteId, zone }));
};
const tiledStyle = useTiledStyle(tileZone, panX, panY, zoom);
// Tiled geometry must track pan/zoom, but the camera lives outside React now; subscribe to the pan event ONLY while tiled and read the live getter.
const [tileTick, setTileTick] = useState(0);
useEffect(() => {
if (!tileZone) return undefined;
const onPan = (): void => setTileTick((t) => t + 1);
window.addEventListener('openswarm:canvas-pan-changed', onPan);
return () => window.removeEventListener('openswarm:canvas-pan-changed', onPan);
}, [tileZone]);
void tileTick;
const cam = getCanvasState();
const tiledStyle = useTiledStyle(tileZone, cam.panX, cam.panY, cam.zoom);
const isFullscreen = tileZone === 'fullscreen';
const mdDx = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dx : 0;
@@ -273,8 +287,8 @@ const NoteCard: React.FC<Props> = ({
data-select-meta={JSON.stringify({ name: 'Note', content: content.slice(0, 60) })}
onPointerDownCapture={(e: React.PointerEvent) => {
onBringToFront?.(noteId, 'note');
// Capture-phase so a click the textarea swallows still selects the note; shift keeps the bubbled toggle path.
if (e.button === 0 && !e.shiftKey) onCardSelect?.(noteId, 'note', false);
// Capture-phase so a click the textarea swallows still selects the note; shift keeps the bubbled toggle path. Pass the target so a textarea press selects without yanking the camera.
if (e.button === 0 && !e.shiftKey) onCardSelect?.(noteId, 'note', false, e.target);
}}
onClick={(e: React.MouseEvent) => {
if (justDraggedRef.current) return;
@@ -258,9 +258,11 @@ export function useTethers({
}
const glowTethers = new Map<string, ReturnType<typeof cardTether>>();
// An "app:<output_id>" glow key targets a VIEW card (AppAgent driving an app); everything else is a browser card.
const glowTarget = (id: string) => (id.startsWith('app:') ? viewCards[id.slice(4)] : browserCards[id]);
for (const [browserId, { sourceId, fading, label }] of Object.entries(glowingBrowserCards)) {
const t = cardTether(
browserCards[browserId],
glowTarget(browserId),
browserId,
sourceId,
`browser-${browserId}`,
@@ -278,7 +280,7 @@ export function useTethers({
// A browser docked below the hub keeps a "Browser" pointer so the link reads at a glance; the right-docked agent/run cases stay label-free (their glow already said it on spawn).
const parent = sessionById.get(s.parent_session_id);
const t = cardTether(
browserCards[s.browser_id],
glowTarget(s.browser_id),
s.browser_id,
s.parent_session_id,
`browser-${s.browser_id}`,
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from 'react';
import { report } from '@/shared/serviceClient';
import { scrollCardContentX } from '@/shared/cardContentScroll';
import { useAppDispatch } from '@/shared/hooks';
import { expandSession } from '@/shared/state/agentsSlice';
import { bringToFront, viewCardKey } from '@/shared/state/dashboardLayoutSlice';
@@ -108,6 +109,8 @@ export function useArrowNav({
focusedCardIdRef.current = focusedCardId;
const canvasZoomRef = useRef(zoom);
canvasZoomRef.current = zoom;
// Set while we're waiting to hear whether the focused card's content absorbed a Left/Right; see the handler for why a held key must not stack these.
const scrollProbeRef = useRef(false);
useEffect(() => {
// Helper: is the currently-focused element a text-entry field the user is actively editing? We only want to suppress dashboard navigation when the user is genuinely typing, not just because an input somewhere happens to have focus from a click long ago.
@@ -124,6 +127,35 @@ export function useArrowNav({
return true;
};
const navigateToNeighbor = (fromCardId: string, direction: Direction) => {
const target = findNearestCard(fromCardId, direction);
if (!target) {
// No card in that direction, shake
if (shakeTimerRef.current) clearTimeout(shakeTimerRef.current);
setShakeDirection(direction);
shakeTimerRef.current = setTimeout(() => {
setShakeDirection(null);
shakeTimerRef.current = null;
}, 400);
return;
}
// Expand + navigate to target + bring to front
report('dashboard', 'arrow_navigated', { direction, from_card: fromCardId, to_card: target.id });
if (target.type === 'agent') {
dispatch(expandSession(target.id));
}
dispatch(bringToFront({ id: target.id, type: target.type }));
setFocusedCardId(target.id);
setTimeout(() => {
const rect = getCardRect(target.id, target.type);
if (rect) canvasActions.fitToCards([rect], 1.15, true);
setTimeout(() => (document.activeElement as HTMLElement)?.blur?.(), 150);
}, 100);
};
const handleKey = (e: KeyboardEvent) => {
if (!isActive) return; // Don't fire shortcuts when dashboard is hidden
@@ -162,32 +194,22 @@ export function useArrowNav({
}
e.preventDefault();
const target = findNearestCard(currentFocused, direction);
if (!target) {
// No card in that direction, shake
if (shakeTimerRef.current) clearTimeout(shakeTimerRef.current);
setShakeDirection(direction);
shakeTimerRef.current = setTimeout(() => {
setShakeDirection(null);
shakeTimerRef.current = null;
}, 400);
// Left/Right belong to the focused card's own content first: while it can still scroll that way it eats the key, and only once it's at its horizontal boundary (or has nothing to scroll sideways) does the arrow go back to meaning card-to-card navigation. Same hand-off the wheel already does in useCanvasControls, so a Sheets card behaves the same under the trackpad and under the keyboard. Up/Down are untouched: most cards scroll vertically, so applying this rule to them would quietly take away vertical nav across the whole canvas.
const fromCardId = currentFocused;
if (direction === 'left' || direction === 'right') {
// A webview card's content lives in another renderer, so the answer can't arrive before this handler returns. Drop repeats while a probe is in flight instead of stacking round-trips: a held key would otherwise queue several, and the ones that land after the card hits its boundary would all navigate.
if (scrollProbeRef.current) return;
scrollProbeRef.current = true;
scrollCardContentX(fromCardId, direction)
.then((scrolled) => {
if (!scrolled) navigateToNeighbor(fromCardId, direction);
})
.finally(() => { scrollProbeRef.current = false; });
return;
}
// Expand + navigate to target + bring to front
report('dashboard', 'arrow_navigated', { direction, from_card: currentFocused, to_card: target.id });
if (target.type === 'agent') {
dispatch(expandSession(target.id));
}
dispatch(bringToFront({ id: target.id, type: target.type }));
setFocusedCardId(target.id);
setTimeout(() => {
const rect = getCardRect(target.id, target.type);
if (rect) canvasActions.fitToCards([rect], 1.15, true);
setTimeout(() => (document.activeElement as HTMLElement)?.blur?.(), 150);
}, 100);
navigateToNeighbor(fromCardId, direction);
};
// Capture phase so we beat MUI Menus/Selects that also listen for arrows. We still bail early on isActivelyEditing, so this doesn't interfere with typing.
@@ -1,6 +1,7 @@
import { useState, useCallback, useRef, useEffect, useMemo, RefObject } from 'react';
import { setCanvasInteractionActive } from '@/shared/canvasInteractionState';
import { getLastInteractedBrowser } from '@/shared/browserFocus';
import { getScrollFocusedCard } from '@/shared/cardScrollFocus';
import { getWebview } from '@/shared/browserRegistry';
import { applyBrowserZoom } from '@/shared/browserZoom';
@@ -9,6 +10,12 @@ const MAX_ZOOM = 3.0;
const ZOOM_IN_FACTOR = 1.1;
const ZOOM_OUT_FACTOR = 1 / ZOOM_IN_FACTOR;
const FIT_PADDING = 200;
// 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.
const FIT_SETTLE_DELAY = FIT_DURATION + 60;
// A mouse notch lands as deltaY 100 where a trackpad sends ~1-10, so cap the per-event zoom delta: uncapped, one notch is a ~24% jump and macOS wheel acceleration stacks them. No-op for trackpads.
const WHEEL_ZOOM_DELTA_CAP = 24;
// Maps the 1 to 100 user setting to an internal multiplier (50 default = 0.004).
function sensitivityToMultiplier(setting: number): number {
@@ -35,6 +42,7 @@ export interface ContentBounds {
export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: ContentBounds, enabled: boolean = true) {
const viewportRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const gridRef = useRef<HTMLDivElement>(null);
const [state, setState] = useState<CanvasState>({ panX: 0, panY: 0, zoom: 1 });
const [isPanning, setIsPanning] = useState(false);
@@ -42,8 +50,9 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
const [cmdHeld, setCmdHeld] = useState(false);
const panStartRef = useRef<{ x: number; y: number; panX: number; panY: number } | null>(null);
// stateRef is the LIVE camera truth (single writer: applyLive / setCanvasState below). React state is a lagging copy committed once per gesture-end, so a 120Hz pan doesn't re-render the card tree per frame. Never sync stateRef FROM state: a render mid-gesture would clobber live with stale.
const stateRef = useRef(state);
stateRef.current = state;
const liveDirtyRef = useRef(false);
const spaceRef = useRef(false);
const cmdRef = useRef(false);
const sensitivityRef = useRef(zoomSensitivity);
@@ -59,6 +68,44 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
const FRICTION = 0.93;
const MIN_VELOCITY = 0.5;
// Paints stateRef onto the DOM: content transform (compositor-only) + dot-grid phase/scale. Also the after-render re-apply, so a foreign React render mid-gesture can't paint the stale committed transform for a frame.
const applyLiveToDom = useCallback(() => {
const { panX, panY, zoom } = stateRef.current;
const content = contentRef.current;
if (content) content.style.transform = `translate(${panX}px, ${panY}px) scale(${zoom})`;
const grid = gridRef.current;
if (grid) {
const spacing = 24 * zoom;
grid.style.backgroundPosition = `${panX % spacing}px ${panY % spacing}px`;
// Dot RADIUS lives in the committed backgroundImage and lags to gesture-end; at 1-4px dots the mid-pinch error is invisible and skipping the per-frame gradient rebuild keeps this handler pure style writes.
grid.style.backgroundSize = `${spacing}px ${spacing}px`;
}
}, []);
// Per-frame camera write during a gesture: DOM + live ref only, NO React commit. Dragging cards re-pin to the cursor off the pan-changed event, same signal the old per-frame commit produced.
const applyLive = useCallback((next: CanvasState) => {
stateRef.current = next;
liveDirtyRef.current = true;
applyLiveToDom();
window.dispatchEvent(new Event('openswarm:canvas-pan-changed'));
}, [applyLiveToDom]);
// Gesture-end: reconcile React (minimap, zoom label, webview suspend) with the live camera in ONE render.
const commitLive = useCallback(() => {
if (!liveDirtyRef.current) return;
liveDirtyRef.current = false;
setState(stateRef.current);
}, []);
// Discrete camera set (minimap jump, fit fallbacks): live + committed in the same call. The ONLY sanctioned writers are this and applyLive; a new pan path calling raw setState reintroduces the camera-snaps-back class.
const setCanvasState = useCallback((updater: CanvasState | ((prev: CanvasState) => CanvasState)) => {
const next = typeof updater === 'function' ? updater(stateRef.current) : updater;
stateRef.current = next;
liveDirtyRef.current = false;
applyLiveToDom();
setState(next);
}, [applyLiveToDom]);
const cancelInertia = useCallback(() => {
if (inertiaFrameRef.current) {
cancelAnimationFrame(inertiaFrameRef.current);
@@ -77,20 +124,18 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
if (Math.abs(velocityX) < MIN_VELOCITY && Math.abs(velocityY) < MIN_VELOCITY) {
inertiaFrameRef.current = null;
commitLive();
springBackIfNeeded();
return;
}
setState((prev) => ({
...prev,
panX: prev.panX + velocityX,
panY: prev.panY + velocityY,
}));
const prev = stateRef.current;
applyLive({ ...prev, panX: prev.panX + velocityX, panY: prev.panY + velocityY });
inertiaFrameRef.current = requestAnimationFrame(step);
};
inertiaFrameRef.current = requestAnimationFrame(step);
}, [cancelInertia]);
}, [cancelInertia, applyLive, commitLive]);
// ---- Soft pan boundaries: spring back if viewport drifts too far from content ----
const BOUNDARY_MARGIN = 800; // extra px beyond content bounds before spring-back
@@ -157,7 +202,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
const step = (now: number) => {
const t = Math.min((now - startTime) / duration, 1);
const ease = 1 - Math.pow(1 - t, 3); // cubic ease-out
setState({
applyLive({
panX: start.panX + (target.panX - start.panX) * ease,
panY: start.panY + (target.panY - start.panY) * ease,
zoom: start.zoom + (target.zoom - start.zoom) * ease,
@@ -166,14 +211,15 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
animFrameRef.current = requestAnimationFrame(step);
} else {
animFrameRef.current = null;
commitLive();
}
};
animFrameRef.current = requestAnimationFrame(step);
}, [cancelAnimation]);
}, [cancelAnimation, applyLive, commitLive]);
animateToRef.current = animateTo;
// Wheel zoom centered on cursor
// Plain wheel zooms at the viewport center; cmd/ctrl+wheel pans vertically; trackpad pinch zooms at the cursor.
useEffect(() => {
const el = viewportRef.current;
if (!el || !enabled) return; // Skip wheel listener when canvas is hidden
@@ -194,23 +240,19 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
pendingPanDx = 0; pendingPanDy = 0;
pendingZoomDy = 0; pendingZoomCenter = null;
const prev = stateRef.current;
if (zCenter && zDy !== 0) {
setState((prev) => {
const factor = Math.pow(2, -zDy * sensitivityToMultiplier(sensitivityRef.current));
const newZoom = clamp(prev.zoom * factor, MIN_ZOOM, MAX_ZOOM);
const ratio = newZoom / prev.zoom;
return {
panX: zCenter.cx - (zCenter.cx - prev.panX) * ratio,
panY: zCenter.cy - (zCenter.cy - prev.panY) * ratio,
zoom: newZoom,
};
const factor = Math.pow(2, -zDy * sensitivityToMultiplier(sensitivityRef.current));
const newZoom = clamp(prev.zoom * factor, MIN_ZOOM, MAX_ZOOM);
const ratio = newZoom / prev.zoom;
// Apply any pan accumulated in the same frame too: a zoom and a pan can now land together (vertical zoom + horizontal pan across a RAF boundary, or a forwarded pan), and dropping it would swallow the gesture.
applyLive({
panX: zCenter.cx - (zCenter.cx - prev.panX) * ratio - dx,
panY: zCenter.cy - (zCenter.cy - prev.panY) * ratio - dy,
zoom: newZoom,
});
} else if (dx !== 0 || dy !== 0) {
setState((prev) => ({
...prev,
panX: prev.panX - dx,
panY: prev.panY - dy,
}));
applyLive({ ...prev, panX: prev.panX - dx, panY: prev.panY - dy });
}
};
@@ -221,6 +263,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
wheelIdleTimer = setTimeout(() => {
wheelIdleTimer = null;
setCanvasInteractionActive(false);
commitLive();
}, 140);
if (wheelRafId != null) return;
wheelRafId = requestAnimationFrame(flushWheel);
@@ -230,8 +273,8 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
const scrollableCache: WeakMap<HTMLElement, 'scrollable' | 'not'> = new WeakMap();
const onWheel = (e: WheelEvent) => {
// Pinch-to-zoom on trackpads sets ctrlKey; plain scroll does not
const isPinchZoom = e.ctrlKey || e.metaKey;
// ctrl/cmd wheel is a modifier gesture: a real held key (cmd/ctrl + scroll → vertical pan) or a trackpad pinch, which also sets ctrlKey (→ zoom at cursor). Either way it bypasses scrollable children and acts on the canvas.
const isModifierWheel = e.ctrlKey || e.metaKey;
// Let scrollable children handle the event when appropriate, but fall through to canvas pan if the child is at its scroll boundary.
const dy = e.deltaMode === 1 ? e.deltaY * 40 : e.deltaY;
@@ -256,7 +299,14 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
scrollableCache.set(target, cls);
}
if (cls === 'scrollable' && !isPinchZoom) {
if (cls === 'scrollable' && !isModifierWheel) {
// Google Maps model: plain scroll zooms the canvas over a CARD (chat, scheduled task) UNLESS you've clicked INTO it. Only a card that isn't scroll-focused diverts to zoom; non-card scrollable UI (dropdowns, menus, nested panels) always scrolls natively, and a focused card scrolls its content.
const cardEl = target.closest('[data-select-id]');
const cardId = cardEl?.getAttribute('data-select-id') ?? null;
if (cardId && cardId !== getScrollFocusedCard()) {
target = target.parentElement;
continue;
}
// Re-read scrollHeight/clientHeight; cached decision is structural, scroll position is dynamic.
const canScrollY = target.scrollHeight > target.clientHeight;
const canScrollX = target.scrollWidth > target.clientWidth;
@@ -289,16 +339,25 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
inertiaFrameRef.current = null;
}
if (isPinchZoom) {
// Pinch gesture → accumulate zoom deltas + last cursor position. factor = 2^(-Σdy·s) which equals the product of per-event factors, so accumulating dy is mathematically identical to applying each event one at a time.
if (isModifierWheel && cmdRef.current) {
// Real cmd/ctrl physically held + scroll → vertical pan. cmdRef is set from a keydown; a trackpad pinch sets ctrlKey with no keydown, so it falls through to the zoom branch below and pinch-to-zoom survives.
pendingPanDy += dy;
scheduleWheelFlush();
} else if (isModifierWheel) {
// Trackpad pinch → accumulate zoom deltas + last cursor position. factor = 2^(-Σdy·s) which equals the product of per-event factors, so accumulating dy is mathematically identical to applying each event one at a time.
const rect = el.getBoundingClientRect();
pendingZoomDy += dy;
pendingZoomCenter = { cx: e.clientX - rect.left, cy: e.clientY - rect.top };
scheduleWheelFlush();
} else {
// Two-finger scroll → accumulate pan deltas.
} else if (Math.abs(dx) > Math.abs(dy)) {
// Horizontal-dominant scroll → pan X; it's the only horizontal-pan gesture. Dominant-axis, so the vertical jitter in a sideways swipe doesn't also zoom.
pendingPanDx += dx;
pendingPanDy += dy;
scheduleWheelFlush();
} else {
// Plain vertical scroll → zoom at the cursor (same anchor as pinch) so the point under the pointer grows toward you, not away. Clamp the per-event delta so a discrete mouse notch is a small step, not a lurch.
const rect = el.getBoundingClientRect();
pendingZoomDy += clamp(dy, -WHEEL_ZOOM_DELTA_CAP, WHEEL_ZOOM_DELTA_CAP);
pendingZoomCenter = { cx: e.clientX - rect.left, cy: e.clientY - rect.top };
scheduleWheelFlush();
}
};
@@ -346,7 +405,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
// Don't leave the flag stuck on if the canvas unmounts mid-gesture.
setCanvasInteractionActive(false);
};
}, [enabled]);
}, [enabled, applyLive, commitLive]);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
@@ -371,12 +430,12 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
const start = panStartRef.current;
const latest = latestDragRef.current;
if (!start || !latest) return;
setState((prev) => ({
...prev,
applyLive({
...stateRef.current,
panX: start.panX + latest.dx,
panY: start.panY + latest.dy,
}));
}, []);
});
}, [applyLive]);
const handleMouseMove = useCallback((e: React.MouseEvent) => {
const start = panStartRef.current;
@@ -427,11 +486,13 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
panStartRef.current = null;
setIsPanning(false);
setCanvasInteractionActive(false);
// Inertia keeps writing live and commits when it settles; otherwise this gesture ends here.
if (!didInertia) commitLive();
// Only spring back if we were actually panning (not on simple clicks)
if (wasPanning && !didInertia) {
springBackIfNeeded();
}
}, [startInertia, springBackIfNeeded]);
}, [startInertia, springBackIfNeeded, commitLive]);
// Clean up panning if mouse leaves the window
useEffect(() => {
@@ -440,11 +501,12 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
panStartRef.current = null;
setIsPanning(false);
setCanvasInteractionActive(false);
commitLive();
}
};
window.addEventListener('mouseup', onUp);
return () => window.removeEventListener('mouseup', onUp);
}, []);
}, [commitLive]);
useEffect(() => {
return () => { cancelAnimation(); cancelInertia(); };
@@ -641,7 +703,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
if (!target) {
// Keep current camera; snapping to (0,0,1) used to desync the minimap.
if (cardRects.length === 0 || !viewportRef.current) {
setState({ panX: 0, panY: 0, zoom: 1 });
setCanvasState({ panX: 0, panY: 0, zoom: 1 });
}
return;
}
@@ -651,7 +713,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
const dPan = Math.abs(cur.panX - target.panX) + Math.abs(cur.panY - target.panY);
const dZoom = Math.abs(cur.zoom - target.zoom);
if (dPan < 5 && dZoom < 0.01) return;
animateTo(target);
animateTo(target, FIT_DURATION);
// 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;
@@ -662,13 +724,53 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
Math.abs(cur2.panX - fresh.panX) +
Math.abs(cur2.panY - fresh.panY) +
Math.abs(cur2.zoom - fresh.zoom) * 1000;
if (drift > 8) setState(fresh);
}, 370);
if (drift > 8) setCanvasState(fresh);
}, FIT_SETTLE_DELAY);
} else {
setState(target);
setCanvasState(target);
}
},
[cancelAnimation, animateTo, computeFitTarget],
[cancelAnimation, animateTo, computeFitTarget, setCanvasState],
);
// 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 }>) => {
const viewport = viewportRef.current;
if (!viewport || cardRects.length === 0) return;
const v = viewport.getBoundingClientRect();
if (v.width <= 0 || v.height <= 0) return;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const r of cardRects) {
minX = Math.min(minX, r.x);
minY = Math.min(minY, r.y);
maxX = Math.max(maxX, r.x + r.width);
maxY = Math.max(maxY, r.y + r.height);
}
if (!isFinite(minX)) return;
const REVEAL_MARGIN = 48;
const cur = stateRef.current;
const fitZoom = Math.min(
(v.width - REVEAL_MARGIN * 2) / (maxX - minX),
(v.height - REVEAL_MARGIN * 2) / (maxY - minY),
);
const zoom = clamp(Math.min(cur.zoom, fitZoom), MIN_ZOOM, MAX_ZOOM);
// If zooming out, keep the viewport-center world point fixed first, then clamp.
const ratio = zoom / cur.zoom;
let panX = v.width / 2 - (v.width / 2 - cur.panX) * ratio;
let panY = v.height / 2 - (v.height / 2 - cur.panY) * ratio;
const left = minX * zoom + panX, right = maxX * zoom + panX;
if (left < REVEAL_MARGIN) panX += REVEAL_MARGIN - left;
else if (right > v.width - REVEAL_MARGIN) panX -= right - (v.width - REVEAL_MARGIN);
const top = minY * zoom + panY, bottom = maxY * zoom + panY;
if (top < REVEAL_MARGIN) panY += REVEAL_MARGIN - top;
else if (bottom > v.height - REVEAL_MARGIN) panY -= bottom - (v.height - REVEAL_MARGIN);
const cur2 = stateRef.current;
if (Math.abs(panX - cur2.panX) < 2 && Math.abs(panY - cur2.panY) < 2 && Math.abs(zoom - cur2.zoom) < 0.005) return;
cancelAnimation();
animateTo({ panX, panY, zoom }, FIT_DURATION);
},
[cancelAnimation, animateTo],
);
const handlers = useMemo(() => ({
@@ -677,9 +779,18 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
onMouseUp: handleMouseUp,
}), [handleMouseDown, handleMouseMove, handleMouseUp]);
// Per-frame pan for edge-pan-during-card-drag: live-only, the caller commits when the drag ends.
const panBy = useCallback((dx: number, dy: number) => {
const prev = stateRef.current;
applyLive({ ...prev, panX: prev.panX + dx, panY: prev.panY + dy });
}, [applyLive]);
const getLiveState = useCallback((): CanvasState => stateRef.current, []);
const actions = useMemo(() => ({
zoomIn, zoomOut, resetZoom, fitToView, fitToCards, animateTo, cancelAnimation, setState,
}), [zoomIn, zoomOut, resetZoom, fitToView, fitToCards, animateTo, cancelAnimation]);
zoomIn, zoomOut, resetZoom, fitToView, fitToCards, revealCards, animateTo, cancelAnimation,
setState: setCanvasState, panBy, commit: commitLive, syncTransform: applyLiveToDom, getLiveState,
}), [zoomIn, zoomOut, resetZoom, fitToView, fitToCards, revealCards, animateTo, cancelAnimation, setCanvasState, panBy, commitLive, applyLiveToDom, getLiveState]);
return {
...state,
@@ -688,6 +799,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?:
cmdHeld,
viewportRef,
contentRef,
gridRef,
handlers,
actions,
} as const;
@@ -8,9 +8,6 @@ import type { CanvasActions } from './useCanvasControls';
type Selection = ReturnType<typeof useDashboardSelection>;
interface UseCardDragArgs {
panX: number;
panY: number;
zoom: number;
viewportRef: RefObject<HTMLDivElement | null>;
canvasActions: CanvasActions;
selection: Selection;
@@ -27,20 +24,12 @@ function axisIntensity(pos: number, lo: number, hi: number): number {
}
export function useCardDrag({
panX,
panY,
zoom,
viewportRef,
canvasActions,
selection,
}: UseCardDragArgs) {
const dispatch = useAppDispatch();
// Notify the currently dragging card (if any) that pan/zoom changed so it can re-pin to the cursor. useEffect rather than render-body dispatchEvent: side effects during render are a React anti-pattern and can fire twice in strict mode. Effect runs after commit, so exactly once per real pan/zoom delta. Edge-pan mutates pan via canvasActions.setState below, so the dispatch lives in the same hook.
useEffect(() => {
window.dispatchEvent(new Event('openswarm:canvas-pan-changed'));
}, [panX, panY, zoom]);
const [multiDragDelta, setMultiDragDelta] = useState<{ dx: number; dy: number } | null>(null);
const [liveDragInfo, setLiveDragInfo] = useState<{ cardId: string; dx: number; dy: number } | null>(null);
const activeDragCardRef = useRef<string | null>(null);
@@ -69,22 +58,20 @@ export function useCardDrag({
const dy = EDGE_MAX_SPEED * axisIntensity(my, rect.top, rect.bottom);
if (dx !== 0 || dy !== 0) {
canvasActions.setState((prev: { panX: number; panY: number; zoom: number }) => ({
...prev,
panX: prev.panX + dx,
panY: prev.panY + dy,
}));
// Live-only write (no React commit per frame); clearDrag commits once when the drag ends.
canvasActions.panBy(dx, dy);
}
edgePanFrameRef.current = requestAnimationFrame(tickEdgePan);
}, [viewportRef, canvasActions]);
const handleCardDragStart = useCallback((id: string, _type: CardType) => {
const handleCardDragStart = useCallback((id: string, type: CardType) => {
activeDragCardRef.current = id;
if (selection.isSelected(id)) {
isMultiDragRef.current = true;
} else {
selection.deselectAll();
// Grabbing an unselected card SELECTS just it (was deselectAll, which left nothing selected, so the next spawn had no anchor and flew to viewport-center far from the card you just moved). Also survives the stale-read where the capture-phase click already selected it.
selection.selectCard(id, type, false);
isMultiDragRef.current = false;
}
}, [selection]);
@@ -93,6 +80,8 @@ export function useCardDrag({
if (mouseX !== undefined && mouseY !== undefined) {
lastMousePosRef.current = { x: mouseX, y: mouseY };
}
// Arm the webview shield on the first real MOVE, not on pointerdown: a plain click also arms the drag machinery, and shielding then made the click-to-focus camera fit skip (it saw a "drag in progress"), so focusing a card took two clicks. On a real drag the shield still goes up before the pointer travels, so the webview neutralization + no-nudge + release-over-webview fixes all hold. Idempotent add.
document.body.classList.add('dashboard-marquee-active');
// Start edge panning only once actual dragging begins; a live frame handle means the loop is already running.
if (edgePanFrameRef.current === null) {
edgePanFrameRef.current = requestAnimationFrame(tickEdgePan);
@@ -107,11 +96,14 @@ export function useCardDrag({
const clearDrag = useCallback(() => {
stopEdgePan();
// Reconcile React with whatever edge-pan wrote live during the drag.
canvasActions.commit();
activeDragCardRef.current = null;
document.body.classList.remove('dashboard-marquee-active');
isMultiDragRef.current = false;
setMultiDragDelta(null);
setLiveDragInfo(null);
}, [stopEdgePan]);
}, [stopEdgePan, canvasActions]);
const handleCardDragEnd = useCallback((dx: number, dy: number, didDrag: boolean) => {
if (didDrag) report('dashboard', 'card_dragged');
@@ -51,6 +51,9 @@ export function useDashboardClipboard({
if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== 'c') return;
const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable) return;
// Highlighted text owns Cmd+C (OS semantics). Without this, clicking a chat selects the CARD, so copying a highlighted message overwrote the clipboard with the card's NAME (the "I copied text but pasted the chat title" bug).
const textSel = window.getSelection();
if (textSel && !textSel.isCollapsed && textSel.toString().trim()) return;
if (selection.selectedIds.size === 0) return;
e.preventDefault();
@@ -1,8 +1,10 @@
import React, { useCallback, useEffect, useRef, type Dispatch, type SetStateAction } from 'react';
import { report } from '@/shared/serviceClient';
import { useAppDispatch } from '@/shared/hooks';
import { store } from '@/shared/state/store';
import { collapseSession, expandSession } from '@/shared/state/agentsSlice';
import { bringToFront } from '@/shared/state/dashboardLayoutSlice';
import { setScrollFocusedCard } from '@/shared/cardScrollFocus';
import type { CardType, useDashboardSelection } from '../state/useDashboardSelection';
import type { useCanvasControls } from './useCanvasControls';
@@ -20,6 +22,19 @@ function isCardTarget(target: EventTarget | null, boundary: EventTarget | null):
return false;
}
const CONTROL_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON', 'A', 'WEBVIEW']);
// True when the press landed on a real control (text field, button, browser URL bar/tabs, note textarea, webview) rather than the card's frame. Walk up ONLY to the card root so a button living above the card never counts.
function pressLandedOnControl(target: EventTarget | null | undefined): boolean {
let el = target as HTMLElement | null;
while (el) {
if (el.hasAttribute(SELECT_ATTR)) return false;
if (CONTROL_TAGS.has(el.tagName) || el.isContentEditable || el.getAttribute('role') === 'button') return true;
el = el.parentElement;
}
return false;
}
interface UseDashboardInteractionsArgs {
canvas: Canvas;
selection: Selection;
@@ -42,7 +57,7 @@ export function useDashboardInteractions({
// Delay single-click collapse so double-click can override
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleCardSelect = useCallback((id: string, type: CardType, shiftKey: boolean) => {
const handleCardSelect = useCallback((id: string, type: CardType, shiftKey: boolean, originTarget?: EventTarget | null) => {
report('dashboard', 'card_clicked', { card_type: type, shift: shiftKey });
if (shiftKey) {
selection.selectCard(id, type, true);
@@ -52,6 +67,9 @@ export function useDashboardInteractions({
selection.selectCard(id, type, false);
dispatch(bringToFront({ id, type }));
// Clicking a control INSIDE a card (text field, button, browser URL bar/tabs, note textarea) selects + raises it but must NOT re-center the camera onto it: yanking focus to a card just to click into its input is hostile (same reasoning as the guest-page and Workflows carve-outs). Card frame/body clicks still auto-focus.
if (pressLandedOnControl(originTarget)) return;
// The Workflows window is an app you click around inside, not a card you re-center every tap. Single-click only raises + selects it; double-click still zoom-to-fits (handleCardDoubleClick). Without this, clicking any button inside it yanked the canvas into a re-zoom.
if (type === 'workflows-hub' || type === 'workflows-monitor') return;
@@ -72,6 +90,8 @@ export function useDashboardInteractions({
}
setFocusedCardId(id);
setTimeout(() => {
// The capture-phase select fires this on pointer DOWN; if the press became a drag (or marquee), re-framing the camera mid-gesture is the "canvas yanks as I start dragging" nudge. The webview shield class is up for exactly that window.
if (document.body.classList.contains('dashboard-marquee-active')) return;
const rect = getCardRect(id, type);
if (rect) canvas.actions.fitToCards([rect], 1.15, true, type === 'browser' ? 0.8 : undefined);
setTimeout(() => {
@@ -87,6 +107,8 @@ export function useDashboardInteractions({
const handleBringToFront = useCallback((id: string, type: CardType) => {
dispatch(bringToFront({ id, type }));
// Pressing ANY part of a card (header, body, composer) focuses it for scrolling, so its content scrolls instead of the canvas zooming (Google Maps model). Fires via onPointerDownCapture on every card, so a click into a chat's composer focuses it even though the body swallows the bubble. Cleared on blank-canvas press.
setScrollFocusedCard(id);
}, [dispatch]);
// A click INSIDE a webview's page never reaches the host DOM; BrowserCard forwards the guest's app-clicked IPC as this event. Select + raise only, no camera fit: you're clicking around inside the page, re-framing the canvas every tap would be hostile (same carve-out as the Workflows window).
@@ -94,8 +116,20 @@ export function useDashboardInteractions({
const onGuestSelect = (e: Event) => {
const browserId = (e as CustomEvent).detail?.browserId;
if (typeof browserId !== 'string' || !browserId) return;
// Mid-drag/marquee a selection change joins the card to the multi-drag (the browser visibly chased the cursor); the shield class is up for exactly that window.
if (document.body.classList.contains('dashboard-marquee-active')) return;
// The guest preload fires app-clicked for the AGENT's clicks too; a working agent driving its own page must not steal selection (it also re-anchored spawn-beside onto its browser).
const st = store.getState();
const working = (s?: { status?: string }) => !!s && (s.status === 'running' || s.status === 'waiting_approval');
const glow = st.dashboardLayout.glowingBrowserCards[browserId];
const agentDriven =
Object.values(st.agents.sessions).some((s) => s.browser_id === browserId && working(s)) ||
(!!glow && !glow.fading && working(st.agents.sessions[glow.sourceId]));
if (agentDriven) return;
selection.selectCard(browserId, 'browser', false);
dispatch(bringToFront({ id: browserId, type: 'browser' }));
// In-guest clicks never reach the host capture handler, so mark the browser focused here, mainly to UN-focus any chat so scroll over other cards behaves right (the browser's own page scroll/zoom is native regardless).
setScrollFocusedCard(browserId);
};
window.addEventListener('openswarm:browser-guest-select', onGuestSelect);
return () => window.removeEventListener('openswarm:browser-guest-select', onGuestSelect);
@@ -117,6 +151,9 @@ export function useDashboardInteractions({
if (e.button !== 0) return;
if (isCardTarget(e.target, e.currentTarget)) return;
// Clicking blank canvas leaves every card: plain scroll zooms the canvas again (Google Maps model).
setScrollFocusedCard(null);
// Canvas click, drop any lingering input focus so arrow-key nav works immediately without the user having to press Escape first.
const active = document.activeElement as HTMLElement | null;
const activeTag = active?.tagName;
@@ -198,7 +198,7 @@ export function useAgentSpawn({
if (bc) rects.push({ x: bc.x, y: bc.y, width: bc.width, height: bc.height });
}
}
canvasActions.fitToCards(rects, 1.15, true, undefined, true);
canvasActions.revealCards(rects);
handleHighlightCard(draftId);
}
@@ -65,7 +65,7 @@ export function useDashboardCardActions({
}
const card = viewCards[focusKey];
if (card) {
canvasActions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], 1.15, true, undefined, true);
canvasActions.revealCards([{ x: card.x, y: card.y, width: card.width, height: card.height }]);
handleHighlightCard(focusKey);
}
}, 200);
@@ -88,7 +88,7 @@ export function useDashboardCardActions({
const newId = Object.keys(allNotes).find((id) => !prevIds.has(id));
if (newId) {
const note = allNotes[newId];
canvasActions.fitToCards([{ x: note.x, y: note.y, width: note.width, height: note.height }], 1.15, true, undefined, true);
canvasActions.revealCards([{ x: note.x, y: note.y, width: note.width, height: note.height }]);
handleHighlightCard(newId);
}
}, 200);
@@ -109,7 +109,7 @@ export function useDashboardCardActions({
setTimeout(() => {
const card = store.getState().dashboardLayout.cards[sessionId];
if (card) {
canvasActions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], 1.15, true);
canvasActions.revealCards([{ x: card.x, y: card.y, width: card.width, height: card.height }]);
handleHighlightCard(sessionId);
}
}, 200);
@@ -114,13 +114,16 @@ export function useDashboardLifecycle({
useEffect(() => {
if (!dashboardId) return;
hasFittedRef.current = false;
restoredExpandedRef.current = false;
setOutputsRefetched(false);
dispatch(resetLayout({ keepBrowserIds: getKeepAliveBrowserIds() }));
// CRITICAL path: these populate the cards the user expects to see on first paint. Don't defer.
dispatch(fetchSessions({ dashboardId }));
dispatch(fetchLayout({ dashboardId }));
// Never wipe+reload the layout while a card drag or marquee is in flight: a spurious mid-gesture nav (e.g. a phantom-dashboard round-trip) would unmount the card under the cursor and the drag silently dies. You can't switch dashboards while holding a drag, so any reset firing now is spurious. The shield class is up for exactly that window. Handlers below still install.
if (!document.body.classList.contains('dashboard-marquee-active')) {
hasFittedRef.current = false;
restoredExpandedRef.current = false;
setOutputsRefetched(false);
dispatch(resetLayout({ keepBrowserIds: getKeepAliveBrowserIds() }));
// CRITICAL path: these populate the cards the user expects to see on first paint. Don't defer.
dispatch(fetchSessions({ dashboardId }));
dispatch(fetchLayout({ dashboardId }));
}
const cleanupBrowserHandler = initBrowserCommandHandler();
// Global broadcasts (spawned browser cards) skip the replay log, so a socket gap loses them; a reconnect refetch is the only way they return.
const unsubReconnect = dashboardWs.on('dashboard:reconnected', () => {
@@ -216,7 +219,7 @@ export function useDashboardLifecycle({
setTimeout(() => {
const card = store.getState().dashboardLayout.cards[agentId];
if (card) {
canvasActions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], 1.15, true);
canvasActions.revealCards([{ x: card.x, y: card.y, width: card.width, height: card.height }]);
handleHighlightCard(agentId);
}
}, 350);
@@ -232,13 +235,7 @@ export function useDashboardLifecycle({
setTimeout(() => {
const card = store.getState().dashboardLayout.browserCards[browserId];
if (card) {
canvasActions.fitToCards(
[{ x: card.x, y: card.y, width: card.width, height: card.height }],
1.15,
true,
0.8,
true,
);
canvasActions.revealCards([{ x: card.x, y: card.y, width: card.width, height: card.height }]);
handleHighlightCard(browserId);
}
}, 200);
@@ -254,7 +251,7 @@ export function useDashboardLifecycle({
setTimeout(() => {
const card = store.getState().dashboardLayout.viewCards[cardKey];
if (card) {
canvasActions.fitToCards([{ x: card.x, y: card.y, width: card.width, height: card.height }], 1.15, true);
canvasActions.revealCards([{ x: card.x, y: card.y, width: card.width, height: card.height }]);
handleHighlightCard(cardKey);
}
}, 200);
@@ -269,11 +266,7 @@ export function useDashboardLifecycle({
setTimeout(() => {
const card = store.getState().dashboardLayout.workflowCards[workflowId];
if (card) {
canvasActions.fitToCards(
[{ x: card.x, y: card.y, width: card.width, height: card.height }],
1.15,
true,
);
canvasActions.revealCards([{ x: card.x, y: card.y, width: card.width, height: card.height }]);
handleHighlightCard(workflowId);
}
}, 200);
@@ -366,7 +359,7 @@ export function useDashboardLifecycle({
const ac = store.getState().dashboardLayout.cards[sid];
if (ac) rects.push({ x: ac.x, y: ac.y, width: ac.width, height: ac.height });
}
canvasActions.fitToCards(rects, 1.15, true);
canvasActions.revealCards(rects);
handleHighlightCard(outputId);
}, 200);
}
@@ -380,7 +373,10 @@ export function useDashboardLifecycle({
if (!dash) return;
if (!dash.auto_named && dash.name !== 'Untitled Dashboard') return;
const hasUserMessage = Object.values(sessions).some(
(s) => s.dashboard_id === dashboardId && s.messages?.some((m) => m.role === 'user'),
(s) => s.dashboard_id === dashboardId && (
s.messages?.some((m) => m.role === 'user') ||
(s.messages.length === 0 && !!s.first_user_message)
),
);
if (!hasUserMessage) return;
namedOnFirstMessageRef.current = dashboardId;
@@ -94,10 +94,11 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
setNewAgentBounce(canvasEmpty && !bounceDismissedRef.current);
}, [canvasEmpty, setNewAgentBounce]);
const canvasStateRef = useRef({ panX: canvas.panX, panY: canvas.panY, zoom: canvas.zoom });
canvasStateRef.current = { panX: canvas.panX, panY: canvas.panY, zoom: canvas.zoom };
// Stable getter, AgentCards read pan/zoom on demand during drag math.
const getCanvasState = useCallback(() => canvasStateRef.current, []);
// Live camera reads: gestures write the transform imperatively and only commit React state at gesture-end, so a render-synced ref would be stale mid-edge-pan (drag math) and inside the 140ms wheel-settle window (spawn placement). Both delegate to the canvas hook's live truth.
const getCanvasState = useCallback(() => canvas.actions.getLiveState(), [canvas.actions]);
const canvasStateRef = useMemo(() => ({
get current() { return canvas.actions.getLiveState(); },
}), [canvas.actions]);
const {
multiDragDelta,
@@ -106,9 +107,6 @@ export function useDashboardController(dashboardId: string, isActive: boolean) {
handleCardDragMove,
handleCardDragEnd,
} = useCardDrag({
panX: canvas.panX,
panY: canvas.panY,
zoom: canvas.zoom,
viewportRef: canvas.viewportRef,
canvasActions: canvas.actions,
selection,
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { CardPosition } from '@/shared/state/dashboardLayoutSlice';
import { setScrollFocusedCard } from '@/shared/cardScrollFocus';
import type { useDashboardSelection } from './useDashboardSelection';
type Selection = ReturnType<typeof useDashboardSelection>;
@@ -45,6 +46,8 @@ export function useDashboardUiState(selection: Selection, cards: Record<string,
if (!cards[pendingSelectSessionId]) return;
setPendingSelectSessionId(null);
selection.selectCard(pendingSelectSessionId, 'agent', false);
// A freshly spawned chat is the active one: focus it for scrolling so its transcript scrolls immediately (Google Maps gate) instead of zooming the canvas until the user clicks it.
setScrollFocusedCard(pendingSelectSessionId);
}, [pendingSelectSessionId, cards, selection]);
const spawnOriginsRef = useRef<Record<string, SpawnOrigin>>({});
+2
View File
@@ -33,6 +33,7 @@ import { Integration, INTEGRATIONS } from './integrations';
import { CATEGORY_ORDER } from './toolsHelpers';
import ToolSection from './cards/ToolSection';
import BrowserPermissionCard from './cards/BrowserPermissionCard';
import AgentWorkflowsSection from './cards/AgentWorkflowsSection';
import RegistryBrowserDialog from './dialogs/RegistryBrowserDialog';
import ToolDialogs from './dialogs/ToolDialogs';
import CustomToolCard from './cards/CustomToolCard';
@@ -178,6 +179,7 @@ const Tools: React.FC = () => {
</Box>
</Collapse>
</Box>
<AgentWorkflowsSection />
<Box sx={{ mb: 2 }}>
<Box onClick={() => setCustomSectionOpen((v) => !v)} sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 1, cursor: 'pointer', userSelect: 'none', '&:hover .section-arrow': { color: c.text.secondary } }}>
@@ -0,0 +1,66 @@
import React, { useEffect, useState } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Chip from '@mui/material/Chip';
import Collapse from '@mui/material/Collapse';
import Switch from '@mui/material/Switch';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight';
import AccountTreeIcon from '@mui/icons-material/AccountTree';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { fetchWorkflows, updateWorkflow } from '@/shared/state/workflowsSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
// Actions-page section: per-workflow opt-in that lets agents run the workflow via the InvokeWorkflow tool.
const AgentWorkflowsSection: React.FC = () => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const workflows = useAppSelector((s) => s.workflows.items);
const [open, setOpen] = useState(true);
useEffect(() => {
dispatch(fetchWorkflows(undefined));
}, [dispatch]);
const list = Object.values(workflows).filter((w) => !w.deleted_at && !w.unsaved);
const exposedCount = list.filter((w) => w.exposed_as_tool).length;
if (list.length === 0) return null;
return (
<Box sx={{ mb: 3 }}>
<Box
onClick={() => setOpen((v) => !v)}
sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 1, cursor: 'pointer', userSelect: 'none', '&:hover .section-arrow': { color: c.text.secondary } }}
>
{open ? <KeyboardArrowDownIcon className="section-arrow" sx={{ fontSize: 18, color: c.text.tertiary, transition: 'color 0.15s' }} /> : <KeyboardArrowRightIcon className="section-arrow" sx={{ fontSize: 18, color: c.text.tertiary, transition: 'color 0.15s' }} />}
<AccountTreeIcon sx={{ fontSize: 14, color: c.text.tertiary }} />
<Typography sx={{ color: c.text.muted, fontWeight: 600, fontSize: '0.8rem', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Workflows agents can run</Typography>
<Chip label={`${exposedCount}/${list.length}`} size="small" sx={{ bgcolor: c.bg.secondary, color: c.text.muted, fontSize: '0.7rem', height: 18, minWidth: 24, '& .MuiChip-label': { px: 0.8 } }} />
</Box>
<Collapse in={open} timeout={0} unmountOnExit>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5, pl: 1 }}>
<Typography sx={{ color: c.text.tertiary, fontSize: '0.75rem', mb: 0.5 }}>
Enabled workflows can be run by your agents as a tool (InvokeWorkflow); the agent waits for the run and reads its result.
</Typography>
{list.map((w) => (
<Box key={w.id} sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5, px: 1, borderRadius: 1, border: `1px solid ${c.border.subtle}`, bgcolor: c.bg.surface }}>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography sx={{ color: c.text.primary, fontSize: '0.85rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{w.title || 'Untitled workflow'}</Typography>
{w.description && (
<Typography sx={{ color: c.text.tertiary, fontSize: '0.72rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{w.description}</Typography>
)}
</Box>
<Switch
size="small"
checked={!!w.exposed_as_tool}
onChange={(e) => dispatch(updateWorkflow({ id: w.id, patch: { exposed_as_tool: e.target.checked } }))}
/>
</Box>
))}
</Box>
</Collapse>
</Box>
);
};
export default AgentWorkflowsSection;
@@ -7,6 +7,7 @@ import { useIframeElementSelector } from './useIframeElementSelector';
import { getAuthToken, ensureAuthToken } from '@/shared/config';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { registerViewWebview, unregisterViewWebview, type ViewWebview } from '@/shared/viewWebviewRegistry';
import { registerViewFrame, unregisterViewFrame } from '@/shared/viewFrameRegistry';
import RunInDesktopMessage from '@/app/components/RunInDesktopMessage';
import { registerWebview, unregisterWebview, setActiveTab, type BrowserWebview } from '@/shared/browserRegistry';
@@ -324,6 +325,15 @@ const ViewPreview = forwardRef<ViewPreviewHandle, Props>(({
return () => unregisterViewWebview(registryId);
}, [useWebview, registryId, iframeSrc]);
// Same registration for the srcdoc path, so the dashboard's arrow keys can reach a non-webview app card's content. Re-runs on reloadKey because a reload swaps the element.
useEffect(() => {
if (useWebview || !registryId) return;
const frame = iframeRef.current;
if (!frame) return;
registerViewFrame(registryId, frame);
return () => unregisterViewFrame(registryId);
}, [useWebview, registryId, iframeSrc, reloadKey]);
// Mirror `interactive` into a ref so the once-per-load did-finish-load listener can read the latest value when it pushes initial state.
const interactiveRef = useRef(interactive);
interactiveRef.current = interactive;
@@ -3,6 +3,7 @@ import type { CSSProperties } from 'react';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { deleteWorkflow } from '@/shared/state/workflowsSlice';
import { isScheduleActive, describeSchedule } from '@/app/pages/Workflows/scheduleUtils';
import ShareButton from '@/app/components/share/ShareButton';
import { colorForWorkflow, useWC } from './uiKit';
import WorkflowTitle from './WorkflowTitle';
import type { AppNav } from './types';
@@ -18,6 +19,7 @@ const LeftRail: React.FC<{ nav: AppNav }> = ({ nav }) => {
const items = useAppSelector((s) => s.workflows.items);
const trashCount = useAppSelector((s) => s.workflows.deleted.length);
const [query, setQuery] = useState('');
const [hovered, setHovered] = useState<string | null>(null);
const workflows = useMemo(() => Object.values(items)
.filter((w) => !w.unsaved)
@@ -93,6 +95,8 @@ const LeftRail: React.FC<{ nav: AppNav }> = ({ nav }) => {
<div
key={w.id}
onClick={() => nav.selectWorkflow(w.id)}
onMouseEnter={() => setHovered(w.id)}
onMouseLeave={() => setHovered((h) => (h === w.id ? null : h))}
style={{ display: 'flex', alignItems: 'center', gap: 9, padding: '5px 9px', borderRadius: 8, cursor: 'pointer', background: isSel ? WC.selBg : 'transparent' }}
>
<div style={{ width: 8, height: 8, borderRadius: '50%', flex: 'none', background: colorForWorkflow(w), opacity: active ? 1 : 0.35 }} />
@@ -104,6 +108,22 @@ const LeftRail: React.FC<{ nav: AppNav }> = ({ nav }) => {
{active ? describeSchedule(w.schedule) : 'Paused'}
</div>
</div>
{/* Faded rather than unmounted on hover-out: ShareButton owns the modal's open state, so unmounting it would close the modal the moment the pointer left the row for the dialog. Also keeps the row from reflowing on hover. */}
<span
onClick={(e) => e.stopPropagation()}
style={{
display: 'flex',
flex: 'none',
opacity: hovered === w.id ? 1 : 0,
pointerEvents: hovered === w.id ? 'auto' : 'none',
transition: 'opacity 0.12s',
}}
>
<ShareButton
target={{ kind: 'workflow', id: w.id, name: w.title || 'Untitled workflow' }}
iconFontSize={13}
/>
</span>
<div
onClick={(e) => { e.stopPropagation(); onDelete(w.id); }}
style={{ width: 22, height: 22, borderRadius: 6, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', color: WC.faint, flex: 'none' }}
@@ -34,16 +34,14 @@ interface Props {
cardWidth: number;
cardHeight: number;
cardZOrder: number;
zoom: number;
panX: number;
panY: number;
getCanvasState: () => { panX: number; panY: number; zoom: number };
onDragStart: (id: string, type: CardType) => void;
onDragMove: (dx: number, dy: number, mouseX?: number, mouseY?: number) => void;
onDragEnd: (dx: number, dy: number, didDrag: boolean) => void;
}
// The live run view, a real canvas card (standard claudeTokens chrome) spawned beside the Workflows window. The orange connector back to the window is drawn by the shared TetherLayer, same mechanism as an agent spinning up a browser.
const RunMonitor: React.FC<Props> = ({ workflow, cardX, cardY, cardWidth, cardHeight, cardZOrder, zoom, panX, panY, onDragStart, onDragMove, onDragEnd }) => {
const RunMonitor: React.FC<Props> = ({ workflow, cardX, cardY, cardWidth, cardHeight, cardZOrder, getCanvasState, onDragStart, onDragMove, onDragEnd }) => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const runs = useAppSelector((s) => s.workflows.runs[workflow.id]);
@@ -53,10 +51,6 @@ const RunMonitor: React.FC<Props> = ({ workflow, cardX, cardY, cardWidth, cardHe
useEffect(() => { dispatch(fetchRuns(workflow.id)); }, [workflow.id, dispatch]);
const panRef = useRef({ panX, panY });
panRef.current = { panX, panY };
const zoomRef = useRef(zoom);
zoomRef.current = zoom;
const dragState = useRef<{ sx: number; sy: number; ox: number; oy: number; spx: number; spy: number } | null>(null);
const didDrag = useRef(false);
const [localPos, setLocalPos] = useState<{ x: number; y: number } | null>(null);
@@ -67,11 +61,12 @@ const RunMonitor: React.FC<Props> = ({ workflow, cardX, cardY, cardWidth, cardHe
if (t.closest('button, [role="button"]')) return;
e.preventDefault(); e.stopPropagation();
dispatch(bringToFront({ id: 'workflows-monitor', type: 'workflows-monitor' }));
dragState.current = { sx: e.clientX, sy: e.clientY, ox: cardX, oy: cardY, spx: panRef.current.panX, spy: panRef.current.panY };
const cs = getCanvasState();
dragState.current = { sx: e.clientX, sy: e.clientY, ox: cardX, oy: cardY, spx: cs.panX, spy: cs.panY };
didDrag.current = false;
onDragStart('workflows-monitor', 'workflows-monitor');
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}, [cardX, cardY, dispatch, onDragStart]);
}, [cardX, cardY, dispatch, onDragStart, getCanvasState]);
const onHeaderMove = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
@@ -79,21 +74,23 @@ const RunMonitor: React.FC<Props> = ({ workflow, cardX, cardY, cardWidth, cardHe
const rdy = e.clientY - dragState.current.sy;
if (!didDrag.current && Math.sqrt(rdx * rdx + rdy * rdy) < DRAG_THRESHOLD) return;
didDrag.current = true;
const z = zoomRef.current;
const pdx = (panRef.current.panX - dragState.current.spx) / z;
const pdy = (panRef.current.panY - dragState.current.spy) / z;
const cs = getCanvasState();
const z = cs.zoom;
const pdx = (cs.panX - dragState.current.spx) / z;
const pdy = (cs.panY - dragState.current.spy) / z;
const dx = rdx / z - pdx;
const dy = rdy / z - pdy;
setLocalPos({ x: dragState.current.ox + dx, y: dragState.current.oy + dy });
// Feed the shared drag channel so the tether tracks live, same as cards.
onDragMove(dx, dy, e.clientX, e.clientY);
}, [onDragMove]);
}, [onDragMove, getCanvasState]);
const onHeaderUp = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
const z = zoomRef.current;
const pdx = (panRef.current.panX - dragState.current.spx) / z;
const pdy = (panRef.current.panY - dragState.current.spy) / z;
const cs = getCanvasState();
const z = cs.zoom;
const pdx = (cs.panX - dragState.current.spx) / z;
const pdy = (cs.panY - dragState.current.spy) / z;
const dx = (e.clientX - dragState.current.sx) / z - pdx;
const dy = (e.clientY - dragState.current.sy) / z - pdy;
if (didDrag.current) {
@@ -107,7 +104,7 @@ const RunMonitor: React.FC<Props> = ({ workflow, cardX, cardY, cardWidth, cardHe
didDrag.current = false;
setLocalPos(null);
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
}, [dispatch, onDragEnd]);
}, [dispatch, onDragEnd, getCanvasState]);
// A pinned run id (clicked from history) wins; otherwise follow the latest run.
const run: WorkflowRun | null =
@@ -1,11 +1,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useAppDispatch } from '@/shared/hooks';
import { closeWorkflowsApp, setWorkflowsHubPosition, setWorkflowsHubSize } from '@/shared/state/dashboardLayoutSlice';
import EventRepeatIcon from '@mui/icons-material/EventRepeat';
import IconButton from '@mui/material/IconButton';
import CloseIcon from '@mui/icons-material/Close';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { useWC, FONT_SERIF } from './uiKit';
import { setWorkflowsHubPosition, setWorkflowsHubSize } from '@/shared/state/dashboardLayoutSlice';
import { useWC } from './uiKit';
import WorkflowsAppContent from './WorkflowsAppContent';
type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
@@ -38,9 +34,7 @@ interface Props {
cardWidth: number;
cardHeight: number;
cardZOrder?: number;
zoom?: number;
panX?: number;
panY?: number;
getCanvasState: () => { panX: number; panY: number; zoom: number };
isSelected?: boolean;
isHighlighted?: boolean;
multiDragDelta?: { dx: number; dy: number } | null;
@@ -53,18 +47,13 @@ interface Props {
const WorkflowsAppCard: React.FC<Props> = ({
cardX, cardY, cardWidth, cardHeight, cardZOrder = 0,
zoom = 1, panX = 0, panY = 0,
getCanvasState,
isSelected = false, isHighlighted = false, multiDragDelta = null,
onCardSelect, onDragStart, onDragMove, onDragEnd, onBringToFront,
}) => {
const WC = useWC();
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const panRef = useRef({ panX, panY });
panRef.current = { panX, panY };
const zoomRef = useRef(zoom);
zoomRef.current = zoom;
// ---- Drag (title bar is the handle) ----
const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number; startPanX: number; startPanY: number } | null>(null);
@@ -82,12 +71,13 @@ const WorkflowsAppCard: React.FC<Props> = ({
if (target.closest('[data-no-drag], button, [role="button"], input, textarea, select')) return;
e.preventDefault();
e.stopPropagation();
dragState.current = { startX: e.clientX, startY: e.clientY, origX: cardX, origY: cardY, startPanX: panRef.current.panX, startPanY: panRef.current.panY };
const cs = getCanvasState();
dragState.current = { startX: e.clientX, startY: e.clientY, origX: cardX, origY: cardY, startPanX: cs.panX, startPanY: cs.panY };
didDrag.current = false;
setIsDragging(true);
onDragStart?.('workflows-hub', 'workflows-hub');
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}, [cardX, cardY, onDragStart]);
}, [cardX, cardY, onDragStart, getCanvasState]);
const onHeaderPointerMove = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
@@ -95,20 +85,22 @@ const WorkflowsAppCard: React.FC<Props> = ({
const rawDy = e.clientY - dragState.current.startY;
if (!didDrag.current && Math.sqrt(rawDx * rawDx + rawDy * rawDy) < DRAG_THRESHOLD) return;
didDrag.current = true;
const z = zoomRef.current;
const panDx = (panRef.current.panX - dragState.current.startPanX) / z;
const panDy = (panRef.current.panY - dragState.current.startPanY) / z;
const cs = getCanvasState();
const z = cs.zoom;
const panDx = (cs.panX - dragState.current.startPanX) / z;
const panDy = (cs.panY - dragState.current.startPanY) / z;
const dx = rawDx / z - panDx;
const dy = rawDy / z - panDy;
setLocalDragPos({ x: dragState.current.origX + dx, y: dragState.current.origY + dy });
onDragMove?.(dx, dy, e.clientX, e.clientY);
}, [onDragMove]);
}, [onDragMove, getCanvasState]);
const onHeaderPointerUp = useCallback((e: React.PointerEvent) => {
if (!dragState.current) return;
const z = zoomRef.current;
const panDx = (panRef.current.panX - dragState.current.startPanX) / z;
const panDy = (panRef.current.panY - dragState.current.startPanY) / z;
const cs = getCanvasState();
const z = cs.zoom;
const panDx = (cs.panX - dragState.current.startPanX) / z;
const panDy = (cs.panY - dragState.current.startPanY) / z;
const dx = (e.clientX - dragState.current.startX) / z - panDx;
const dy = (e.clientY - dragState.current.startY) / z - panDy;
if (didDrag.current) {
@@ -125,7 +117,7 @@ const WorkflowsAppCard: React.FC<Props> = ({
setLocalDragPos(null);
setIsDragging(false);
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
}, [dispatch, onDragEnd]);
}, [dispatch, onDragEnd, getCanvasState]);
// ---- Resize ----
const resizeRef = useRef<{ dir: ResizeDir; sx0: number; sy0: number; ox: number; oy: number; ow: number; oh: number } | null>(null);
@@ -144,8 +136,9 @@ const WorkflowsAppCard: React.FC<Props> = ({
const compute = useCallback((e: React.PointerEvent) => {
if (!resizeRef.current) return null;
const { dir, sx0, sy0, ox, oy, ow, oh } = resizeRef.current;
const dx = (e.clientX - sx0) / zoomRef.current;
const dy = (e.clientY - sy0) / zoomRef.current;
const z2 = getCanvasState().zoom;
const dx = (e.clientX - sx0) / z2;
const dy = (e.clientY - sy0) / z2;
let nx = ox, ny = oy, nw = ow, nh = oh;
if (dir.includes('e')) nw = ow + dx;
if (dir.includes('w')) { nw = ow - dx; nx = ox + dx; }
@@ -216,31 +209,14 @@ const WorkflowsAppCard: React.FC<Props> = ({
transition: noTransition ? 'none' : 'box-shadow 0.3s ease, border-color 0.2s ease',
}}
>
{/* TITLE BAR (drag handle) */}
<div
onPointerDown={onHeaderPointerDown}
onPointerMove={onHeaderPointerMove}
onPointerUp={onHeaderPointerUp}
style={{ height: 42, flex: 'none', display: 'flex', alignItems: 'center', padding: '0 16px', borderBottom: `1px solid ${WC.line}`, background: WC.panel, gap: 14, cursor: isDragging ? 'grabbing' : 'grab', touchAction: 'none', userSelect: 'none' }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<EventRepeatIcon sx={{ fontSize: 18, color: WC.accent, display: 'block' }} />
<span style={{ fontFamily: FONT_SERIF, fontSize: 14.5, fontWeight: 500, color: WC.ink, letterSpacing: '-0.01em', lineHeight: 1, transform: 'translateY(2.5px)' }}>Workflows</span>
</div>
<div style={{ flex: 1 }} />
<IconButton
aria-label="Close"
data-no-drag
size="small"
onClick={(e) => { e.stopPropagation(); dispatch(closeWorkflowsApp()); }}
onPointerDown={(e) => e.stopPropagation()}
sx={{ color: c.text.tertiary, '&:hover': { color: c.status.error, bgcolor: `${c.status.error}14` } }}
>
<CloseIcon fontSize="small" />
</IconButton>
</div>
<WorkflowsAppContent />
<WorkflowsAppContent
header={{
onPointerDown: onHeaderPointerDown,
onPointerMove: onHeaderPointerMove,
onPointerUp: onHeaderPointerUp,
dragging: isDragging,
}}
/>
{HANDLE_DEFS.map(({ dir, css }) => (
<div
@@ -1,12 +1,17 @@
import React, { useEffect, useMemo, useState } from 'react';
import EventRepeatIcon from '@mui/icons-material/EventRepeat';
import CloseIcon from '@mui/icons-material/Close';
import IconButton from '@mui/material/IconButton';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { clearWorkflowsAppTarget } from '@/shared/state/dashboardLayoutSlice';
import { clearWorkflowsAppTarget, closeWorkflowsApp } from '@/shared/state/dashboardLayoutSlice';
import {
fetchWorkflows, fetchAllRuns, fetchPausedState, fetchActiveRuns, fetchDeletedWorkflows,
} from '@/shared/state/workflowsSlice';
import { fetchMissedRuns } from '@/shared/state/missedRunsSlice';
import { FONT_SANS, useWC } from './uiKit';
import type { AppMode, CalView, AppNav } from './types';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import ShareButton from '@/app/components/share/ShareButton';
import { FONT_SANS, FONT_SERIF, useWC } from './uiKit';
import type { AppMode, CalView, AppNav, CardHeader } from './types';
import LeftRail from './LeftRail';
import HomeView from './HomeView';
import CalendarView from './CalendarView';
@@ -14,9 +19,10 @@ import DetailView from './DetailView';
import ComposeView from './ComposeView';
import TrashView from './TrashView';
// The three-pane Workflows body, independent of how it's framed (canvas card). Holds nav + data; the card chrome (title bar drag handle, resize) wraps it.
const WorkflowsAppContent: React.FC = () => {
// The three-pane Workflows body plus its title bar. The card wraps this with drag/resize geometry and passes the drag handlers in; the title bar lives here because Share needs to know which workflow is open.
const WorkflowsAppContent: React.FC<{ header: CardHeader }> = ({ header }) => {
const WC = useWC();
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const target = useAppSelector((s) => s.dashboardLayout.workflowsAppTarget);
const dashboardId = useAppSelector((s) => s.tempState.lastDashboardId) || undefined;
@@ -26,6 +32,10 @@ const WorkflowsAppContent: React.FC = () => {
const [calView, setCalView] = useState<CalView>('month');
const [refDate, setRefDate] = useState<Date>(() => new Date());
// goHome leaves selectedId set, so gate on the mode too or Share lingers in the title bar after leaving the workflow.
const shared = useAppSelector((s) => (selectedId ? s.workflows.items[selectedId] : undefined));
const selected = mode === 'detail' ? shared : undefined;
useEffect(() => {
dispatch(fetchWorkflows(dashboardId));
dispatch(fetchAllRuns(200));
@@ -56,13 +66,53 @@ const WorkflowsAppContent: React.FC = () => {
}), [mode, selectedId, calView, refDate, dashboardId, dispatch]);
return (
<div style={{ flex: 1, display: 'flex', minHeight: 0, fontFamily: FONT_SANS, color: WC.ink, background: WC.page }}>
<LeftRail nav={nav} />
{mode === 'home' && <HomeView nav={nav} />}
{mode === 'calendar' && <CalendarView nav={nav} />}
{mode === 'detail' && selectedId && <DetailView workflowId={selectedId} nav={nav} />}
{mode === 'new' && <ComposeView nav={nav} />}
{mode === 'trash' && <TrashView />}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0, fontFamily: FONT_SANS, color: WC.ink, background: WC.page }}>
{/* TITLE BAR (drag handle) */}
<div
onPointerDown={header.onPointerDown}
onPointerMove={header.onPointerMove}
onPointerUp={header.onPointerUp}
style={{ height: 42, flex: 'none', display: 'flex', alignItems: 'center', padding: '0 16px', borderBottom: `1px solid ${WC.line}`, background: WC.panel, gap: 14, cursor: header.dragging ? 'grabbing' : 'grab', touchAction: 'none', userSelect: 'none' }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<EventRepeatIcon sx={{ fontSize: 18, color: WC.accent, display: 'block' }} />
<span style={{ fontFamily: FONT_SERIF, fontSize: 14.5, fontWeight: 500, color: WC.ink, letterSpacing: '-0.01em', lineHeight: 1, transform: 'translateY(2.5px)' }}>Workflows</span>
</div>
<div style={{ flex: 1 }} />
{selected && (
// The share dialog portals to the body but its events still bubble the React tree, so stop them here or dragging the card follows a click inside the modal.
<span
data-no-drag
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
style={{ display: 'flex' }}
>
<ShareButton
target={{ kind: 'workflow', id: selected.id, name: selected.title || 'Untitled workflow' }}
iconFontSize={17}
/>
</span>
)}
<IconButton
aria-label="Close"
data-no-drag
size="small"
onClick={(e) => { e.stopPropagation(); dispatch(closeWorkflowsApp()); }}
onPointerDown={(e) => e.stopPropagation()}
sx={{ color: c.text.tertiary, '&:hover': { color: c.status.error, bgcolor: `${c.status.error}14` } }}
>
<CloseIcon fontSize="small" />
</IconButton>
</div>
<div style={{ flex: 1, display: 'flex', minHeight: 0 }}>
<LeftRail nav={nav} />
{mode === 'home' && <HomeView nav={nav} />}
{mode === 'calendar' && <CalendarView nav={nav} />}
{mode === 'detail' && selectedId && <DetailView workflowId={selectedId} nav={nav} />}
{mode === 'new' && <ComposeView nav={nav} />}
{mode === 'trash' && <TrashView />}
</div>
</div>
);
};
@@ -1,6 +1,16 @@
import type { PointerEvent } from 'react';
export type AppMode = 'home' | 'calendar' | 'detail' | 'new' | 'trash';
export type CalView = 'week' | 'month';
// The card owns drag geometry but the title bar renders inside the content (it needs nav state to know which workflow to share), so the card hands its drag handlers down.
export interface CardHeader {
onPointerDown: (e: PointerEvent) => void;
onPointerMove: (e: PointerEvent) => void;
onPointerUp: (e: PointerEvent) => void;
dragging: boolean;
}
// Navigation + ephemeral UI state for the Workflows app window. Data lives in Redux; this is only "where am I looking right now".
export interface AppNav {
mode: AppMode;