apps: canvas-chat App Builder seeds + sync meta.json + stale-approval filter

App Builder launched from canvas chat now seeds the React template and
registers an Output row tied to the session id, so the Apps sidebar
lights up immediately. Adds a post-session-complete sync that reads
meta.json from the workspace folder and updates the Output row's name
and description, ending the 'Untitled App' problem. Also adds a stale-
approval filter to the WS replay: resolved approvals no longer
resurrect as dead cards when the user navigates back to a chat.
This commit is contained in:
ciregenz
2026-05-20 02:09:03 -07:00
parent ab0c265d7e
commit 6092fc0966
8 changed files with 297 additions and 70 deletions
+65
View File
@@ -717,6 +717,46 @@ class AgentManager:
os.makedirs(effective_cwd, exist_ok=True)
# Canvas-chat App Builder launch: when the user picks "App Builder"
# mode from the chat-input dropdown (no preexisting workspace, no
# target_directory passed in), the legacy code path only created an
# empty folder, so the agent could write files but the app never
# showed up in the Apps sidebar (no Output row, which is what the
# sidebar reads). Mirror the /workspace/seed endpoint's behavior
# here: seed the React template + register an Output row with
# workspace_id = session_id. Idempotent; safe if the session is
# ever re-launched with the same id.
if config.mode == "view-builder" and not config.target_directory:
try:
from backend.apps.outputs.outputs import (
ensure_webapp_workspace_seeded_and_registered,
_load,
)
output_id = ensure_webapp_workspace_seeded_and_registered(
workspace_id=session_id,
folder=effective_cwd,
session_id=session_id,
)
if output_id:
# Broadcast the new row so the Apps sidebar lights up
# immediately, even before the user clicks into it. The
# row name is still the placeholder ("Untitled App") at
# this point; the post-session meta-sync below fires a
# second upsert with the real name once the agent has
# written meta.json.
try:
new_output = _load(output_id)
await ws_manager.broadcast_global("agent:output_upserted", {
"output": new_output.model_dump(mode="json"),
})
except Exception:
logger.exception("post-seed output_upserted broadcast failed")
except Exception:
logger.exception(
"view-builder workspace seed/register failed; session will "
"still launch but the app may not appear in Apps sidebar"
)
# If the fallback chain landed on the user's home directory (no
# project dir, no default_folder set), re-route to a dedicated
# scratch workspace under ~/.openswarm/workspaces/<session_id>.
@@ -3599,6 +3639,31 @@ class AgentManager:
})
finally:
if session_id in self.sessions:
# For canvas-launched App Builder sessions, the workspace
# folder IS the session_id (see launch_agent), so meta.json
# lives at outputs_workspace/<session_id>/meta.json. Read it
# and propagate name/description into the Output row before
# the terminal status fires; without this, the row stays
# "Untitled App" forever because no React component polls
# the file on the canvas path. Best-effort, only acts when
# the row's name is still the default placeholder.
if session.mode == "view-builder":
try:
from backend.apps.outputs.outputs import sync_output_from_meta_json, _load_all
if sync_output_from_meta_json(session_id):
# Broadcast the renamed row so the sidebar
# flips from "Untitled App" to the real name
# without waiting for the next mount.
try:
matching = [o for o in _load_all() if o.workspace_id == session_id]
if matching:
await ws_manager.broadcast_global("agent:output_upserted", {
"output": matching[0].model_dump(mode="json"),
})
except Exception:
logger.exception("post-sync output_upserted broadcast failed")
except Exception:
logger.exception("post-session meta sync failed")
await ws_manager.send_to_session(session_id, "agent:status", {
"session_id": session_id,
"status": session.status,
+15 -1
View File
@@ -41,9 +41,23 @@ async def list_sessions(dashboard_id: str = ""):
@agents.router.get("/sessions/{session_id}")
async def get_session(session_id: str):
"""Returns the session by id.
Falls back to a disk load when the session isn't in the in-memory
dict. Without this, any surface that queries a session before the
dashboard has restored it (Apps editor opened cold, deep link to a
chat, a workflow step inspecting an old session) hits a 404 even
though the JSON file is sitting on disk. The disk-load path is
O(1) memory hit after the first call: resume_session moves the
session into agent_manager.sessions and the next GET short-circuits
on the in-memory check.
"""
session = agent_manager.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
try:
session = await agent_manager.resume_session(session_id)
except ValueError:
raise HTTPException(status_code=404, detail="Session not found")
return session.model_dump(mode="json")
@agents.router.post("/launch")
+38
View File
@@ -132,6 +132,22 @@ class ConnectionManager:
}
if events:
# Drop already-resolved approval requests from the replay. The
# ring buffer holds every event we ever stamped, including the
# original `agent:approval_request`. Without this filter, a
# client that reconnects (e.g. after navigating away and back,
# which re-mounts AgentChat with last_seq=0) re-fires every
# past approval as if it were live, but the backing future was
# popped from pending_futures the moment the user answered, so
# the resurrected card is a dead no-op. Lifecycle is simple:
# send_approval_request() inserts into pending_futures BEFORE
# the event is stamped, and resolve_approval()/timeout/cancel
# all pop it; so "in pending_futures" is the authoritative
# is-still-live signal for the request_id. A process restart
# wipes pending_futures, which is correct because
# reconcile_on_startup also marks waiting_approval sessions as
# stopped so there's nothing to answer anyway.
events = self._filter_stale_approvals(events)
for s in events:
try:
await websocket.send_text(s)
@@ -161,6 +177,28 @@ class ConnectionManager:
"current_seq": newest if newest is not None else 0,
}
def _filter_stale_approvals(self, events: list[str]) -> list[str]:
"""Return events minus any `agent:approval_request` whose request_id
is no longer in pending_futures. JSON parse is per-event but replay
only runs on (re)connect, so it isn't a hot path.
"""
alive = self.pending_futures
out: list[str] = []
for payload_str in events:
try:
parsed = json.loads(payload_str)
except (ValueError, TypeError):
out.append(payload_str)
continue
if parsed.get("event") != "agent:approval_request":
out.append(payload_str)
continue
data = parsed.get("data") or {}
request_id = data.get("request_id")
if request_id and request_id in alive:
out.append(payload_str)
return out
async def broadcast_global(self, event: str, data: dict):
"""Send a message to all global (dashboard) connections.
+112
View File
@@ -384,6 +384,118 @@ async def read_workspace(workspace_id: str):
return {"files": files, "meta": meta, "path": os.path.abspath(folder)}
def sync_output_from_meta_json(workspace_id: str) -> bool:
"""Read meta.json from the workspace folder; if it has a non-empty
name or description that differs from the linked Output row, update
the row. Returns True if anything changed.
Idempotent and best-effort: missing workspace, missing meta.json,
malformed JSON, or no linked Output all return False silently.
Why this exists: the Apps editor's React component polls meta.json
every few seconds and propagates name/description into the Output
via autosave. The canvas-chat App Builder launch has no such
poller, so apps stayed named "Untitled App" forever even after
the agent wrote a real name into meta.json. Calling this from the
session-complete hook closes that gap on the one event we know
fires exactly once per session.
"""
try:
folder = os.path.join(WORKSPACE_DIR, workspace_id)
meta_path = os.path.join(folder, "meta.json")
if not os.path.exists(meta_path):
return False
with open(meta_path) as f:
meta = json.load(f)
if not isinstance(meta, dict):
return False
name = str(meta.get("name") or "").strip()
description = str(meta.get("description") or "").strip()
if not name and not description:
return False
matching = [o for o in _load_all() if o.workspace_id == workspace_id]
if not matching:
return False
output = matching[0]
changed = False
# Only overwrite the default placeholder ("Untitled App" / "") so a
# user who explicitly renamed the app in the UI isn't clobbered by
# a stale meta.json from a prior agent turn.
if name and output.name in ("", "Untitled App") and output.name != name:
output.name = name
changed = True
if description and not output.description and output.description != description:
output.description = description
changed = True
if changed:
output.updated_at = datetime.now().isoformat()
_save(output)
return changed
except (OSError, json.JSONDecodeError, ValueError):
return False
except Exception:
logger.exception("sync_output_from_meta_json failed for %s", workspace_id)
return False
def ensure_webapp_workspace_seeded_and_registered(
workspace_id: str,
folder: str,
session_id: Optional[str] = None,
) -> Optional[str]:
"""Idempotently seed the webapp template into `folder` and register an
Output row pointing at `workspace_id`. Used by the canvas-chat launch
path so picking "App Builder" from the mode dropdown produces the same
sidebar visibility as the Apps editor's `/workspace/seed` flow.
When `session_id` is supplied, it is persisted on the Output row so the
Apps editor can reattach to the same chat history later (without this
link, double-clicking the app card opens an empty editor instead of
the conversation the user already had with the agent).
Idempotency:
- If `run.sh` already exists in the folder, skip the template copy
(matches the seed_workspace endpoint's idempotency guard).
- If any Output already points at this workspace_id, reuse it but
still attach session_id if it's missing.
Returns the output_id on success, None on failure (best-effort; the
caller's session still launches even if registration fails).
"""
try:
os.makedirs(folder, exist_ok=True)
already_seeded = os.path.exists(os.path.join(folder, "run.sh"))
if not already_seeded:
from backend.apps.outputs.runtime import _find_free_port
frontend_port = _find_free_port()
seed_webapp_template_workspace(folder, frontend_port)
with open(os.path.join(folder, "SKILL.md"), "w") as f:
f.write(load_app_builder_skill())
existing = [o for o in _load_all() if o.workspace_id == workspace_id]
if existing:
output = existing[0]
if session_id and output.session_id != session_id:
output.session_id = session_id
output.updated_at = datetime.now().isoformat()
_save(output)
return output.id
now = datetime.now().isoformat()
output = Output(
name="Untitled App",
description="",
icon="view_quilt",
files={},
workspace_id=workspace_id,
session_id=session_id,
created_at=now,
updated_at=now,
)
_save(output)
return output.id
except Exception:
logger.exception("ensure_webapp_workspace_seeded_and_registered failed for %s", workspace_id)
return None
@outputs.router.post("/workspace/seed")
async def seed_workspace(body: WorkspaceSeedRequest):
"""Create a workspace folder and pre-seed it.
@@ -75,7 +75,10 @@ export const TrustedFilePatterns: React.FC = () => {
}
}, [patterns, load]);
if (patterns === null) return null;
// Hide the whole section until the user actually has trusted patterns;
// an empty "no patterns yet" card was just visual bloat for the 99% case.
// The approval-time checkbox is what teaches the user this feature exists.
if (!patterns || patterns.length === 0) return null;
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
@@ -85,45 +88,39 @@ export const TrustedFilePatterns: React.FC = () => {
<Typography sx={{ fontSize: '0.8rem', color: c.text.secondary, lineHeight: 1.45 }}>
Files like SSH keys and shell startup files normally ask before each write, even when you've set Write to "always allow". Patterns you've chosen to always allow appear below. Remove one to start asking again.
</Typography>
{patterns.length === 0 ? (
<Typography sx={{ fontSize: '0.78rem', color: c.text.tertiary, fontStyle: 'italic', mt: 0.5 }}>
No trusted patterns yet. You'll see a checkbox the first time the agent tries to write a sensitive file.
</Typography>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', border: `1px solid ${c.border.subtle}`, borderRadius: 1.5, overflow: 'hidden', mt: 0.5 }}>
{patterns.map((pat, idx) => (
<Box
key={pat}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
px: 1.5,
py: 1,
borderTop: idx === 0 ? 'none' : `1px solid ${c.border.subtle}`,
bgcolor: c.bg.surface,
}}
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography sx={{ fontSize: '0.82rem', color: c.text.primary, fontWeight: 500 }}>
{PATTERN_LABELS[pat] || pat}
</Typography>
<Typography sx={{ fontSize: '0.72rem', color: c.text.tertiary, fontFamily: c.font.mono, mt: 0.15 }}>
{pat}
</Typography>
</Box>
<IconButton
size="small"
onClick={() => revoke(pat)}
aria-label={`Remove ${PATTERN_LABELS[pat] || pat}`}
sx={{ color: c.text.tertiary, '&:hover': { color: c.status.error } }}
>
<DeleteOutlineIcon sx={{ fontSize: 18 }} />
</IconButton>
<Box sx={{ display: 'flex', flexDirection: 'column', border: `1px solid ${c.border.subtle}`, borderRadius: 1.5, overflow: 'hidden', mt: 0.5 }}>
{patterns.map((pat, idx) => (
<Box
key={pat}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
px: 1.5,
py: 1,
borderTop: idx === 0 ? 'none' : `1px solid ${c.border.subtle}`,
bgcolor: c.bg.surface,
}}
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography sx={{ fontSize: '0.82rem', color: c.text.primary, fontWeight: 500 }}>
{PATTERN_LABELS[pat] || pat}
</Typography>
<Typography sx={{ fontSize: '0.72rem', color: c.text.tertiary, fontFamily: c.font.mono, mt: 0.15 }}>
{pat}
</Typography>
</Box>
))}
</Box>
)}
<IconButton
size="small"
onClick={() => revoke(pat)}
aria-label={`Remove ${PATTERN_LABELS[pat] || pat}`}
sx={{ color: c.text.tertiary, '&:hover': { color: c.status.error } }}
>
<DeleteOutlineIcon sx={{ fontSize: 18 }} />
</IconButton>
</Box>
))}
</Box>
</Box>
);
};
@@ -3,11 +3,9 @@ import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Collapse from '@mui/material/Collapse';
import IconButton from '@mui/material/IconButton';
import Chip from '@mui/material/Chip';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import TerminalIcon from '@mui/icons-material/Terminal';
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
import { AgentMessage, ToolGroupMeta } from '@/shared/state/agentsSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { sanitizeSvgString } from '@/shared/sanitizeSvg';
@@ -153,40 +151,20 @@ const ToolGroupBubble: React.FC<Props> = React.memo(({ group, isSessionRunning =
{deniedCount} denied
</Typography>
)}
{/* Fixed-width fraction + count chip so the header row stops
reflowing as the count climbs from 9 → 10 → 11 → 12 during
parallel tool execution. Without min-widths, every digit-
boundary nudges the header text wider, which shifts the
chevron, which shifts the entire transcript below. The
tabular-nums + minWidth pair locks both the fraction and
the chip to a stable size for any 1-3 digit count. */}
{/* Single fraction renders progress AND total; the green color
alone signals completion, and the fraction's denominator
makes the separate ×N chip redundant. tabular-nums +
minWidth keep the position stable as digits change. */}
{allDone && completedCount > 0 && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.3, minWidth: 44, justifyContent: 'flex-end' }}>
<CheckCircleOutlineIcon sx={{ fontSize: 12, color: c.status.success }} />
<Typography sx={{ color: c.status.success, fontSize: '0.68rem', fontVariantNumeric: 'tabular-nums' }}>
{completedCount}/{group.callCount}
</Typography>
</Box>
<Typography sx={{ color: c.status.success, fontSize: '0.68rem', fontVariantNumeric: 'tabular-nums', fontFamily: c.font.mono, minWidth: 36, textAlign: 'right' }}>
{completedCount}/{group.callCount}
</Typography>
)}
{!allDone && pendingCount > 0 && (
<Typography sx={{ color: c.text.tertiary, fontSize: '0.68rem', fontFamily: c.font.mono, fontVariantNumeric: 'tabular-nums', minWidth: 36, textAlign: 'right' }}>
{completedCount}/{group.callCount}
</Typography>
)}
<Chip
label={`×${group.callCount}`}
size="small"
sx={{
height: 18,
minWidth: 36,
fontSize: '0.7rem',
fontWeight: 600,
bgcolor: c.bg.secondary,
color: c.text.muted,
fontVariantNumeric: 'tabular-nums',
'& .MuiChip-label': { px: 0.75 },
}}
/>
<IconButton size="small" sx={{ color: c.text.tertiary, p: 0.15 }}>
{expanded ? <ExpandLessIcon sx={{ fontSize: 16 }} /> : <ExpandMoreIcon sx={{ fontSize: 16 }} />}
</IconButton>
+13 -1
View File
@@ -136,7 +136,18 @@ export const executeOutput = createAsyncThunk(
const outputsSlice = createSlice({
name: 'outputs',
initialState,
reducers: {},
reducers: {
/** Upsert an Output row from a server-pushed WS event (canvas-launched
* App Builder seeds the row on launch; meta-sync renames it at session
* end). Merges over existing fields so a row that already has agent-
* generated content doesn't lose anything from a partial server push.
*/
upsertOutput(state, action: { payload: Output; type: string }) {
const incoming = action.payload;
const existing = state.items[incoming.id];
state.items[incoming.id] = existing ? { ...existing, ...incoming } : incoming;
},
},
extraReducers: (builder) => {
builder
.addCase(fetchOutputs.pending, (state) => { state.loading = true; })
@@ -153,4 +164,5 @@ const outputsSlice = createSlice({
},
});
export const { upsertOutput } = outputsSlice.actions;
export default outputsSlice.reducer;
@@ -24,6 +24,7 @@ import {
} from '../state/agentsSlice';
import { streamStart, streamDelta, streamEnd } from '../state/streamingSlice';
import { addBrowserCardFromBackend, removeBrowserCard, setBrowserCardPosition, setGlowingBrowserCards, GRID_GAP } from '../state/dashboardLayoutSlice';
import { upsertOutput } from '../state/outputsSlice';
import { getAuthToken } from '../config';
import { notifyAgentCompletion } from '../notifications';
@@ -488,6 +489,16 @@ class WebSocketManager {
}
break;
case 'agent:output_upserted':
// Emitted by the backend when an Output row is created (canvas-launched
// App Builder seed) or updated (post-session meta.json sync). The
// upsert reducer merges over an existing row so a UI that already
// loaded the row doesn't lose locally-applied fields.
if (data.output && data.output.id) {
store.dispatch(upsertOutput(data.output));
}
break;
case 'agent:stream_start':
case 'agent:stream_delta':
case 'agent:stream_end':