mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-13 21:27:41 +02:00
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:
@@ -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>>({});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getWebview, findWebviewByDomain, type BrowserWebview } from './browserRegistry';
|
||||
import { getWebview, findWebviewByDomain, hasDomReady, markDomReady, isPendingLoad, wakePendingLoad, clearPendingLoad, type BrowserWebview } from './browserRegistry';
|
||||
import { store } from './state/store';
|
||||
import { resumeBrowserCard } from './state/dashboardLayoutSlice';
|
||||
import { dashboardWs } from './ws/WebSocketManager';
|
||||
@@ -189,8 +189,40 @@ async function countSafeRoutes(wv: BrowserWebview): Promise<number> {
|
||||
} catch { return 0; }
|
||||
}
|
||||
|
||||
// Electron queues executeJavaScript until the page "stops loading", and pages with straggler subresources (recaptcha/tracker iframes) can stay isLoading for minutes, starving EVERY command into its backend timeout (the wedged-webview tail). Once the document itself is ready, wv.stop() cancels only the stragglers and fires did-stop-loading, which flushes the queue; a genuinely-still-loading document (no dom-ready yet) is left alone.
|
||||
const STUCK_EVAL_GRACE_MS = 2500;
|
||||
const STUCK_EVAL_LIMIT_MS = 9000;
|
||||
|
||||
async function evalInPage(wv: BrowserWebview, code: string): Promise<any> {
|
||||
const run = wv.executeJavaScript(code).then((v) => {
|
||||
markDomReady(wv);
|
||||
return { done: true as const, value: v };
|
||||
});
|
||||
const grace = new Promise<{ done: false }>((r) => setTimeout(() => r({ done: false }), STUCK_EVAL_GRACE_MS));
|
||||
let first = await Promise.race([run, grace]);
|
||||
if (!first.done) {
|
||||
let stopped = false;
|
||||
try {
|
||||
if (wv.isLoading() && hasDomReady(wv)) {
|
||||
wv.stop();
|
||||
stopped = true;
|
||||
}
|
||||
} catch {
|
||||
// torn-down webview; the limit below surfaces it
|
||||
}
|
||||
const limit = new Promise<{ done: false }>((r) => setTimeout(() => r({ done: false }), STUCK_EVAL_LIMIT_MS));
|
||||
first = await Promise.race([run, limit]);
|
||||
if (!first.done) {
|
||||
throw new Error(stopped
|
||||
? 'page never finished loading even after cancelling stragglers'
|
||||
: 'page is still loading; retry shortly');
|
||||
}
|
||||
}
|
||||
return first.value;
|
||||
}
|
||||
|
||||
async function handleGetText(wv: BrowserWebview): Promise<Record<string, any>> {
|
||||
const text: string = await wv.executeJavaScript(
|
||||
const text: string = await evalInPage(wv,
|
||||
'document.body.innerText.substring(0, 15000)'
|
||||
);
|
||||
// Sampled HERE (on a read), not on navigate: by the time the agent reads the page, the SPA's XHR/fetch have fired, so routes are actually captured.
|
||||
@@ -272,7 +304,7 @@ async function handleClick(wv: BrowserWebview, params: Record<string, any>): Pro
|
||||
clickY: window.innerHeight > 0 ? y / window.innerHeight : 0.5,
|
||||
};
|
||||
})()`;
|
||||
const result = await wv.executeJavaScript(code);
|
||||
const result = await evalInPage(wv, code);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -300,7 +332,7 @@ async function handleType(wv: BrowserWebview, params: Record<string, any>): Prom
|
||||
text: 'Typed into: ' + el.tagName.toLowerCase() + (el.id ? '#' + el.id : ''),
|
||||
};
|
||||
})()`;
|
||||
const result = await wv.executeJavaScript(code);
|
||||
const result = await evalInPage(wv, code);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -316,12 +348,63 @@ const KEY_NAME_MAP: Record<string, string> = {
|
||||
Del: 'Delete',
|
||||
};
|
||||
|
||||
interface CdpKeyDescriptor { key: string; code: string; vk: number; text?: string }
|
||||
|
||||
const CDP_KEYS: Record<string, CdpKeyDescriptor> = {
|
||||
Enter: { key: 'Enter', code: 'Enter', vk: 13, text: '\r' },
|
||||
Tab: { key: 'Tab', code: 'Tab', vk: 9 },
|
||||
Escape: { key: 'Escape', code: 'Escape', vk: 27 },
|
||||
Backspace: { key: 'Backspace', code: 'Backspace', vk: 8 },
|
||||
Delete: { key: 'Delete', code: 'Delete', vk: 46 },
|
||||
ArrowUp: { key: 'ArrowUp', code: 'ArrowUp', vk: 38 },
|
||||
ArrowDown: { key: 'ArrowDown', code: 'ArrowDown', vk: 40 },
|
||||
ArrowLeft: { key: 'ArrowLeft', code: 'ArrowLeft', vk: 37 },
|
||||
ArrowRight: { key: 'ArrowRight', code: 'ArrowRight', vk: 39 },
|
||||
Home: { key: 'Home', code: 'Home', vk: 36 },
|
||||
End: { key: 'End', code: 'End', vk: 35 },
|
||||
PageUp: { key: 'PageUp', code: 'PageUp', vk: 33 },
|
||||
PageDown: { key: 'PageDown', code: 'PageDown', vk: 34 },
|
||||
' ': { key: ' ', code: 'Space', vk: 32, text: ' ' },
|
||||
};
|
||||
|
||||
// Loose names the model actually sends, folded onto the canonical DOM names above.
|
||||
const CDP_KEY_ALIASES: Record<string, string> = {
|
||||
Up: 'ArrowUp', Down: 'ArrowDown', Left: 'ArrowLeft', Right: 'ArrowRight',
|
||||
Space: ' ', Spacebar: ' ', Esc: 'Escape', Del: 'Delete', Return: 'Enter',
|
||||
};
|
||||
|
||||
function cdpKeyDescriptor(rawKey: string): CdpKeyDescriptor | null {
|
||||
const canonical = CDP_KEY_ALIASES[rawKey] || rawKey;
|
||||
const named = CDP_KEYS[canonical];
|
||||
if (named) return named;
|
||||
if (canonical.length === 1) {
|
||||
const upper = canonical.toUpperCase();
|
||||
const code = /[a-z]/i.test(canonical) ? `Key${upper}` : /[0-9]/.test(canonical) ? `Digit${canonical}` : '';
|
||||
return { key: canonical, code, vk: upper.charCodeAt(0), text: canonical };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function handlePressKey(wv: BrowserWebview, params: Record<string, any>): Promise<Record<string, any>> {
|
||||
const rawKey = (params.key as string) || '';
|
||||
if (!rawKey) return { error: 'key parameter is required' };
|
||||
await evalInPage(wv, 'document.body && document.body.focus && document.body.focus(); true');
|
||||
const desc = cdpKeyDescriptor(rawKey);
|
||||
if (desc) {
|
||||
try {
|
||||
// CDP key events are trusted AND scoped to THIS webview no matter where the user's cursor sits; the sendInputEvent path delivered to whatever had focus, which is the "agent typed into my note" bug. keyDown-with-text inserts the char; bare named keys use rawKeyDown so no stray char lands.
|
||||
const down: Record<string, any> = { type: desc.text ? 'keyDown' : 'rawKeyDown', key: desc.key, windowsVirtualKeyCode: desc.vk, nativeVirtualKeyCode: desc.vk };
|
||||
if (desc.code) down.code = desc.code;
|
||||
if (desc.text) down.text = desc.text;
|
||||
await sendCdp(wv, 'Input.dispatchKeyEvent', down);
|
||||
const up: Record<string, any> = { type: 'keyUp', key: desc.key, windowsVirtualKeyCode: desc.vk, nativeVirtualKeyCode: desc.vk };
|
||||
if (desc.code) up.code = desc.code;
|
||||
await sendCdp(wv, 'Input.dispatchKeyEvent', up);
|
||||
return { text: `Pressed ${rawKey}` };
|
||||
} catch { /* fall through to the legacy path so a CDP hiccup never makes a key dead */ }
|
||||
}
|
||||
// Legacy focus-dependent fallback (exotic keys or CDP unavailable): keeps every key that worked before working.
|
||||
const keyCode = KEY_NAME_MAP[rawKey] || rawKey;
|
||||
await wv.executeJavaScript('document.body && document.body.focus && document.body.focus(); true');
|
||||
// Native OS-level key events have isTrusted=true, so hostile sites' keyboard handlers respect them.
|
||||
wv.sendInputEvent({ type: 'keyDown', keyCode });
|
||||
wv.sendInputEvent({ type: 'char', keyCode });
|
||||
wv.sendInputEvent({ type: 'keyUp', keyCode });
|
||||
@@ -348,7 +431,7 @@ async function handleClickPoint(wv: BrowserWebview, params: Record<string, any>)
|
||||
// host element's box. One cheap round-trip; falls back to the element box.
|
||||
let vw = wv.clientWidth, vh = wv.clientHeight;
|
||||
try {
|
||||
const d = await wv.executeJavaScript('({w: window.innerWidth, h: window.innerHeight})');
|
||||
const d = await evalInPage(wv, '({w: window.innerWidth, h: window.innerHeight})');
|
||||
if (d && d.w > 0 && d.h > 0) { vw = d.w; vh = d.h; }
|
||||
} catch { /* use the element box as a fallback */ }
|
||||
const x = (cx / 100) * vw;
|
||||
@@ -1122,7 +1205,7 @@ async function handleScroll(wv: BrowserWebview, params: Record<string, any>): Pr
|
||||
};
|
||||
})()`;
|
||||
try {
|
||||
const result = await wv.executeJavaScript(code);
|
||||
const result = await evalInPage(wv, code);
|
||||
const status = result.atBottom ? ' (reached bottom)' : result.atTop ? ' (reached top)' : '';
|
||||
return {
|
||||
text: `Scrolled ${direction} by ${result.scrolled}px${status}. Position: ${result.scrollTop}/${result.scrollHeight - result.clientHeight}px`,
|
||||
@@ -1150,7 +1233,7 @@ async function handleWait(wv: BrowserWebview, params: Record<string, any>): Prom
|
||||
const elapsed = Date.now() - start;
|
||||
if (elapsed >= ms) break;
|
||||
try {
|
||||
const probe = JSON.parse(await wv.executeJavaScript(probeJs));
|
||||
const probe = JSON.parse(await evalInPage(wv, probeJs));
|
||||
probeErrors = 0;
|
||||
if (probe.elems !== lastElems) { lastElems = probe.elems; elemsChangedAt = Date.now(); }
|
||||
const domStable = Date.now() - elemsChangedAt;
|
||||
@@ -1238,7 +1321,7 @@ async function handleGetElements(wv: BrowserWebview, params: Record<string, any>
|
||||
return { elements: results, total: interactive.length, url: location.href, title: document.title };
|
||||
})()`;
|
||||
try {
|
||||
const result = await wv.executeJavaScript(code);
|
||||
const result = await evalInPage(wv, code);
|
||||
return { text: JSON.stringify(result, null, 2), url: wv.getURL() };
|
||||
} catch (err: any) {
|
||||
return { error: `Failed to get elements: ${err?.message || String(err)}` };
|
||||
@@ -1263,7 +1346,7 @@ async function handleDetectWebMCP(wv: BrowserWebview): Promise<Record<string, an
|
||||
return { present: true, tools };
|
||||
})()`;
|
||||
try {
|
||||
const r = await wv.executeJavaScript(code);
|
||||
const r = await evalInPage(wv, code);
|
||||
if (!r || !r.present) {
|
||||
return { text: 'No WebMCP on this page (navigator.modelContext not present). Use the normal browser tools.', url: wv.getURL() };
|
||||
}
|
||||
@@ -1328,7 +1411,7 @@ async function handleReplayRoute(wv: BrowserWebview, params: Record<string, any>
|
||||
} catch (e) { return { error: String((e && e.message) || e) }; }
|
||||
})()`;
|
||||
try {
|
||||
const res = await wv.executeJavaScript(code);
|
||||
const res = await evalInPage(wv, code);
|
||||
if (res.error) return { error: `Replay failed: ${res.error}` };
|
||||
return { text: `${method} ${absUrl} -> HTTP ${res.status}\n${res.body}`, status: res.status, url: wv.getURL() };
|
||||
} catch (err: any) {
|
||||
@@ -1340,7 +1423,7 @@ async function handleEvaluate(wv: BrowserWebview, params: Record<string, any>):
|
||||
const expression = params.expression as string;
|
||||
if (!expression) return { error: 'expression parameter is required' };
|
||||
try {
|
||||
const result = await wv.executeJavaScript(expression);
|
||||
const result = await evalInPage(wv, expression);
|
||||
const text = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
|
||||
// evaluate is the agent's main read path; sample routes here too (XHRs have fired by now) so the backend can surface the fast network tier once.
|
||||
const routes_available = await countSafeRoutes(wv);
|
||||
@@ -1351,7 +1434,7 @@ async function handleEvaluate(wv: BrowserWebview, params: Record<string, any>):
|
||||
}
|
||||
|
||||
// The registry is renderer-local and a card briefly unregisters on remount / tab-switch; a command landing in that gap shouldn't hard-fail. Wait a bounded window for (re)registration before giving up, so the error stays a real "card is gone" signal rather than a transient race.
|
||||
async function awaitWebview(browserId: string, tabId?: string): Promise<BrowserWebview | undefined> {
|
||||
async function awaitWebview(browserId: string, tabId?: string, action?: string): Promise<BrowserWebview | undefined> {
|
||||
// A suspended (snapshot-swapped) card has no webview at all; wake it and wait out the remount + page reload before the command touches it.
|
||||
const wasSuspended = !!store.getState().dashboardLayout.suspendedBrowserCards[browserId];
|
||||
if (wasSuspended) store.dispatch(resumeBrowserCard(browserId));
|
||||
@@ -1371,6 +1454,24 @@ async function awaitWebview(browserId: string, tabId?: string): Promise<BrowserW
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
}
|
||||
}
|
||||
// A lazy background tab mounts at about:blank with its real page deferred; an agent command needs
|
||||
// the real page, so wake it and wait out the load, same as a resumed suspended card. A navigate
|
||||
// is about to load its own url, so just drop the deferred load instead of loading the old one first.
|
||||
if (wv && isPendingLoad(wv)) {
|
||||
if (action === 'navigate') {
|
||||
clearPendingLoad(wv);
|
||||
} else if (wakePendingLoad(wv)) {
|
||||
const loadDeadline = Date.now() + 12000;
|
||||
while (Date.now() < loadDeadline) {
|
||||
try {
|
||||
if (!wv.isLoading() && wv.getURL() !== 'about:blank') break;
|
||||
} catch {
|
||||
// mid-load hiccup; keep waiting
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
}
|
||||
}
|
||||
}
|
||||
return wv;
|
||||
}
|
||||
|
||||
@@ -1418,6 +1519,18 @@ async function handlePerformAction(params: Record<string, any>): Promise<Record<
|
||||
if (!wv) {
|
||||
return { error: `No ${domain} browser card is open. Open ${domain} in an OpenSwarm browser card and sign in, then retry.` };
|
||||
}
|
||||
// findWebviewByDomain can resolve a deferred background tab by its intended url; wake it and wait out the load before driving it, so the session-borrow shims never act on an about:blank tab.
|
||||
if (isPendingLoad(wv) && wakePendingLoad(wv)) {
|
||||
const loadDeadline = Date.now() + 12000;
|
||||
while (Date.now() < loadDeadline) {
|
||||
try {
|
||||
if (!wv.isLoading() && wv.getURL() !== 'about:blank') break;
|
||||
} catch {
|
||||
// mid-load hiccup; keep waiting
|
||||
}
|
||||
await new Promise((res) => setTimeout(res, 150));
|
||||
}
|
||||
}
|
||||
const steps = Array.isArray(params.steps) ? params.steps : [];
|
||||
const results: Record<string, any>[] = [];
|
||||
for (const step of steps) {
|
||||
@@ -1447,7 +1560,7 @@ async function runBrowserCommand(
|
||||
dashboardWs.send('browser:result', { request_id, ...result });
|
||||
return;
|
||||
}
|
||||
const wv = await awaitWebview(browser_id, tab_id || undefined);
|
||||
const wv = await awaitWebview(browser_id, tab_id || undefined, action);
|
||||
if (!wv) {
|
||||
dashboardWs.send('browser:result', {
|
||||
request_id,
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface BrowserWebview extends HTMLElement {
|
||||
reload: () => void;
|
||||
canGoBack: () => boolean;
|
||||
canGoForward: () => boolean;
|
||||
stop: () => void;
|
||||
getURL: () => string;
|
||||
getTitle: () => string;
|
||||
isLoading: () => boolean;
|
||||
@@ -44,8 +45,61 @@ function makeKey(browserId: string, tabId: string): string {
|
||||
return `${browserId}:${tabId}`;
|
||||
}
|
||||
|
||||
// Electron suspends webContents.executeJavaScript until the page "stops loading", and pages with straggler iframes (LinkedIn's recaptcha/trackers) can stay isLoading for minutes; the guarded eval in browserCommandHandler needs to know the document itself is usable before it dares wv.stop().
|
||||
const domReadyDocs = new WeakSet<BrowserWebview>();
|
||||
const loadTrackingArmed = new WeakSet<BrowserWebview>();
|
||||
|
||||
function armLoadStateTracking(wv: BrowserWebview): void {
|
||||
if (loadTrackingArmed.has(wv)) return;
|
||||
loadTrackingArmed.add(wv);
|
||||
wv.addEventListener('dom-ready', () => domReadyDocs.add(wv));
|
||||
// a real main-frame navigation starts a new document; in-page (SPA pushState) ones don't
|
||||
wv.addEventListener('did-navigate', () => domReadyDocs.delete(wv));
|
||||
}
|
||||
|
||||
export function hasDomReady(wv: BrowserWebview): boolean {
|
||||
return domReadyDocs.has(wv);
|
||||
}
|
||||
|
||||
export function markDomReady(wv: BrowserWebview): void {
|
||||
domReadyDocs.add(wv);
|
||||
}
|
||||
|
||||
export function registerWebview(browserId: string, tabId: string, wv: BrowserWebview): void {
|
||||
registry.set(makeKey(browserId, tabId), wv);
|
||||
armLoadStateTracking(wv);
|
||||
}
|
||||
|
||||
// Lazy-tab loading: a background tab mounts its <webview> (so it stays registered + resolvable
|
||||
// exactly like a live one) but defers loadURL until it's actually needed, so a many-tab card
|
||||
// doesn't load every page at once. The tab is never starved: it's woken when it becomes active
|
||||
// OR the moment an agent command resolves it.
|
||||
const pendingLoad = new WeakMap<BrowserWebview, () => void>();
|
||||
const intendedUrl = new WeakMap<BrowserWebview, string>();
|
||||
|
||||
export function registerPendingLoad(wv: BrowserWebview, url: string, load: () => void): void {
|
||||
pendingLoad.set(wv, load);
|
||||
intendedUrl.set(wv, url);
|
||||
}
|
||||
|
||||
export function isPendingLoad(wv: BrowserWebview): boolean {
|
||||
return pendingLoad.has(wv);
|
||||
}
|
||||
|
||||
// Fire a lazy tab's deferred load exactly once; returns true if it was pending (the caller then
|
||||
// waits out the page load, same as a resumed suspended card). No-op on an already-loaded tab.
|
||||
export function wakePendingLoad(wv: BrowserWebview): boolean {
|
||||
const load = pendingLoad.get(wv);
|
||||
if (!load) return false;
|
||||
pendingLoad.delete(wv);
|
||||
load();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Drop a lazy tab's deferred load WITHOUT firing it: an agent navigate is about to load a
|
||||
// different url, so loading the old intended url first would be wasted work.
|
||||
export function clearPendingLoad(wv: BrowserWebview): void {
|
||||
pendingLoad.delete(wv);
|
||||
}
|
||||
|
||||
export function unregisterWebview(browserId: string, tabId: string): void {
|
||||
@@ -84,13 +138,23 @@ export function findBrowserByWebContentsId(wcId: number): string | undefined {
|
||||
// LIVE url (not a stale persisted card.url) so the action lands on the real tab.
|
||||
export function findWebviewByDomain(domain: string): BrowserWebview | undefined {
|
||||
const d = domain.toLowerCase().replace(/^\./, '');
|
||||
for (const wv of registry.values()) {
|
||||
const matchesHost = (u: string): boolean => {
|
||||
try {
|
||||
const host = new URL(wv.getURL()).hostname.toLowerCase();
|
||||
if (host === d || host.endsWith('.' + d)) return wv;
|
||||
const host = new URL(u).hostname.toLowerCase();
|
||||
return host === d || host.endsWith('.' + d);
|
||||
} catch {
|
||||
// about:blank or a torn-down webview has no parseable URL; skip it.
|
||||
return false;
|
||||
}
|
||||
};
|
||||
for (const wv of registry.values()) {
|
||||
if (matchesHost(wv.getURL())) return wv;
|
||||
}
|
||||
// A lazy background tab sits at about:blank, so its LIVE url can't match; fall back to its
|
||||
// INTENDED (deferred) url so the session-borrow shims still find + wake it. The caller wakes it.
|
||||
for (const wv of registry.values()) {
|
||||
const pend = intendedUrl.get(wv);
|
||||
if (pend && pendingLoad.has(wv) && matchesHost(pend)) return wv;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// Run: node --test frontend/src/shared/browserRegistryLazy.test.ts
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
registerWebview,
|
||||
unregisterWebview,
|
||||
registerPendingLoad,
|
||||
isPendingLoad,
|
||||
wakePendingLoad,
|
||||
clearPendingLoad,
|
||||
findWebviewByDomain,
|
||||
type BrowserWebview,
|
||||
} from './browserRegistry.ts';
|
||||
|
||||
// Minimal fake webview: the registry only calls addEventListener (load tracking) + getURL.
|
||||
function fakeWebview(url: string): BrowserWebview {
|
||||
return {
|
||||
getURL: () => url,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
} as unknown as BrowserWebview;
|
||||
}
|
||||
|
||||
test('a lazy tab is resolvable by its INTENDED url while deferred, then wakes exactly once', () => {
|
||||
const wv = fakeWebview('about:blank');
|
||||
registerWebview('b1', 't1', wv);
|
||||
let loaded = 0;
|
||||
registerPendingLoad(wv, 'https://tiktok.com/@me', () => { loaded += 1; });
|
||||
|
||||
assert.equal(isPendingLoad(wv), true);
|
||||
// about:blank live url can't match, but the intended-url fallback finds it for the session-borrow shims.
|
||||
assert.equal(findWebviewByDomain('tiktok.com'), wv);
|
||||
|
||||
assert.equal(wakePendingLoad(wv), true);
|
||||
assert.equal(loaded, 1);
|
||||
// Second wake is a no-op (already loaded), so an agent re-touching the tab can't double-load it.
|
||||
assert.equal(wakePendingLoad(wv), false);
|
||||
assert.equal(loaded, 1);
|
||||
assert.equal(isPendingLoad(wv), false);
|
||||
|
||||
unregisterWebview('b1', 't1');
|
||||
});
|
||||
|
||||
test('clearPendingLoad drops the deferred load without firing it (navigate replaces the url)', () => {
|
||||
const wv = fakeWebview('about:blank');
|
||||
registerWebview('b2', 't2', wv);
|
||||
let loaded = 0;
|
||||
registerPendingLoad(wv, 'https://old.example.com', () => { loaded += 1; });
|
||||
|
||||
clearPendingLoad(wv);
|
||||
assert.equal(isPendingLoad(wv), false);
|
||||
assert.equal(wakePendingLoad(wv), false);
|
||||
assert.equal(loaded, 0);
|
||||
|
||||
unregisterWebview('b2', 't2');
|
||||
});
|
||||
|
||||
test('a live-url tab still matches by its real url (unchanged path)', () => {
|
||||
const wv = fakeWebview('https://youtube.com/watch?v=x');
|
||||
registerWebview('b3', 't3', wv);
|
||||
assert.equal(findWebviewByDomain('youtube.com'), wv);
|
||||
assert.equal(isPendingLoad(wv), false);
|
||||
unregisterWebview('b3', 't3');
|
||||
});
|
||||
|
||||
test('a live tab wins over a deferred tab for the same domain', () => {
|
||||
const live = fakeWebview('https://reddit.com/r/x');
|
||||
const lazy = fakeWebview('about:blank');
|
||||
registerWebview('b4', 'live', live);
|
||||
registerWebview('b4', 'lazy', lazy);
|
||||
registerPendingLoad(lazy, 'https://reddit.com/r/y', () => {});
|
||||
// The already-loaded tab is preferred; the deferred one is only a fallback.
|
||||
assert.equal(findWebviewByDomain('reddit.com'), live);
|
||||
unregisterWebview('b4', 'live');
|
||||
unregisterWebview('b4', 'lazy');
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { getWebview } from './browserRegistry';
|
||||
import { getViewWebview } from './viewWebviewRegistry';
|
||||
import { getViewFrame } from './viewFrameRegistry';
|
||||
|
||||
// One arrow press moves the content about a wheel notch, so a held key and a trackpad flick cover ground at a comparable rate.
|
||||
const ARROW_STEP_PX = 120;
|
||||
|
||||
// Walks up from whatever sits at the middle of the view (a key press has no cursor to aim with) to the first ancestor that can still scroll horizontally the way dx points, nudges it, and reports whether anything actually moved. The boundary test is the same one the wheel path uses in useCanvasControls, so keys and trackpad hand the gesture back to the canvas at the same moment.
|
||||
// This runs in two worlds: stringified into a <webview> guest renderer, and called directly on a same-origin srcdoc iframe. Keep it self-contained - no imports, no closure references - or the stringified copy lands in the guest with dangling names.
|
||||
function scrollContentX(doc: Document, win: Window, dx: number): boolean {
|
||||
const nudge = (node: Element | null): boolean => {
|
||||
if (!node) return false;
|
||||
const el = node as HTMLElement;
|
||||
if (el.scrollWidth <= el.clientWidth) return false;
|
||||
// The document's own scroller reports overflowX 'visible' yet still scrolls, so it skips the overflow test the way a real browser does.
|
||||
const isViewport = el === doc.scrollingElement;
|
||||
const overflowX = win.getComputedStyle(el).overflowX;
|
||||
if (!isViewport && overflowX !== 'auto' && overflowX !== 'scroll') return false;
|
||||
const atRight = el.scrollLeft + el.clientWidth >= el.scrollWidth - 1;
|
||||
const atLeft = el.scrollLeft <= 1;
|
||||
if ((dx > 0 && atRight) || (dx < 0 && atLeft)) return false;
|
||||
// Instant, not smooth: a page with scroll-behavior smooth would otherwise still be animating when the next key repeat arrives.
|
||||
el.scrollBy({ left: dx, behavior: 'instant' });
|
||||
return true;
|
||||
};
|
||||
|
||||
let node: Element | null = doc.elementFromPoint(
|
||||
Math.floor(win.innerWidth / 2),
|
||||
Math.floor(win.innerHeight / 2),
|
||||
);
|
||||
while (node) {
|
||||
if (nudge(node)) return true;
|
||||
node = node.parentElement;
|
||||
}
|
||||
return nudge(doc.scrollingElement);
|
||||
}
|
||||
|
||||
// Present on real Electron webviews; a browser card falls back to a plain iframe on locked-out Windows builds, which has none of this.
|
||||
interface GuestWebview {
|
||||
executeJavaScript?: (code: string) => Promise<unknown>;
|
||||
}
|
||||
|
||||
/** Scrolls a card's own content sideways. True means the card absorbed the arrow, so the dashboard must not also navigate to a neighbor. */
|
||||
export async function scrollCardContentX(cardId: string, direction: 'left' | 'right'): Promise<boolean> {
|
||||
const dx = direction === 'right' ? ARROW_STEP_PX : -ARROW_STEP_PX;
|
||||
|
||||
const guest = (getWebview(cardId) ?? getViewWebview(cardId)) as GuestWebview | undefined;
|
||||
if (guest?.executeJavaScript) {
|
||||
// A guest is a separate renderer: the host can't read its scrollLeft, so the whole scroll-or-boundary decision has to be made over there and come back as a yes/no.
|
||||
try {
|
||||
const scrolled = await guest.executeJavaScript(`(${scrollContentX})(document, window, ${dx})`);
|
||||
return scrolled === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Srcdoc app card: same-origin, so the host can walk the frame's DOM directly. A cross-origin frame throws on contentWindow access; treat that as "didn't scroll" and let the arrow navigate.
|
||||
const frame = getViewFrame(cardId);
|
||||
try {
|
||||
const win = frame?.contentWindow;
|
||||
if (!win) return false;
|
||||
return scrollContentX(win.document, win, dx);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// The card you've clicked INTO, so plain scroll reads its content (chat transcript, scheduled-task
|
||||
// list) while scroll everywhere else zooms the canvas (Google Maps model). Imperative + read on the
|
||||
// wheel handler so no re-render; cleared when you click blank canvas. Browser/app cards aren't tracked
|
||||
// here: their guest page owns its own scroll/zoom (Maps, Figma), so plain wheel always stays in them.
|
||||
let scrollFocusedCardId: string | null = null;
|
||||
|
||||
export function setScrollFocusedCard(id: string | null): void {
|
||||
scrollFocusedCardId = id;
|
||||
}
|
||||
|
||||
export function getScrollFocusedCard(): string | null {
|
||||
return scrollFocusedCardId;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
import { normalizeSessionName } from './sessionDisplay';
|
||||
import { mergeSessionMessages } from './mergeSessionMessages';
|
||||
|
||||
const AGENTS_API = `${API_BASE}/agents`;
|
||||
|
||||
@@ -84,6 +85,12 @@ export interface AgentSession {
|
||||
cost_usd: number;
|
||||
tokens: { input: number; output: number };
|
||||
messages: AgentMessage[];
|
||||
/** Compact dashboard-list metadata; full messages are fetched when a chat opens. */
|
||||
last_message_preview?: string;
|
||||
first_user_message?: string;
|
||||
message_count?: number;
|
||||
/** WS seq high-water at snapshot time (GET /sessions only); seeds the resume cursor so connect skips replaying what REST just delivered. */
|
||||
event_seq?: number;
|
||||
pending_approvals: ApprovalRequest[];
|
||||
branches: Record<string, MessageBranch>;
|
||||
active_branch_id: string;
|
||||
@@ -344,25 +351,47 @@ export const fetchSession = createAsyncThunk(
|
||||
|
||||
export const launchAndSendFirstMessage = createAsyncThunk(
|
||||
'agents/launchAndSendFirstMessage',
|
||||
async ({ draftId, config, prompt, mode, model, provider, images, contextPaths, forcedTools, attachedSkills, selectedBrowserIds, selectedAppIds, selectedSettingIds }: LaunchAndSendPayload) => {
|
||||
const launchRes = await fetch(`${AGENTS_API}/launch`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
const launchData = await launchRes.json();
|
||||
const session = launchData.session as AgentSession;
|
||||
async ({ draftId, config, prompt, mode, model, provider, images, contextPaths, forcedTools, attachedSkills, selectedBrowserIds, selectedAppIds, selectedSettingIds }: LaunchAndSendPayload, { dispatch }) => {
|
||||
// Optimistic bubble on the DRAFT before the three round-trips (launch/message/refetch): without it the first message of every fresh chat rendered nothing until the network came back. The fulfilled rekey swaps in the server session, which carries the real turn by then.
|
||||
const clientMessageId = _genOptimisticId();
|
||||
dispatch(addOptimisticMessage({
|
||||
sessionId: draftId,
|
||||
clientMessageId,
|
||||
prompt,
|
||||
contextPaths,
|
||||
forcedTools,
|
||||
attachedSkills: attachedSkills?.map((s) => ({ id: s.id, name: s.name })),
|
||||
images: images?.map((img) => ({ data: img.data, media_type: img.media_type })),
|
||||
hidden: false,
|
||||
}));
|
||||
try {
|
||||
const launchRes = await fetch(`${AGENTS_API}/launch`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
const launchData = await launchRes.json();
|
||||
const session = launchData.session as AgentSession;
|
||||
|
||||
await fetch(`${AGENTS_API}/sessions/${session.id}/message`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt, mode, model, provider, images, context_paths: contextPaths, forced_tools: forcedTools, attached_skills: attachedSkills, selected_browser_ids: selectedBrowserIds, selected_app_output_ids: selectedAppIds, selected_setting_ids: selectedSettingIds }),
|
||||
});
|
||||
// Only the launch response is load-bearing (it mints the session id); the message POST runs off the critical path so the rekey (and the chat's stream hookup) doesn't wait a round trip. The optimistic bubble already shows the message and flips to failed if this dies.
|
||||
fetch(`${AGENTS_API}/sessions/${session.id}/message`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt, mode, model, provider, images, context_paths: contextPaths, forced_tools: forcedTools, attached_skills: attachedSkills, selected_browser_ids: selectedBrowserIds, selected_app_output_ids: selectedAppIds, selected_setting_ids: selectedSettingIds, client_message_id: clientMessageId }),
|
||||
}).then((res) => {
|
||||
if (!res.ok) throw new Error(`first message failed: ${res.status}`);
|
||||
}).catch(() => {
|
||||
// The bubble lives on whichever session the rekey race left it in; one of these no-ops.
|
||||
dispatch(markOptimisticFailed({ sessionId: session.id, clientMessageId }));
|
||||
dispatch(markOptimisticFailed({ sessionId: draftId, clientMessageId }));
|
||||
dispatch(updateSessionStatus({ sessionId: session.id, status: 'completed' }));
|
||||
});
|
||||
|
||||
const refreshRes = await fetch(`${AGENTS_API}/sessions/${session.id}`);
|
||||
const updatedSession = await refreshRes.json() as AgentSession;
|
||||
|
||||
return { draftId, session: updatedSession };
|
||||
return { draftId, session };
|
||||
} catch (err) {
|
||||
dispatch(markOptimisticFailed({ sessionId: draftId, clientMessageId }));
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -505,7 +534,8 @@ export const deleteSession = createAsyncThunk(
|
||||
export const fetchHistory = createAsyncThunk(
|
||||
'agents/fetchHistory',
|
||||
async ({ dashboardId }: { dashboardId?: string } = {}) => {
|
||||
const params = new URLSearchParams({ limit: '10000' });
|
||||
// closed_only: an OPEN session landing in state.history made updateSession's resurrection gate swallow its terminal frames (card stuck running, final answer invisible). Search (searchHistory) keeps the full pool.
|
||||
const params = new URLSearchParams({ limit: '10000', closed_only: '1' });
|
||||
if (dashboardId) params.set('dashboard_id', dashboardId);
|
||||
const res = await fetch(`${AGENTS_API}/history?${params}`);
|
||||
const data = await res.json();
|
||||
@@ -692,7 +722,8 @@ const agentsSlice = createSlice({
|
||||
if (state.history[action.payload.id]) {
|
||||
if (action.payload.status === 'running' || action.payload.mode === 'browser-agent') {
|
||||
delete state.history[action.payload.id];
|
||||
} else {
|
||||
} else if (!state.sessions[action.payload.id]) {
|
||||
// Gate only truly-closed sessions (no live card): a late frame must not resurrect them. A LIVE session that leaked into history used to have its completed frame swallowed here, leaving the card stuck running.
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -709,6 +740,9 @@ const agentsSlice = createSlice({
|
||||
state.sessions[action.payload.id] = {
|
||||
...action.payload,
|
||||
name: normalizeSessionName(action.payload.name),
|
||||
// Status frames replay stale on WS reconnect; the transcript and branch set only move forward here (fetchSession owns server-side deletes).
|
||||
messages: mergeSessionMessages(existing?.messages, action.payload.messages, false),
|
||||
branches: { ...existing?.branches, ...action.payload.branches },
|
||||
pending_approvals: mergedApprovals,
|
||||
tool_group_meta: { ...existing?.tool_group_meta, ...action.payload.tool_group_meta },
|
||||
};
|
||||
@@ -1193,8 +1227,21 @@ const agentsSlice = createSlice({
|
||||
.addCase(launchAndSendFirstMessage.fulfilled, (state, action) => {
|
||||
const { draftId, session } = action.payload;
|
||||
const shouldExpand = action.meta.arg.expand !== false;
|
||||
// The swap uses the LAUNCH response (no refetch round trip), so the user's message exists only as the draft's optimistic bubble; carry it (never the seeded greeting, which is cosmetic and must not reach the server session) plus anything the WS already landed under the server id.
|
||||
const carried = [
|
||||
...(state.sessions[session.id]?.messages ?? []),
|
||||
...(state.sessions[draftId]?.messages ?? []).filter((m) => m.optimistic_status),
|
||||
];
|
||||
delete state.sessions[draftId];
|
||||
state.sessions[session.id] = { ...session, name: normalizeSessionName(session.name), tool_group_meta: session.tool_group_meta ?? {}, pending_approvals: session.pending_approvals ?? [] };
|
||||
state.sessions[session.id] = {
|
||||
...session,
|
||||
name: normalizeSessionName(session.name),
|
||||
// The first message POST is in flight; its failure path flips this back (same optimism as sendMessage.pending).
|
||||
status: 'running',
|
||||
messages: mergeSessionMessages(carried, session.messages, false),
|
||||
tool_group_meta: session.tool_group_meta ?? {},
|
||||
pending_approvals: session.pending_approvals ?? [],
|
||||
};
|
||||
state.activeSessionId = session.id;
|
||||
state.draftLaunchMap[draftId] = session.id;
|
||||
state.expandedSessionIds = state.expandedSessionIds.map((id) => (id === draftId ? session.id : id));
|
||||
@@ -1362,35 +1409,18 @@ const agentsSlice = createSlice({
|
||||
const session = action.payload;
|
||||
const existing = state.sessions[session.id];
|
||||
// Preserve local messages the server snapshot doesn't carry yet. On remount mid-stream (leave the chat + come back) this fetch's snapshot predates the just-sent user turn, so a blind replace wiped the user's own bubble while the assistant stream (separate slice) kept going. The WS echo clears optimistic_status the instant it arrives, so the message is usually "confirmed but not yet server-persisted" rather than still 'pending' (that's why a pending-only filter missed it). Gate on the session being LIVE: on a running/streaming session, carry forward any local message the snapshot lacks; on a settled session the snapshot is authoritative (so a server-side delete isn't resurrected).
|
||||
const incomingMsgs = session.messages ?? [];
|
||||
// Live by EITHER side's account: a send on a completed chat flips local status to running while the racing snapshot still says completed and lacks the new turn; trusting only the snapshot wiped the user bubble until the run finished.
|
||||
const isLive = (s?: string) => s === 'running' || s === 'waiting_approval';
|
||||
// A streaming session counts as live even if neither status says 'running' (streaming lives in streamingSlice). Without this, a mid-stream reopen dropped the just-sent user bubble until the turn finished.
|
||||
const streamingActive = !!(session as AgentSession & { _streamingActive?: boolean })._streamingActive;
|
||||
const liveStatus = streamingActive || isLive(session.status) || isLive(existing?.status);
|
||||
const incomingClientIds = new Set(
|
||||
incomingMsgs.map((m) => m.client_message_id).filter(Boolean),
|
||||
);
|
||||
const incomingIds = new Set(incomingMsgs.map((m) => m.id));
|
||||
// An optimistic message (no WS echo yet) is preserved even when both sides read settled: right after a send on a completed chat, NEITHER status has flipped to running, and the racing snapshot wiped the just-typed bubble for seconds. It can't be a deleted-message resurrection; the server has never confirmed it existed.
|
||||
const surviving = (existing?.messages ?? []).filter(
|
||||
(m) =>
|
||||
(liveStatus || m.optimistic_status) &&
|
||||
!incomingIds.has(m.id) &&
|
||||
!(m.client_message_id && incomingClientIds.has(m.client_message_id)),
|
||||
);
|
||||
// Place survivors by timestamp, not blindly at the end: when the snapshot already carries the agent's reply, appending the just-sent user bubble rendered the OUTPUT above the INPUT. Insert before the first incoming message that is newer.
|
||||
const mergedMessages = surviving.length ? [...incomingMsgs] : incomingMsgs;
|
||||
for (const m of surviving) {
|
||||
const at = mergedMessages.findIndex((x) => (x.timestamp || '') > (m.timestamp || ''));
|
||||
if (at === -1) mergedMessages.push(m);
|
||||
else mergedMessages.splice(at, 0, m);
|
||||
}
|
||||
delete (session as AgentSession & { _streamingActive?: boolean })._streamingActive;
|
||||
// Deletes only apply on a settled session: a snapshot racing a live turn is stale, not authoritative.
|
||||
const stableMessages = mergeSessionMessages(existing?.messages, session.messages, !liveStatus);
|
||||
state.sessions[session.id] = {
|
||||
...session,
|
||||
name: normalizeSessionName(session.name),
|
||||
messages: mergedMessages,
|
||||
messages: stableMessages,
|
||||
pending_approvals: session.pending_approvals ?? existing?.pending_approvals ?? [],
|
||||
tool_group_meta: session.tool_group_meta ?? existing?.tool_group_meta ?? {},
|
||||
// mcp_suggestions live in client state only (the backend never returns them in the session payload). Preserve them across refresh so the suggestion banner stays put until the user dismisses it or activates one.
|
||||
@@ -1414,13 +1444,17 @@ const agentsSlice = createSlice({
|
||||
})
|
||||
.addCase(fetchBrowserAgentChildren.fulfilled, (state, action) => {
|
||||
for (const session of action.payload) {
|
||||
if (!state.sessions[session.id]) {
|
||||
const existing = state.sessions[session.id];
|
||||
if (!existing) {
|
||||
state.sessions[session.id] = {
|
||||
...session,
|
||||
name: normalizeSessionName(session.name),
|
||||
tool_group_meta: session.tool_group_meta ?? {},
|
||||
pending_approvals: session.pending_approvals ?? [],
|
||||
};
|
||||
} else if (existing.messages.length === 0 && session.messages.length > 0) {
|
||||
// Hydrate a child the trimmed session-list poll left message-less; don't touch one mid-stream (already has messages).
|
||||
existing.messages = session.messages;
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -392,8 +392,18 @@ export function findOpenSpotNear(
|
||||
};
|
||||
}
|
||||
|
||||
// Spiral by ring perimeter; right/down preference for stability.
|
||||
// Ring order approximates distance but returns the first-in-scan cell, which flings a card to a
|
||||
// far corner when the near cells are blocked (a big browser + expanded chats). Instead pick the
|
||||
// cell CLOSEST to the anchor by real distance: scan outward, and once a ring yields a free cell,
|
||||
// scan ONE more ring (a ring-r corner ~r*1.41 can lose to a ring-(r+1) edge) then take the nearest.
|
||||
const MAX_RING = 32;
|
||||
const spotDist = (col: number, row: number): number => {
|
||||
const x = GRID_ORIGIN.x + col * cellW;
|
||||
const y = GRID_ORIGIN.y + row * cellH;
|
||||
return Math.hypot(x - anchorX, y - anchorY);
|
||||
};
|
||||
let best: { col: number; row: number; d: number } | null = null;
|
||||
let firstHitRing = -1;
|
||||
for (let r = 1; r <= MAX_RING; r++) {
|
||||
for (let dy = -r; dy <= r; dy++) {
|
||||
for (let dx = -r; dx <= r; dx++) {
|
||||
@@ -401,14 +411,20 @@ export function findOpenSpotNear(
|
||||
const col = baseCol + dx;
|
||||
const row = baseRow + dy;
|
||||
if (col < 0 || row < 0) continue;
|
||||
if (cellFree(col, row)) {
|
||||
return {
|
||||
x: GRID_ORIGIN.x + col * cellW,
|
||||
y: GRID_ORIGIN.y + row * cellH,
|
||||
};
|
||||
}
|
||||
if (!cellFree(col, row)) continue;
|
||||
const d = spotDist(col, row);
|
||||
if (!best || d < best.d) best = { col, row, d };
|
||||
}
|
||||
}
|
||||
if (best && firstHitRing === -1) firstHitRing = r;
|
||||
// Scan one ring past the first hit (a ring-r corner can lose to a ring-(r+1) edge), then commit.
|
||||
if (firstHitRing !== -1 && r >= firstHitRing + 1) break;
|
||||
}
|
||||
if (best) {
|
||||
return {
|
||||
x: GRID_ORIGIN.x + best.col * cellW,
|
||||
y: GRID_ORIGIN.y + best.row * cellH,
|
||||
};
|
||||
}
|
||||
|
||||
// Pathological, full canvas occupied near anchor. Fall back to the global first-empty scan so we never return an overlap.
|
||||
@@ -511,8 +527,14 @@ export function computeSpawnPosition(
|
||||
return placeBesideCard(state, anchor.beside, newW, newH, expandedSessionIds);
|
||||
}
|
||||
if (anchor.viewportCenter) {
|
||||
// Land dead-center, "in front of you", even if a card is already there. Overlap is intentional (new card sits on top via its higher zOrder); dodging to free space is exactly the "spawned off to the side" behavior we're removing.
|
||||
return { x: anchor.viewportCenter.x - newW / 2, y: anchor.viewportCenter.y - newH / 2 };
|
||||
// Closest open gap to the viewport center: dead-center-with-overlap stacked spawns invisibly on top of each other (two center spawns in a row = the second fully covers the first). The spiral stays center-biased so it still reads as "in front of you".
|
||||
return findOpenSpotNear(
|
||||
anchor.viewportCenter.x - newW / 2,
|
||||
anchor.viewportCenter.y - newH / 2,
|
||||
collectOccupiedRects(state, expandedSessionIds),
|
||||
newW,
|
||||
newH,
|
||||
);
|
||||
}
|
||||
return findOpenGridCell(collectOccupiedRects(state, expandedSessionIds), newW, newH);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { AgentMessage } from './agentsSlice';
|
||||
|
||||
/** Merge a server snapshot's message list over the store's, so a stale or partial snapshot can
|
||||
* never wipe the transcript: WS status frames replay from seq 0 on every (re)connect (the
|
||||
* launch-time zero-message frame included), and whichever socket lands last used to blind-replace
|
||||
* newer local state, which is how first messages and edited histories vanished.
|
||||
*
|
||||
* allowDeletes: only the settled-session REST fetch may honor a server-side delete; WS frames and
|
||||
* the draft rekey never drop a local message the snapshot lacks. Optimistic messages always survive. */
|
||||
export function mergeSessionMessages(
|
||||
existing: AgentMessage[] | undefined,
|
||||
incoming: AgentMessage[] | undefined,
|
||||
allowDeletes: boolean,
|
||||
): AgentMessage[] {
|
||||
const incomingMsgs = incoming ?? [];
|
||||
const existingMsgs = existing ?? [];
|
||||
const incomingIds = new Set(incomingMsgs.map((m) => m.id));
|
||||
const incomingClientIds = new Set(
|
||||
incomingMsgs.map((m) => m.client_message_id).filter(Boolean),
|
||||
);
|
||||
const surviving = existingMsgs.filter(
|
||||
(m) =>
|
||||
(!allowDeletes || m.optimistic_status) &&
|
||||
!incomingIds.has(m.id) &&
|
||||
!(m.client_message_id && incomingClientIds.has(m.client_message_id)),
|
||||
);
|
||||
// Place survivors by timestamp, not blindly at the end: when the snapshot already carries the agent's reply, appending the just-sent user bubble rendered the OUTPUT above the INPUT.
|
||||
const merged = surviving.length ? [...incomingMsgs] : incomingMsgs;
|
||||
for (const m of surviving) {
|
||||
const at = merged.findIndex((x) => (x.timestamp || '') > (m.timestamp || ''));
|
||||
if (at === -1) merged.push(m);
|
||||
else merged.splice(at, 0, m);
|
||||
}
|
||||
// Keep the EXISTING object for any message the snapshot didn't change: fresh JSON clones of identical messages break every bubble's React.memo (a whole-transcript re-render hitch per frame).
|
||||
const prevById = new Map(existingMsgs.map((m) => [m.id, m]));
|
||||
const contentUnchanged = (a: AgentMessage, b: AgentMessage): boolean =>
|
||||
typeof a.content === 'string' && typeof b.content === 'string'
|
||||
? a.content === b.content
|
||||
: Array.isArray(a.content) && Array.isArray(b.content) && a.content.length === b.content.length;
|
||||
return merged.map((m) => {
|
||||
const prev = prevById.get(m.id);
|
||||
return prev && prev.timestamp === m.timestamp && prev.role === m.role && contentUnchanged(prev, m) ? prev : m;
|
||||
});
|
||||
}
|
||||
@@ -36,8 +36,13 @@ export function displayChatTitle(session: AgentSession | null | undefined): stri
|
||||
return session.name;
|
||||
}
|
||||
const firstUserMsg = session.messages?.find((m) => m.role === 'user');
|
||||
if (firstUserMsg && typeof firstUserMsg.content === 'string') {
|
||||
const truncated = truncateForTitle(firstUserMsg.content);
|
||||
const firstUserContent = firstUserMsg && typeof firstUserMsg.content === 'string'
|
||||
? firstUserMsg.content
|
||||
: session.messages.length === 0
|
||||
? session.first_user_message
|
||||
: undefined;
|
||||
if (firstUserContent) {
|
||||
const truncated = truncateForTitle(firstUserContent);
|
||||
if (truncated) return truncated;
|
||||
}
|
||||
return session.mode === 'view-builder' ? 'Untitled App' : SESSION_NAME_PLACEHOLDER;
|
||||
|
||||
@@ -69,6 +69,8 @@ export interface Workflow {
|
||||
deleted_at?: string | null;
|
||||
system_prompt: string | null;
|
||||
use_synced_prompt: boolean;
|
||||
/** Agents may run this workflow via the InvokeWorkflow tool (opt-in per workflow on the Actions page). */
|
||||
exposed_as_tool?: boolean;
|
||||
steps: WorkflowStep[];
|
||||
actions: ActionsConfig;
|
||||
schedule: ScheduleConfig;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// Srcdoc app-card iframes keyed by card key. Mirror of viewWebviewRegistry for the outputs that render as an iframe instead of a <webview> (no serve URL): the dashboard's arrow-key handler needs a handle on the card's content to scroll it, and a srcdoc frame is same-origin, so no IPC is involved.
|
||||
const registry = new Map<string, HTMLIFrameElement>();
|
||||
|
||||
export function registerViewFrame(cardKey: string, frame: HTMLIFrameElement): void {
|
||||
registry.set(cardKey, frame);
|
||||
}
|
||||
|
||||
export function unregisterViewFrame(cardKey: string): void {
|
||||
registry.delete(cardKey);
|
||||
}
|
||||
|
||||
export function getViewFrame(cardKey: string): HTMLIFrameElement | undefined {
|
||||
return registry.get(cardKey);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
// Live app-card preview webviews keyed by output id. The delete path looks a card's <webview> up here to quiesce its GPU surface BEFORE React rips the element out; without it, deleting a couple of large app cards at once tears down several live SharedImage surfaces in one frame, which piles up "non-existent mailbox" errors and kills the GPU process (taking the whole app down with no dump). Mirror of browserRegistry, for the non-CDP preview webviews.
|
||||
export interface ViewWebview extends HTMLElement {
|
||||
loadURL: (url: string) => Promise<void>;
|
||||
// Optional: present on real Electron webviews, absent on any non-Electron stand-in, so callers must ?.() it.
|
||||
executeJavaScript?: (code: string) => Promise<unknown>;
|
||||
}
|
||||
|
||||
const registry = new Map<string, ViewWebview>();
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
clearTurnLabel,
|
||||
} from '../state/agentsSlice';
|
||||
import { streamStart, streamDelta, streamEnd, clearStreamingForSession } from '../state/streamingSlice';
|
||||
import { addBrowserCardFromBackend, markBrowserCardEnding, keepBrowserCardOpen, placeBesideCard, placeBelowCard, placeBrowserBesideChat, setBrowserCardPosition, setGlowingBrowserCards, fadeGlowingBrowserCards, clearGlowingBrowserCards, GRID_GAP, WORKFLOW_CARD_GAP, openWorkflowsApp, openWorkflowMonitor } from '../state/dashboardLayoutSlice';
|
||||
import { addBrowserCardFromBackend, markBrowserCardEnding, keepBrowserCardOpen, placeBesideCard, placeBelowCard, placeBrowserBesideChat, setBrowserCardPosition, setGlowingBrowserCards, fadeGlowingBrowserCards, clearGlowingBrowserCards, removeBrowserCard, GRID_GAP, WORKFLOW_CARD_GAP, openWorkflowsApp, openWorkflowMonitor } from '../state/dashboardLayoutSlice';
|
||||
import { upsertOutput } from '../state/outputsSlice';
|
||||
import { fetchSettings } from '../state/settingsSlice';
|
||||
import { displaySessionName } from '../state/sessionDisplay';
|
||||
@@ -324,6 +324,20 @@ class WebSocketManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Cross-socket dedupe: the backend fans every session frame out to BOTH the dashboard socket and the chat's own socket (same stamped seq), so an expanded chat parsed and reduced everything twice, and whichever copy landed second could be a replayed stale one. Time-windowed rather than a high-water mark so a deliberate later replay (gap recovery resets lastSeq to 0) is never starved.
|
||||
if (typeof msg.seq === 'number' && session_id) {
|
||||
const key = `${session_id}:${msg.seq}`;
|
||||
const now = Date.now();
|
||||
const seen = _recentFrameTimes.get(key);
|
||||
if (seen !== undefined && now - seen < FRAME_DEDUPE_WINDOW_MS) return;
|
||||
_recentFrameTimes.set(key, now);
|
||||
if (_recentFrameTimes.size > 4000) {
|
||||
for (const [k, t] of _recentFrameTimes) {
|
||||
if (now - t >= FRAME_DEDUPE_WINDOW_MS) _recentFrameTimes.delete(k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Connection-scoped frames (no business-logic side effects) -----
|
||||
|
||||
if (event === 'server:pong') {
|
||||
@@ -351,6 +365,10 @@ class WebSocketManager {
|
||||
// Reset lastSeq, the REST refetch is the new authoritative baseline; subsequent server events with seq numbers will re-establish the high-water mark. Also wipe the cross-mount persistent map so a remount during this gap window doesn't resurrect the stale value.
|
||||
this.lastSeq = 0;
|
||||
_sessionLastSeq.delete(session_id);
|
||||
// The recovery replay re-delivers seqs possibly seen moments ago; drop them from the dedupe window so it's never starved.
|
||||
for (const k of _recentFrameTimes.keys()) {
|
||||
if (k.startsWith(`${session_id}:`)) _recentFrameTimes.delete(k);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -384,6 +402,17 @@ class WebSocketManager {
|
||||
store.dispatch(trackAgentNotification(session_id));
|
||||
}
|
||||
|
||||
// An AppAgent driving an app card announces itself only via this status event (no card_added like browsers), so light the app card here. Keyed by the parent chat like browser glows, so the same terminal fade below clears it.
|
||||
const p_sess = data.session;
|
||||
if (p_sess && p_sess.mode === 'browser-agent' && typeof p_sess.browser_id === 'string' && p_sess.browser_id.startsWith('app:')
|
||||
&& (p_sess.status === 'running' || p_sess.status === 'waiting_approval')) {
|
||||
store.dispatch(setGlowingBrowserCards({
|
||||
browserIds: [p_sess.browser_id],
|
||||
sessionId: p_sess.parent_session_id || p_sess.id,
|
||||
label: 'Use App',
|
||||
}));
|
||||
}
|
||||
|
||||
// Fade this session's browser glows on the terminal transition HERE, not only in AgentChat's effect: a collapsed chat is unmounted at finish, and a never-faded glow pins the browser's renderer (exempt from suspend + the webview cap) forever.
|
||||
const newStatus = data.status ?? data.session?.status;
|
||||
const wasWorking = prevStatus === 'running' || prevStatus === 'waiting_approval';
|
||||
@@ -791,6 +820,13 @@ class WebSocketManager {
|
||||
}
|
||||
break;
|
||||
|
||||
case 'dashboard:browser_card_evict':
|
||||
// A wedged card the backend is tearing down BEFORE it spawns a recovery card. Remove it now (no fade, no Keep pill) so its <webview> unmounts and stops starving the renderer while the fresh card mounts.
|
||||
if (data.browser_id) {
|
||||
store.dispatch(removeBrowserCard(data.browser_id));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'dashboard:browser_card_added':
|
||||
if (data.browser_card) {
|
||||
// Tag with origin dashboard so the card renders only on the dashboard that spawned it, without this, a browser spawned by an agent on dashboard A leaks into whatever dashboard the user is currently viewing (the global browserCards dict + unfiltered render).
|
||||
@@ -929,6 +965,17 @@ export const dashboardWs = new WebSocketManager(`${WS_BASE}/ws/dashboard`, { ski
|
||||
// Per-session high-water mark for the resume protocol. Survives across AgentChat mounts/unmounts so reopening a chat doesn't re-trigger a full replay from the server's ring buffer. Why this exists: AgentChat uses `key={session.id}` on the embedded instance inside AgentCard, so every expand/collapse remounts the component, which constructs a fresh WebSocketManager. Without this persistent map, each fresh manager starts at last_seq=0 and asks the server for the entire buffered history. The server faithfully replays it, the client renders the typewriter animation again, and the user sees their completed chat "type itself out" on every reopen. Lifetime: tied to the JS module load, which means the page tab. Lost on full app reload (intentional, that should re-hydrate from REST). On backend restart the buffers are wiped anyway, so a stale lastSeq pointing past the buffer top falls into the "fresh client" path on the server (last_seq>0 but no buffer) which short-circuits to a no-op replay. Safe.
|
||||
const _sessionLastSeq: Map<string, number> = new Map();
|
||||
|
||||
// (session_id:seq) -> arrival time; entries older than the window are prunable. Bounded by event rate x window, not session count.
|
||||
const FRAME_DEDUPE_WINDOW_MS = 5_000;
|
||||
const _recentFrameTimes: Map<string, number> = new Map();
|
||||
|
||||
/** Seed the resume cursor from a REST hydrate (GET /sessions returns event_seq), so the follow-up WS connect replays only what happened AFTER the snapshot instead of the whole ring buffer the client just received as JSON. Never lowers an existing high-water mark. */
|
||||
export function seedSessionSeq(sessionId: string, seq: number): void {
|
||||
if (typeof seq !== 'number' || seq <= 0) return;
|
||||
const cur = _sessionLastSeq.get(sessionId) ?? 0;
|
||||
if (seq > cur) _sessionLastSeq.set(sessionId, seq);
|
||||
}
|
||||
|
||||
export function createSessionWs(sessionId: string): WebSocketManager {
|
||||
return new WebSocketManager(`${WS_BASE}/ws/agents/${sessionId}`, { sessionId });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user