mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[eric] apps: edit selected App in place, no duplicate on launch
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -125,6 +125,7 @@ export interface AgentConfig {
|
||||
max_turns?: number;
|
||||
target_directory?: string;
|
||||
dashboard_id?: string;
|
||||
selected_app_output_ids?: string[];
|
||||
}
|
||||
|
||||
export interface HistorySession {
|
||||
|
||||
Reference in New Issue
Block a user