[aidan] ui/ux: app navigation

This commit is contained in:
abccodes
2026-06-14 06:58:34 -07:00
parent 4a6c5881b1
commit 5521542fd5
3 changed files with 102 additions and 11 deletions
@@ -9,8 +9,8 @@ import RestartAltIcon from '@mui/icons-material/RestartAlt';
import CloseIcon from '@mui/icons-material/Close';
import GridViewRoundedIcon from '@mui/icons-material/GridViewRounded';
import { Output, SERVE_BASE } from '@/shared/state/outputsSlice';
import { setViewCardPosition, setViewCardSize, removeViewCard } from '@/shared/state/dashboardLayoutSlice';
import { useAppDispatch } from '@/shared/hooks';
import { setViewCardPosition, setViewCardSize, removeViewCard, setActiveViewCardId } from '@/shared/state/dashboardLayoutSlice';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { API_BASE, getAuthToken } from '@/shared/config';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import ViewPreview, { ViewPreviewHandle } from '@/app/pages/Views/ViewPreview';
@@ -75,6 +75,23 @@ const DashboardViewCard: React.FC<Props> = ({
const dispatch = useAppDispatch();
const scrollOverlayRef = useOverlayScrollPassthrough(isSelected);
const previewRef = useRef<ViewPreviewHandle>(null);
const activeViewCardId = useAppSelector((s) => s.dashboardLayout.activeViewCardId);
const interactive = activeViewCardId === output.id;
// Deselecting the card exits interact mode (click anywhere else on canvas).
useEffect(() => {
if (!isSelected && interactive) dispatch(setActiveViewCardId(null));
}, [isSelected, interactive, dispatch]);
// Escape exits interact mode.
useEffect(() => {
if (!interactive) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') dispatch(setActiveViewCardId(null));
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [interactive, dispatch]);
const [inputData] = useState<Record<string, any>>(() => getDefault(output.input_schema));
const [backendResult] = useState<Record<string, any> | null>(null);
@@ -288,7 +305,9 @@ const DashboardViewCard: React.FC<Props> = ({
borderRadius: `${c.radius.lg}px`,
border: isHighlighted
? `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`
@@ -406,6 +425,8 @@ const DashboardViewCard: React.FC<Props> = ({
output={output}
inputData={inputData}
backendResult={backendResult}
interactive={interactive}
onAppClicked={() => dispatch(setActiveViewCardId(output.id))}
/>
</Box>
@@ -487,7 +508,9 @@ const DashboardOutputPreview: React.FC<{
output: Output;
inputData: Record<string, any>;
backendResult: any;
}> = ({ previewRef, output, inputData, backendResult }) => {
interactive: boolean;
onAppClicked: () => void;
}> = ({ previewRef, output, inputData, backendResult, interactive, onAppClicked }) => {
const tokens = useClaudeTokens();
const dispatch = useAppDispatch();
const workspaceId = output.workspace_id ?? null;
@@ -587,6 +610,8 @@ const DashboardOutputPreview: React.FC<{
frontendCode={output.files?.['index.html'] ?? ''}
inputData={inputData}
backendResult={backendResult}
interactive={interactive}
onAppClicked={onAppClicked}
/>
);
};
+64 -7
View File
@@ -55,6 +55,10 @@ interface Props {
onConsoleMessage?: (level: string, text: string) => void;
/** Fires once the embedded app has actually painted, so cold-start placeholders don't unmount during the vite-ready to first-paint gap. */
onContentLoad?: () => void;
/** True when the user has clicked into the app; preload stops forwarding canvas gestures and lets the app handle all events. */
interactive?: boolean;
/** Fired when the preload reports a mousedown inside the guest, so the host can flip the card into interactive mode. */
onAppClicked?: () => void;
}
function buildSrcdoc(
@@ -92,6 +96,8 @@ const ViewPreview = forwardRef<ViewPreviewHandle, Props>(({
style,
onConsoleMessage,
onContentLoad,
interactive = false,
onAppClicked,
}, ref) => {
const iframeRef = useRef<HTMLIFrameElement>(null);
const webviewRef = useRef<any>(null);
@@ -219,22 +225,72 @@ const ViewPreview = forwardRef<ViewPreviewHandle, Props>(({
}
}, [srcdoc, useWebview]);
// Forward webview-console events (preload wraps console.*) to onConsoleMessage; iframe path has no equivalent.
// Listen for preload IPC: console forwarding, canvas wheel forwarding
// (matches BrowserCard so apps share the same dashboard pan/zoom defaults),
// and the app-clicked notification that flips the card into interact mode.
useEffect(() => {
if (!useWebview || !onConsoleMessage) return;
if (!useWebview) return;
const wv = webviewRef.current;
if (!wv) return;
const handler = (e: any) => {
if (e?.channel !== 'webview-console') return;
const arg = Array.isArray(e.args) ? e.args[0] : undefined;
if (!arg) return;
onConsoleMessage(arg.level || 'log', arg.text || '');
if (e?.channel === 'webview-console') {
if (!onConsoleMessage) return;
const arg = Array.isArray(e.args) ? e.args[0] : undefined;
if (!arg) return;
onConsoleMessage(arg.level || 'log', arg.text || '');
return;
}
if (e?.channel === 'canvas-wheel-zoom') {
const payload = e.args?.[0] || {};
const wvRect = wv.getBoundingClientRect();
const fx = typeof payload.fracX === 'number' ? payload.fracX : 0.5;
const fy = typeof payload.fracY === 'number' ? payload.fracY : 0.5;
window.dispatchEvent(
new CustomEvent('openswarm:canvas-wheel-zoom', {
detail: {
deltaY: payload.deltaY ?? 0,
deltaMode: payload.deltaMode ?? 0,
clientX: wvRect.left + fx * wvRect.width,
clientY: wvRect.top + fy * wvRect.height,
},
}),
);
return;
}
if (e?.channel === 'canvas-wheel-pan') {
const payload = e.args?.[0] || {};
window.dispatchEvent(
new CustomEvent('openswarm:canvas-wheel-pan', {
detail: {
deltaX: payload.deltaX ?? 0,
deltaY: payload.deltaY ?? 0,
deltaMode: payload.deltaMode ?? 0,
},
}),
);
return;
}
if (e?.channel === 'app-clicked') {
onAppClicked?.();
return;
}
};
wv.addEventListener?.('ipc-message', handler);
return () => {
try { wv.removeEventListener?.('ipc-message', handler); } catch (_e) {}
};
}, [useWebview, onConsoleMessage, iframeSrc]);
}, [useWebview, onConsoleMessage, onAppClicked, iframeSrc]);
// 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;
useEffect(() => {
if (!useWebview) return;
const wv = webviewRef.current;
if (!wv) return;
try { wv.send?.('openswarm:set-interactive', { interactive }); } catch (_e) {}
}, [useWebview, interactive]);
// Webviews use did-finish-load instead of onLoad; did-fail-load retries with 500ms to 5s backoff (Vite may not have bound yet when frontend_url arrives).
useEffect(() => {
@@ -256,6 +312,7 @@ const ViewPreview = forwardRef<ViewPreviewHandle, Props>(({
retryDelay = 500;
cancelRetry();
handleNavigationLoad();
try { wv.send?.('openswarm:set-interactive', { interactive: interactiveRef.current }); } catch (_) {}
};
const onFail = (e: any) => {
// Guard on isMainFrame (subresource 404s fire too) and ERR_ABORTED (user-cancel).
@@ -98,6 +98,8 @@ export interface DashboardLayoutState {
suspendedBrowserCards: Record<string, { dataUrl: string; capturedAt: number }>;
/** Transient: spawned cards that are about to be removed; surfaces the fade + Keep pill. */
endingBrowserCards: Record<string, { status: 'completed' | 'error'; at: number }>;
/** Transient: id of the view card the user has clicked into; preload stops forwarding canvas gestures while set. */
activeViewCardId: string | null;
}
const initialState: DashboardLayoutState = {
@@ -116,6 +118,7 @@ const initialState: DashboardLayoutState = {
pendingFocusNoteId: null,
suspendedBrowserCards: {},
endingBrowserCards: {},
activeViewCardId: null,
};
interface LayoutPayload {
@@ -565,6 +568,11 @@ const dashboardLayoutSlice = createSlice({
removeViewCard(state, action: PayloadAction<string>) {
delete state.viewCards[action.payload];
if (state.activeViewCardId === action.payload) state.activeViewCardId = null;
},
setActiveViewCardId(state, action: PayloadAction<string | null>) {
state.activeViewCardId = action.payload;
},
addBrowserCard(state, action: PayloadAction<{ url: string; expandedSessionIds?: string[] }>) {
@@ -1080,6 +1088,7 @@ export const {
setViewCardPosition,
setViewCardSize,
removeViewCard,
setActiveViewCardId,
addBrowserCard,
addBrowserCardFromBackend,
setBrowserCardPosition,