diff --git a/backend/apps/agents/core/models.py b/backend/apps/agents/core/models.py index 4578ef7e..e66ac124 100644 --- a/backend/apps/agents/core/models.py +++ b/backend/apps/agents/core/models.py @@ -13,6 +13,9 @@ class AgentConfig(BaseModel): max_turns: Optional[int] = None target_directory: Optional[str] = None dashboard_id: Optional[str] = None + # App cards the user picked to edit. When exactly one resolves, launch + # binds the chat's cwd to that app instead of seeding a new "Untitled App". + selected_app_output_ids: Optional[list[str]] = None class ApprovalRequest(BaseModel): id: str = Field(default_factory=lambda: uuid4().hex) diff --git a/backend/apps/agents/manager/AgentLaunchMixin.py b/backend/apps/agents/manager/AgentLaunchMixin.py index 382a7b0f..ddf3abba 100644 --- a/backend/apps/agents/manager/AgentLaunchMixin.py +++ b/backend/apps/agents/manager/AgentLaunchMixin.py @@ -34,6 +34,21 @@ class AgentLaunchMixin: async def launch_agent(self, config: AgentConfig) -> AgentSession: session_id = uuid4().hex + # Editing an existing App: when the user selected exactly one App card + # in App Builder mode, point the chat at that app's workspace so it + # edits in place. Without this the view-builder seed below fires (no + # target_directory) and registers a fresh empty "Untitled App" dupe. + if ( + config.mode == "view-builder" + and not config.target_directory + and config.selected_app_output_ids + and len(config.selected_app_output_ids) == 1 + ): + from backend.apps.outputs.workspace_io import app_workspace_dir + bound = app_workspace_dir(config.selected_app_output_ids[0]) + if bound: + config.target_directory = bound + mode_tools, _, mode_folder = resolve_mode(config.mode, get_all_tool_names) tools = mode_tools diff --git a/backend/apps/outputs/workspace_io.py b/backend/apps/outputs/workspace_io.py index 33e6d44a..8f857e38 100644 --- a/backend/apps/outputs/workspace_io.py +++ b/backend/apps/outputs/workspace_io.py @@ -7,7 +7,7 @@ import os from fastapi import HTTPException from backend.apps.outputs.models import Output -from backend.config.paths import OUTPUTS_DIR as DATA_DIR +from backend.config.paths import OUTPUTS_DIR as DATA_DIR, OUTPUTS_WORKSPACE_DIR from backend.config.json_store import read_json_or_none, atomic_write_json logger = logging.getLogger(__name__) @@ -46,6 +46,18 @@ def load_output(output_id: str) -> Output | None: return Output(**data) if data is not None else None +def app_workspace_dir(output_id: str) -> str | None: + """Resolve an App (Output) id to its on-disk workspace folder, or None if + the app or its folder is gone. Shared by the prompt-context builder (which + files the agent should edit) and launch (binds the chat's cwd to the app so + editing it doesn't seed a duplicate 'Untitled App').""" + output = load_output(output_id) + if not output or not output.workspace_id: + return None + path = os.path.abspath(os.path.join(OUTPUTS_WORKSPACE_DIR, output.workspace_id)) + return path if os.path.isdir(path) else None + + # Build/install/cache directories that the polling endpoint must never # descend into. Without this skip-list the workspace endpoint reads # `node_modules/` (300 MB of MUI source, when it's a real dir and not a diff --git a/backend/tests/test_app_edit_bind.py b/backend/tests/test_app_edit_bind.py new file mode 100644 index 00000000..be6a2420 --- /dev/null +++ b/backend/tests/test_app_edit_bind.py @@ -0,0 +1,58 @@ +"""Editing an existing App must bind to its workspace, never seed a dupe. + +`app_workspace_dir` is the resolver launch_agent uses to turn a selected App +(Output) id into the cwd it should edit in place. If it returns a real path, +launch sets target_directory and the view-builder seed is skipped; if it +returns None the seed fires and a duplicate "Untitled App" is born (the bug +this locks out). Path constants are module-level, so (like test_seed_no_clobber) +we monkeypatch a temp tree. +""" +import json +import os + +import pytest + +from backend.apps.outputs import workspace_io as wio +from backend.apps.outputs.models import Output + + +@pytest.fixture +def out_root(tmp_path, monkeypatch): + data = tmp_path / "outputs" + ws = tmp_path / "outputs_workspace" + data.mkdir() + ws.mkdir() + monkeypatch.setattr(wio, "DATA_DIR", str(data)) + monkeypatch.setattr(wio, "OUTPUTS_WORKSPACE_DIR", str(ws)) + return data, ws + + +def _write_output(data_dir, **kw): + o = Output(**kw) + with open(os.path.join(str(data_dir), f"{o.id}.json"), "w") as f: + json.dump(o.model_dump(), f) + return o + + +def test_resolves_existing_app_workspace(out_root): + data, ws = out_root + os.makedirs(os.path.join(str(ws), "ws-app")) + o = _write_output(data, name="Voxelcraft", workspace_id="ws-app") + assert wio.app_workspace_dir(o.id) == os.path.abspath(os.path.join(str(ws), "ws-app")) + + +def test_missing_output_returns_none(out_root): + # Deleted/bogus selection -> no bind -> launch falls through to a normal new build. + assert wio.app_workspace_dir("doesnotexist") is None + + +def test_output_without_workspace_returns_none(out_root): + data, _ = out_root + o = _write_output(data, name="NoWorkspace", workspace_id=None) + assert wio.app_workspace_dir(o.id) is None + + +def test_output_with_vanished_folder_returns_none(out_root): + data, _ = out_root + o = _write_output(data, name="Gone", workspace_id="ws-vanished") # folder never created + assert wio.app_workspace_dir(o.id) is None diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index 9c1768c8..c8237d3b 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -407,6 +407,9 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose // session lands dashboard_id=null, drops out of the reconcile filter, and its card vanishes // the instant you send (looked like "the chat quit when I clicked an option"). if (session?.dashboard_id) config.dashboard_id = session.dashboard_id; + // Editing an existing app: bind the launch to it so the backend edits in + // place instead of seeding a duplicate empty app (App Builder mode only). + if (msg.selectedAppIds?.length) config.selected_app_output_ids = msg.selectedAppIds; 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, selectedAppIds: msg.selectedAppIds, selectedSettingIds: msg.selectedSettingIds }) ).then((action) => { diff --git a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx index bedf49b5..ecf1cfc8 100644 --- a/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx +++ b/frontend/src/app/pages/Dashboard/DashboardToolbar.tsx @@ -49,6 +49,7 @@ interface Props { forcedTools?: string[], attachedSkills?: Array<{ id: string; name: string; content: string }>, selectedBrowserIds?: string[], + selectedAppIds?: string[], ) => void; onAddView: (outputId: string) => void; onHistoryResume: (sessionId: string) => void; @@ -213,8 +214,9 @@ const DashboardToolbar = React.forwardRef( forcedTools?: string[], attachedSkills?: Array<{ id: string; name: string; content: string }>, selectedBrowserIds?: string[], + selectedAppIds?: string[], ) => { - onSend(message, mode, model, images, contextPaths, forcedTools, attachedSkills, selectedBrowserIds); + onSend(message, mode, model, images, contextPaths, forcedTools, attachedSkills, selectedBrowserIds, selectedAppIds); }, [onSend, mode, model], ); diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts index 918ccc09..1cba1dc2 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts @@ -127,6 +127,7 @@ export function useAgentSpawn({ forcedTools?: string[], attachedSkills?: Array<{ id: string; name: string; content: string }>, selectedBrowserIds?: string[], + selectedAppIds?: string[], ) => { setToolbarOpen(false); report('dashboard', 'agent_created', { mode, model, has_images: !!images?.length, has_context: !!contextPaths?.length, has_browser: !!selectedBrowserIds?.length }); @@ -148,6 +149,9 @@ export function useAgentSpawn({ } const config: AgentConfig = { name: 'New chat', model, mode, dashboard_id: dashboardId }; + // Editing an existing app: bind the launch to it so the backend edits in + // place instead of seeding a duplicate empty app (App Builder mode only). + if (selectedAppIds?.length) config.selected_app_output_ids = selectedAppIds; dispatch( launchAndSendFirstMessage({ @@ -161,6 +165,7 @@ export function useAgentSpawn({ forcedTools, attachedSkills, selectedBrowserIds, + selectedAppIds, expand: expandNewChats, }), ).then((action) => { diff --git a/frontend/src/shared/state/agentsSlice.ts b/frontend/src/shared/state/agentsSlice.ts index 0592abe5..25631095 100644 --- a/frontend/src/shared/state/agentsSlice.ts +++ b/frontend/src/shared/state/agentsSlice.ts @@ -125,6 +125,7 @@ export interface AgentConfig { max_turns?: number; target_directory?: string; dashboard_id?: string; + selected_app_output_ids?: string[]; } export interface HistorySession {