[eric] apps: edit selected App in place, no duplicate on launch

This commit is contained in:
ciregenz
2026-06-24 18:29:34 -07:00
committed by ciregenz
parent 2ad3000562
commit 9ea40fbf7b
8 changed files with 101 additions and 2 deletions
+3
View File
@@ -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)
@@ -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
+13 -1
View File
@@ -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
+58
View File
@@ -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
@@ -407,6 +407,9 @@ const AgentChat: React.FC<AgentChatProps> = ({ 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) => {
@@ -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<HTMLDivElement, Props>(
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],
);
@@ -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) => {
+1
View File
@@ -125,6 +125,7 @@ export interface AgentConfig {
max_turns?: number;
target_directory?: string;
dashboard_id?: string;
selected_app_output_ids?: string[];
}
export interface HistorySession {