[eric] App Builder fixes: preview loads (auth-token threaded into iframe URL + sub-resource paths), agent keeps running when you switch tabs

(session+workspace persisted on the app), Settings default model/thinking now flow into App + Skill Builder drafts
This commit is contained in:
ciregenz
2026-04-27 15:09:25 -07:00
parent 356b185f30
commit f4cded781b
9 changed files with 253 additions and 17 deletions
+1
View File
@@ -0,0 +1 @@
backend/mcp-bundles/** linguist-vendored
+9
View File
@@ -27,6 +27,11 @@ class Output(BaseModel):
permission: str = "ask"
auto_run_config: Optional[AutoRunConfig] = None
thumbnail: Optional[str] = None
# Linkage so reopening the App Builder reattaches to the in-progress session
# and reuses the same on-disk workspace folder instead of seeding a fresh one
# (which would orphan the running agent + lose chat history on every navigate).
session_id: Optional[str] = None
workspace_id: Optional[str] = None
created_at: str = Field(default_factory=lambda: datetime.now().isoformat())
updated_at: str = Field(default_factory=lambda: datetime.now().isoformat())
@@ -71,6 +76,8 @@ class OutputCreate(BaseModel):
files: dict[str, str] = Field(default_factory=dict)
auto_run_config: Optional[dict[str, Any]] = None
thumbnail: Optional[str] = None
session_id: Optional[str] = None
workspace_id: Optional[str] = None
@model_validator(mode="before")
@classmethod
@@ -101,6 +108,8 @@ class OutputUpdate(BaseModel):
permission: Optional[str] = None
auto_run_config: Optional[dict[str, Any]] = None
thumbnail: Optional[str] = None
session_id: Optional[str] = None
workspace_id: Optional[str] = None
@model_validator(mode="before")
@classmethod
+58 -1
View File
@@ -1,5 +1,6 @@
import json
import os
import re
import logging
import mimetypes
import base64
@@ -7,6 +8,7 @@ from datetime import datetime
from contextlib import asynccontextmanager
from fastapi import HTTPException, Query
from fastapi.responses import Response
from backend.auth import get_auth_token
from jsonschema import validate as schema_validate, ValidationError as SchemaValidationError
from backend.config.Apps import SubApp
from backend.apps.outputs.models import (
@@ -79,6 +81,53 @@ def _inject_data_into_html(html: str, input_json: str = "{}", result_json: str =
return f"{injection}\n{html}"
# URL schemes / prefixes that must NOT have ?token= appended. These are either
# external (CDNs, mailto) or non-network references that the auth middleware
# never sees. Anything else is treated as a same-origin relative URL pointing
# at our /api/outputs/.../serve/ subtree, which DOES need the token.
_ABSOLUTE_URL_PREFIXES = (
"http://", "https://", "//", "data:", "blob:",
"mailto:", "tel:", "javascript:", "about:", "#",
)
_HREF_SRC_ATTR_RE = re.compile(
r"""(\s(?:href|src))\s*=\s*(["'])([^"']+)\2""",
re.IGNORECASE,
)
def _inject_token_into_relative_urls(html: str, token: str) -> str:
"""Append `?token=<t>` to every relative href/src in the served HTML.
Browsers strip the parent iframe URL's query string before resolving
relative `<link href="styles.css">` / `<script src="x.js">`, so without
this rewrite the sub-resource fetch lands at the auth middleware with no
credentials and gets a 401. Idempotent: skips URLs that already carry a
`token=` param. Skips absolute URLs (CDN, data:, etc.) — see prefix list.
"""
if not token:
return html
def _patch(match: re.Match) -> str:
attr, quote, url = match.group(1), match.group(2), match.group(3)
lowered = url.lower().lstrip()
if lowered.startswith(_ABSOLUTE_URL_PREFIXES):
return match.group(0)
if "token=" in url:
return match.group(0)
# Split off any hash fragment so `?token=` lands in the query, not in
# the fragment: `page.html?v=1#sec` → `page.html?v=1&token=X#sec`.
hash_idx = url.find("#")
if hash_idx >= 0:
base, frag = url[:hash_idx], url[hash_idx:]
else:
base, frag = url, ""
sep = "&" if "?" in base else "?"
return f'{attr}={quote}{base}{sep}token={token}{frag}{quote}'
return _HREF_SRC_ATTR_RE.sub(_patch, html)
def _decode_data_param(d: str) -> tuple[str, str]:
"""Decode the base64-encoded _d query param into (input_json, result_json)."""
try:
@@ -170,6 +219,10 @@ async def serve_workspace_file(workspace_id: str, filepath: str, _d: str = ""):
if filepath == "index.html":
input_json, result_json = _decode_data_param(_d) if _d else ("{}", "null")
content = _inject_data_into_html(content, input_json, result_json)
# Iframe sub-resource fetches (<link>, <script src>, <img>) drop the
# parent's ?token= query string, so rewrite the HTML to put the token
# back on every relative URL — otherwise sub-resources 401.
content = _inject_token_into_relative_urls(content, get_auth_token())
mime, _ = mimetypes.guess_type(filepath)
return Response(content=content, media_type=mime or "text/plain")
@@ -186,6 +239,7 @@ async def serve_output_file(output_id: str, filepath: str, _d: str = ""):
if filepath == "index.html":
input_json, result_json = _decode_data_param(_d) if _d else ("{}", "null")
content = _inject_data_into_html(content, input_json, result_json)
content = _inject_token_into_relative_urls(content, get_auth_token())
mime, _ = mimetypes.guess_type(filepath)
return Response(content=content, media_type=mime or "text/plain")
@@ -216,7 +270,10 @@ async def read_workspace(workspace_id: str):
except (json.JSONDecodeError, ValueError):
pass
return {"files": files, "meta": meta}
# Include `path` so the frontend can rehydrate without re-calling /seed.
# /seed unconditionally overwrites, which would clobber any in-progress edits
# the agent made since the last save.
return {"files": files, "meta": meta, "path": os.path.abspath(folder)}
@outputs.router.post("/workspace/seed")
+4 -1
View File
@@ -107,7 +107,10 @@ async def _auth_middleware(request: Request, call_next):
# (CLI path — CLI sends x-api-key with our token as value).
headers = dict(request.headers)
x_api_key = headers.get("x-api-key") or headers.get("X-API-Key")
auth_ok = request_matches_token(headers)
# Accept `?token=<token>` query param too. Required for browser-driven
# GETs that can't set headers — notably the App Builder iframe loading
# /api/outputs/.../serve/index.html via <iframe src="...">.
auth_ok = request_matches_token(headers, query_params=dict(request.query_params))
if not auth_ok and x_api_key:
import secrets as _s
from backend.auth import get_auth_token as _gt
@@ -103,10 +103,40 @@ const SkillBuilderChat: React.FC<SkillBuilderChatProps> = ({ onSkillPreview, onS
[workspacePath],
);
// Honor Settings → default_model + default_thinking_level. createDraftSession's
// hardcoded 'sonnet' / undefined-thinking would otherwise win and force every
// Skill Builder draft onto Sonnet + Auto thinking.
const defaultModel = useAppSelector((s) => s.settings.data.default_model);
const defaultThinkingLevel = useAppSelector((s) => s.settings.data.default_thinking_level);
const settingsLoaded = useAppSelector((s) => s.settings.loaded);
const modelsByProvider = useAppSelector((s) => s.models.byProvider);
const modelsLoaded = useAppSelector((s) => s.models.loaded);
const initSession = useCallback(async () => {
const wsId = `skill-ws-${Date.now().toString(36)}`;
setStableWorkspaceId(wsId);
// Resolve provider from the model registry (mirrors ChatInput.tsx provider map).
const PROVIDER_MAP: Record<string, string> = {
anthropic: 'anthropic',
'openswarm pro': 'anthropic',
openai: 'openai',
google: 'gemini',
xai: 'openrouter',
meta: 'openrouter',
deepseek: 'openrouter',
mistral: 'openrouter',
qwen: 'openrouter',
cohere: 'openrouter',
};
let resolvedProvider: string | undefined;
for (const [prov, models] of Object.entries(modelsByProvider)) {
if (models.some((m: any) => m.value === defaultModel)) {
resolvedProvider = PROVIDER_MAP[prov.toLowerCase()] || prov.toLowerCase();
break;
}
}
try {
const res = await fetch(`${SKILLS_WORKSPACE_API}/workspace/seed`, {
method: 'POST',
@@ -119,19 +149,30 @@ const SkillBuilderChat: React.FC<SkillBuilderChatProps> = ({ onSkillPreview, onS
mode: 'skill-builder',
setActive: false,
targetDirectory: data.path,
model: defaultModel || undefined,
provider: resolvedProvider,
thinkingLevel: defaultThinkingLevel || undefined,
}));
setInitialDraftId(action.payload.draftId);
} catch {
const action = dispatch(createDraftSession({ mode: 'skill-builder', setActive: false }));
const action = dispatch(createDraftSession({
mode: 'skill-builder',
setActive: false,
model: defaultModel || undefined,
provider: resolvedProvider,
thinkingLevel: defaultThinkingLevel || undefined,
}));
setInitialDraftId(action.payload.draftId);
}
}, [dispatch]);
}, [dispatch, defaultModel, defaultThinkingLevel, modelsByProvider]);
useEffect(() => {
if (draftCreated.current) return;
// Wait for settings + model registry so we don't snapshot stale 'sonnet'.
if (!settingsLoaded || !modelsLoaded) return;
draftCreated.current = true;
initSession();
}, [initSession]);
}, [initSession, settingsLoaded, modelsLoaded]);
// Poll workspace for updates
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
+105 -5
View File
@@ -27,7 +27,7 @@ import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { createDraftSession, removeDraftSession, AgentMessage } from '@/shared/state/agentsSlice';
import { createDraftSession, removeDraftSession, fetchSession, AgentMessage } from '@/shared/state/agentsSlice';
import { createOutput, updateOutput, Output, executeOutput, OutputExecuteResult, autoRunOutput, autoRunAgentOutput, cleanupAutoRunAgent, AutoRunConfig, SERVE_BASE } from '@/shared/state/outputsSlice';
import { createSessionWs } from '@/shared/ws/WebSocketManager';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
@@ -559,14 +559,73 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
const [initialDraftId, setInitialDraftId] = useState<string | null>(null);
const [workspacePath, setWorkspacePath] = useState<string | null>(null);
const [stableWorkspaceId] = useState(() => `ws-${Date.now().toString(36)}`);
// Reuse the workspace_id stored on the Output if present so we don't seed a
// fresh folder every time the editor remounts (which would orphan the agent's
// in-progress edits and lose chat continuity). Only mint a new id for first-
// time outputs that don't yet have one persisted.
const [stableWorkspaceId] = useState(() => output?.workspace_id || `ws-${Date.now().toString(36)}`);
const draftCreated = useRef(false);
// Honor the user's Settings → default_model + default_thinking_level.
// Without this, createDraftSession's hardcoded 'sonnet' / undefined-thinking
// fallbacks win and App Builder always opens on Sonnet + Auto thinking
// regardless of what the user picked in Settings.
const defaultModel = useAppSelector((s) => s.settings.data.default_model);
const defaultThinkingLevel = useAppSelector((s) => s.settings.data.default_thinking_level);
const settingsLoaded = useAppSelector((s) => s.settings.loaded);
const modelsByProvider = useAppSelector((s) => s.models.byProvider);
const modelsLoaded = useAppSelector((s) => s.models.loaded);
useEffect(() => {
if (draftCreated.current) return;
// Wait for settings + model registry before seeding the draft, otherwise
// we'd snapshot the Redux initial 'sonnet' default and ignore the user's pick.
if (!settingsLoaded || !modelsLoaded) return;
draftCreated.current = true;
// Resolve provider from the model registry. Group names mirror the
// provider map in ChatInput.tsx (Anthropic / OpenSwarm Pro → 'anthropic',
// Google → 'gemini', xAI/Meta/etc → 'openrouter').
const PROVIDER_MAP: Record<string, string> = {
anthropic: 'anthropic',
'openswarm pro': 'anthropic',
openai: 'openai',
google: 'gemini',
xai: 'openrouter',
meta: 'openrouter',
deepseek: 'openrouter',
mistral: 'openrouter',
qwen: 'openrouter',
cohere: 'openrouter',
};
let resolvedProvider: string | undefined;
for (const [prov, models] of Object.entries(modelsByProvider)) {
if (models.some((m: any) => m.value === defaultModel)) {
resolvedProvider = PROVIDER_MAP[prov.toLowerCase()] || prov.toLowerCase();
break;
}
}
(async () => {
// Reattach branch: this Output already has a session + workspace from a
// prior visit. Skip seeding (would clobber any in-progress edits the
// agent made) and skip createDraftSession (would orphan the live session).
// Just resolve the workspace path and tell AgentChat which session to bind to.
if (output?.session_id && output?.workspace_id) {
try {
const res = await fetch(`${WORKSPACE_API}/${output.workspace_id}`);
if (res.ok) {
const data = await res.json();
if (data.path) setWorkspacePath(data.path);
}
} catch { /* path is best-effort; chat still works without it */ }
// Pull the latest session state from the backend so the chat catches up
// on anything the agent did while the user was on another tab.
dispatch(fetchSession(output.session_id));
setInitialDraftId(output.session_id);
return;
}
const seedBody: Record<string, any> = { workspace_id: stableWorkspaceId };
if (output) {
const seedFiles: Record<string, string> = { ...output.files };
@@ -588,14 +647,23 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
mode: 'view-builder',
setActive: false,
targetDirectory: data.path,
model: defaultModel || undefined,
provider: resolvedProvider,
thinkingLevel: defaultThinkingLevel || undefined,
}));
setInitialDraftId(action.payload.draftId);
} catch {
const action = dispatch(createDraftSession({ mode: 'view-builder', setActive: false }));
const action = dispatch(createDraftSession({
mode: 'view-builder',
setActive: false,
model: defaultModel || undefined,
provider: resolvedProvider,
thinkingLevel: defaultThinkingLevel || undefined,
}));
setInitialDraftId(action.payload.draftId);
}
})();
}, [dispatch, output, stableWorkspaceId]);
}, [dispatch, output, stableWorkspaceId, settingsLoaded, modelsLoaded, defaultModel, defaultThinkingLevel, modelsByProvider]);
const effectiveSessionId = useAppSelector((state) => {
if (!initialDraftId) return null;
@@ -671,14 +739,46 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
prevAgentActive.current = isAgentActive;
}, [isAgentActive, workspaceId, pollWorkspace]);
// Hold the latest session status in a ref so the unmount cleanup can read it
// at teardown time (the cleanup closure would otherwise capture a stale value
// from when the effect first ran).
const sessionStatusRef = useRef<string | null>(null);
sessionStatusRef.current = agentStatus;
const isLaunchedRef = useRef(false);
isLaunchedRef.current = isLaunched;
useEffect(() => {
return () => {
if (initialDraftId) {
// Only garbage-collect drafts the user abandoned without launching. Once
// a session is launched, the agent runs on the backend independent of
// the frontend — leave the Redux entry alive so navigating away doesn't
// wipe in-progress work or chat history.
if (initialDraftId && sessionStatusRef.current === 'draft' && !isLaunchedRef.current) {
dispatch(removeDraftSession(initialDraftId));
}
};
}, [initialDraftId, dispatch]);
// Persist session_id + workspace_id onto the saved Output the moment the
// session goes from draft to launched. Without this, reopening the App later
// would have no way to find its in-progress session and would seed a fresh one.
// Use `createdId` (state) not `createdIdRef.current` so the effect re-fires
// after autosave creates the Output for a brand-new app. `output` prop is a
// parent snapshot that doesn't refresh, so we dedup via a ref.
const persistedLinkageRef = useRef<string | null>(null);
useEffect(() => {
const eid = output?.id ?? createdId;
if (!eid || !effectiveSessionId || !isLaunched) return;
const fingerprint = `${eid}:${effectiveSessionId}:${stableWorkspaceId}`;
if (persistedLinkageRef.current === fingerprint) return;
persistedLinkageRef.current = fingerprint;
dispatch(updateOutput({
id: eid,
session_id: effectiveSessionId,
workspace_id: stableWorkspaceId,
}));
}, [effectiveSessionId, isLaunched, output?.id, createdId, stableWorkspaceId, dispatch]);
const schemaText = files['schema.json'] ?? '{"type":"object","properties":{},"required":[]}';
const parsedSchema = useMemo(() => {
+19 -2
View File
@@ -2,6 +2,7 @@ import React, { useRef, useEffect, useMemo, forwardRef, useImperativeHandle, use
import Box from '@mui/material/Box';
import { useElementSelection } from '@/app/components/ElementSelectionContext';
import { useIframeElementSelector } from './useIframeElementSelector';
import { getAuthToken, ensureAuthToken } from '@/shared/config';
export interface ViewPreviewHandle {
reload: () => void;
@@ -54,6 +55,19 @@ const ViewPreview = forwardRef<ViewPreviewHandle, Props>(({
const iframeRef = useRef<HTMLIFrameElement>(null);
const ctx = useElementSelection();
const [reloadKey, setReloadKey] = useState(0);
// Track auth token in state so the iframe URL is rebuilt the moment the
// token IPC roundtrip resolves. Without this, the first render runs while
// _authTokenCache is still '' and the iframe loads a tokenless URL → 401
// → the JSON error renders inside the preview pane.
const [authToken, setAuthToken] = useState(() => getAuthToken());
useEffect(() => {
if (authToken) return;
let cancelled = false;
ensureAuthToken().then((tok) => {
if (!cancelled && tok) setAuthToken(tok);
});
return () => { cancelled = true; };
}, [authToken]);
useEffect(() => {
if (ctx && iframeRef.current) {
@@ -65,10 +79,13 @@ const ViewPreview = forwardRef<ViewPreviewHandle, Props>(({
const iframeSrc = useMemo(() => {
if (!serveUrl) return undefined;
// Don't ship a tokenless URL — the backend auth middleware would 401 and
// the iframe would render the JSON error. Wait for the token to load.
if (!authToken) return undefined;
const dataParam = encodeDataParam(inputData, backendResult);
const sep = serveUrl.includes('?') ? '&' : '?';
return `${serveUrl}${sep}_d=${encodeURIComponent(dataParam)}&_v=${reloadKey}`;
}, [serveUrl, inputData, backendResult, reloadKey]);
return `${serveUrl}${sep}_d=${encodeURIComponent(dataParam)}&_v=${reloadKey}&token=${encodeURIComponent(authToken)}`;
}, [serveUrl, inputData, backendResult, reloadKey, authToken]);
const srcdoc = useMemo(() => {
if (serveUrl || !frontendCode) return undefined;
+9 -5
View File
@@ -436,14 +436,14 @@ const agentsSlice = createSlice({
initialState,
reducers: {
createDraftSession: {
reducer(state, action: PayloadAction<{ draftId: string; mode: string; setActive: boolean; targetDirectory?: string }>) {
const { draftId, mode, setActive, targetDirectory } = action.payload;
reducer(state, action: PayloadAction<{ draftId: string; mode: string; setActive: boolean; targetDirectory?: string; model?: string; provider?: string; thinkingLevel?: 'off' | 'low' | 'medium' | 'high' | 'auto' }>) {
const { draftId, mode, setActive, targetDirectory, model, provider, thinkingLevel } = action.payload;
state.sessions[draftId] = {
id: draftId,
name: 'New chat',
status: 'draft',
provider: 'anthropic',
model: 'sonnet',
provider: provider || 'anthropic',
model: model || 'sonnet',
mode,
worktree_path: null,
branch_name: null,
@@ -461,6 +461,7 @@ const agentsSlice = createSlice({
streamingMessage: null,
target_directory: targetDirectory || null,
tool_group_meta: {},
thinking_level: thinkingLevel,
};
if (setActive) {
state.activeSessionId = draftId;
@@ -469,13 +470,16 @@ const agentsSlice = createSlice({
}
}
},
prepare(opts?: { mode?: string; setActive?: boolean; targetDirectory?: string }) {
prepare(opts?: { mode?: string; setActive?: boolean; targetDirectory?: string; model?: string; provider?: string; thinkingLevel?: 'off' | 'low' | 'medium' | 'high' | 'auto' }) {
return {
payload: {
draftId: `draft-${Date.now().toString(36)}`,
mode: opts?.mode || 'agent',
setActive: opts?.setActive !== false,
targetDirectory: opts?.targetDirectory,
model: opts?.model,
provider: opts?.provider,
thinkingLevel: opts?.thinkingLevel,
},
};
},
@@ -25,6 +25,10 @@ export interface Output {
permission: string;
auto_run_config?: AutoRunConfig | null;
thumbnail?: string | null;
// Linkage so reopening App Builder reattaches to the in-progress session
// and reuses the on-disk workspace folder instead of seeding a fresh one.
session_id?: string | null;
workspace_id?: string | null;
created_at: string;
updated_at: string;
}