From b02f03c0deab600941fb4c2f25c5ac22f324a05f Mon Sep 17 00:00:00 2001 From: ahmad-ajmal Date: Wed, 30 Sep 2026 13:28:00 +0100 Subject: [PATCH] fix: keep chat-started Agent App setups resumable (#448) --- app/agent_app/manager.py | 8 + app/agent_app/origin_notices.py | 94 +++++++ app/agent_app/pending_setups.py | 179 ++++++++++++++ app/agent_app/wizard.py | 5 +- app/agent_base.py | 1 + app/data/action/agent_app_actions.py | 100 +++++--- app/session/session_manager.py | 10 + app/triggers/sources.py | 3 + app/ui_layer/adapters/browser_adapter.py | 80 ++++-- .../frontend/src/components/Chat/Chat.tsx | 15 ++ .../Chat/ResumeSetupCard.module.css | 77 ++++++ .../src/components/Chat/ResumeSetupCard.tsx | 42 ++++ .../frontend/src/components/layout/NavBar.tsx | 5 + .../src/components/ui/ChatSetupHost.tsx | 101 ++++++++ .../src/components/ui/CreateAgentAppModal.tsx | 54 +--- .../src/components/ui/CreateCustomWizard.tsx | 53 ++-- .../browser/frontend/src/locales/en/chat.json | 8 + .../frontend/src/locales/en/components.json | 4 + .../browser/frontend/src/locales/es/chat.json | 8 + .../frontend/src/locales/es/components.json | 4 + .../browser/frontend/src/locales/id/chat.json | 8 + .../frontend/src/locales/id/components.json | 4 + .../browser/frontend/src/locales/ja/chat.json | 8 + .../frontend/src/locales/ja/components.json | 4 + .../browser/frontend/src/locales/ko/chat.json | 8 + .../frontend/src/locales/ko/components.json | 4 + .../frontend/src/locales/zh-CN/chat.json | 8 + .../src/locales/zh-CN/components.json | 4 + .../frontend/src/locales/zh-TW/chat.json | 8 + .../src/locales/zh-TW/components.json | 4 + .../browser/frontend/src/store/index.ts | 2 + .../src/store/resources/ResourceSync.ts | 1 + .../frontend/src/store/resources/catalog.ts | 8 + .../src/store/selectors/agentAppSetup.ts | 30 +++ .../src/store/slices/agentAppSetupSlice.ts | 139 +++++++++++ .../frontend/src/store/uiState/catalog.ts | 7 +- .../browser/frontend/src/types/index.ts | 36 +++ app/ui_layer/events/resource_changes.py | 11 + tests/test_agent_app_pending_setups.py | 232 ++++++++++++++++++ tests/test_resource_changes.py | 5 + tests/test_ws_auth.py | 5 +- 41 files changed, 1244 insertions(+), 143 deletions(-) create mode 100644 app/agent_app/origin_notices.py create mode 100644 app/agent_app/pending_setups.py create mode 100644 app/ui_layer/browser/frontend/src/components/Chat/ResumeSetupCard.module.css create mode 100644 app/ui_layer/browser/frontend/src/components/Chat/ResumeSetupCard.tsx create mode 100644 app/ui_layer/browser/frontend/src/components/ui/ChatSetupHost.tsx create mode 100644 app/ui_layer/browser/frontend/src/store/selectors/agentAppSetup.ts create mode 100644 app/ui_layer/browser/frontend/src/store/slices/agentAppSetupSlice.ts create mode 100644 tests/test_agent_app_pending_setups.py diff --git a/app/agent_app/manager.py b/app/agent_app/manager.py index 2be97b48..140742c6 100644 --- a/app/agent_app/manager.py +++ b/app/agent_app/manager.py @@ -267,6 +267,14 @@ def __init__(self, workspace_root: Path): self.workspace_root / "agent_app_instances.json", self.ports ) + # Chat-started setup interviews not yet finalized or cancelled + # (pending_setups.py). Persisted so a closed popup can be resumed. + from app.agent_app.pending_setups import PendingSetupRegistry + + self.pending_setups = PendingSetupRegistry( + self.workspace_root / "agent_app_pending_setups.json" + ) + # Session and trigger management (set via bind_session_manager) self._session_manager: Optional["SessionManager"] = None self._trigger_service: Optional["TriggerService"] = None diff --git a/app/agent_app/origin_notices.py b/app/agent_app/origin_notices.py new file mode 100644 index 00000000..402b4605 --- /dev/null +++ b/app/agent_app/origin_notices.py @@ -0,0 +1,94 @@ +"""Notices to the ORIGIN session: the chat that ran ``agent_app_scaffold``. + +After the scaffold hands the setup questions to the user, that chat's agent +has ended its turn and only hears back through these triggers. This module is +the single place that writes their text, so the agent's view of a pending +setup (pending_setups.py) has exactly two endings: created or cancelled. + +Both are best-effort. A failed notice is logged and never breaks the +finalize or cancel it reports. +""" + +from __future__ import annotations + +from typing import Any, Optional + +from .pending_setups import PendingSetup + +try: + from loguru import logger +except ImportError: # pragma: no cover - loguru is always present in-app + import logging + + logger = logging.getLogger(__name__) + + +async def notify_setup_created( + trigger_service: Optional[Any], session_id: str, project_id: str, project_name: str +) -> None: + """The setup questions were answered and the project now exists.""" + from app.triggers import TriggerSource + + await _emit( + trigger_service, + TriggerSource.AGENT_APP_CREATED, + session_id, + ( + f"FYI: the setup questions were answered — Agent App " + f"'{project_name}' (project_id {project_id}) has been created and " + "its build is running in its own session. No action and no " + "message needed: acknowledge silently with end_turn unless the " + "user has asked for something. Remember the project_id for " + "future requests about this app." + ), + {"project_id": project_id}, + ) + + +async def notify_setup_cancelled( + trigger_service: Optional[Any], setup: PendingSetup +) -> None: + """The user cancelled the setup, so no project will be created.""" + from app.triggers import TriggerSource + + await _emit( + trigger_service, + TriggerSource.AGENT_APP_SETUP_CANCELLED, + setup.origin_session_id, + ( + f"FYI: the user cancelled the setup for Agent App '{setup.name}'. " + "No project was created. Do not ask the setup questions again or " + "call agent_app_scaffold for it unless the user asks. No message " + "needed: acknowledge silently with end_turn unless the user has " + "asked for something." + ), + {"wizard_id": setup.wizard_id}, + ) + + +async def _emit( + trigger_service: Optional[Any], + source: Any, + session_id: str, + description: str, + payload: dict, +) -> None: + if not trigger_service or not session_id: + return + try: + from app.triggers import TriggerSpec + + await trigger_service.emit( + TriggerSpec( + source=source, + description=description, + priority=10, + session_id=session_id, + payload=payload, + ) + ) + except Exception as e: + logger.debug(f"[AGENT_APP:SETUP] origin-session notice failed: {e}") + + +__all__ = ["notify_setup_created", "notify_setup_cancelled"] diff --git a/app/agent_app/pending_setups.py b/app/agent_app/pending_setups.py new file mode 100644 index 00000000..db57d9f0 --- /dev/null +++ b/app/agent_app/pending_setups.py @@ -0,0 +1,179 @@ +"""Pending chat-started setups: setup interviews the user has not finished. + +When the agent runs ``agent_app_scaffold`` from a chat and the wizard has +questions, no project exists yet; the user answers in the Create Custom +wizard and finalize creates the project. Until then the setup lives here, so +closing the popup, reloading the page or connecting a new tab never loses it +(issue #448, docs/plans/agent-app-setup-resume-plan.md). + +Lifecycle:: + + opened ──► (hidden ⇄ shown)* ──► finalized (project created; record removed) + └► cancelled (record removed; origin agent told) + +Hiding and showing the popup are browser-only; the record is created by the +scaffold action and removed by finalize, cancel, or deletion of the chat that +started it. + +This module owns persistence only. It knows nothing about browsers, triggers +or the agent: the UI layer turns the surrounding messages into resource +invalidations, and ``origin_notices`` tells the agent. The setup is Agent App +state, which is why it is not stored as a chat message. + +Only the chat path persists setups. The "+" modal's Create Custom flow is +opened by the user, so closing it is a cancel and nothing is kept. +""" + +from __future__ import annotations + +import json +import os +import threading +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional + +try: + from loguru import logger +except ImportError: # pragma: no cover - loguru is always present in-app + import logging + + logger = logging.getLogger(__name__) + + +def _name_key(name: str) -> str: + """Case- and whitespace-insensitive app-name key used for dedup.""" + return " ".join(str(name or "").split()).casefold() + + +@dataclass(frozen=True) +class PendingSetup: + """A chat-started setup interview the user has not finished yet.""" + + wizard_id: str + origin_session_id: str + name: str + config: Dict[str, Any] + questions: List[Dict[str, Any]] + created_at: float = field(default_factory=time.time) + + def to_dict(self) -> Dict[str, Any]: + """Wire shape: the same fields ``agent_app_wizard_open`` carries.""" + return { + "wizardId": self.wizard_id, + "originSessionId": self.origin_session_id, + "name": self.name, + "config": self.config, + "questions": self.questions, + "createdAt": self.created_at, + } + + @classmethod + def from_dict(cls, d: Dict[str, Any]) -> "PendingSetup": + return cls( + wizard_id=str(d["wizardId"]), + origin_session_id=str(d.get("originSessionId") or ""), + name=str(d.get("name") or ""), + config=dict(d.get("config") or {}), + questions=list(d.get("questions") or []), + created_at=float(d.get("createdAt") or time.time()), + ) + + +class PendingSetupRegistry: + """Persisted pending chat setups, keyed by wizard id. Thread-safe.""" + + def __init__(self, path: Path) -> None: + self._path = Path(path) + self._lock = threading.RLock() + self._by_id: Dict[str, PendingSetup] = {} + self._load() + + def add(self, setup: PendingSetup) -> None: + with self._lock: + self._by_id[setup.wizard_id] = setup + self._save() + + def get(self, wizard_id: str) -> Optional[PendingSetup]: + with self._lock: + return self._by_id.get(wizard_id) + + def find(self, origin_session_id: str, name: str) -> Optional[PendingSetup]: + """The pending setup this session already opened for an app of this + name, so a repeated scaffold reopens it instead of re-interviewing.""" + key = _name_key(name) + with self._lock: + for setup in self._by_id.values(): + if ( + setup.origin_session_id == origin_session_id + and _name_key(setup.name) == key + ): + return setup + return None + + def remove(self, wizard_id: str) -> Optional[PendingSetup]: + """Drop a setup; returns it, or None when it was already gone.""" + with self._lock: + setup = self._by_id.pop(wizard_id, None) + if setup is not None: + self._save() + return setup + + def remove_for_session(self, session_id: str) -> List[PendingSetup]: + """Drop every setup started from ``session_id`` (the chat was deleted).""" + with self._lock: + removed = [ + s for s in self._by_id.values() if s.origin_session_id == session_id + ] + for setup in removed: + del self._by_id[setup.wizard_id] + if removed: + self._save() + return removed + + def list(self) -> List[PendingSetup]: + """All pending setups, oldest first.""" + with self._lock: + return sorted(self._by_id.values(), key=lambda s: s.created_at) + + # ── persistence ───────────────────────────────────────────────────── + + def _load(self) -> None: + try: + raw = json.loads(self._path.read_text(encoding="utf-8")) + except FileNotFoundError: + return + except Exception as e: + logger.warning(f"[PENDING_SETUPS] unreadable registry, starting empty: {e}") + return + for d in raw.get("setups", []): + try: + setup = PendingSetup.from_dict(d) + except Exception as e: + logger.warning(f"[PENDING_SETUPS] skipping malformed record: {e}") + continue + self._by_id[setup.wizard_id] = setup + + def _save(self) -> None: + # Write-then-replace so a crash mid-write never truncates the file. + try: + self._path.parent.mkdir(parents=True, exist_ok=True) + payload = {"setups": [s.to_dict() for s in self._by_id.values()]} + tmp = self._path.with_suffix(self._path.suffix + ".tmp") + tmp.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + os.replace(tmp, self._path) + except Exception as e: + logger.error(f"[PENDING_SETUPS] could not persist registry: {e}") + + +def get_pending_setups() -> Optional[PendingSetupRegistry]: + """The process-wide registry owned by the Agent App manager, or None + before the manager exists (early boot, headless).""" + from app.agent_app import get_agent_app_manager + + mgr = get_agent_app_manager() + return getattr(mgr, "pending_setups", None) if mgr is not None else None + + +__all__ = ["PendingSetup", "PendingSetupRegistry", "get_pending_setups"] diff --git a/app/agent_app/wizard.py b/app/agent_app/wizard.py index 3c46ed41..bede90de 100644 --- a/app/agent_app/wizard.py +++ b/app/agent_app/wizard.py @@ -15,8 +15,11 @@ saved to /reference/requirements.md. Everything here runs BEFORE the project or its session exists — no -project is created until the wizard finalizes, so a cancelled wizard +project is created until the wizard finalizes, so a cancelled modal wizard leaves nothing behind except its staging folder (swept opportunistically). +The chat path (agent_app_scaffold) is the exception: its unfinished +interview is kept as a PendingSetup (pending_setups.py) until the user +finishes or cancels it, so closing the popup never loses it. Attachments upload to a staging area (agent_app/_staging/wizard/) and move into the project at finalize time. diff --git a/app/agent_base.py b/app/agent_base.py index 96c2aea4..c261c9b6 100644 --- a/app/agent_base.py +++ b/app/agent_base.py @@ -161,6 +161,7 @@ class TriggerData: TriggerSource.AGENT_APP_CRASH_FIX.value, TriggerSource.AGENT_APP_IMPORT.value, TriggerSource.AGENT_APP_CREATED.value, + TriggerSource.AGENT_APP_SETUP_CANCELLED.value, TriggerSource.AGENT_APP_APP_REQUEST.value, } diff --git a/app/data/action/agent_app_actions.py b/app/data/action/agent_app_actions.py index 58a4bf39..590355df 100644 --- a/app/data/action/agent_app_actions.py +++ b/app/data/action/agent_app_actions.py @@ -19,9 +19,11 @@ "allocates ports, registers the project in the user's Agent App list, " "and runs a requirements check: if the chat already answers everything " "a builder needs, the build is dispatched immediately — otherwise " - "setup questions open in a popup in the user's browser (the same " - "interview the Create Agent App wizard uses) and the build starts " - "automatically when the user answers them. Follow the returned " + "setup questions open in the user's browser (the same interview the " + "Create Agent App wizard uses, resumable from a card in the chat) and " + "the build starts automatically when the user answers them. Calling " + "it again for an app whose setup is still pending reopens the same " + "questions. Follow the returned " "message either way. Do NOT write project files or call " "agent_app_notify_ready yourself." ), @@ -89,9 +91,9 @@ "type": "string", "example": "abc12345", "description": ( - "The created project ID. ABSENT when setup questions opened " - "in the user's browser instead — the project is created when " - "they answer." + "The created project ID. ABSENT when setup questions are " + "pending in the user's browser instead — the project is " + "created when they answer." ), }, "project_path": { @@ -162,9 +164,44 @@ async def agent_app_scaffold(input_data: dict) -> dict: # ONLY what the chat left genuinely open (marketplace reuse check # included). Open questions → NO project is created here; the # wizard UI is summoned and its finalize creates the project - # exactly as the Add Agent App modal does. A cancelled popup, like - # a cancelled modal wizard, leaves nothing behind. - from app.agent_app import wizard + # exactly as the Add Agent App modal does. Until then the setup is + # a persisted PendingSetup (app/agent_app/pending_setups.py): the + # user can close the popup and resume it from a card pinned in this + # chat, or cancel it, which notifies this session. + from agent_core.core.session import MAIN_SESSION_ID + from app.agent_app import broadcast_agent_app_wizard_open, wizard + from app.agent_app.pending_setups import PendingSetup + + origin_session = input_data.get("_session_id") or MAIN_SESSION_ID + pending_message = ( + "No project created yet: {count} setup question(s) are open in " + "the user's browser and stay pinned in this chat as a 'Resume " + "setup' card until answered or cancelled. The project is created " + "and the build starts automatically when the user answers them — " + "you will be notified with the project_id then (and " + "agent_app_list_projects finds any project later); if they " + "cancel, you will be told that instead. Tell the user to answer " + "the setup questions, then end your turn. Do NOT relay the " + "questions in chat and do NOT build anything. If the user says " + "the questions are gone, point them to the Resume setup card " + "above the chat input, or call this action again for the same " + "app, which reopens the same questions." + ) + + # Already pending for this app in this chat (the user closed the + # popup and asked for it back): reopen the SAME questions. No new + # interview call, so the questions cannot change under the user. + existing = manager.pending_setups.find(origin_session, name) + if existing is not None: + try: + if await broadcast_agent_app_wizard_open(existing.to_dict()): + return { + "status": "success", + "message": "Reopened the pending setup. " + + pending_message.format(count=len(existing.questions)), + } + except Exception: + pass chat_context = str(input_data.get("chat_context") or "").strip() wizard_config = { @@ -194,44 +231,33 @@ async def agent_app_scaffold(input_data: dict) -> dict: if questions: # Summon the SAME Create Custom wizard UI the Add Agent App - # modal uses, opened at the interview step. Fail-open: no - # browser to show the popup (headless) → fall through and - # build without questions. + # modal uses, opened at the interview step. Persisted first, so + # a tab that connects later (or reloads) still gets it; the + # wizard round-trips originSessionId to finalize, which notifies + # this session of the created project. Fail-open: no browser + # adapter (headless) → drop the record and build without + # questions. import uuid as _uuid - from app.agent_app import broadcast_agent_app_wizard_open - + setup = PendingSetup( + wizard_id=f"chat_{_uuid.uuid4().hex[:12]}", + origin_session_id=origin_session, + name=name, + config=wizard_config, + questions=questions, + ) + manager.pending_setups.add(setup) _opened = False try: - _opened = await broadcast_agent_app_wizard_open( - { - "wizardId": f"chat_{_uuid.uuid4().hex[:12]}", - "config": wizard_config, - "questions": questions, - # Round-tripped through the wizard to finalize, which - # notifies this session of the created project. - "originSessionId": input_data.get("_session_id") or "", - } - ) + _opened = await broadcast_agent_app_wizard_open(setup.to_dict()) except Exception: _opened = False if _opened: return { "status": "success", - "message": ( - f"No project created yet: {len(questions)} setup " - "question(s) just opened in a popup in the user's " - "browser. The project is created and the build " - "starts automatically when the user answers them — " - "you will be notified with the project_id then " - "(and agent_app_list_projects finds any project " - "later). Tell the user to answer the setup " - "questions that just appeared, then end your turn. " - "Do NOT relay the questions in chat, do NOT build " - "anything, and do NOT call this action again for " - "the same app." - ), + "message": pending_message.format(count=len(questions)), } + manager.pending_setups.remove(setup.wizard_id) project = await manager.create_project( name=name, diff --git a/app/session/session_manager.py b/app/session/session_manager.py index ad805c37..b00fa2ee 100644 --- a/app/session/session_manager.py +++ b/app/session/session_manager.py @@ -122,6 +122,16 @@ def _on_session_delete(session_id: str) -> None: logger.warning( f"[SessionManager] Failed to clear activity rows for {session_id}: {e}" ) + try: + from app.agent_app.pending_setups import get_pending_setups + + registry = get_pending_setups() + if registry is not None: + registry.remove_for_session(session_id) + except Exception as e: + logger.warning( + f"[SessionManager] Failed to drop pending setups for {session_id}: {e}" + ) class SessionManager(_SessionManager): diff --git a/app/triggers/sources.py b/app/triggers/sources.py index 06495d82..ebad90f6 100644 --- a/app/triggers/sources.py +++ b/app/triggers/sources.py @@ -41,6 +41,9 @@ class TriggerSource(str, Enum): # tells that agent the wizard finalized and which project resulted, so # later references ("add data to it") resolve without asking the user. AGENT_APP_CREATED = "agent_app_created" + # Also lands in the ORIGIN session: the user cancelled a pending chat + # setup (pending_setups.py), so the agent stops asking for answers. + AGENT_APP_SETUP_CANCELLED = "agent_app_setup_cancelled" # An Agent App app fired a declared trigger at the agent (spec # TRIGGERS-PLAN): a validated agent_requests row exists and the bridge's # capability/consent/era gates all passed. Lands in the project's session. diff --git a/app/ui_layer/adapters/browser_adapter.py b/app/ui_layer/adapters/browser_adapter.py index 2a171d2a..13243e55 100644 --- a/app/ui_layer/adapters/browser_adapter.py +++ b/app/ui_layer/adapters/browser_adapter.py @@ -1335,6 +1335,10 @@ async def _websocket_handler( }, } ) + # Pending chat setups, pushed for the same reason: a setup summoned + # while no tab was open (or before a reload) must still show its + # Resume card. + channel.send_json(self._agent_app_setup_list_message()) except Exception: self._ws_clients.discard(ws) self._channels.pop(ws, None) @@ -2105,6 +2109,12 @@ async def _handle_ws_message(self, data: Dict[str, Any], ws=None) -> None: elif msg_type == "agent_app_wizard_finalize": await self._handle_agent_app_wizard_finalize(data) + elif msg_type == "agent_app_setup_list": + await self._send_to(ws, self._agent_app_setup_list_message()) + + elif msg_type == "agent_app_setup_cancel": + await self._handle_agent_app_setup_cancel(data) + elif msg_type == "agent_app_theme_update": await self._handle_agent_app_theme_update(data) @@ -2923,6 +2933,37 @@ async def _handle_agent_app_wizard_interview(self, data: Dict[str, Any]) -> None } ) + def _agent_app_setup_list_message(self) -> Dict[str, Any]: + """Every pending chat setup (pending_setups.py), as the reply to + ``agent_app_setup_list`` and the connect push.""" + return { + "type": "agent_app_setup_list", + "data": { + "setups": [ + s.to_dict() for s in self._agent_app_manager.pending_setups.list() + ], + }, + } + + async def _handle_agent_app_setup_cancel(self, data: Dict[str, Any]) -> None: + """The user cancelled a pending chat setup: drop it and tell the chat + that started it, so its agent stops asking for answers. Closing the + popup is not a cancel (it stays resumable) and never reaches here.""" + from app.agent_app.origin_notices import notify_setup_cancelled + + wizard_id = str(data.get("wizardId") or "") + setup = self._agent_app_manager.pending_setups.remove(wizard_id) + if setup is not None: + await notify_setup_cancelled( + self._agent_app_manager._trigger_service, setup + ) + await self._broadcast( + { + "type": "agent_app_setup_cancel", + "data": {"success": setup is not None, "wizardId": wizard_id}, + } + ) + async def _handle_agent_app_wizard_finalize(self, data: Dict[str, Any]) -> None: """Wizard step 3: synthesize the requirements document, create the project, move staged attachments in, queue the build run in the @@ -3002,6 +3043,11 @@ async def _handle_agent_app_wizard_finalize(self, data: Dict[str, Any]) -> None: auth_mode=auth_mode, style_pack=style_pack, ) + # The project exists, so the setup is finished: drop its pending + # record now, before any later step can fail and leave a Resume + # card that would create a duplicate. Chat path only; a "+" modal + # wizard never had a record, so this is a no-op there. + self._agent_app_manager.pending_setups.remove(wizard_id) # Staged files: uploaded icon → app favicon, references → # /reference/. An uploaded icon wins over a lucide pick. @@ -3100,32 +3146,14 @@ async def _handle_agent_app_wizard_finalize(self, data: Dict[str, Any]) -> None: get_factory_host().set_origin_session(project.id, origin_session) except Exception as e: logger.debug(f"[AGENT_APP:WIZARD] origin persist failed: {e}") - try: - from app.triggers import TriggerSource, TriggerSpec - - await self._agent_app_manager._trigger_service.emit( - TriggerSpec( - source=TriggerSource.AGENT_APP_CREATED, - description=( - f"FYI: the setup questions were answered — " - f"Agent App '{project.name}' (project_id " - f"{project.id}) has been created and its " - "build is running in its own session. No " - "action and no message needed: acknowledge " - "silently with end_turn unless the user has " - "asked for something. Remember the " - "project_id for future requests about this " - "app." - ), - priority=10, - session_id=origin_session, - payload={"project_id": project.id}, - ) - ) - except Exception as e: - logger.debug( - f"[AGENT_APP:WIZARD] origin-session notify failed: {e}" - ) + from app.agent_app.origin_notices import notify_setup_created + + await notify_setup_created( + self._agent_app_manager._trigger_service, + origin_session, + project.id, + project.name, + ) await self._broadcast( { diff --git a/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx b/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx index f56cf906..2e4a9080 100644 --- a/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx +++ b/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx @@ -41,6 +41,9 @@ import { selectPendingQuestions, } from '../../store/selectors/messages' import { QuestionBox } from './QuestionBox' +import { ResumeSetupCard } from './ResumeSetupCard' +import { selectSetupsForSession } from '../../store/selectors/agentAppSetup' +import { requestCancelSetup, showSetup } from '../../store/slices/agentAppSetupSlice' import { mergeTimeline, type TimelineEntry } from './timeline' import { selectSessionActivity } from '../../store/selectors/activity' import { selectSessionBusy, selectSessionRunState } from '../../store/selectors/agent' @@ -187,6 +190,9 @@ export function Chat({ sessionId, placeholder }: ChatProps) { // Unanswered agent questions (oldest first). The first one is pinned in a // QuestionBox above the composer; answering/dismissing advances the queue. const pendingQuestions = useAppSelector(state => selectPendingQuestions(state, sessionId)) + // Agent App setups this chat started and the user hasn't finished: the + // oldest gets a Resume card under the question box (ChatSetupHost shows it). + const pendingSetups = useAppSelector(state => selectSetupsForSession(state, sessionId)) const hasMoreMessages = useAppSelector(state => selectSessionHasMoreMessages(state, sessionId)) const historyStatus = useAppSelector(state => selectSessionHistoryStatus(state, sessionId)) const loadingOlderMessages = useAppSelector(state => selectSessionLoadingOlderMessages(state, sessionId)) @@ -1429,6 +1435,15 @@ export function Chat({ sessionId, placeholder }: ChatProps) { onDismiss={handleQuestionDismiss} /> )} + {pendingSetups.length > 0 && ( + dispatch(showSetup(pendingSetups[0].wizardId))} + onCancel={() => dispatch(requestCancelSetup(pendingSetups[0].wizardId))} + /> + )}
1. */ + queueTotal: number + /** Reopen the setup's questions where the user left off. */ + onResume: () => void + /** Ask to end the setup for good (the caller confirms). */ + onCancel: () => void +} + +/** + * Pinned above the chat composer while an Agent App setup this chat started + * is unanswered, so closing its popup never loses it (issue #448). Sits in + * the same slot as QuestionBox and shares its look. + */ +export function ResumeSetupCard({ setup, queueTotal, onResume, onCancel }: ResumeSetupCardProps) { + const { t } = useTranslation(['chat']) + + return ( +
+ +
+ {t('chat:setupResume.title', { name: setup.name })} + {t('chat:setupResume.hint')} +
+ {queueTotal > 1 && ( + {t('chat:setupResume.queuePosition', { total: queueTotal })} + )} +
+ + +
+
+ ) +} diff --git a/app/ui_layer/browser/frontend/src/components/layout/NavBar.tsx b/app/ui_layer/browser/frontend/src/components/layout/NavBar.tsx index 773d09f7..0031f382 100644 --- a/app/ui_layer/browser/frontend/src/components/layout/NavBar.tsx +++ b/app/ui_layer/browser/frontend/src/components/layout/NavBar.tsx @@ -33,6 +33,7 @@ import { ConfirmModal } from '../ui/ConfirmModal' import { tourAnchorProps, useTourEnvAction, type TourAnchorId } from '../../tour' import { usePersistedState, useSkillCreator } from '../../hooks' import { CreateAgentAppModal } from '../ui/CreateAgentAppModal' +import { ChatSetupHost } from '../ui/ChatSetupHost' import { SkillCreatorModal } from '../ui/SkillCreatorModal' import { AgentAppIcon } from '../ui/AgentAppIcon' import type { SessionInfo } from '../../types' @@ -967,6 +968,10 @@ export function NavBar({ collapsed = false, onToggleCollapsed }: NavBarProps) { onInstalled={handleProjectCreated} /> + {/* Setups the agent started from a chat: independent of the "+" modal, + so neither can hide the other. */} + + void +} + +/** + * Presents pending chat-started Agent App setups (agentAppSetupSlice). + * + * Always mounted, so a setup the agent summons pops up wherever the user is. + * The X only hides the popup: the setup stays pending and the Resume card in + * its chat reopens it with the answers given so far. Cancelling asks for + * confirmation here, whichever surface (wizard or card) asked for it. + */ +export function ChatSetupHost({ onCreated }: ChatSetupHostProps) { + const { t } = useTranslation(['components']) + const dispatch = useAppDispatch() + const shown = useAppSelector(selectShownSetup) + const pendingCancel = useAppSelector(selectSetupPendingCancel) + + return ( + <> + {shown && } + { if (pendingCancel) dispatch(cancelSetup(pendingCancel.wizardId)) }} + onCancel={() => dispatch(dismissCancelSetup())} + /> + + ) +} + +interface ChatSetupModalProps { + setup: PendingAgentAppSetup + onCreated: (projectId: string) => void +} + +/** One setup's wizard, entered at the interview step. Mount with key={wizardId}. */ +function ChatSetupModal({ setup, onCreated }: ChatSetupModalProps) { + const { t } = useTranslation(['components']) + const dispatch = useAppDispatch() + const { send, onMessage } = useSettingsWebSocket() + // The wizard reads `progress` only when it mounts, then reports changes back. + const [progress, setProgress] = usePersistedState(UI_STATE.agentApp.setupProgress(setup.wizardId)) + + const hide = () => dispatch(hideSetup()) + + return ( + + + {t('components:createAgentApp.setupQuestions', { name: setup.name || String(setup.config?.name || 'Agent App') })} + + } + > + dispatch(requestCancelSetup(setup.wizardId))} + onCreated={(projectId: string) => { + dispatch(clearSetupProgress(setup.wizardId)) + onCreated(projectId) + }} + /> + + ) +} diff --git a/app/ui_layer/browser/frontend/src/components/ui/CreateAgentAppModal.tsx b/app/ui_layer/browser/frontend/src/components/ui/CreateAgentAppModal.tsx index 65ce7266..dc4c05a7 100644 --- a/app/ui_layer/browser/frontend/src/components/ui/CreateAgentAppModal.tsx +++ b/app/ui_layer/browser/frontend/src/components/ui/CreateAgentAppModal.tsx @@ -81,25 +81,6 @@ export function CreateAgentAppModal({ isOpen, onClose, onInstalled }: CreateAgen } }) - // Chat-path requirements phase: agent_app_scaffold generated setup - // questions (creating nothing yet) and the backend summons the SAME - // Create Custom wizard, pre-seeded and opened at the interview step - // (agent_app_wizard_open); its finalize creates the project as usual. - const [chatWizard, setChatWizard] = useState<{ - wizardId: string - config: Record - questions: any[] - originSessionId?: string - } | null>(null) - useEffect( - () => - onMessage('agent_app_wizard_open', (data: any) => { - if (data?.wizardId && Array.isArray(data.questions) && data.questions.length > 0) { - setChatWizard(data) - } - }), - [onMessage] - ) // Accumulate projectIds from completed installs — navigate only when all installs finish const pendingNavigationsRef = useRef([]) @@ -337,41 +318,8 @@ export function CreateAgentAppModal({ isOpen, onClose, onInstalled }: CreateAgen // Escape key intentionally does NOT close this modal — user must use the X button - // Chat-summoned wizard: same component, entered at the interview step. - // Renders regardless of isOpen — the summons comes from the backend, not - // the "+" button. Closing it mid-interview leaves nothing behind (no - // project exists until finalize), same as cancelling the modal wizard. - if (chatWizard && !isOpen) { - return ( - setChatWizard(null)} - size="full" - closeOnOverlayClick={false} - closeOnEsc={false} - title={ - <> - - {t('components:createAgentApp.setupQuestions', { name: String(chatWizard.config?.name || 'Agent App') })} - - } - > - setChatWizard(null)} - onCreated={(projectId: string) => { - setChatWizard(null) - onInstalledRef.current?.(projectId) - }} - /> - - ) - } - // Fully unmount when closed and no installs pending; stay mounted (invisible) while installs run - if (!isOpen && installingIds.size === 0 && !chatWizard) return null + if (!isOpen && installingIds.size === 0) return null if (!isOpen) return <> // mounted but invisible — keeps onMessage listeners alive const tabsConfig = [ diff --git a/app/ui_layer/browser/frontend/src/components/ui/CreateCustomWizard.tsx b/app/ui_layer/browser/frontend/src/components/ui/CreateCustomWizard.tsx index fa2b6728..766e347b 100644 --- a/app/ui_layer/browser/frontend/src/components/ui/CreateCustomWizard.tsx +++ b/app/ui_layer/browser/frontend/src/components/ui/CreateCustomWizard.tsx @@ -8,6 +8,7 @@ import { Button } from './Button' import { AGENT_APP_ICONS } from './AgentAppIcon' import { PRESET_THEMES, ThemeMiniPreview } from '../../pages/AgentApp/themeCatalog' import { formatNumber } from '../../i18n/format' +import type { AgentAppInterviewQuestion, AgentAppSetupProgress, PendingAgentAppSetup } from '../../types' import styles from './CreateCustomWizard.module.css' /** @@ -29,28 +30,23 @@ interface CreateCustomWizardProps { onMessage: (type: string, handler: (data: any) => void) => () => void onClose: () => void onCreated?: (projectId: string) => void - /** Chat-path entry (agent_app_wizard_open broadcast): the scaffold action - * generated round-1 questions without creating anything — open at the + /** Chat-path entry (a pending setup from agent_app_scaffold): round-1 + * questions were generated without creating anything — open at the * interview step with config pre-seeded. Finalize then creates the * project exactly as the modal path does (Chat → Interview → Finalize → - * Scaffold); a cancelled popup leaves nothing behind. */ - initial?: { - wizardId: string - config: Record - questions: InterviewQuestion[] - /** Session that ran agent_app_scaffold — round-tripped to finalize so - * the backend can tell that agent which project was created. */ - originSessionId?: string - } + * Scaffold). originSessionId is round-tripped to finalize so the backend + * can tell that chat's agent which project was created. */ + initial?: Pick + /** Chat path: where the user got to last time, restored on resume. */ + progress?: AgentAppSetupProgress | null + /** Chat path: called as the interview advances, so the caller can keep it. */ + onProgress?: (progress: AgentAppSetupProgress) => void + /** Chat path: the configure step's Cancel ends the setup for good. Without + * it (the "+" modal), Cancel just closes, like the X. */ + onCancel?: () => void } -interface InterviewQuestion { - id: string - question: string - why?: string - multiSelect: boolean - options: string[] -} +type InterviewQuestion = AgentAppInterviewQuestion const MAX_WORDS = 5000 @@ -154,7 +150,9 @@ function newWizardId(): string { // ── component ─────────────────────────────────────────────────────────────── -export function CreateCustomWizard({ send, onMessage, onClose, onCreated, initial }: CreateCustomWizardProps) { +export function CreateCustomWizard({ + send, onMessage, onClose, onCreated, initial, progress, onProgress, onCancel, +}: CreateCustomWizardProps) { const { t } = useTranslation(['components', 'common', 'agentapp']) // Explicit (type-checked) key literals for the configure-step vocabulary. @@ -199,13 +197,20 @@ export function CreateCustomWizard({ send, onMessage, onClose, onCreated, initia // — interview state — const [interviewLoading, setInterviewLoading] = useState(false) const [interviewError, setInterviewError] = useState(null) - const [questions, setQuestions] = useState(initial?.questions || []) - const [qIndex, setQIndex] = useState(0) - const [answers, setAnswers] = useState>({}) + const [questions, setQuestions] = useState(progress?.questions || initial?.questions || []) + const [qIndex, setQIndex] = useState(progress?.qIndex ?? 0) + const [answers, setAnswers] = useState>(progress?.answers || {}) const [freeText, setFreeText] = useState('') // True once the backend has run its one adapt follow-up round — sent back // on finalize so it never loops. - const [followupDone, setFollowupDone] = useState(false) + const [followupDone, setFollowupDone] = useState(progress?.followupDone ?? false) + + // Report interview progress (chat path) so a closed popup resumes here. + const onProgressRef = useRef(onProgress) + onProgressRef.current = onProgress + useEffect(() => { + onProgressRef.current?.({ questions, answers, qIndex, followupDone }) + }, [questions, answers, qIndex, followupDone]) // — creating state — const [createError, setCreateError] = useState(null) @@ -731,7 +736,7 @@ export function CreateCustomWizard({ send, onMessage, onClose, onCreated, initia
- +