diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index 955ef29f..d7e4f754 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -56,6 +56,7 @@ from backend.apps.agents.manager.session.history_compaction import ( ) from backend.apps.agents.manager.prompt.prompt_context import ( _build_browser_context, + _build_selected_app_context, _build_connected_tools_context, _build_mcp_registry_summary, _compose_system_prompt, @@ -212,6 +213,9 @@ class AgentManager: def _build_browser_context(self, dashboard_id: str | None, selected_browser_ids: list[str] | None = None) -> str | None: return _build_browser_context(dashboard_id, selected_browser_ids) + def _build_selected_app_context(self, selected_app_output_ids: list[str] | None) -> str | None: + return _build_selected_app_context(selected_app_output_ids) + def _get_pre_selected_browser_ids(self, dashboard_id: str | None) -> list[str]: return _get_pre_selected_browser_ids(dashboard_id) @@ -384,7 +388,7 @@ class AgentManager: def _resolve_context_paths(self, context_paths: list | None) -> str: return _resolve_context_paths(context_paths) - async def _run_agent_loop(self, session_id: str, prompt: str, images: list | None = None, context_paths: list | None = None, forced_tools: list[str] | None = None, attached_skills: list | None = None, fork_session: bool = False, selected_browser_ids: list[str] | None = None): + async def _run_agent_loop(self, session_id: str, prompt: str, images: list | None = None, context_paths: list | None = None, forced_tools: list[str] | None = None, attached_skills: list | None = None, fork_session: bool = False, selected_browser_ids: list[str] | None = None, selected_app_output_ids: list[str] | None = None): """Run the Claude Agent SDK query loop for a session.""" session = self.sessions.get(session_id) if not session: @@ -1107,6 +1111,14 @@ class AgentManager: skill_block = f"\n{load_app_builder_skill()}\n" composed_prompt = f"{composed_prompt}\n\n{skill_block}" if composed_prompt else skill_block + # App cards the user picked via the dashboard element picker: give + # the agent each app's on-disk path + meta + SKILL.md pointer so it + # can edit them in place (the dashboard card's runtime live-reloads). + # Additive and independent of view-builder mode above. + app_ctx = self._build_selected_app_context(selected_app_output_ids) + if app_ctx: + composed_prompt = f"{composed_prompt}\n\n{app_ctx}" if composed_prompt else app_ctx + # Per-turn estimate of framework overhead (subtracted from displayed # input). Conservative on purpose so honest over-shows beat lies. # 16K Claude Code preset, 12K base+deferred tools, ~3K/MCP (real @@ -3214,6 +3226,7 @@ class AgentManager: attached_skills: list | None = None, hidden: bool = False, selected_browser_ids: list[str] | None = None, + selected_app_output_ids: list[str] | None = None, client_message_id: str | None = None, ): """Send a follow-up message to an existing session.""" @@ -3341,7 +3354,7 @@ class AgentManager: if fast_verdict != "no": task = asyncio.create_task(self._run_browser_fast_path(session_id, prompt, selected_browser_ids, fast_brief, fast_verdict)) else: - task = asyncio.create_task(self._run_agent_loop(session_id, prompt, images=images, context_paths=context_paths, forced_tools=forced_tools, attached_skills=attached_skills, selected_browser_ids=selected_browser_ids)) + task = asyncio.create_task(self._run_agent_loop(session_id, prompt, images=images, context_paths=context_paths, forced_tools=forced_tools, attached_skills=attached_skills, selected_browser_ids=selected_browser_ids, selected_app_output_ids=selected_app_output_ids)) self.tasks[session_id] = task async def _run_browser_fast_path(self, session_id: str, prompt: str, selected_browser_ids: list[str] | None, brief: str = "", verdict: str = "act"): diff --git a/backend/apps/agents/agents.py b/backend/apps/agents/agents.py index f32e05f5..537b9e89 100644 --- a/backend/apps/agents/agents.py +++ b/backend/apps/agents/agents.py @@ -98,6 +98,7 @@ async def send_message(session_id: str, body: dict): attached_skills=body.get("attached_skills"), hidden=body.get("hidden", False), selected_browser_ids=body.get("selected_browser_ids"), + selected_app_output_ids=body.get("selected_app_output_ids"), client_message_id=body.get("client_message_id"), ) return {"ok": True} diff --git a/backend/apps/agents/manager/prompt/prompt_context.py b/backend/apps/agents/manager/prompt/prompt_context.py index 16afb6c7..116ffec3 100644 --- a/backend/apps/agents/manager/prompt/prompt_context.py +++ b/backend/apps/agents/manager/prompt/prompt_context.py @@ -174,6 +174,65 @@ def _build_browser_context(dashboard_id: str | None, selected_browser_ids: list[ return "\n".join(lines) +def _build_selected_app_context(selected_app_output_ids: list[str] | None) -> str | None: + """Build a context block for dashboard App cards the user selected to edit. + + Resolves each Output id to its on-disk workspace so the agent edits the + right files; the dashboard card's Vite runtime live-reloads on save. Skips + deleted apps / missing folders, returns None if nothing resolves. + """ + if not selected_app_output_ids: + return None + import os + from backend.apps.outputs.workspace_io import load_output + from backend.config.paths import OUTPUTS_WORKSPACE_DIR + + entries: list[str] = [] + for output_id in selected_app_output_ids: + try: + output = load_output(output_id) + except Exception: + output = None + if not output or not output.workspace_id: + continue + path = os.path.abspath(os.path.join(OUTPUTS_WORKSPACE_DIR, output.workspace_id)) + if not os.path.isdir(path): + continue + meta_raw = "" + meta_path = os.path.join(path, "meta.json") + if os.path.isfile(meta_path): + try: + with open(meta_path, "r", encoding="utf-8") as f: + meta_raw = f.read().strip() + except Exception: + meta_raw = "" + name = output.name or "Untitled App" + lines = [ + f'- App: "{name}"', + f" Workspace path: {path}", + f" Entry point: {os.path.join(path, 'index.html')}", + ] + if meta_raw: + lines.append(f" meta.json: {meta_raw}") + lines.append( + f" Before changing anything, Read {os.path.join(path, 'SKILL.md')} " + f"for the App platform spec." + ) + entries.append("\n".join(lines)) + + if not entries: + return None + return ( + "\n" + "The user selected these App cards on the dashboard for you to edit. " + "They are existing web apps; edit the files in place at the paths below " + "and the dashboard preview live-reloads on save. Do not scaffold a new " + "project or write files anywhere else.\n\n" + + "\n\n".join(entries) + + "\n" + ) + + def _get_pre_selected_browser_ids(dashboard_id: str | None) -> list[str]: """Return browser_ids of all browser cards currently on the dashboard.""" if not dashboard_id: diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index b0925caf..4723f036 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -147,6 +147,7 @@ interface QueuedMessage { forcedTools?: string[]; attachedSkills?: Array<{ id: string; name: string; content: string }>; selectedBrowserIds?: string[]; + selectedAppIds?: string[]; } interface AgentChatProps { @@ -300,7 +301,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose if (session?.system_prompt) config.system_prompt = session.system_prompt; if (session?.target_directory) config.target_directory = session.target_directory; dispatch( - launchAndSendFirstMessage({ draftId: id, config, prompt: msg.prompt, mode, model, images: msg.images, contextPaths: msg.contextPaths, forcedTools: msg.forcedTools, attachedSkills: msg.attachedSkills, selectedBrowserIds: msg.selectedBrowserIds }) + launchAndSendFirstMessage({ draftId: id, config, prompt: msg.prompt, mode, model, images: msg.images, contextPaths: msg.contextPaths, forcedTools: msg.forcedTools, attachedSkills: msg.attachedSkills, selectedBrowserIds: msg.selectedBrowserIds, selectedAppIds: msg.selectedAppIds }) ).then((action) => { if (launchAndSendFirstMessage.fulfilled.match(action)) { const realId = action.payload.session.id; @@ -314,7 +315,7 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose if (msg.selectedBrowserIds?.length) { dispatch(setGlowingBrowserCards({ browserIds: msg.selectedBrowserIds, sessionId: id, label: 'Use Browser' })); } - dispatch(sendMessageThunk({ sessionId: id, prompt: msg.prompt, mode, model, images: msg.images, contextPaths: msg.contextPaths, forcedTools: msg.forcedTools, attachedSkills: msg.attachedSkills, selectedBrowserIds: msg.selectedBrowserIds })) + dispatch(sendMessageThunk({ sessionId: id, prompt: msg.prompt, mode, model, images: msg.images, contextPaths: msg.contextPaths, forcedTools: msg.forcedTools, attachedSkills: msg.attachedSkills, selectedBrowserIds: msg.selectedBrowserIds, selectedAppIds: msg.selectedAppIds })) .then((action) => { if (sendMessageThunk.rejected.match(action)) { setAwaitingResponse(false); @@ -580,10 +581,11 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose forcedTools?: string[], attachedSkills?: Array<{ id: string; name: string; content: string }>, selectedBrowserIds?: string[], + selectedAppIds?: string[], ) => { if (!id) return; scrollToBottom(); - const msg: QueuedMessage = { prompt, images, contextPaths, forcedTools, attachedSkills, selectedBrowserIds }; + const msg: QueuedMessage = { prompt, images, contextPaths, forcedTools, attachedSkills, selectedBrowserIds, selectedAppIds }; if (agentBusy) { messageQueueRef.current.push(msg); setQueueLength(messageQueueRef.current.length); diff --git a/frontend/src/app/pages/AgentChat/ChatInput.tsx b/frontend/src/app/pages/AgentChat/ChatInput.tsx index 243a0c51..9d02a1f4 100644 --- a/frontend/src/app/pages/AgentChat/ChatInput.tsx +++ b/frontend/src/app/pages/AgentChat/ChatInput.tsx @@ -23,7 +23,7 @@ export type { AttachedImage, ForcedToolGroup, ChatInputHandle }; export type { AttachedSkill } from '@/app/components/editor/richEditorUtils'; interface Props { - onSend: (message: string, images?: Array<{ data: string; media_type: string }>, contextPaths?: ContextPath[], forcedTools?: string[], attachedSkills?: Array<{ id: string; name: string; content: string }>, selectedBrowserIds?: string[]) => void; + onSend: (message: string, images?: Array<{ data: string; media_type: string }>, contextPaths?: ContextPath[], forcedTools?: string[], attachedSkills?: Array<{ id: string; name: string; content: string }>, selectedBrowserIds?: string[], selectedAppIds?: string[]) => void; disabled?: boolean; mode: string; onModeChange: (mode: string) => void; @@ -213,6 +213,9 @@ const ChatInput = forwardRef(({ onSend, disabled, mode, const browserIds = selectedEls .filter((el) => el.semanticType === 'browser-card' && el.semanticData?.selectId) .map((el) => el.semanticData!.selectId as string); + const appIds = selectedEls + .filter((el) => el.semanticType === 'view-card' && el.semanticData?.selectId) + .map((el) => el.semanticData!.selectId as string); onSend( trimmed, sendImages, @@ -220,6 +223,7 @@ const ChatInput = forwardRef(({ onSend, disabled, mode, allForcedToolNames.length > 0 ? allForcedToolNames : undefined, sendSkills, browserIds.length > 0 ? browserIds : undefined, + appIds.length > 0 ? appIds : undefined, ); if (editor.tagName === 'TEXTAREA') (editor as unknown as HTMLTextAreaElement).value = ''; else editor.innerHTML = ''; deleteDraft(ownerId); diff --git a/frontend/src/shared/state/agentsSlice.ts b/frontend/src/shared/state/agentsSlice.ts index 2e1d5b4c..291a9a5b 100644 --- a/frontend/src/shared/state/agentsSlice.ts +++ b/frontend/src/shared/state/agentsSlice.ts @@ -191,6 +191,7 @@ export interface SendMessagePayload { attachedSkills?: Array<{ id: string; name: string; content: string }>; hidden?: boolean; selectedBrowserIds?: string[]; + selectedAppIds?: string[]; } function _genOptimisticId(): string { @@ -199,7 +200,7 @@ function _genOptimisticId(): string { export const sendMessage = createAsyncThunk( 'agents/sendMessage', - async ({ sessionId, prompt, mode, model, provider, images, contextPaths, forcedTools, attachedSkills, hidden, selectedBrowserIds }: SendMessagePayload, { dispatch }) => { + async ({ sessionId, prompt, mode, model, provider, images, contextPaths, forcedTools, attachedSkills, hidden, selectedBrowserIds, selectedAppIds }: SendMessagePayload, { dispatch }) => { // Mint client id and dispatch optimistic bubble before awaiting the network; id round-trips for echo dedupe. const clientMessageId = _genOptimisticId(); dispatch(addOptimisticMessage({ @@ -216,7 +217,7 @@ export const sendMessage = createAsyncThunk( const res = await fetch(`${AGENTS_API}/sessions/${sessionId}/message`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ prompt, mode, model, provider, images, context_paths: contextPaths, forced_tools: forcedTools, attached_skills: attachedSkills, hidden, selected_browser_ids: selectedBrowserIds, client_message_id: clientMessageId }), + body: JSON.stringify({ prompt, mode, model, provider, images, context_paths: contextPaths, forced_tools: forcedTools, attached_skills: attachedSkills, hidden, selected_browser_ids: selectedBrowserIds, selected_app_output_ids: selectedAppIds, client_message_id: clientMessageId }), }); if (!res.ok) throw new Error(`send failed: ${res.status}`); } catch (err) { @@ -276,6 +277,7 @@ export interface LaunchAndSendPayload { attachedSkills?: Array<{ id: string; name: string; content: string }>; expand?: boolean; selectedBrowserIds?: string[]; + selectedAppIds?: string[]; } export const fetchSession = createAsyncThunk( @@ -293,7 +295,7 @@ export const fetchSession = createAsyncThunk( export const launchAndSendFirstMessage = createAsyncThunk( 'agents/launchAndSendFirstMessage', - async ({ draftId, config, prompt, mode, model, provider, images, contextPaths, forcedTools, attachedSkills, selectedBrowserIds }: LaunchAndSendPayload) => { + async ({ draftId, config, prompt, mode, model, provider, images, contextPaths, forcedTools, attachedSkills, selectedBrowserIds, selectedAppIds }: LaunchAndSendPayload) => { const launchRes = await fetch(`${AGENTS_API}/launch`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -305,7 +307,7 @@ export const launchAndSendFirstMessage = createAsyncThunk( await fetch(`${AGENTS_API}/sessions/${session.id}/message`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ prompt, mode, model, provider, images, context_paths: contextPaths, forced_tools: forcedTools, attached_skills: attachedSkills, selected_browser_ids: selectedBrowserIds }), + body: JSON.stringify({ prompt, mode, model, provider, images, context_paths: contextPaths, forced_tools: forcedTools, attached_skills: attachedSkills, selected_browser_ids: selectedBrowserIds, selected_app_output_ids: selectedAppIds }), }); const refreshRes = await fetch(`${AGENTS_API}/sessions/${session.id}`);