Inject selected-app context into dashboard agent chat (#69)

When a user selects an App card on the dashboard via the "Select UI element" picker, thread the selected Output ids to the backend (mirroring the selected_browser_ids path) and inject a per-app context block into the system prompt: each app's absolute workspace path, entry point, meta.json, and a pointer to its SKILL.md. This lets the agent edit existing apps in place from the main view (the dashboard card's Vite runtime live-reloads on save) without switching to the Apps tab.
This commit is contained in:
Aidan
2026-06-10 21:29:02 -07:00
committed by GitHub
parent f38f478486
commit e8e19661e6
6 changed files with 91 additions and 10 deletions
+15 -2
View File
@@ -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"<app_builder_reference>\n{load_app_builder_skill()}\n</app_builder_reference>"
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"):
+1
View File
@@ -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}
@@ -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 (
"<selected_app_context>\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</selected_app_context>"
)
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:
@@ -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<AgentChatProps> = ({ 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<AgentChatProps> = ({ 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<AgentChatProps> = ({ 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);
@@ -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<ChatInputHandle, Props>(({ 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<ChatInputHandle, Props>(({ 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);
+6 -4
View File
@@ -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}`);