From 9fee353fda3c5a19477766d2aafc0116437e872b Mon Sep 17 00:00:00 2001 From: SirKentut <81878031+SirKentut@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:21:12 -0700 Subject: [PATCH 01/57] [pierre] swarm: de-lazy WorkflowExportable now that the workflow store is on this branch The module was written when the workflow store lived only on eric/workflow, so every store touch went through p_store()/p_model() try/except-returning-None. Both resolve on this branch (the round-trip test already asserts as much), so those guards had become dead code that would swallow a genuine ImportError in storage.py and surface it to the user as "this build doesn't support workflows yet; please update OpenSwarm". Imports storage/Workflow at module level, drops the None branches, and rewrites the stale docstring. The safety contract is unchanged and still covered by test_workflow_sanitize_disables_schedule_and_strips_pii: an imported workflow never auto-runs, and the sharer's phone number never rides along. --- backend/apps/swarm/entities/workflows.py | 47 +++++------------------- 1 file changed, 10 insertions(+), 37 deletions(-) diff --git a/backend/apps/swarm/entities/workflows.py b/backend/apps/swarm/entities/workflows.py index 42a8e92e..0b2ddef3 100644 --- a/backend/apps/swarm/entities/workflows.py +++ b/backend/apps/swarm/entities/workflows.py @@ -1,8 +1,5 @@ """WorkflowExportable: shares a scheduled-task/workflow recipe (steps, schedule -shape, actions, model). The workflow store lives on the eric/workflow branch and -is NOT on eric/dev yet, so every store touch is lazy: on a build without it, -export finds nothing and import fails with a clear message, and the module still -imports cleanly. It lights up the moment the workflow forward-port lands. +shape, actions, model). Safety: an imported workflow must never silently start running on someone else's machine, so the schedule is forced off on import (the importer re-arms it). The @@ -12,6 +9,8 @@ from __future__ import annotations from backend.apps.swarm.exportable import DepRef, ExportContext, RemapTable from backend.apps.swarm.models import EntityType, Requirement, RequirementKind +from backend.apps.workflows import storage +from backend.apps.workflows.models import Workflow P_BUILTIN_MODES = {"agent", "ask", "plan", "view-builder", "skill-builder"} @@ -52,10 +51,7 @@ class WorkflowExportable: @classmethod def load(cls, local_id: str) -> "WorkflowExportable | None": - store = p_store() - if store is None: - return None - wf = store.get_workflow(local_id) + wf = storage.get_workflow(local_id) if wf is None: return None data = wf.model_dump(mode="json") @@ -92,38 +88,15 @@ class WorkflowExportable: @classmethod def import_(cls, payload: dict, files: dict[str, bytes], remap: RemapTable) -> str: - store = p_store() - model = p_model() - if store is None or model is None: - from backend.apps.swarm.ziputil import BundleError - raise BundleError("this build doesn't support workflows yet; please update OpenSwarm") clean = sanitize_workflow(payload) clean.pop("id", None) # fresh id via the model's default_factory - wf = model(**clean) - store.save_workflow(wf) + wf = Workflow(**clean) + storage.save_workflow(wf) return wf.id @classmethod def rollback(cls, local_id: str) -> None: - store = p_store() - if store is not None: - try: - store.delete_workflow(local_id) - except Exception: - pass - - -def p_store(): - try: - from backend.apps.workflows import storage - return storage - except Exception: - return None - - -def p_model(): - try: - from backend.apps.workflows.models import Workflow - return Workflow - except Exception: - return None + try: + storage.delete_workflow(local_id) + except Exception: + pass From 6ee392839f723f8359226882d87c3586adf108fd Mon Sep 17 00:00:00 2001 From: SirKentut <81878031+SirKentut@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:55:00 -0700 Subject: [PATCH 02/57] [pierre] workflows: share the open workflow as a .swarm from the hub title bar Adds the Share affordance to the Workflows hub, left of the close button. It only appears in detail view, so it always has an unambiguous target; goHome leaves selectedId set, hence the explicit mode check. Everything under it already existed: WorkflowExportable is registered and ShareKind already had 'workflow'. The hub simply never rendered a ShareButton. The title bar moves from the card into the content. The card is at 281 of the linter's 300-line cap, so lifting nav state up into it would have breached the cap; pushing the bar down to the state instead keeps both files well under, and splits on a better seam anyway (card = drag/resize geometry, content = the app and its chrome). The card hands its drag handlers down via CardHeader. The share dialog portals to the body but its events still bubble the React tree, so the wrapper stops click/pointerdown; without it, dragging the card follows a click inside the modal. Verified in the running app: share icon absent on Home, present in detail and left of the X, and the modal builds a real workflow bundle. --- .../pages/Workflows/app/WorkflowsAppCard.tsx | 42 +++-------- .../Workflows/app/WorkflowsAppContent.tsx | 74 ++++++++++++++++--- frontend/src/app/pages/Workflows/app/types.ts | 10 +++ 3 files changed, 82 insertions(+), 44 deletions(-) diff --git a/frontend/src/app/pages/Workflows/app/WorkflowsAppCard.tsx b/frontend/src/app/pages/Workflows/app/WorkflowsAppCard.tsx index 60c150ad..88aab2f6 100644 --- a/frontend/src/app/pages/Workflows/app/WorkflowsAppCard.tsx +++ b/frontend/src/app/pages/Workflows/app/WorkflowsAppCard.tsx @@ -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'; @@ -58,7 +54,6 @@ const WorkflowsAppCard: React.FC = ({ onCardSelect, onDragStart, onDragMove, onDragEnd, onBringToFront, }) => { const WC = useWC(); - const c = useClaudeTokens(); const dispatch = useAppDispatch(); const panRef = useRef({ panX, panY }); @@ -216,31 +211,14 @@ const WorkflowsAppCard: React.FC = ({ transition: noTransition ? 'none' : 'box-shadow 0.3s ease, border-color 0.2s ease', }} > - {/* TITLE BAR (drag handle) */} -
-
- - Workflows -
-
- { e.stopPropagation(); dispatch(closeWorkflowsApp()); }} - onPointerDown={(e) => e.stopPropagation()} - sx={{ color: c.text.tertiary, '&:hover': { color: c.status.error, bgcolor: `${c.status.error}14` } }} - > - - -
- - + {HANDLE_DEFS.map(({ dir, css }) => (
{ +// 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('month'); const [refDate, setRefDate] = useState(() => 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 ( -
- - {mode === 'home' && } - {mode === 'calendar' && } - {mode === 'detail' && selectedId && } - {mode === 'new' && } - {mode === 'trash' && } +
+ {/* TITLE BAR (drag handle) */} +
+
+ + Workflows +
+
+ {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. + e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + style={{ display: 'flex' }} + > + + + )} + { e.stopPropagation(); dispatch(closeWorkflowsApp()); }} + onPointerDown={(e) => e.stopPropagation()} + sx={{ color: c.text.tertiary, '&:hover': { color: c.status.error, bgcolor: `${c.status.error}14` } }} + > + + +
+ +
+ + {mode === 'home' && } + {mode === 'calendar' && } + {mode === 'detail' && selectedId && } + {mode === 'new' && } + {mode === 'trash' && } +
); }; diff --git a/frontend/src/app/pages/Workflows/app/types.ts b/frontend/src/app/pages/Workflows/app/types.ts index ca086556..6340614b 100644 --- a/frontend/src/app/pages/Workflows/app/types.ts +++ b/frontend/src/app/pages/Workflows/app/types.ts @@ -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; From 1375e6126bcb69c294d81c1fd6b973adcb6801a7 Mon Sep 17 00:00:00 2001 From: SirKentut <81878031+SirKentut@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:58:27 -0700 Subject: [PATCH 03/57] [pierre] workflows: share any workflow straight from the sidebar row on hover Puts Share beside the existing trash icon on each rail row, so a workflow can be shared without opening it first. Trash keeps its current always-visible styling. The button is faded out rather than unmounted when the row loses hover: ShareButton owns the modal's open state, so unmounting on hover-out would slam the dialog shut the instant the pointer left the row to reach it. Keeping it mounted also stops the row reflowing as the icon appears. --- .../src/app/pages/Workflows/app/LeftRail.tsx | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/frontend/src/app/pages/Workflows/app/LeftRail.tsx b/frontend/src/app/pages/Workflows/app/LeftRail.tsx index ad74e9d3..11625702 100644 --- a/frontend/src/app/pages/Workflows/app/LeftRail.tsx +++ b/frontend/src/app/pages/Workflows/app/LeftRail.tsx @@ -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(null); const workflows = useMemo(() => Object.values(items) .filter((w) => !w.unsaved) @@ -93,6 +95,8 @@ const LeftRail: React.FC<{ nav: AppNav }> = ({ nav }) => {
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' }} >
@@ -104,6 +108,22 @@ const LeftRail: React.FC<{ nav: AppNav }> = ({ nav }) => { {active ? describeSchedule(w.schedule) : 'Paused'}
+ {/* 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. */} + e.stopPropagation()} + style={{ + display: 'flex', + flex: 'none', + opacity: hovered === w.id ? 1 : 0, + pointerEvents: hovered === w.id ? 'auto' : 'none', + transition: 'opacity 0.12s', + }} + > + +
{ 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' }} From fb8745f9a3fe37bea7bcd2b340fef40a21a19daa Mon Sep 17 00:00:00 2001 From: SirKentut <81878031+SirKentut@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:04:47 -0700 Subject: [PATCH 04/57] [pierre] share: refresh the workflow list after importing a workflow .swarm Import already handled workflow bundles, but nothing told the UI. Unlike a dashboard there is no route to navigate to, so the hub -- which only fetches on mount -- kept showing a stale list and the imported workflow looked like it had vanished until a remount. Refetch on a workflow-root import. Import drops dashboard_id and /list keeps unassigned workflows for every dashboard, so it shows up wherever the user is. --- frontend/src/app/components/share/ImportEntryPoint.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/components/share/ImportEntryPoint.tsx b/frontend/src/app/components/share/ImportEntryPoint.tsx index a9a8ead0..8089d2b5 100644 --- a/frontend/src/app/components/share/ImportEntryPoint.tsx +++ b/frontend/src/app/components/share/ImportEntryPoint.tsx @@ -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((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(null); const digestRef = useRef(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( From a422d4d79ee4aa08047251e9ed6436f5c1b08c3e Mon Sep 17 00:00:00 2001 From: abccodes Date: Mon, 13 Jul 2026 21:21:30 -0700 Subject: [PATCH 05/57] [aidan] fix/chat-history: stop retaining unopened histories --- backend/apps/agents/agents.py | 42 +++++++++---- backend/tests/test_agent_session_list.py | 59 +++++++++++++++++++ .../shell/BrowserAgentInlineFeed.tsx | 16 +++-- .../app/pages/Dashboard/cards/AgentCard.tsx | 13 ++-- .../hooks/lifecycle/useDashboardLifecycle.ts | 5 +- frontend/src/shared/state/agentsSlice.ts | 4 ++ frontend/src/shared/state/sessionDisplay.ts | 9 ++- 7 files changed, 125 insertions(+), 23 deletions(-) create mode 100644 backend/tests/test_agent_session_list.py diff --git a/backend/apps/agents/agents.py b/backend/apps/agents/agents.py index 34291509..07c059d0 100644 --- a/backend/apps/agents/agents.py +++ b/backend/apps/agents/agents.py @@ -1,15 +1,16 @@ -from backend.config.Apps import SubApp -from backend.apps.agents.agent_manager import agent_manager -from backend.apps.agents.core.ws_manager import ws_manager -from backend.apps.agents.core.models import AgentConfig, ApprovalResponse -from backend.apps.agents.manager.session.history_compaction import estimate_post_compact_input -from contextlib import asynccontextmanager -from fastapi import WebSocket, WebSocketDisconnect, HTTPException -from fastapi.responses import JSONResponse import asyncio -import json import logging import time +from contextlib import asynccontextmanager +from typing import Any, Dict + +from fastapi import HTTPException +from typeguard import typechecked + +from backend.apps.agents.agent_manager import agent_manager +from backend.apps.agents.core.models import AgentConfig, AgentSession, ApprovalResponse +from backend.apps.agents.manager.session.history_compaction import estimate_post_compact_input +from backend.config.Apps import SubApp logger = logging.getLogger(__name__) @@ -39,10 +40,31 @@ async def agents_lifespan(): agents = SubApp("agents", agents_lifespan) +@typechecked +def p_session_list_item(session: AgentSession) -> Dict[str, Any]: + """Serialize dashboard metadata without retaining the full chat history.""" + data = session.model_dump(mode="json", exclude={"messages"}) + messages = session.messages + last_content = messages[-1].content if messages else "" + first_user_content = next( + (message.content for message in messages if message.role == "user"), + "", + ) + data.update( + messages=[], + last_message_preview=last_content[:120] if isinstance(last_content, str) else "", + first_user_message=( + first_user_content[:200] if isinstance(first_user_content, str) else "" + ), + message_count=len(messages), + ) + return data + + @agents.router.get("/sessions") async def list_sessions(dashboard_id: str = ""): sessions = agent_manager.get_all_sessions(dashboard_id=dashboard_id or None) - return {"sessions": [s.model_dump(mode="json") for s in sessions]} + return {"sessions": [p_session_list_item(s) for s in sessions]} @agents.router.get("/activity") async def agent_activity(): diff --git a/backend/tests/test_agent_session_list.py b/backend/tests/test_agent_session_list.py new file mode 100644 index 00000000..7fbefeb6 --- /dev/null +++ b/backend/tests/test_agent_session_list.py @@ -0,0 +1,59 @@ +import asyncio + +from pytest import MonkeyPatch + +from backend.apps.agents import agents as agents_module +from backend.apps.agents.core.models import AgentSession, Message + + +def test_session_list_item_replaces_messages_with_compact_metadata( + monkeypatch: MonkeyPatch, +) -> None: + first_prompt = "p" * 250 + last_reply = "r" * 150 + session = AgentSession( + name="Test session", + messages=[ + Message(role="system", content="system"), + Message(role="user", content=first_prompt), + Message(role="assistant", content=last_reply), + ], + ) + + monkeypatch.setattr( + agents_module.agent_manager, + "get_all_sessions", + lambda dashboard_id=None: [session], + ) + item = asyncio.run(agents_module.list_sessions())["sessions"][0] + + assert item["messages"] == [] + assert item["message_count"] == 3 + assert item["first_user_message"] == first_prompt[:200] + assert item["last_message_preview"] == last_reply[:120] + + +def test_session_list_item_handles_empty_and_non_text_content( + monkeypatch: MonkeyPatch, +) -> None: + sessions = [ + AgentSession(name="Empty"), + AgentSession( + name="Images", + messages=[Message(role="user", content=[{"type": "image"}])], + ), + ] + monkeypatch.setattr( + agents_module.agent_manager, + "get_all_sessions", + lambda dashboard_id=None: sessions, + ) + empty, non_text = asyncio.run(agents_module.list_sessions())["sessions"] + + assert empty["messages"] == [] + assert empty["message_count"] == 0 + assert empty["first_user_message"] == "" + assert empty["last_message_preview"] == "" + assert non_text["message_count"] == 1 + assert non_text["first_user_message"] == "" + assert non_text["last_message_preview"] == "" diff --git a/frontend/src/app/pages/AgentChat/shell/BrowserAgentInlineFeed.tsx b/frontend/src/app/pages/AgentChat/shell/BrowserAgentInlineFeed.tsx index 2f933f6d..65aa7383 100644 --- a/frontend/src/app/pages/AgentChat/shell/BrowserAgentInlineFeed.tsx +++ b/frontend/src/app/pages/AgentChat/shell/BrowserAgentInlineFeed.tsx @@ -206,20 +206,24 @@ const BrowserAgentInlineFeed: React.FC = ({ parentSessionId, browserId }) } }, [browserSessions.length, 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), diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index 693e898c..2022c0c5 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -339,15 +339,20 @@ const AgentCard: React.FC = ({ 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(() => { @@ -653,7 +658,7 @@ const AgentCard: React.FC = ({ ).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]; diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts index 40472bcd..1db5dfd4 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts @@ -366,7 +366,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; diff --git a/frontend/src/shared/state/agentsSlice.ts b/frontend/src/shared/state/agentsSlice.ts index 18c72f5b..11db25a8 100644 --- a/frontend/src/shared/state/agentsSlice.ts +++ b/frontend/src/shared/state/agentsSlice.ts @@ -84,6 +84,10 @@ 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; pending_approvals: ApprovalRequest[]; branches: Record; active_branch_id: string; diff --git a/frontend/src/shared/state/sessionDisplay.ts b/frontend/src/shared/state/sessionDisplay.ts index 1ecc7db7..c68c5af3 100644 --- a/frontend/src/shared/state/sessionDisplay.ts +++ b/frontend/src/shared/state/sessionDisplay.ts @@ -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; From 4707439d6c451cb318cb39e17c39e4b6ea822a5d Mon Sep 17 00:00:00 2001 From: SirKentut <81878031+SirKentut@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:00:28 -0700 Subject: [PATCH 06/57] pierre/scroll-to-zoom: plain wheel zooms at viewport center Plain vertical wheel/two-finger scroll over the canvas now zooms (anchored at the viewport center, reusing the zoomIn/zoomOut anchor) instead of panning the y-axis. Horizontal-dominant scroll still pans X, gated on the dominant axis so a sideways swipe's vertical jitter can't also zoom. cmd/ctrl+wheel and trackpad pinch still zoom at the cursor. flushWheel now folds a same-frame pan into the zoom branch, since a vertical zoom and a horizontal pan can now be accumulated in one RAF tick; dropping the pan would swallow the gesture. --- .../hooks/interaction/useCanvasControls.ts | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts index 1b1071f8..78561168 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts @@ -173,7 +173,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: animateToRef.current = animateTo; - // Wheel zoom centered on cursor + // Plain wheel zooms at the viewport center; cmd/ctrl+wheel and trackpad pinch zoom at the cursor. useEffect(() => { const el = viewportRef.current; if (!el || !enabled) return; // Skip wheel listener when canvas is hidden @@ -199,9 +199,10 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: 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. return { - panX: zCenter.cx - (zCenter.cx - prev.panX) * ratio, - panY: zCenter.cy - (zCenter.cy - prev.panY) * ratio, + panX: zCenter.cx - (zCenter.cx - prev.panX) * ratio - dx, + panY: zCenter.cy - (zCenter.cy - prev.panY) * ratio - dy, zoom: newZoom, }; }); @@ -290,15 +291,20 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: } 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. + // Pinch / cmd+wheel → 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, anchored at the viewport center (same anchor as zoomIn/zoomOut), not the cursor. + const rect = el.getBoundingClientRect(); + pendingZoomDy += dy; + pendingZoomCenter = { cx: rect.width / 2, cy: rect.height / 2 }; scheduleWheelFlush(); } }; From c4704ff27a0704f31e7db7e68792fd02f4b1aa03 Mon Sep 17 00:00:00 2001 From: SirKentut <81878031+SirKentut@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:03:02 -0700 Subject: [PATCH 07/57] pierre/clamp-mouse-wheel-zoom: tame discrete wheel notches A mouse-wheel notch arrives as deltaY 100 where a trackpad sends ~1-10; piped through the zoom curve at default sensitivity that's a ~24% jump per notch, and macOS wheel acceleration stacks them. Clamp the per-event zoom delta to +/-24 so each notch is a small predictable step. No-op for trackpads (their deltas are already under the cap), so the continuous pinch/scroll curve is unchanged. --- .../pages/Dashboard/hooks/interaction/useCanvasControls.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts index 78561168..3ba5757f 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts @@ -9,6 +9,8 @@ const MAX_ZOOM = 3.0; const ZOOM_IN_FACTOR = 1.1; const ZOOM_OUT_FACTOR = 1 / ZOOM_IN_FACTOR; const FIT_PADDING = 200; +// 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 { @@ -301,9 +303,9 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: pendingPanDx += dx; scheduleWheelFlush(); } else { - // Plain vertical scroll → zoom, anchored at the viewport center (same anchor as zoomIn/zoomOut), not the cursor. + // Plain vertical scroll → zoom, anchored at the viewport center (same anchor as zoomIn/zoomOut), not the cursor. Clamp the per-event delta so a discrete mouse notch is a small step, not a lurch. const rect = el.getBoundingClientRect(); - pendingZoomDy += dy; + pendingZoomDy += clamp(dy, -WHEEL_ZOOM_DELTA_CAP, WHEEL_ZOOM_DELTA_CAP); pendingZoomCenter = { cx: rect.width / 2, cy: rect.height / 2 }; scheduleWheelFlush(); } From b8cd6fe2b3b454ce157eb1bda327ff72ff4bd8c1 Mon Sep 17 00:00:00 2001 From: SirKentut <81878031+SirKentut@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:11:02 -0700 Subject: [PATCH 08/57] pierre/cmd-scroll-vertical-pan: cmd/ctrl+scroll pans vertically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cmd/ctrl + scroll now pans the canvas up/down instead of zooming at the cursor. The catch: a trackpad pinch reports as a wheel with ctrlKey set, indistinguishable at the event level from a real Ctrl+wheel. Gate on the physically-held key (cmdRef, tracked via keydown): a real modifier down pans; a pinch has ctrlKey without any keydown, so it still zooms at the cursor. Plain wheel continues to zoom at the viewport center. The webview-forwarded cmd/ctrl+wheel path (canvas-wheel-zoom) stays cursor-zoom — that channel can't tell a pinch from a held key, and keeping it zoom preserves pinch-to-zoom while hovering a browser card. --- .../hooks/interaction/useCanvasControls.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts index 3ba5757f..a524ed9e 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts @@ -175,7 +175,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: animateToRef.current = animateTo; - // Plain wheel zooms at the viewport center; cmd/ctrl+wheel and trackpad pinch zoom at the 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 @@ -233,8 +233,8 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: const scrollableCache: WeakMap = 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; @@ -259,7 +259,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: scrollableCache.set(target, cls); } - if (cls === 'scrollable' && !isPinchZoom) { + if (cls === 'scrollable' && !isModifierWheel) { // Re-read scrollHeight/clientHeight; cached decision is structural, scroll position is dynamic. const canScrollY = target.scrollHeight > target.clientHeight; const canScrollX = target.scrollWidth > target.clientWidth; @@ -292,8 +292,12 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: inertiaFrameRef.current = null; } - if (isPinchZoom) { - // Pinch / cmd+wheel → 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 }; From 5956f377e8d892309bfb16d45b1fe78f504f02eb Mon Sep 17 00:00:00 2001 From: SirKentut <81878031+SirKentut@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:27:16 -0700 Subject: [PATCH 09/57] [pierre] fix: scroll focused card horizontally with Left/Right arrow keys --- .../hooks/interaction/useArrowNav.ts | 66 ++++++++++++------ frontend/src/app/pages/Views/ViewPreview.tsx | 10 +++ frontend/src/shared/cardContentScroll.ts | 67 +++++++++++++++++++ frontend/src/shared/viewFrameRegistry.ts | 14 ++++ frontend/src/shared/viewWebviewRegistry.ts | 2 + 5 files changed, 137 insertions(+), 22 deletions(-) create mode 100644 frontend/src/shared/cardContentScroll.ts create mode 100644 frontend/src/shared/viewFrameRegistry.ts diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useArrowNav.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useArrowNav.ts index 0b14aa95..91fd4527 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useArrowNav.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useArrowNav.ts @@ -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. diff --git a/frontend/src/app/pages/Views/ViewPreview.tsx b/frontend/src/app/pages/Views/ViewPreview.tsx index a5cb35e2..d5792678 100644 --- a/frontend/src/app/pages/Views/ViewPreview.tsx +++ b/frontend/src/app/pages/Views/ViewPreview.tsx @@ -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(({ 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; diff --git a/frontend/src/shared/cardContentScroll.ts b/frontend/src/shared/cardContentScroll.ts new file mode 100644 index 00000000..4cda2236 --- /dev/null +++ b/frontend/src/shared/cardContentScroll.ts @@ -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 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; +} + +/** 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 { + 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; + } +} diff --git a/frontend/src/shared/viewFrameRegistry.ts b/frontend/src/shared/viewFrameRegistry.ts new file mode 100644 index 00000000..8cf228cb --- /dev/null +++ b/frontend/src/shared/viewFrameRegistry.ts @@ -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 (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(); + +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); +} diff --git a/frontend/src/shared/viewWebviewRegistry.ts b/frontend/src/shared/viewWebviewRegistry.ts index 2f6d0e23..866825e0 100644 --- a/frontend/src/shared/viewWebviewRegistry.ts +++ b/frontend/src/shared/viewWebviewRegistry.ts @@ -1,6 +1,8 @@ // Live app-card preview webviews keyed by output id. The delete path looks a card's 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; + // Optional: present on real Electron webviews, absent on any non-Electron stand-in, so callers must ?.() it. + executeJavaScript?: (code: string) => Promise; } const registry = new Map(); From 224bc07a526e0b19eb28d7e8885b86a553711ce4 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 15 Jul 2026 01:05:09 -0700 Subject: [PATCH 10/57] [eric] dashboard: snap card-framing camera in 150ms (was a lazy 320ms glide) --- .../Dashboard/hooks/interaction/useCanvasControls.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts index 1b1071f8..58f81f46 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts @@ -9,6 +9,10 @@ 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; // Maps the 1 to 100 user setting to an internal multiplier (50 default = 0.004). function sensitivityToMultiplier(setting: number): number { @@ -651,7 +655,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; @@ -663,7 +667,7 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: Math.abs(cur2.panY - fresh.panY) + Math.abs(cur2.zoom - fresh.zoom) * 1000; if (drift > 8) setState(fresh); - }, 370); + }, FIT_SETTLE_DELAY); } else { setState(target); } From e52f4a1b8a6d3a3e0d661c2246eb6a6fac66ab3d Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 15 Jul 2026 12:58:44 -0700 Subject: [PATCH 11/57] [eric] agents: rehydrate browser-agent children the trimmed session-list poll leaves message-less (feed no longer blanks) --- .../app/pages/AgentChat/shell/BrowserAgentInlineFeed.tsx | 8 ++++++-- frontend/src/shared/state/agentsSlice.ts | 6 +++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/pages/AgentChat/shell/BrowserAgentInlineFeed.tsx b/frontend/src/app/pages/AgentChat/shell/BrowserAgentInlineFeed.tsx index 65aa7383..b636a9a4 100644 --- a/frontend/src/app/pages/AgentChat/shell/BrowserAgentInlineFeed.tsx +++ b/frontend/src/app/pages/AgentChat/shell/BrowserAgentInlineFeed.tsx @@ -197,14 +197,18 @@ const BrowserAgentInlineFeed: React.FC = ({ 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. + const needsChildFetch = + browserSessions.length === 0 || + browserSessions.some((s) => (s.message_count ?? 0) > 0 && s.messages.length === 0); useEffect(() => { - if (browserSessions.length === 0 && fetchedForSession.current !== parentSessionId) { + if (needsChildFetch && fetchedForSession.current !== parentSessionId) { fetchedForSession.current = parentSessionId; dispatch(fetchBrowserAgentChildren(parentSessionId)) .unwrap() .catch(() => { fetchedForSession.current = null; }); } - }, [browserSessions.length, parentSessionId, dispatch]); + }, [needsChildFetch, parentSessionId, dispatch]); const sessionsWithHistoricalEntries = useMemo(() => { return browserSessions.map((session) => { diff --git a/frontend/src/shared/state/agentsSlice.ts b/frontend/src/shared/state/agentsSlice.ts index 11db25a8..504f8acd 100644 --- a/frontend/src/shared/state/agentsSlice.ts +++ b/frontend/src/shared/state/agentsSlice.ts @@ -1416,13 +1416,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; } } }) From 20d07a65e309a5017795685a57b329e4d3e7413a Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 15 Jul 2026 14:24:53 -0700 Subject: [PATCH 12/57] [eric] models: add GPT-5.6 (API-key lane); Sonnet 5 already listed, no codex-breaking cx entry on the pinned router --- backend/apps/agents/providers/registry.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/backend/apps/agents/providers/registry.py b/backend/apps/agents/providers/registry.py index 1b38c21a..36426a85 100644 --- a/backend/apps/agents/providers/registry.py +++ b/backend/apps/agents/providers/registry.py @@ -93,6 +93,10 @@ BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = { "context_window": 400_000, "router_model_id": "cx/gpt-5.4-mini", "api": "codex", "subscription_only": True, "reasoning": True}, # gpt-5.3-codex (+ high/xhigh) removed: superseded by GPT-5.5 as OpenAI's recommended Codex model, and high/xhigh were never separate models (just reasoning-effort variants), so they were redundant clutter. API-key entries: route through 9Router's `cp-openai` provider-node (registered by sync_openai_api_key) so 9Router's translator dispatches to our local openai-passthrough proxy. The passthrough renames `max_tokens` → `max_completion_tokens` before forwarding to api.openai.com, fixing OpenAI's GPT-5 family 400. The bare router_model_id (e.g. "gpt-5.5") still appears in the request body; only the routing prefix changes. + # GPT-5.6 (newest, 2026-07): API-key lane only. A cx/gpt-5.6 subscription entry is deliberately NOT added, cx/ 404s newer OpenAI models on the pinned 9Router 0.3.60 (same reason gpt-5.5's cx entry stays pulled); add one once the pin moves and cx/gpt-5.6 resolves. Unverified against a live router/OpenAI here (no creds); pull if it errors live. + {"value": "gpt-5.6-api", "label": "GPT-5.6 (API key)", + "context_window": 1_000_000, "router_model_id": "cp-openai/gpt-5.6", "model_id": "gpt-5.6", + "api": "openai", "reasoning": True, "route": "api"}, {"value": "gpt-5.5-api", "label": "GPT-5.5 (API key)", "context_window": 1_000_000, "router_model_id": "cp-openai/gpt-5.5", "model_id": "gpt-5.5", "api": "openai", "reasoning": True, "route": "api"}, @@ -368,6 +372,7 @@ COST_PER_1M_TOKENS: dict[tuple[str, str], tuple[float, float]] = { ("Anthropic", "fable-5-api"): (10.0, 50.0), ("Anthropic", "haiku"): (1.0, 5.0), # OpenAI; Codex subscription path, user pays nothing per token + ("OpenAI", "gpt-5.6"): (0.0, 0.0), ("OpenAI", "gpt-5.5"): (0.0, 0.0), ("OpenAI", "gpt-5.4"): (0.0, 0.0), ("OpenAI", "gpt-5.4-mini"): (0.0, 0.0), From e1dd537a944a81c91fb09e690458485d24e09942 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 15 Jul 2026 14:43:41 -0700 Subject: [PATCH 13/57] [eric] models: hold GPT-5.6 (Sol/Terra/Luna) not offered; Responses-API-only + 9Router 0.3.60 speaks chat/completions = no working lane --- backend/apps/agents/providers/registry.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/backend/apps/agents/providers/registry.py b/backend/apps/agents/providers/registry.py index 36426a85..a2d0e1ff 100644 --- a/backend/apps/agents/providers/registry.py +++ b/backend/apps/agents/providers/registry.py @@ -93,10 +93,13 @@ BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = { "context_window": 400_000, "router_model_id": "cx/gpt-5.4-mini", "api": "codex", "subscription_only": True, "reasoning": True}, # gpt-5.3-codex (+ high/xhigh) removed: superseded by GPT-5.5 as OpenAI's recommended Codex model, and high/xhigh were never separate models (just reasoning-effort variants), so they were redundant clutter. API-key entries: route through 9Router's `cp-openai` provider-node (registered by sync_openai_api_key) so 9Router's translator dispatches to our local openai-passthrough proxy. The passthrough renames `max_tokens` → `max_completion_tokens` before forwarding to api.openai.com, fixing OpenAI's GPT-5 family 400. The bare router_model_id (e.g. "gpt-5.5") still appears in the request body; only the routing prefix changes. - # GPT-5.6 (newest, 2026-07): API-key lane only. A cx/gpt-5.6 subscription entry is deliberately NOT added, cx/ 404s newer OpenAI models on the pinned 9Router 0.3.60 (same reason gpt-5.5's cx entry stays pulled); add one once the pin moves and cx/gpt-5.6 resolves. Unverified against a live router/OpenAI here (no creds); pull if it errors live. - {"value": "gpt-5.6-api", "label": "GPT-5.6 (API key)", - "context_window": 1_000_000, "router_model_id": "cp-openai/gpt-5.6", "model_id": "gpt-5.6", - "api": "openai", "reasoning": True, "route": "api"}, + # GPT-5.6 (Sol / Terra / Luna, 2026-07) is HELD, not offered: it is Responses-API-only + # (api model ids gpt-5.6-sol [alias gpt-5.6], gpt-5.6-terra, gpt-5.6-luna; per-1M in/out + # $5/$30, $2.50/$15, $1/$6). Our lane goes user -> 9Router 0.3.60 -> cp-openai passthrough, + # and 0.3.60 only speaks /chat/completions, so a gpt-5.6 request hits the wrong endpoint and + # OpenAI rejects it (plus it is a trusted-partner limited preview, so most keys 404 anyway). + # No working lane = not offered (same rule as gpt-5.5's cx entry). Enable all three tiers + # once 9Router can translate to /v1/responses AND the model is generally available. {"value": "gpt-5.5-api", "label": "GPT-5.5 (API key)", "context_window": 1_000_000, "router_model_id": "cp-openai/gpt-5.5", "model_id": "gpt-5.5", "api": "openai", "reasoning": True, "route": "api"}, @@ -372,7 +375,6 @@ COST_PER_1M_TOKENS: dict[tuple[str, str], tuple[float, float]] = { ("Anthropic", "fable-5-api"): (10.0, 50.0), ("Anthropic", "haiku"): (1.0, 5.0), # OpenAI; Codex subscription path, user pays nothing per token - ("OpenAI", "gpt-5.6"): (0.0, 0.0), ("OpenAI", "gpt-5.5"): (0.0, 0.0), ("OpenAI", "gpt-5.4"): (0.0, 0.0), ("OpenAI", "gpt-5.4-mini"): (0.0, 0.0), From 0190e232aed3b1539f2632e3198fb9339d7f0582 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 15 Jul 2026 15:44:10 -0700 Subject: [PATCH 14/57] [eric] share: user-confirmed Export anyway when the file secret heuristic trips (download only; own credential fields stay blocked) --- backend/apps/swarm/closure.py | 4 ++-- backend/apps/swarm/models.py | 2 ++ backend/apps/swarm/swarm.py | 2 +- backend/apps/swarm/ziputil.py | 20 +++++++++++-------- backend/tests/test_swarm_bundle.py | 9 +++++++++ .../src/app/components/share/ShareModal.tsx | 18 ++++++++++++++--- frontend/src/app/components/share/shareApi.ts | 4 ++-- 7 files changed, 43 insertions(+), 16 deletions(-) diff --git a/backend/apps/swarm/closure.py b/backend/apps/swarm/closure.py index 9d13c8ce..b09ec962 100644 --- a/backend/apps/swarm/closure.py +++ b/backend/apps/swarm/closure.py @@ -126,9 +126,9 @@ def build_manifest(root_type: EntityType, root_id: str) -> Manifest: return p_assemble(root_type, root_id)[0] -def build_bundle(root_type: EntityType, root_id: str) -> tuple[bytes, str]: +def build_bundle(root_type: EntityType, root_id: str, allow_file_secrets: bool = False) -> tuple[bytes, str]: manifest, payloads, files = p_assemble(root_type, root_id) - raw = pack(manifest.model_dump(by_alias=True, mode="json"), payloads, files) + raw = pack(manifest.model_dump(by_alias=True, mode="json"), payloads, files, allow_file_secrets=allow_file_secrets) return raw, manifest.root.name diff --git a/backend/apps/swarm/models.py b/backend/apps/swarm/models.py index c5c02460..c47f7652 100644 --- a/backend/apps/swarm/models.py +++ b/backend/apps/swarm/models.py @@ -104,6 +104,8 @@ class ReviewSummary(BaseModel): class ExportRequest(BaseModel): type: EntityType id: str + # User-confirmed "export anyway": skips the file-content secret heuristic on direct download only; denied payload fields stay blocked. + allow_secrets: bool = False class ExportPreflightResponse(BaseModel): diff --git a/backend/apps/swarm/swarm.py b/backend/apps/swarm/swarm.py index 57c3a66f..ceca79ca 100644 --- a/backend/apps/swarm/swarm.py +++ b/backend/apps/swarm/swarm.py @@ -71,7 +71,7 @@ async def export_preflight(body: ExportRequest) -> ExportPreflightResponse: @swarm.router.post("/export") async def export_bundle(body: ExportRequest) -> Response: try: - raw, name = closure.build_bundle(body.type, body.id) + raw, name = closure.build_bundle(body.type, body.id, allow_file_secrets=body.allow_secrets) except BundleError as e: raise HTTPException(status_code=400, detail=str(e)) fname = closure.swarm_filename(name) diff --git a/backend/apps/swarm/ziputil.py b/backend/apps/swarm/ziputil.py index 4e1e3ceb..f47f240c 100644 --- a/backend/apps/swarm/ziputil.py +++ b/backend/apps/swarm/ziputil.py @@ -37,21 +37,25 @@ def p_content_digest(entries: dict[str, bytes]) -> str: return h.hexdigest() -def pack(manifest: dict, payloads: dict[str, dict], files: dict[str, bytes]) -> bytes: +def pack(manifest: dict, payloads: dict[str, dict], files: dict[str, bytes], allow_file_secrets: bool = False) -> bytes: """payloads: bundle_id -> JSON payload (-> entities//payload.json). - files: full zip path -> bytes (e.g. entities//files/).""" + files: full zip path -> bytes (e.g. entities//files/). + allow_file_secrets is a user-confirmed override for the FILE-content heuristic only + (workspace code trips it on look-alike strings); denied payload fields are our own + credential store and are never exportable, override or not.""" for bid, payload in payloads.items(): leaked = find_denied_keys(payload) if leaked: raise BundleError( f"refusing to export: secret-shaped field(s) in {bid}: {leaked[:3]}" ) - leaky_files = find_secrets_in_files(files) - if leaky_files: - raise BundleError( - f"refusing to export: a secret-shaped value is in {leaky_files[0]}; " - "remove it (use an environment variable) and try again" - ) + if not allow_file_secrets: + leaky_files = find_secrets_in_files(files) + if leaky_files: + raise BundleError( + f"refusing to export: a secret-shaped value is in {leaky_files[0]}; " + "remove it (use an environment variable) and try again" + ) entries: dict[str, bytes] = {} for bid, payload in payloads.items(): entries[f"entities/{bid}/payload.json"] = json.dumps(payload, indent=2).encode("utf-8") diff --git a/backend/tests/test_swarm_bundle.py b/backend/tests/test_swarm_bundle.py index 410991f8..a1b50e51 100644 --- a/backend/tests/test_swarm_bundle.py +++ b/backend/tests/test_swarm_bundle.py @@ -110,6 +110,15 @@ def test_pack_allows_clean_workspace_file(): assert zipfile.is_zipfile(io.BytesIO(raw)) +def test_pack_export_anyway_overrides_file_scan_but_never_denied_keys(): + # User-confirmed override ships a flagged workspace FILE (trusted recipient); our own credential fields stay unexportable no matter what. + leak = b"const KEY = 'sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAA';\n" + raw = pack({"format_version": 1}, {"bid1": {"name": "ok"}}, {"entities/bid1/files/config.js": leak}, allow_file_secrets=True) + assert zipfile.is_zipfile(io.BytesIO(raw)) + with pytest.raises(BundleError): + pack({"format_version": 1}, {"bid1": {"api_key": "leak"}}, {}, allow_file_secrets=True) + + def test_app_export_drops_machine_env(tmp_path, monkeypatch): # The live .env holds the source machine's absolute paths + pinned port; it must never ride along. .env.example (portable) does. from backend.apps.swarm.entities import apps as appmod diff --git a/frontend/src/app/components/share/ShareModal.tsx b/frontend/src/app/components/share/ShareModal.tsx index 0b571459..0b61fa25 100644 --- a/frontend/src/app/components/share/ShareModal.tsx +++ b/frontend/src/app/components/share/ShareModal.tsx @@ -55,11 +55,11 @@ const ShareModal: React.FC = ({ 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 = ({ 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 = ({ target, open, onClose }) => { + {secretOverridable && ( + + )} ) : preflight ? ( @@ -179,7 +191,7 @@ const ShareModal: React.FC = ({ target, open, onClose }) => {