From 566eb5ace3bf620a1de61ccd0afa88f94c802812 Mon Sep 17 00:00:00 2001 From: Code UX Date: Sat, 11 Jul 2026 03:27:20 +0000 Subject: [PATCH 1/8] feat(task T02): implement via codex --- .../templates/qs-create-desktop-app.md | 21 +++++ .../quicksprints/templates/qs-create-game.md | 21 +++++ .../templates/qs-create-online-shop.md | 21 +++++ .../templates/qs-create-portfolio.md | 21 +++++ .../templates/qs-create-web-app.md | 21 +++++ .../chat/ChatCreateAppQuickActions.tsx | 80 ++++++++--------- .../src/v2/hooks/use-chat-thread-data.ts | 17 ++-- .../src/v2/lib/chat-widget-view-models.ts | 17 +++- docs-web/content/docs/user-dashboard-chat.mdx | 6 +- docs-web/content/docs/user-quicksprints.mdx | 2 + docs-web/user/dashboard/chat.md | 6 +- docs-web/user/quicksprints.md | 2 + docs/dashboard/dashboard-guide.md | 2 +- docs/dashboard/quicksprint-templates.md | 14 ++- docs/settings/guidance.md | 2 + src/contracts/connection-chat-types.ts | 8 +- .../chat/create-app-quickaction-catalog.ts | 89 +++++++++++++++++++ src/domain/quicksprint/quicksprint-catalog.ts | 5 ++ .../settings/design-guidance-catalog.ts | 22 ++++- src/services/chat-thread-runtime-service.ts | 50 ++++++++--- .../create-app-quickaction-catalog.test.ts | 82 +++++++++++++++++ .../quicksprint/quicksprint-catalog.test.ts | 28 +++++- .../settings/design-guidance-catalog.test.ts | 15 +++- tests/dashboard/v2/chat-page-shell.test.tsx | 6 ++ 24 files changed, 474 insertions(+), 84 deletions(-) create mode 100644 .code-ux/quicksprints/templates/qs-create-desktop-app.md create mode 100644 .code-ux/quicksprints/templates/qs-create-game.md create mode 100644 .code-ux/quicksprints/templates/qs-create-online-shop.md create mode 100644 .code-ux/quicksprints/templates/qs-create-portfolio.md create mode 100644 .code-ux/quicksprints/templates/qs-create-web-app.md create mode 100644 src/domain/chat/create-app-quickaction-catalog.ts create mode 100644 tests/backend/domain/chat/create-app-quickaction-catalog.test.ts diff --git a/.code-ux/quicksprints/templates/qs-create-desktop-app.md b/.code-ux/quicksprints/templates/qs-create-desktop-app.md new file mode 100644 index 0000000000..3d44e0fd80 --- /dev/null +++ b/.code-ux/quicksprints/templates/qs-create-desktop-app.md @@ -0,0 +1,21 @@ +---json +{ + "id": "qs-create-desktop-app", + "name": "Create Desktop App", + "description": "Plan a repository-aware desktop application with safe native integration and lifecycle behavior.", + "icon": "Monitor", + "category": "product", + "categoryColor": "#6366f1", + "defaultTaskCount": 8, + "purpose": "create-app", + "purposeLabel": "Create App", + "purposeDescription": "Repository-aware product planning for web, desktop, commerce, portfolio, and game experiences." +} +--- +You are a senior desktop product engineer planning a production-ready desktop application. + +Inspect the repository before planning: read its instructions, manifests, current architecture, process boundaries, UI system, persistence and filesystem code, packaging, tests, and release configuration. Determine from evidence whether to extend an existing desktop shell or establish the smallest compatible foundation. Apply the catalog-selected tech-stack and styleguide guidance supplied with this run, while treating repository-local security and architecture rules as authoritative. + +Plan the product journeys plus window lifecycle, typed privileged-operation boundaries, local data safety, offline and recovery behavior, updates or packaging where supported, keyboard navigation, responsive window resizing, and clear permission/error states. Reuse existing infrastructure. Do not assume Electron beyond the supplied guidance when repository evidence requires a compatible alternative, and do not invent a framework, folder structure, or platform service. Do not ask for confirmation; make bounded, explicit assumptions from available evidence. + +Return only an implementation-ready product DAG. Every subtask must identify affected files or evidence-based file areas, dependencies, the exact deliverable, acceptance criteria, and verification. Sequence process contracts and persistence before dependent UI flows, cover startup/shutdown and interrupted-state recovery, and conclude with security, accessibility, automated tests, build, packaging, and runnable smoke validation. diff --git a/.code-ux/quicksprints/templates/qs-create-game.md b/.code-ux/quicksprints/templates/qs-create-game.md new file mode 100644 index 0000000000..1d303864f2 --- /dev/null +++ b/.code-ux/quicksprints/templates/qs-create-game.md @@ -0,0 +1,21 @@ +---json +{ + "id": "qs-create-game", + "name": "Create Game", + "description": "Plan a repository-aware playable experience with deterministic state, responsive interaction, and performance budgets.", + "icon": "Gamepad2", + "category": "product", + "categoryColor": "#22c55e", + "defaultTaskCount": 9, + "purpose": "create-app", + "purposeLabel": "Create App", + "purposeDescription": "Repository-aware product planning for web, desktop, commerce, portfolio, and game experiences." +} +--- +You are a senior game and interaction engineer planning a polished playable experience. + +Inspect the repository before planning: read its instructions, manifests, runtime and rendering architecture, update loop, input handling, asset pipeline, state model, audio, UI system, persistence, tests, and build targets. Determine the supported platform and whether the task extends an existing game or establishes a compatible vertical slice. Apply the catalog-selected tech-stack and game-experience guidance supplied with this run, subordinating it to stronger repository and platform constraints. + +Define the core play loop, player goal, rules, controls, feedback, progression, pause/restart, win/fail, save or checkpoint behavior when applicable, and the smallest coherent content set. Make simulation and lifecycle state deterministic and testable; set evidence-based budgets for frame time, loading, memory, and assets. Include remappable or discoverable controls where supported, keyboard and non-pointer access, alternatives for color/sound/motion cues, reduced-motion behavior, and readable focus/status UI. Do not invent an engine, framework, folder layout, platform service, or asset source. Do not ask for confirmation; use repository evidence and document bounded assumptions inside tasks. + +Return only an implementation-ready product DAG. Every subtask must name affected files or evidence-based file areas, dependencies, concrete behavior, acceptance criteria, and verification. Sequence state/input/rendering foundations before dependent gameplay and interface slices, then include accessibility, performance instrumentation, deterministic tests, build checks, and a runnable play-through smoke test covering pause, recovery, completion, and failure. diff --git a/.code-ux/quicksprints/templates/qs-create-online-shop.md b/.code-ux/quicksprints/templates/qs-create-online-shop.md new file mode 100644 index 0000000000..c3006f8d0a --- /dev/null +++ b/.code-ux/quicksprints/templates/qs-create-online-shop.md @@ -0,0 +1,21 @@ +---json +{ + "id": "qs-create-online-shop", + "name": "Create Onlineshop", + "description": "Plan a trustworthy repository-aware commerce experience from discovery through order completion.", + "icon": "ShoppingCart", + "category": "product", + "categoryColor": "#f97316", + "defaultTaskCount": 8, + "purpose": "create-app", + "purposeLabel": "Create App", + "purposeDescription": "Repository-aware product planning for web, desktop, commerce, portfolio, and game experiences." +} +--- +You are a senior commerce product engineer planning a trustworthy online shop. + +Inspect the repository before planning: read its instructions, manifests, architecture, product and catalog models, authentication, data boundaries, UI patterns, tests, integrations, and deployment path. Establish whether the work extends existing commerce capabilities or needs a compatible foundation. Apply the catalog-selected tech-stack and ecommerce styleguide guidance supplied with this run without overriding stronger repository constraints. + +Plan evidence-led customer journeys for discovery, search or navigation, product evaluation, variants and availability, cart, pricing, checkout, order confirmation, account or guest recovery, and operational management only where the repository supports it. Treat money, inventory, tax, shipping, payment, privacy, idempotency, validation, and failure recovery as explicit boundaries. Do not invent vendors, framework choices, schemas, or directories. Do not seek confirmation; record bounded assumptions and make uncertain integrations replaceable. + +Return only an implementation-ready product DAG. Each subtask must name affected files or evidence-based file areas, dependencies, concrete behavior, acceptance criteria, and verification. Sequence domain contracts and secure service boundaries before dependent storefront slices; cover empty/loading/error/out-of-stock/payment-failure states, responsive and keyboard-accessible interaction, deterministic tests, build checks, and an end-to-end purchase-path smoke test. diff --git a/.code-ux/quicksprints/templates/qs-create-portfolio.md b/.code-ux/quicksprints/templates/qs-create-portfolio.md new file mode 100644 index 0000000000..66a65d107a --- /dev/null +++ b/.code-ux/quicksprints/templates/qs-create-portfolio.md @@ -0,0 +1,21 @@ +---json +{ + "id": "qs-create-portfolio", + "name": "Create Portfolio", + "description": "Plan a distinctive repository-aware portfolio with clear narrative, proof, and contact paths.", + "icon": "BriefcaseBusiness", + "category": "product", + "categoryColor": "#ec4899", + "defaultTaskCount": 7, + "purpose": "create-app", + "purposeLabel": "Create App", + "purposeDescription": "Repository-aware product planning for web, desktop, commerce, portfolio, and game experiences." +} +--- +You are a senior product designer and frontend engineer planning a distinctive, credible portfolio. + +Inspect the repository before planning: read its instructions, manifests, current pages and routes, content sources, design tokens, assets, metadata, tests, build, and deployment configuration. Infer the owner, audience, available proof, and whether to evolve an existing site or create the smallest compatible structure. Apply the catalog-selected tech-stack and marketing-site guidance supplied with this run while preserving stronger local design and content rules. + +Plan a coherent narrative across the first viewport, selected work, capabilities, evidence, biography, and contact or conversion path. Use real repository content and assets when present, identify missing content as a typed or clearly bounded input rather than fabricating claims, and include responsive layout, semantic structure, keyboard access, reduced motion, performance, metadata, sharing, and search discoverability. Do not assume a framework, CMS, visual recipe, directory layout, or deployment vendor. Do not ask for confirmation; make defensible assumptions visible in implementation tasks. + +Return only an implementation-ready product DAG. Each subtask must identify affected files or evidence-based file areas, dependencies, exact deliverables, acceptance criteria, and verification. Build content and design foundations before dependent page slices, then cover interactions, accessibility, asset performance, metadata, tests, build, and representative viewport smoke validation. diff --git a/.code-ux/quicksprints/templates/qs-create-web-app.md b/.code-ux/quicksprints/templates/qs-create-web-app.md new file mode 100644 index 0000000000..74c8cac5e6 --- /dev/null +++ b/.code-ux/quicksprints/templates/qs-create-web-app.md @@ -0,0 +1,21 @@ +---json +{ + "id": "qs-create-web-app", + "name": "Create Web App", + "description": "Plan a repository-aware web application as an implementation-ready product DAG.", + "icon": "Globe2", + "category": "product", + "categoryColor": "#0ea5e9", + "defaultTaskCount": 8, + "purpose": "create-app", + "purposeLabel": "Create App", + "purposeDescription": "Repository-aware product planning for web, desktop, commerce, portfolio, and game experiences." +} +--- +You are a senior product engineer planning a production-ready web application. + +Inspect the repository before planning: read its instructions, manifests, architecture, existing product flows, design system, assets, tests, build and deployment paths. Decide from evidence whether this is a greenfield implementation or an extension of an existing product. Apply the catalog-selected tech-stack and styleguide guidance supplied with this run, reconciling it with stronger repository-local constraints. + +Define the product outcome, primary users, essential journeys, information architecture, data and service boundaries, responsive and accessible interaction states, operational concerns, and measurable acceptance criteria. Reuse established modules and conventions. Do not assume a framework, hosting platform, directory layout, or generic starter stack that the repository does not support. Do not ask for confirmation; resolve ordinary ambiguity from repository evidence and state implementation-relevant assumptions in the plan. + +Return only an implementation-ready product DAG. Each subtask must name its affected files or evidence-based file areas, concrete deliverable, dependencies, acceptance criteria, and verification. Order foundations before dependent vertical slices, make dependencies explicit, include loading/empty/error/disabled states and keyboard accessibility, and finish with integration, quality, build, and runtime validation. Avoid vague research tasks, disconnected polish work, and tasks that cannot be implemented independently when their dependencies are complete. diff --git a/dashboard/src/v2/components/chat/ChatCreateAppQuickActions.tsx b/dashboard/src/v2/components/chat/ChatCreateAppQuickActions.tsx index 2f2b666510..926a24eafe 100644 --- a/dashboard/src/v2/components/chat/ChatCreateAppQuickActions.tsx +++ b/dashboard/src/v2/components/chat/ChatCreateAppQuickActions.tsx @@ -1,26 +1,15 @@ import type { FunctionComponent } from "preact"; -import { Globe2, Monitor } from "lucide-preact"; +import { BriefcaseBusiness, Gamepad2, Globe2, Monitor, ShoppingCart } from "lucide-preact"; import type { DashboardCreateAppQuickactionKind } from "../../types.js"; +import { CREATE_APP_QUICKACTION_CATALOG } from "../../../../../src/domain/chat/create-app-quickaction-catalog.js"; -const CREATE_APP_ACTIONS: Array<{ - kind: DashboardCreateAppQuickactionKind; - label: string; - description: string; - icon: typeof Monitor; -}> = [ - { - kind: "desktop_app", - label: "Create Desktop App", - description: "Launch a desktop app sprint", - icon: Monitor, - }, - { - kind: "web_app", - label: "Create Web App", - description: "Launch a web app sprint", - icon: Globe2, - }, -]; +const CREATE_APP_ACTION_ICONS: Record = { + web_app: Globe2, + desktop_app: Monitor, + online_shop: ShoppingCart, + portfolio: BriefcaseBusiness, + game: Gamepad2, +}; export const ChatCreateAppQuickActions: FunctionComponent<{ disabled?: boolean; @@ -37,29 +26,34 @@ export const ChatCreateAppQuickActions: FunctionComponent<{ return (
-
- {CREATE_APP_ACTIONS.map(({ kind, label, description, icon: Icon }) => ( - - ))} +
+ {CREATE_APP_QUICKACTION_CATALOG.map(({ kind, displayLabel, appKindLabel }) => { + const Icon = CREATE_APP_ACTION_ICONS[kind]; + const label = displayLabel; + const description = `Launch ${kind === "online_shop" ? "an" : "a"} ${appKindLabel.toLowerCase()} sprint`; + return ( + + ); + })}
{status} diff --git a/dashboard/src/v2/hooks/use-chat-thread-data.ts b/dashboard/src/v2/hooks/use-chat-thread-data.ts index 0f424992f2..c736863be5 100644 --- a/dashboard/src/v2/hooks/use-chat-thread-data.ts +++ b/dashboard/src/v2/hooks/use-chat-thread-data.ts @@ -25,6 +25,7 @@ import { useConfirmDialog } from "./use-confirm-dialog.js"; import type { RefObject } from "preact"; import type { DashboardSettings, ExecutionDashboardSnapshot, TechstackCatalogEntrySettings } from "../../types.js"; import { DEFAULT_DASHBOARD_SETTINGS } from "../../lib/settings.js"; +import { getCreateAppQuickactionSpec } from "../../../../src/domain/chat/create-app-quickaction-catalog.js"; export const upsertMessage = (messages: ChatMessageRecord[], nextMessage: ChatMessageRecord): ChatMessageRecord[] => { if (messages.some((message) => message.id === nextMessage.id)) { @@ -121,20 +122,10 @@ export const resolveDisplayDeliveryStatus = ( return status; }; -const CREATE_APP_QUICKACTION_TEMPLATE_IDS: Record = { - web_app: "qs-create-web-app", - desktop_app: "qs-create-desktop-app", -}; - const NEW_THREAD_DRAFT_CONTEXT_KEY = "new-thread"; const CHAT_DRAFT_WRITE_DEBOUNCE_MS = 500; const LOCAL_CHAT_DRAFT_STORAGE_PREFIX = "codeux.chat.localDraft"; -const CREATE_APP_QUICKACTION_BODIES: Record = { - web_app: "Create a web app", - desktop_app: "Create a desktop app", -}; - const createCryptoRandomId = (): string => { const cryptoApi = globalThis.crypto; if (typeof cryptoApi?.randomUUID === "function") { @@ -217,13 +208,14 @@ const buildCreateAppQuickactionMetadata = ( dashboardSettings?: DashboardSettings | null, ): DashboardCreateAppQuickactionMetadata => { const { id, entry } = resolveCreateAppTechstackEntry(dashboardSettings); + const spec = getCreateAppQuickactionSpec(kind); return { quickaction: { type: "create_app", kind, requestId: createQuickactionRequestId(kind), - templateId: CREATE_APP_QUICKACTION_TEMPLATE_IDS[kind], + templateId: spec.templateId, stackSummary: buildCreateAppStackSummary(kind, id, entry), suggestionTags: uniqueStackSuggestionTags(entry), }, @@ -962,7 +954,8 @@ export const useChatThreadData = (options: { return; } - const bodyMarkdown = CREATE_APP_QUICKACTION_BODIES[kind]; + const appKindLabel = getCreateAppQuickactionSpec(kind).appKindLabel.toLowerCase(); + const bodyMarkdown = `Create ${kind === "online_shop" ? "an" : "a"} ${appKindLabel}`; setSending(true); try { const thread = selectedThread || await createThreadForCompose(); diff --git a/dashboard/src/v2/lib/chat-widget-view-models.ts b/dashboard/src/v2/lib/chat-widget-view-models.ts index 2d20fc3786..cf5f416544 100644 --- a/dashboard/src/v2/lib/chat-widget-view-models.ts +++ b/dashboard/src/v2/lib/chat-widget-view-models.ts @@ -31,7 +31,7 @@ export interface ChatWidgetState { suppressBodyMarkdown?: boolean; } -export type AppCreationKind = "web_app" | "desktop_app" | "unknown"; +export type AppCreationKind = "web_app" | "desktop_app" | "online_shop" | "portfolio" | "game" | "unknown"; export type AppCreationProgressStageStatus = "pending" | "running" | "completed" | "failed"; @@ -499,6 +499,15 @@ const normalizeAppCreationKind = (value: unknown): AppCreationKind => { if (normalized === "desktop_app" || normalized === "desktop") { return "desktop_app"; } + if (normalized === "online_shop" || normalized === "shop" || normalized === "online_store") { + return "online_shop"; + } + if (normalized === "portfolio") { + return "portfolio"; + } + if (normalized === "game") { + return "game"; + } return "unknown"; }; @@ -508,6 +517,12 @@ const formatAppCreationKindLabel = (kind: AppCreationKind): string => { return "Web app"; case "desktop_app": return "Desktop app"; + case "online_shop": + return "Online shop"; + case "portfolio": + return "Portfolio"; + case "game": + return "Game"; case "unknown": default: return "App"; diff --git a/docs-web/content/docs/user-dashboard-chat.mdx b/docs-web/content/docs/user-dashboard-chat.mdx index 67490da59d..45db843b3f 100644 --- a/docs-web/content/docs/user-dashboard-chat.mdx +++ b/docs-web/content/docs/user-dashboard-chat.mdx @@ -49,15 +49,15 @@ When the Project Manager schedules its own continuation, that follow-up is visib ## Create app quickactions -Use **Create Web App** or **Create Desktop App** when you want Code UX to start an app-building sprint in the selected project from chat. In Threads mode, the buttons sit beside the composer and are also available in an empty thread. In 3D Chat, the idle Web App and Desktop App quickactions send the same kind of request through the active thread. +Use **Create Web App**, **Create Desktop App**, **Create Onlineshop**, **Create Portfolio**, or **Create Game** when you want Code UX to start an app-building sprint in the selected project from chat. In Threads mode, the buttons sit beside the composer and are also available in an empty thread. -Clicking either quickaction starts immediately. You do not need to type composer text first, and Code UX does not show a confirmation step. If there is no active thread yet, the dashboard creates one, posts a short visible message such as `Create a web app`, and starts the matching quicksprint in detached `Plan & Start` mode while you stay in Chat. +Clicking a quickaction starts immediately. You do not need to type composer text first, and Code UX does not show a confirmation step. If there is no active thread yet, the dashboard creates one, posts a short visible message such as `Create a web app`, and starts the matching quicksprint in detached `Plan & Start` mode while you stay in Chat. Its catalog-selected experience guidance applies to that plan only and does not change saved project settings. The quickaction carries the active project's effective techstack into planning: the selected catalog entry when assigned, or the catalog default when the project is unassigned. Stack item labels become suggestion tags, so the progress widget and planner begin from the same stack context visible in the dashboard. The transcript then shows an app progress widget instead of raw status data. The widget reports: -- whether the sprint is for a web app or desktop app +- whether the sprint is for a web app, desktop app, online shop, portfolio, or game - the app sprint name - selected stack details such as framework, runtime, package manager, styling, and tests when available - planning stages from Planning through Plan, Showing each Task, Start, and Finish diff --git a/docs-web/content/docs/user-quicksprints.mdx b/docs-web/content/docs/user-quicksprints.mdx index 84e3267efd..3f5d4b39b3 100644 --- a/docs-web/content/docs/user-quicksprints.mdx +++ b/docs-web/content/docs/user-quicksprints.mdx @@ -10,6 +10,8 @@ Quicksprint templates are scoped to a **project**. They are stored in the databa The dashboard surface for them is the **Quicksprint panel** on the **Sprints** page. +The bundled catalog includes repository-aware **Create Web App**, **Create Desktop App**, **Create Onlineshop**, **Create Portfolio**, and **Create Game** templates. Their prompts inspect the selected repository, apply the experience guidance chosen by the create-app catalog, and produce an implementation-ready dependency DAG without assuming a generic stack. + ## Anatomy of a template A template has: diff --git a/docs-web/user/dashboard/chat.md b/docs-web/user/dashboard/chat.md index 67490da59d..45db843b3f 100644 --- a/docs-web/user/dashboard/chat.md +++ b/docs-web/user/dashboard/chat.md @@ -49,15 +49,15 @@ When the Project Manager schedules its own continuation, that follow-up is visib ## Create app quickactions -Use **Create Web App** or **Create Desktop App** when you want Code UX to start an app-building sprint in the selected project from chat. In Threads mode, the buttons sit beside the composer and are also available in an empty thread. In 3D Chat, the idle Web App and Desktop App quickactions send the same kind of request through the active thread. +Use **Create Web App**, **Create Desktop App**, **Create Onlineshop**, **Create Portfolio**, or **Create Game** when you want Code UX to start an app-building sprint in the selected project from chat. In Threads mode, the buttons sit beside the composer and are also available in an empty thread. -Clicking either quickaction starts immediately. You do not need to type composer text first, and Code UX does not show a confirmation step. If there is no active thread yet, the dashboard creates one, posts a short visible message such as `Create a web app`, and starts the matching quicksprint in detached `Plan & Start` mode while you stay in Chat. +Clicking a quickaction starts immediately. You do not need to type composer text first, and Code UX does not show a confirmation step. If there is no active thread yet, the dashboard creates one, posts a short visible message such as `Create a web app`, and starts the matching quicksprint in detached `Plan & Start` mode while you stay in Chat. Its catalog-selected experience guidance applies to that plan only and does not change saved project settings. The quickaction carries the active project's effective techstack into planning: the selected catalog entry when assigned, or the catalog default when the project is unassigned. Stack item labels become suggestion tags, so the progress widget and planner begin from the same stack context visible in the dashboard. The transcript then shows an app progress widget instead of raw status data. The widget reports: -- whether the sprint is for a web app or desktop app +- whether the sprint is for a web app, desktop app, online shop, portfolio, or game - the app sprint name - selected stack details such as framework, runtime, package manager, styling, and tests when available - planning stages from Planning through Plan, Showing each Task, Start, and Finish diff --git a/docs-web/user/quicksprints.md b/docs-web/user/quicksprints.md index 84e3267efd..3f5d4b39b3 100644 --- a/docs-web/user/quicksprints.md +++ b/docs-web/user/quicksprints.md @@ -10,6 +10,8 @@ Quicksprint templates are scoped to a **project**. They are stored in the databa The dashboard surface for them is the **Quicksprint panel** on the **Sprints** page. +The bundled catalog includes repository-aware **Create Web App**, **Create Desktop App**, **Create Onlineshop**, **Create Portfolio**, and **Create Game** templates. Their prompts inspect the selected repository, apply the experience guidance chosen by the create-app catalog, and produce an implementation-ready dependency DAG without assuming a generic stack. + ## Anatomy of a template A template has: diff --git a/docs/dashboard/dashboard-guide.md b/docs/dashboard/dashboard-guide.md index a0eed2c94c..9cb0c8040a 100644 --- a/docs/dashboard/dashboard-guide.md +++ b/docs/dashboard/dashboard-guide.md @@ -567,7 +567,7 @@ Legacy runtime: - Chat page now provides a `Threads / Invocations` toggle to switch between human conversation threads and read-only execution invocations. - Chat page UI is redesigned with animated identities, structured widgets for rich messages, and automatic worker pickup derived from active project routing. - Sprint and task references in chat messages and invocation transcripts resolve to live status widgets when they match real records in the selected project. Sprint widgets link to the matching Sprint page, task widgets link to the matching Tasks page, and ambiguous bare task keys are left as normal text instead of being guessed. -- Chat Threads mode exposes **Create Desktop App** and **Create Web App** quickactions beside the composer, including empty threads. Clicking either control posts the short visible chat message immediately, creates a thread first when needed, and does not open a confirmation dialog or switch to Invocations. The dashboard includes the active project's effective techstack in the quickaction metadata, using the assigned catalog entry or the catalog default plus stack item labels as suggestion tags. The backend launches the matching detached quicksprint with `Plan & Start`, then the transcript shows an app progress widget with app kind, sprint name, stack fields, metadata-driven stages, and suggestion tags. Users can keep typing in the same thread while planning runs; follow-up messages are queued until tasks exist, then appended to the sprint-level goal under `Additional direction from chat`. +- Chat Threads mode exposes **Create Web App**, **Create Desktop App**, **Create Onlineshop**, **Create Portfolio**, and **Create Game** quickactions beside the composer, including empty threads. Clicking a control posts a short visible chat message immediately, creates a thread first when needed, and does not open a confirmation dialog or switch to Invocations. The dashboard includes the active project's effective techstack in the quickaction metadata, using the assigned catalog entry or the catalog default plus stack item labels as suggestion tags. The backend launches the catalog-mapped detached quicksprint with `Plan & Start` and applies its experience-specific guidance only to that plan without changing saved project settings. The transcript then shows an app progress widget with app kind, sprint name, stack fields, metadata-driven stages, and suggestion tags. Users can keep typing in the same thread while planning runs; follow-up messages are queued until tasks exist, then appended to the sprint-level goal under `Additional direction from chat`. - Agent replies can append optional prompt suggestion tags from `metadata.promptSuggestions` below the normal markdown message. Each tag can show one supported generic icon (`sparkles`, `search`, `edit`, `code`, `terminal`, `bug`, `check`, `play`, `refresh`, `settings`, `file`, `folder`, `git-branch`, `git-pull-request`, `database`, `shield`, `book-open`, `message-circle`, `list-checks`, `rocket`, `zap`, `lightbulb`, `clipboard`, `download`, `upload`, `eye`, `package`, `server`, `clock`, or `help-circle`) and sends the next-step prompt immediately when clicked, without changing the composer or read-only invocation transcripts. - Chat page logs invocation activity explicitly in the background, providing observable execution artifacts directly in the chat view. - Chat page filters the "Threads" mode to show user-facing conversation threads (`scope === "project"`). diff --git a/docs/dashboard/quicksprint-templates.md b/docs/dashboard/quicksprint-templates.md index 2a121e3f82..7a11444230 100644 --- a/docs/dashboard/quicksprint-templates.md +++ b/docs/dashboard/quicksprint-templates.md @@ -107,14 +107,15 @@ The REST API and MCP `manage_quicksprints` tool expose these actions: `QuicksprintService.launchDetachedQuicksprint` is the internal primitive for chat quickactions that need a sprint record immediately while planning continues in the background. It creates the sprint synchronously, starts the same planning flow with the same prompt composition and override resolution as `execute`, and returns a planning request descriptor with `projectId`, `sprintId`, `templateId`, `submitMode`, `clientRequestId`, planner options for request tracking, and the detached planning promise. Chat create-app quickactions attach to that promise so the backend can update the app progress widget and append any queued thread follow-ups to the sprint goal after planning resolves, without frontend polling. The existing REST `/api/projects/:projectId/quicksprints/execute` route and MCP `execute` / `start` actions remain awaited: they return only after planning completes or fails. -Current built-in purpose set: +Current built-in purpose sets: - `Fullstack JS App` +- `Create App` That purpose selector is intentionally future-facing. Additional built-in sets can be added later for other language and product families without redesigning the Quicksprint browse flow. ## Built-In Templates -The current `Fullstack JS App` purpose set ships with six built-ins: +The `Fullstack JS App` purpose set ships with six built-ins: - `Code Quality & Performance Audit` - `Security Vulnerability Scan` - `UI Usability & Accessibility Audit` @@ -122,6 +123,15 @@ The current `Fullstack JS App` purpose set ships with six built-ins: - `UI - Responsive Layout Improvements` - `UI - Interactions & Design Improvements` +The `Create App` purpose set ships with five repository-aware product templates: +- `Create Web App` +- `Create Desktop App` +- `Create Onlineshop` +- `Create Portfolio` +- `Create Game` + +Chat quickactions resolve their display label, app-kind label, stable template id, and transient design-guidance selection from `src/domain/chat/create-app-quickaction-catalog.ts`. The backend validates the kind/template pair and adds the selected guidance to that planning request; it does not save those selections to project settings. Each create-app prompt inspects the repository, avoids generic stack assumptions and confirmation steps, and asks for an implementation-ready dependency DAG. + These templates are designed to produce strong planning subtasks without assuming any repository-specific file layout. ## Prompt Design Rules diff --git a/docs/settings/guidance.md b/docs/settings/guidance.md index 716f8e817e..3af95fe172 100644 --- a/docs/settings/guidance.md +++ b/docs/settings/guidance.md @@ -35,6 +35,8 @@ A practical review flow is: Built-in guidance is protected. Deleting a selected custom entry clears that selection back to None for the edited scope. +The built-in `game-experience` styleguide is for playable products and covers input feedback, performance budgets, deterministic lifecycle state, recovery, and accessible controls. It is distinct from `gaming-companion`, which remains available for game-adjacent statistics, loadout, matchmaking, and community interfaces. + Before applying changes, check: - Whether the value affects provider credentials, Docker runtime behavior, Git automation, memory retention, or destructive cleanup. diff --git a/src/contracts/connection-chat-types.ts b/src/contracts/connection-chat-types.ts index 90dd9574ec..b00ac816ea 100644 --- a/src/contracts/connection-chat-types.ts +++ b/src/contracts/connection-chat-types.ts @@ -83,7 +83,13 @@ export interface ConversationRuntimeState { createAppQuickaction?: DashboardCreateAppQuickactionRuntimeState | null; } -export const DASHBOARD_CREATE_APP_QUICKACTION_KINDS = ["web_app", "desktop_app"] as const; +export const DASHBOARD_CREATE_APP_QUICKACTION_KINDS = [ + "web_app", + "desktop_app", + "online_shop", + "portfolio", + "game", +] as const; export type DashboardCreateAppQuickactionKind = typeof DASHBOARD_CREATE_APP_QUICKACTION_KINDS[number]; export const DASHBOARD_APP_PROGRESS_WIDGET_TYPE = "app_progress" as const; diff --git a/src/domain/chat/create-app-quickaction-catalog.ts b/src/domain/chat/create-app-quickaction-catalog.ts new file mode 100644 index 0000000000..5ffe43b88c --- /dev/null +++ b/src/domain/chat/create-app-quickaction-catalog.ts @@ -0,0 +1,89 @@ +import type { DashboardCreateAppQuickactionKind } from "../../contracts/connection-chat-types.js"; +import { + CODE_UX_AWARD_WINNING_STYLEGUIDE_ID, + CODE_UX_PRODUCT_TECH_STACK_ID, + ECOMMERCE_STYLEGUIDE_ID, + ELECTRON_DESKTOP_APP_TECH_STACK_ID, + GAME_EXPERIENCE_STYLEGUIDE_ID, + MARKETING_SITE_STYLEGUIDE_ID, +} from "../settings/design-guidance-catalog.js"; + +export interface CreateAppQuickactionDesignGuidanceSelection { + selectedTechStackId: string; + selectedStyleguideId: string; +} + +export interface CreateAppQuickactionSpec { + kind: DashboardCreateAppQuickactionKind; + displayLabel: string; + appKindLabel: string; + templateId: string; + designGuidance: CreateAppQuickactionDesignGuidanceSelection; +} + +export const CREATE_APP_QUICKACTION_CATALOG: readonly CreateAppQuickactionSpec[] = [ + { + kind: "web_app", + displayLabel: "Create Web App", + appKindLabel: "Web app", + templateId: "qs-create-web-app", + designGuidance: { + selectedTechStackId: CODE_UX_PRODUCT_TECH_STACK_ID, + selectedStyleguideId: CODE_UX_AWARD_WINNING_STYLEGUIDE_ID, + }, + }, + { + kind: "desktop_app", + displayLabel: "Create Desktop App", + appKindLabel: "Desktop app", + templateId: "qs-create-desktop-app", + designGuidance: { + selectedTechStackId: ELECTRON_DESKTOP_APP_TECH_STACK_ID, + selectedStyleguideId: CODE_UX_AWARD_WINNING_STYLEGUIDE_ID, + }, + }, + { + kind: "online_shop", + displayLabel: "Create Onlineshop", + appKindLabel: "Online shop", + templateId: "qs-create-online-shop", + designGuidance: { + selectedTechStackId: CODE_UX_PRODUCT_TECH_STACK_ID, + selectedStyleguideId: ECOMMERCE_STYLEGUIDE_ID, + }, + }, + { + kind: "portfolio", + displayLabel: "Create Portfolio", + appKindLabel: "Portfolio", + templateId: "qs-create-portfolio", + designGuidance: { + selectedTechStackId: CODE_UX_PRODUCT_TECH_STACK_ID, + selectedStyleguideId: MARKETING_SITE_STYLEGUIDE_ID, + }, + }, + { + kind: "game", + displayLabel: "Create Game", + appKindLabel: "Game", + templateId: "qs-create-game", + designGuidance: { + selectedTechStackId: CODE_UX_PRODUCT_TECH_STACK_ID, + selectedStyleguideId: GAME_EXPERIENCE_STYLEGUIDE_ID, + }, + }, +] as const; + +const CREATE_APP_QUICKACTION_BY_KIND = new Map( + CREATE_APP_QUICKACTION_CATALOG.map((spec) => [spec.kind, spec]), +); + +export function getCreateAppQuickactionSpec( + kind: DashboardCreateAppQuickactionKind, +): CreateAppQuickactionSpec { + const spec = CREATE_APP_QUICKACTION_BY_KIND.get(kind); + if (!spec) { + throw new Error(`Unsupported create-app quickaction kind: ${kind}`); + } + return spec; +} diff --git a/src/domain/quicksprint/quicksprint-catalog.ts b/src/domain/quicksprint/quicksprint-catalog.ts index db127a40ab..7fd491f0ff 100644 --- a/src/domain/quicksprint/quicksprint-catalog.ts +++ b/src/domain/quicksprint/quicksprint-catalog.ts @@ -17,6 +17,11 @@ const DEFAULT_TEMPLATE_ORDER = [ "qs-ui-design", "qs-ui-responsive", "qs-ui-interactions", + "qs-create-web-app", + "qs-create-desktop-app", + "qs-create-online-shop", + "qs-create-portfolio", + "qs-create-game", ] as const; function resolveTemplateDirectories(): string[] { diff --git a/src/domain/settings/design-guidance-catalog.ts b/src/domain/settings/design-guidance-catalog.ts index 8ee4a1374b..4983f144d7 100644 --- a/src/domain/settings/design-guidance-catalog.ts +++ b/src/domain/settings/design-guidance-catalog.ts @@ -3,6 +3,10 @@ import type { DesignGuidanceEntrySettings, DesignGuidanceSettings } from "../../ export const DESIGN_GUIDANCE_NONE_ID = "none"; export const CODE_UX_AWARD_WINNING_STYLEGUIDE_ID = "code-ux-award-winning"; export const CODE_UX_PRODUCT_TECH_STACK_ID = "code-ux-product-stack"; +export const ELECTRON_DESKTOP_APP_TECH_STACK_ID = "electron-desktop-app"; +export const ECOMMERCE_STYLEGUIDE_ID = "ecommerce"; +export const MARKETING_SITE_STYLEGUIDE_ID = "marketing-site"; +export const GAME_EXPERIENCE_STYLEGUIDE_ID = "game-experience"; const ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,79}$/; @@ -70,13 +74,13 @@ const DEFAULT_STYLEGUIDES: DesignGuidanceEntrySettings[] = [ "Make dependencies, priority, progress, and blocked states visible without overwhelming the board or list. Optimize for quick updates, batch scanning, and clear transitions between planning and delivery.", ), createEntry( - "ecommerce", + ECOMMERCE_STYLEGUIDE_ID, "Commerce", "Shopping and merchandising flows where confidence and conversion matter.", "Build trust through clear product evidence, transparent pricing states, frictionless comparison, and strong checkout feedback. Keep promotions subordinate to product comprehension and task completion.", ), createEntry( - "marketing-site", + MARKETING_SITE_STYLEGUIDE_ID, "Marketing Site", "Narrative pages for positioning, proof, and conversion.", "Create an immediate first-viewport signal for the offer, then support it with concrete proof, crisp scannable sections, and purposeful calls to action. Avoid generic hero composition when product evidence can carry the story.", @@ -123,6 +127,18 @@ const DEFAULT_STYLEGUIDES: DesignGuidanceEntrySettings[] = [ "Game-adjacent interfaces for stats, loadouts, matchmaking, or community.", "Support fast recognition, playful but legible interaction, and strong state changes. Keep competitive data, inventory, or social actions structured enough for repeated use.", ), + createEntry( + GAME_EXPERIENCE_STYLEGUIDE_ID, + "Game Experience", + "Playable experiences with responsive controls, legible state, stable performance, and inclusive interaction.", + [ + "Design the core play loop around immediate, consistent input feedback and clearly distinguish controllable, interactive, hazardous, collectible, paused, completed, and failed states.", + "Keep frame-time, startup, asset loading, memory, and update-loop costs within budgets supported by the repository; avoid visual effects or background work that compromise input responsiveness.", + "Model gameplay, menus, pause/resume, save or checkpoint, restart, and error recovery as explicit deterministic states so transitions are testable and interrupted sessions recover safely.", + "Make controls discoverable and remappable where the platform permits, support keyboard and non-pointer operation, never communicate essential state through color, sound, or motion alone, and respect reduced-motion and audio preferences.", + "Use animation, sound, haptics, and visual emphasis to reinforce timing and consequence without obscuring targets, interface text, focus, status, or player agency.", + ].join("\n"), + ), createEntry( "documentation-knowledge-base", "Documentation and Knowledge Base", @@ -152,7 +168,7 @@ const DEFAULT_TECH_STACKS: DesignGuidanceEntrySettings[] = [ "Keep API contracts explicit, validate inputs at boundaries, preserve transaction and logging behavior, and write focused tests around success, validation failure, and operational error paths.", ), createEntry( - "electron-desktop-app", + ELECTRON_DESKTOP_APP_TECH_STACK_ID, "Electron Desktop App", "A desktop shell with web UI, local filesystem integration, and process lifecycle concerns.", "Respect process boundaries, startup recovery, filesystem safety, and native-window behavior. Keep renderer UI responsive while moving privileged work through typed main-process services.", diff --git a/src/services/chat-thread-runtime-service.ts b/src/services/chat-thread-runtime-service.ts index 615beec4bb..8f7be1b669 100644 --- a/src/services/chat-thread-runtime-service.ts +++ b/src/services/chat-thread-runtime-service.ts @@ -26,7 +26,10 @@ import type { UpdateConversationThreadInput, UpdateConversationThreadRouteInput, } from "../contracts/connection-chat-types.js"; -import { DASHBOARD_APP_PROGRESS_WIDGET_TYPE } from "../contracts/connection-chat-types.js"; +import { + DASHBOARD_APP_PROGRESS_WIDGET_TYPE, + DASHBOARD_CREATE_APP_QUICKACTION_KINDS, +} from "../contracts/connection-chat-types.js"; import type { DetachedQuicksprintLaunchInput, DetachedQuicksprintLaunchResult, @@ -48,6 +51,8 @@ import { getCorrelationId } from "../shared/logging/correlation-id.js"; import type { AgentMcpAccessConfig } from "../contracts/agent-preset-types.js"; import { dashboardReplyAgentMcpAccess, isSchedulerOnlyAgentMcpAccess } from "./agent-mcp-access.js"; import { buildProviderInvocationWorkspaceOptions } from "../infrastructure/providers/cli/invocation-workspace-preparer.js"; +import { getCreateAppQuickactionSpec } from "../domain/chat/create-app-quickaction-catalog.js"; +import { getDesignGuidanceCatalog } from "../domain/settings/design-guidance-catalog.js"; interface ChatThreadRuntimeServiceDependencies { connectionChatRepository: ConnectionChatRepository; @@ -168,13 +173,16 @@ function readString(value: unknown): string | null { function normalizeCreateAppQuickactionKind(value: unknown): DashboardCreateAppQuickactionKind | null { const normalized = readString(value); - if (normalized === "web_app" || normalized === "web") { - return "web_app"; - } - if (normalized === "desktop_app" || normalized === "desktop") { - return "desktop_app"; + const aliases: Record = { + web: "web_app", + desktop: "desktop_app", + shop: "online_shop", + online_store: "online_shop", + }; + if (normalized && DASHBOARD_CREATE_APP_QUICKACTION_KINDS.includes(normalized as DashboardCreateAppQuickactionKind)) { + return normalized as DashboardCreateAppQuickactionKind; } - return null; + return normalized ? aliases[normalized] ?? null : null; } function readStringList(value: unknown): string[] { @@ -252,15 +260,19 @@ function parseCreateAppQuickactionMetadata(metadata: Record | n if (!requestId) { throw new Error("Create app quickaction metadata is missing requestId."); } - const templateId = readString(quickaction.templateId ?? root.templateId); - if (!templateId) { + const suppliedTemplateId = readString(quickaction.templateId ?? root.templateId); + if (!suppliedTemplateId) { throw new Error("Create app quickaction metadata is missing templateId."); } + const spec = getCreateAppQuickactionSpec(kind); + if (suppliedTemplateId !== spec.templateId) { + throw new Error(`Create app quickaction template does not match ${kind}.`); + } return { kind, requestId, - templateId, + templateId: spec.templateId, taskCount: readPositiveInteger(quickaction.taskCount ?? root.taskCount, 5), stackSummary: readCreateAppStackSummary(quickaction.stackSummary ?? root.stackSummary, kind), suggestionTags: readStringList(quickaction.suggestionTags ?? root.suggestionTags), @@ -692,7 +704,7 @@ export class ChatThreadRuntimeService { upToMessageId: userMessage.id, }); - const appLabel = quickaction.kind === "web_app" ? "web app" : "desktop app"; + const appLabel = getCreateAppQuickactionSpec(quickaction.kind).appKindLabel.toLowerCase(); const progressMessage = this.deps.connectionChatRepository.postSystemMessage(projectId, { threadId: thread.id, bodyMarkdown: `Started a ${appLabel} sprint: **${launch.sprint.name}**. Planning is running now; add any directional details here and they can be appended after planning finishes.`, @@ -1070,7 +1082,17 @@ export class ChatThreadRuntimeService { } private buildCreateAppAdditionalPrompt(bodyMarkdown: string, quickaction: NormalizedCreateAppQuickaction): string { - const appLabel = quickaction.kind === "web_app" ? "web application" : "desktop application"; + const spec = getCreateAppQuickactionSpec(quickaction.kind); + const appLabel = spec.kind === "web_app" || spec.kind === "desktop_app" + ? `${spec.appKindLabel.slice(0, -3).toLowerCase()}application` + : spec.appKindLabel.toLowerCase(); + const appArticle = /^[aeiou]/i.test(appLabel) ? "an" : "a"; + const guidanceCatalog = getDesignGuidanceCatalog(); + const techStackGuidance = guidanceCatalog.techStacks.find((entry) => entry.id === spec.designGuidance.selectedTechStackId); + const styleguideGuidance = guidanceCatalog.styleguides.find((entry) => entry.id === spec.designGuidance.selectedStyleguideId); + if (!techStackGuidance || !styleguideGuidance) { + throw new Error(`Create app quickaction guidance is unavailable for ${quickaction.kind}.`); + } const stackLines = this.formatCreateAppStackSummary(quickaction.stackSummary); const suggestionLine = quickaction.suggestionTags.length > 0 ? `Suggestion tags from the dashboard: ${quickaction.suggestionTags.join(", ")}.` @@ -1079,7 +1101,9 @@ export class ChatThreadRuntimeService { return [ "Dashboard create-app quickaction.", `Quickaction request id: ${quickaction.requestId}.`, - `Create an app sprint for a ${appLabel}.`, + `Create an app sprint for ${appArticle} ${appLabel}.`, + `Apply tech-stack guidance \`${techStackGuidance.id}\` (${techStackGuidance.name}):\n${techStackGuidance.instructionMarkdown}`, + `Apply styleguide guidance \`${styleguideGuidance.id}\` (${styleguideGuidance.name}):\n${styleguideGuidance.instructionMarkdown}`, stackLines ? `Suggested stack summary:\n${stackLines}` : "No suggested stack summary was provided; infer the right stack from the selected project before planning.", suggestionLine, `Original dashboard message:\n${bodyMarkdown.trim()}`, diff --git a/tests/backend/domain/chat/create-app-quickaction-catalog.test.ts b/tests/backend/domain/chat/create-app-quickaction-catalog.test.ts new file mode 100644 index 0000000000..cd5a646427 --- /dev/null +++ b/tests/backend/domain/chat/create-app-quickaction-catalog.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { DASHBOARD_CREATE_APP_QUICKACTION_KINDS } from "../../../../src/contracts/connection-chat-types.js"; +import { + CREATE_APP_QUICKACTION_CATALOG, + getCreateAppQuickactionSpec, +} from "../../../../src/domain/chat/create-app-quickaction-catalog.js"; +import { BUILTIN_QUICKSPRINT_TEMPLATES } from "../../../../src/domain/quicksprint/quicksprint-catalog.js"; +import { getDesignGuidanceCatalog } from "../../../../src/domain/settings/design-guidance-catalog.js"; + +describe("create-app-quickaction-catalog", () => { + it("defines every stable kind, label, template, app label, and guidance selection", () => { + expect(CREATE_APP_QUICKACTION_CATALOG).toEqual([ + { + kind: "web_app", + displayLabel: "Create Web App", + appKindLabel: "Web app", + templateId: "qs-create-web-app", + designGuidance: { + selectedTechStackId: "code-ux-product-stack", + selectedStyleguideId: "code-ux-award-winning", + }, + }, + { + kind: "desktop_app", + displayLabel: "Create Desktop App", + appKindLabel: "Desktop app", + templateId: "qs-create-desktop-app", + designGuidance: { + selectedTechStackId: "electron-desktop-app", + selectedStyleguideId: "code-ux-award-winning", + }, + }, + { + kind: "online_shop", + displayLabel: "Create Onlineshop", + appKindLabel: "Online shop", + templateId: "qs-create-online-shop", + designGuidance: { + selectedTechStackId: "code-ux-product-stack", + selectedStyleguideId: "ecommerce", + }, + }, + { + kind: "portfolio", + displayLabel: "Create Portfolio", + appKindLabel: "Portfolio", + templateId: "qs-create-portfolio", + designGuidance: { + selectedTechStackId: "code-ux-product-stack", + selectedStyleguideId: "marketing-site", + }, + }, + { + kind: "game", + displayLabel: "Create Game", + appKindLabel: "Game", + templateId: "qs-create-game", + designGuidance: { + selectedTechStackId: "code-ux-product-stack", + selectedStyleguideId: "game-experience", + }, + }, + ]); + expect(CREATE_APP_QUICKACTION_CATALOG.map((entry) => entry.kind)) + .toEqual(DASHBOARD_CREATE_APP_QUICKACTION_KINDS); + }); + + it("resolves every selection to valid guidance and a matching built-in template", () => { + const guidance = getDesignGuidanceCatalog(); + const techStackIds = new Set(guidance.techStacks.map((entry) => entry.id)); + const styleguideIds = new Set(guidance.styleguides.map((entry) => entry.id)); + const templatesById = new Map(BUILTIN_QUICKSPRINT_TEMPLATES.map((template) => [template.id, template])); + + for (const spec of CREATE_APP_QUICKACTION_CATALOG) { + expect(getCreateAppQuickactionSpec(spec.kind)).toBe(spec); + expect(techStackIds.has(spec.designGuidance.selectedTechStackId)).toBe(true); + expect(styleguideIds.has(spec.designGuidance.selectedStyleguideId)).toBe(true); + expect(templatesById.get(spec.templateId)?.name).toBe(spec.displayLabel); + } + expect(new Set(CREATE_APP_QUICKACTION_CATALOG.map((entry) => entry.templateId)).size).toBe(5); + }); +}); diff --git a/tests/backend/domain/quicksprint/quicksprint-catalog.test.ts b/tests/backend/domain/quicksprint/quicksprint-catalog.test.ts index e90d5d5153..36807d41f9 100644 --- a/tests/backend/domain/quicksprint/quicksprint-catalog.test.ts +++ b/tests/backend/domain/quicksprint/quicksprint-catalog.test.ts @@ -10,9 +10,14 @@ describe("BUILTIN_QUICKSPRINT_TEMPLATES", () => { "qs-ui-design", "qs-ui-responsive", "qs-ui-interactions", + "qs-create-web-app", + "qs-create-desktop-app", + "qs-create-online-shop", + "qs-create-portfolio", + "qs-create-game", ]); - for (const template of BUILTIN_QUICKSPRINT_TEMPLATES) { + for (const template of BUILTIN_QUICKSPRINT_TEMPLATES.slice(0, 6)) { expect(template.isBuiltIn).toBe(true); expect(template.purpose).toBe("fullstack-js-app"); expect(template.purposeLabel).toBe("Fullstack JS App"); @@ -21,6 +26,27 @@ describe("BUILTIN_QUICKSPRINT_TEMPLATES", () => { } }); + it("loads all create-app templates through the built-in Markdown catalog parser", () => { + const createAppTemplates = BUILTIN_QUICKSPRINT_TEMPLATES.filter((template) => template.purpose === "create-app"); + + expect(createAppTemplates.map((template) => template.id)).toEqual([ + "qs-create-web-app", + "qs-create-desktop-app", + "qs-create-online-shop", + "qs-create-portfolio", + "qs-create-game", + ]); + for (const template of createAppTemplates) { + expect(template.isBuiltIn).toBe(true); + expect(template.purposeLabel).toBe("Create App"); + expect(template.agentInstructionMarkdown).toMatch(/inspect the repository/i); + expect(template.agentInstructionMarkdown).toMatch(/catalog-selected/i); + expect(template.agentInstructionMarkdown).toMatch(/implementation-ready product DAG/i); + expect(template.agentInstructionMarkdown).toMatch(/Do not ask for confirmation|Do not seek confirmation/i); + expect(template.agentInstructionMarkdown).toMatch(/Do not assume|Do not invent/i); + } + }); + it("keeps built-in prompts project-agnostic and avoids overly prescriptive hardcoded UI values", () => { for (const template of BUILTIN_QUICKSPRINT_TEMPLATES) { expect(template.agentInstructionMarkdown).not.toMatch(/src\//); diff --git a/tests/backend/domain/settings/design-guidance-catalog.test.ts b/tests/backend/domain/settings/design-guidance-catalog.test.ts index eb45cf577b..458d7cb9ba 100644 --- a/tests/backend/domain/settings/design-guidance-catalog.test.ts +++ b/tests/backend/domain/settings/design-guidance-catalog.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { CODE_UX_AWARD_WINNING_STYLEGUIDE_ID, DESIGN_GUIDANCE_NONE_ID, + GAME_EXPERIENCE_STYLEGUIDE_ID, getDefaultDesignGuidanceStyleguides, getDefaultDesignGuidanceTechStacks, getDesignGuidanceCatalog, @@ -14,7 +15,7 @@ describe("design-guidance-catalog", () => { const styleguides = getDefaultDesignGuidanceStyleguides(); const techStacks = getDefaultDesignGuidanceTechStacks(); - expect(styleguides).toHaveLength(17); + expect(styleguides).toHaveLength(18); expect(styleguides[0]?.id).toBe(DESIGN_GUIDANCE_NONE_ID); expect(styleguides.some((entry) => entry.id === CODE_UX_AWARD_WINNING_STYLEGUIDE_ID)).toBe(true); expect(techStacks[0]?.id).toBe(DESIGN_GUIDANCE_NONE_ID); @@ -27,6 +28,18 @@ describe("design-guidance-catalog", () => { } }); + it("provides concrete game interaction, performance, state, and accessibility guidance", () => { + const gameGuidance = getDefaultDesignGuidanceStyleguides() + .find((entry) => entry.id === GAME_EXPERIENCE_STYLEGUIDE_ID); + + expect(gameGuidance).toBeDefined(); + expect(gameGuidance?.instructionMarkdown).toMatch(/input feedback/i); + expect(gameGuidance?.instructionMarkdown).toMatch(/frame-time|performance/i); + expect(gameGuidance?.instructionMarkdown).toMatch(/deterministic states/i); + expect(gameGuidance?.instructionMarkdown).toMatch(/keyboard|remappable/i); + expect(gameGuidance?.instructionMarkdown).toMatch(/reduced-motion/i); + }); + it("keeps default styleguides in the backend catalog when hidden from visible choices", () => { const settings = sanitizeDesignGuidanceSettings({ selectedStyleguideId: CODE_UX_AWARD_WINNING_STYLEGUIDE_ID, diff --git a/tests/dashboard/v2/chat-page-shell.test.tsx b/tests/dashboard/v2/chat-page-shell.test.tsx index 7a163a21ad..f4f1415f92 100644 --- a/tests/dashboard/v2/chat-page-shell.test.tsx +++ b/tests/dashboard/v2/chat-page-shell.test.tsx @@ -153,9 +153,15 @@ describe("ChatPageShell", () => { const desktopAction = getByRole("button", { name: "Create Desktop App" }); const webAction = getByRole("button", { name: "Create Web App" }); + const shopAction = getByRole("button", { name: "Create Onlineshop" }); + const portfolioAction = getByRole("button", { name: "Create Portfolio" }); + const gameAction = getByRole("button", { name: "Create Game" }); expect(desktopAction).toBeEnabled(); expect(webAction).toBeEnabled(); + expect(shopAction).toBeEnabled(); + expect(portfolioAction).toBeEnabled(); + expect(gameAction).toBeEnabled(); expect(getByText("Create app quick actions are available.")).toHaveAttribute("aria-live", "polite"); fireEvent.click(desktopAction); From 4da9cc3629ba3cdf48784010b4f09e40ffa52936 Mon Sep 17 00:00:00 2001 From: Code UX Date: Sat, 11 Jul 2026 03:30:35 +0000 Subject: [PATCH 2/8] feat(task T01): implement via codex --- .../components/__tests__/SourcesGrid.test.tsx | 1 + dashboard/src/v2/hooks/use-chat-page-data.ts | 10 ++ .../hooks/use-project-initialization-state.ts | 53 ++++++++ dashboard/src/v2/lib/project-api.ts | 11 ++ dashboard/src/v2/types.ts | 2 + docs-web/content/docs/developer-http-api.mdx | 1 + .../content/docs/user-dashboard-projects.mdx | 2 + docs-web/developer/http-api.md | 1 + docs-web/user/dashboard/projects.md | 2 + docs/dashboard/project-initialization.md | 3 + .../lifecycle/dashboard-lifecycle-service.ts | 3 + src/contracts/project-management-types.ts | 9 ++ src/domain/projects/project-initializer.ts | 6 +- src/infrastructure/git/remote-repo-creator.ts | 47 ++++--- src/repositories/db/app-db-migrations.ts | 1 + src/repositories/db/app-db-schema.ts | 1 + .../project-management-repository.ts | 12 +- .../project-summary-query.ts | 1 + src/server/code-ux-server.ts | 6 + src/server/dashboard-server.ts | 2 + src/server/project-routes.ts | 36 ++++++ .../project-initialization-state-service.ts | 116 ++++++++++++++++++ .../projects/project-initializer.test.ts | 5 +- .../git/remote-repo-creator.test.ts | 18 +-- .../project-management-repository.test.ts | 42 +++++++ ...roject-initialization-state-routes.test.ts | 64 ++++++++++ ...oject-initialization-state-service.test.ts | 92 ++++++++++++++ .../use-project-initialization-state.test.tsx | 62 ++++++++++ 28 files changed, 575 insertions(+), 34 deletions(-) create mode 100644 dashboard/src/v2/hooks/use-project-initialization-state.ts create mode 100644 src/services/project-initialization-state-service.ts create mode 100644 tests/backend/server/project-initialization-state-routes.test.ts create mode 100644 tests/backend/services/project-initialization-state-service.test.ts create mode 100644 tests/dashboard/v2/use-project-initialization-state.test.tsx diff --git a/dashboard/src/v2/components/__tests__/SourcesGrid.test.tsx b/dashboard/src/v2/components/__tests__/SourcesGrid.test.tsx index 9b1afedc3f..ac519e23a2 100644 --- a/dashboard/src/v2/components/__tests__/SourcesGrid.test.tsx +++ b/dashboard/src/v2/components/__tests__/SourcesGrid.test.tsx @@ -35,6 +35,7 @@ const createSource = (id: number, updatedAt: string): Source => ({ repoUrl: null, sourceType: "local", sourceRef: `/tmp/project-${id}`, + initializationMode: "existing", gitProvider: "local", gitHostDomain: null, defaultBranch: "main", diff --git a/dashboard/src/v2/hooks/use-chat-page-data.ts b/dashboard/src/v2/hooks/use-chat-page-data.ts index eca5b97982..68b1b2feba 100644 --- a/dashboard/src/v2/hooks/use-chat-page-data.ts +++ b/dashboard/src/v2/hooks/use-chat-page-data.ts @@ -16,6 +16,7 @@ import type { AgentPresetRecord, ExecutionInvocationRecord, Sprint, Task } from import { useSprints } from "../../hooks/useSprints.js"; import { useProjectTasks } from "./use-project-tasks.js"; import { clearChatDraftFromUrl, readChatDraftFromLocation } from "../lib/no-project-chat-assistant.js"; +import { useProjectInitializationState } from "./use-project-initialization-state.js"; /** "stage" is the cinematic 3D chat view; threads/invocations are the classic panes. */ export type ChatMode = "stage" | "threads" | "invocations"; @@ -58,6 +59,11 @@ export const useChatPageData = (options?: { composerRef?: RefObject Promise; +} { + const unavailableState = useMemo(() => projectId ? ({ + projectId, + initializationMode: "existing", + repositoryState: "unavailable", + canCreateInitialAppQuickactions: false, + }) : null, [projectId]); + + const fetchResource = useCallback(async (signal?: AbortSignal) => { + if (!projectId) { + return null; + } + return await fetchProjectInitializationState(projectId, signal); + }, [projectId]); + + const { data, loading, error, refetch } = useRealtimeResource({ + initialData: unavailableState, + fetchResource, + realtime: projectId ? { + scopes: [`project:${projectId}`, `project:${projectId}:git`], + shouldRefetch: shouldRefreshProjectInitializationState, + } : undefined, + isAlreadyLoaded: !projectId, + }); + + return useMemo(() => ({ + data: error ? unavailableState : data, + loading, + error, + refresh: async () => { + await refetch({ silent: true }); + }, + }), [data, error, loading, refetch, unavailableState]); +} diff --git a/dashboard/src/v2/lib/project-api.ts b/dashboard/src/v2/lib/project-api.ts index fbf3f0b492..af083a2a5f 100644 --- a/dashboard/src/v2/lib/project-api.ts +++ b/dashboard/src/v2/lib/project-api.ts @@ -9,6 +9,7 @@ import type { LocalFileBrowserResponse, PlanSprintOptions, ProjectCollectionResponse, + ProjectInitializationState, ProjectSummary, ProjectSetupRequestInput, ProjectSetupResult, @@ -47,6 +48,16 @@ export const fetchProjects = async (signal?: AbortSignal): Promise("/api/projects", { signal }); }; +export const fetchProjectInitializationState = async ( + projectId: string, + signal?: AbortSignal, +): Promise => { + return fetchJson( + `/api/projects/${encodeURIComponent(projectId)}/initialization-state`, + { signal }, + ); +}; + export const fetchLocalDirectories = async (directoryPath?: string): Promise => { const url = new URL("/api/local-directories", window.location.origin); if (directoryPath?.trim()) { diff --git a/dashboard/src/v2/types.ts b/dashboard/src/v2/types.ts index a065c192fc..77324dc5fa 100644 --- a/dashboard/src/v2/types.ts +++ b/dashboard/src/v2/types.ts @@ -86,6 +86,7 @@ import type { PlanningOverrides, PlanSprintOptions, ProjectCollectionResponse, + ProjectInitializationState, ProjectSetupOptions, ProjectSetupRequestInput, ProjectSetupResult, @@ -360,6 +361,7 @@ export type { PullInboxInput, RecordConversationMessageHistoryInput, ProjectCollectionResponse, + ProjectInitializationState, ProjectSetupOptions, ProjectSetupRequestInput, ProjectSetupResult, diff --git a/docs-web/content/docs/developer-http-api.mdx b/docs-web/content/docs/developer-http-api.mdx index d2ef616868..88ca761880 100644 --- a/docs-web/content/docs/developer-http-api.mdx +++ b/docs-web/content/docs/developer-http-api.mdx @@ -32,6 +32,7 @@ This page lists every endpoint, grouped by domain. Path parameters use `:name` n | `GET` | `/api/projects` | List projects. | | `POST` | `/api/projects` | Create. Body: `CreateProjectInput`. | | `GET` | `/api/projects/:projectId` | Get one. | +| `GET` | `/api/projects/:projectId/initialization-state` | Lightweight persisted initialization provenance and fail-closed seeded-checkout eligibility. | | `PATCH` | `/api/projects/:projectId` | Update. | | `DELETE` | `/api/projects/:projectId` | Delete. | | `PUT` | `/api/projects/:projectId/select` | Set active project. | diff --git a/docs-web/content/docs/user-dashboard-projects.mdx b/docs-web/content/docs/user-dashboard-projects.mdx index dbe8ac43d6..71094c777c 100644 --- a/docs-web/content/docs/user-dashboard-projects.mdx +++ b/docs-web/content/docs/user-dashboard-projects.mdx @@ -18,6 +18,8 @@ Click **New Project** on the Projects page to initialize a new repository throug New project creation always writes an explicit project techstack override. New local projects additionally receive `git.githubMode: LOCAL`; new remote projects do not. To set up a web or desktop app inside an existing project, use **Create Web App** or **Create Desktop App** in Chat Threads beside the composer. Those quickactions operate in the selected project, create a chat thread when needed, and launch the matching detached quicksprint without opening the new-project modal. +Code UX persists whether a project was imported or initialized as a new local/remote repository. Initial-app quickactions are eligible only while a persisted new project is still a clean, one-commit seed containing exactly `README.md` and the Code UX `.gitignore`. Additional files, commits, setup artifacts, dirty state, missing checkouts, or inspection failures disable eligibility; imported and legacy projects are never inferred to be new from their source type or age. + The form collects: | Field | Required | Description | diff --git a/docs-web/developer/http-api.md b/docs-web/developer/http-api.md index 421b0e5611..9988ba8a5e 100644 --- a/docs-web/developer/http-api.md +++ b/docs-web/developer/http-api.md @@ -32,6 +32,7 @@ This page lists every endpoint, grouped by domain. Path parameters use `:name` n | `GET` | `/api/projects` | List projects. | | `POST` | `/api/projects` | Create. Body: `CreateProjectInput`. | | `GET` | `/api/projects/:projectId` | Get one. | +| `GET` | `/api/projects/:projectId/initialization-state` | Lightweight persisted initialization provenance and fail-closed seeded-checkout eligibility. | | `PATCH` | `/api/projects/:projectId` | Update. | | `DELETE` | `/api/projects/:projectId` | Delete. | | `PUT` | `/api/projects/:projectId/select` | Set active project. | diff --git a/docs-web/user/dashboard/projects.md b/docs-web/user/dashboard/projects.md index 1aefa8587b..02bee38286 100644 --- a/docs-web/user/dashboard/projects.md +++ b/docs-web/user/dashboard/projects.md @@ -18,6 +18,8 @@ Click **New Project** on the Projects page to initialize a new repository throug New project creation always writes an explicit project techstack override. New local projects additionally receive `git.githubMode: LOCAL`; new remote projects do not. To set up a web or desktop app inside an existing project, use **Create Web App** or **Create Desktop App** in Chat Threads beside the composer. Those quickactions operate in the selected project, create a chat thread when needed, and launch the matching detached quicksprint without opening the new-project modal. +Code UX persists whether a project was imported or initialized as a new local/remote repository. Initial-app quickactions are eligible only while a persisted new project is still a clean, one-commit seed containing exactly `README.md` and the Code UX `.gitignore`. Additional files, commits, setup artifacts, dirty state, missing checkouts, or inspection failures disable eligibility; imported and legacy projects are never inferred to be new from their source type or age. + The form collects: | Field | Required | Description | diff --git a/docs/dashboard/project-initialization.md b/docs/dashboard/project-initialization.md index 0bf7fa5238..36c40ee00e 100644 --- a/docs/dashboard/project-initialization.md +++ b/docs/dashboard/project-initialization.md @@ -9,6 +9,9 @@ Project Initialization runs a repository-specific setup pass through the `Projec - Imported Git URL projects do not receive git-mode or techstack overrides. They continue to inherit the remote git and unassigned techstack defaults unless the operator explicitly changes project or sprint settings or runs setup techstack detection. - `New Project` reuses the same Add Project modal with the `new_project` source selected. The modal exposes `Local Repo` / `Remote Repo` init modes instead of setup scope controls. - Chat includes `Create Web App` and `Create Desktop App` setup quickactions for the currently selected project. Threads mode exposes them beside the composer, including empty threads, and 3D Chat keeps its idle setup chips. The thread quickactions post typed `create_app` metadata that launches the matching detached quicksprint; they do not open the new-project modal or create/import a Code UX project. +- Project persistence records whether creation used `existing`, `new-local`, or `new-remote`. Imported and pre-migration projects default to `existing`; source type and creation time are never used to infer new-project provenance. +- Initial-app quickactions use `GET /api/projects/:projectId/initialization-state`. Eligibility requires persisted new-project provenance, a clean checkout with exactly one commit, and exactly the seeded `README.md` plus Code UX `.gitignore`; missing repositories, setup artifacts, additional files or commits, and inspection failures return unavailable or modified state with eligibility disabled. +- The initialization-state dashboard resource listens to both project structure and dedicated git realtime scopes. Repository inspection remains outside project-list loading, so the project collection does not carry or compute git status payloads. - All `new_project` submissions, local or remote, include an explicit project `techstack` override. New local projects also include `git.githubMode: LOCAL`; new remote projects do not. - The `new_project` branch hides the Project Setup Agent section entirely and routes creation through the backend `initMode` fields. - New local project creation treats the directory path as optional. When no directory is selected, the dashboard submits the project name and the backend resolves it under the user's home directory; relative typed paths resolve from the user's home directory, while absolute paths selected through the desktop picker are used as-is. diff --git a/src/app/lifecycle/dashboard-lifecycle-service.ts b/src/app/lifecycle/dashboard-lifecycle-service.ts index e76dec6b52..cafd97c202 100644 --- a/src/app/lifecycle/dashboard-lifecycle-service.ts +++ b/src/app/lifecycle/dashboard-lifecycle-service.ts @@ -92,6 +92,7 @@ import type { LocalMcpInstallResult, LocalMcpSetupInfo, } from "../../services/local-mcp-cli-config-service.js"; +import type { ProjectInitializationStateService } from "../../services/project-initialization-state-service.js"; const updateCheckerService = new UpdateCheckerService(); @@ -104,6 +105,7 @@ export interface BootDashboardDeps { appDbStorage: AppDbStorage; settingsRepository: SettingsRepository; projectManagementRepository: ProjectManagementRepository; + projectInitializationStateService: ProjectInitializationStateService; projectRuntimeRepository: ProjectRuntimeRepository; executionRepository: ExecutionRepository; connectionChatRepository: ConnectionChatRepository; @@ -657,6 +659,7 @@ export async function bootDashboard(deps: BootDashboardDeps): Promise deps.projectManagementRepository.getProject(projectId), + getProjectInitializationState: (projectId) => deps.projectInitializationStateService.getProjectInitializationState(projectId), updateProject: (projectId, input) => deps.projectManagementRepository.updateProject(projectId, input), deleteProject: (projectId) => deps.projectManagementRepository.deleteProject(projectId), selectProject: (projectId) => { diff --git a/src/contracts/project-management-types.ts b/src/contracts/project-management-types.ts index f8fec60ce1..85f16366b2 100644 --- a/src/contracts/project-management-types.ts +++ b/src/contracts/project-management-types.ts @@ -11,6 +11,14 @@ export type TaskPriority = "critical" | "high" | "medium" | "low"; export type TaskExecutorType = "auto" | "docker_cli" | "jules" | "mcp_worker"; export type GitProvider = "github" | "gitlab" | "local"; export type ProjectInitMode = "existing" | "new-local" | "new-remote"; +export type ProjectInitializationRepositoryState = "initial" | "modified" | "unavailable"; + +export interface ProjectInitializationState { + projectId: string; + initializationMode: ProjectInitMode; + repositoryState: ProjectInitializationRepositoryState; + canCreateInitialAppQuickactions: boolean; +} export interface ProjectSummary { id: string; @@ -20,6 +28,7 @@ export interface ProjectSummary { repoUrl: string | null; sourceType: ProjectSourceType; sourceRef: string; + initializationMode: ProjectInitMode; gitProvider: GitProvider; gitHostDomain: string | null; defaultBranch: string | null; diff --git a/src/domain/projects/project-initializer.ts b/src/domain/projects/project-initializer.ts index 9a1ee9971d..bc0599254d 100644 --- a/src/domain/projects/project-initializer.ts +++ b/src/domain/projects/project-initializer.ts @@ -105,7 +105,7 @@ export async function initializeProject( ...input, sourceType: "local", sourceRef: safeSourceRef, - initMode: undefined, + initMode: mode, })); } @@ -141,10 +141,10 @@ export async function initializeProject( sourceType: "git", sourceRef: result.remoteUrl, cloneDir: cloneParentDir, - initMode: undefined, + initMode: mode, })); } // "existing" or absent — original behavior - return deps.createProject(withProjectManagerDashboardDefault(input)); + return deps.createProject(withProjectManagerDashboardDefault({ ...input, initMode: "existing" })); } diff --git a/src/infrastructure/git/remote-repo-creator.ts b/src/infrastructure/git/remote-repo-creator.ts index c2d0e060fb..5aa7c67f32 100644 --- a/src/infrastructure/git/remote-repo-creator.ts +++ b/src/infrastructure/git/remote-repo-creator.ts @@ -40,20 +40,25 @@ const cloneRepository = async (remoteUrl: string, cloneParentDir: string, repoNa ); }; -async function seedCodeUxGitignore(remoteUrl: string, localPath: ValidatedPath, hostToken?: string): Promise { - const changed = await ensureCodeUxGitignoreEntry(localPath); - if (!changed) { - return; - } +async function seedEmptyRemoteRepository( + remoteUrl: string, + localPath: ValidatedPath, + projectName: string, + defaultBranch: string, + hostToken?: string, +): Promise { + fs.writeFileSync(path.join(localPath, "README.md"), `# ${projectName.trim() || "Project"}\n\nInitialized with Code UX.\n`); + await ensureCodeUxGitignoreEntry(localPath); const env = (await buildGitHttpAuthEnvWithFallbacks(remoteUrl, { githubToken: hostToken, gitlabToken: hostToken, })) || process.env; await runCommandStrict("git", ["config", "user.email", "code-ux@local"], localPath); await runCommandStrict("git", ["config", "user.name", "Code UX"], localPath); - await runCommandStrict("git", ["add", ".gitignore"], localPath); - await runCommandStrict("git", ["commit", "-m", "chore: ignore Code UX runtime files"], localPath); - await runCommandStrict("git", ["push", "origin", "HEAD"], localPath, env); + await runCommandStrict("git", ["checkout", "-B", defaultBranch], localPath); + await runCommandStrict("git", ["add", "README.md", ".gitignore"], localPath); + await runCommandStrict("git", ["commit", "-m", "Initial commit"], localPath); + await runCommandStrict("git", ["push", "-u", "origin", "HEAD"], localPath, env); } /** @@ -90,11 +95,9 @@ export async function createGitHubRepo(opts: { "Content-Type": "application/json", "X-GitHub-Api-Version": "2022-11-28", }, - // auto_init creates an initial commit with a README and a default branch, - // so the cloned repo starts with real content. Without it the remote is - // empty (no commits, unborn default branch) and sprints fail to prepare a - // base branch. - body: JSON.stringify({ name: opts.repoName, private: opts.isPrivate, auto_init: true }), + // Seed locally after cloning so README.md and the Code UX .gitignore land + // together in one initial commit. + body: JSON.stringify({ name: opts.repoName, private: opts.isPrivate, auto_init: false }), signal: AbortSignal.timeout(API_TIMEOUT_MS), }); const text = await response.text(); @@ -110,7 +113,7 @@ export async function createGitHubRepo(opts: { await cloneRepository(remoteUrl, safeParentDir, opts.repoName, opts.hostToken); const localPath = safeTargetDir; - await seedCodeUxGitignore(remoteUrl, localPath, opts.hostToken); + await seedEmptyRemoteRepository(remoteUrl, localPath, opts.repoName, "main", opts.hostToken); return { localPath, remoteUrl }; } catch (error: any) { const message = error.stderr?.toString() || error.message; @@ -155,11 +158,9 @@ export async function createGitLabRepo(opts: { name: opts.repoName, path: opts.repoName, visibility: opts.isPrivate ? "private" : "public", - // initialize_with_readme seeds an initial commit + default branch so the - // cloned repo has real content. Without it the clone is empty (no - // commits, unborn default branch) and sprints fail to prepare a base. - initialize_with_readme: true, - ...(opts.defaultBranch?.trim() ? { default_branch: opts.defaultBranch.trim() } : {}), + // Seed locally after cloning so README.md and the Code UX .gitignore land + // together in one initial commit. + initialize_with_readme: false, }), signal: AbortSignal.timeout(API_TIMEOUT_MS), }); @@ -176,7 +177,13 @@ export async function createGitLabRepo(opts: { const localPath = safeTargetDir; await cloneRepository(remoteUrl, safeParentDir, opts.repoName, opts.hostToken); - await seedCodeUxGitignore(remoteUrl, localPath, opts.hostToken); + await seedEmptyRemoteRepository( + remoteUrl, + localPath, + opts.repoName, + opts.defaultBranch?.trim() || "main", + opts.hostToken, + ); return { localPath, remoteUrl }; } catch (error: any) { diff --git a/src/repositories/db/app-db-migrations.ts b/src/repositories/db/app-db-migrations.ts index 6ff8c04fee..e95f206af5 100644 --- a/src/repositories/db/app-db-migrations.ts +++ b/src/repositories/db/app-db-migrations.ts @@ -582,6 +582,7 @@ export function runMigrations(db: DatabaseAdapter): void { ensureNodeFlowTables(db); ensureCustomDashboardTables(db); + ensureColumn(db, "projects", "initialization_mode", "TEXT NOT NULL DEFAULT 'existing'"); ensureColumn(db, "provider_invocations", "tool_call_count", "INTEGER NOT NULL DEFAULT 0"); ensureColumn(db, "sprints", "showcase_pinned", "INTEGER NOT NULL DEFAULT 0"); diff --git a/src/repositories/db/app-db-schema.ts b/src/repositories/db/app-db-schema.ts index ca7991fa07..40c90682ff 100644 --- a/src/repositories/db/app-db-schema.ts +++ b/src/repositories/db/app-db-schema.ts @@ -18,6 +18,7 @@ CREATE TABLE IF NOT EXISTS projects ( base_dir TEXT NOT NULL, repo_url TEXT, source_id TEXT, + initialization_mode TEXT NOT NULL DEFAULT 'existing', default_branch TEXT, feature_branch_prefix TEXT, status TEXT NOT NULL DEFAULT 'idle', diff --git a/src/repositories/project-management-repository.ts b/src/repositories/project-management-repository.ts index 302721c325..580b3bea98 100644 --- a/src/repositories/project-management-repository.ts +++ b/src/repositories/project-management-repository.ts @@ -11,6 +11,7 @@ import type { LinkedIssueProvider, LinkedIssueSourceKind, ProjectCollectionResponse, + ProjectInitMode, ProjectSourceType, ProjectSummary, SprintLinkedIssueInput, @@ -62,6 +63,7 @@ interface ProjectRow { name: string; base_dir: string; repo_url: string | null; + initialization_mode: ProjectInitMode | null; default_branch: string | null; feature_branch_prefix: string | null; status: ProjectSummary["status"]; @@ -221,8 +223,8 @@ export class ProjectManagementRepository { const insert = this.db.prepare(` INSERT INTO projects ( - id, slug, name, base_dir, repo_url, source_id, default_branch, feature_branch_prefix, status, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + id, slug, name, base_dir, repo_url, source_id, initialization_mode, default_branch, feature_branch_prefix, status, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); const insertSource = this.db.prepare(` INSERT INTO project_sources (id, project_id, source_type, source_ref, created_at) @@ -237,6 +239,7 @@ export class ProjectManagementRepository { baseDir, repoUrl, null, + normalizeProjectInitMode(input.initMode), input.defaultBranch?.trim() || "main", input.featureBranchPrefix?.trim() || "feature/", input.status || "idle", @@ -1279,6 +1282,7 @@ export class ProjectManagementRepository { repoUrl: effectiveRepoUrl, sourceType, sourceRef, + initializationMode: normalizeProjectInitMode(row.initialization_mode), gitProvider: provider, gitHostDomain: hostDomain, defaultBranch: row.default_branch, @@ -1875,6 +1879,10 @@ function mapEffectiveSprintStatus( } } +function normalizeProjectInitMode(value: ProjectInitMode | null | undefined): ProjectInitMode { + return value === "new-local" || value === "new-remote" ? value : "existing"; +} + function emptyProjectSummaryAggregation(): ProjectSummaryAggregation { return { sprintsCount: 0, diff --git a/src/repositories/project-management/project-summary-query.ts b/src/repositories/project-management/project-summary-query.ts index b4201fa4a3..07b468b5bf 100644 --- a/src/repositories/project-management/project-summary-query.ts +++ b/src/repositories/project-management/project-summary-query.ts @@ -40,6 +40,7 @@ export const projectSummaryQuery = { p.name, p.base_dir, p.repo_url, + p.initialization_mode, p.default_branch, p.feature_branch_prefix, p.status, diff --git a/src/server/code-ux-server.ts b/src/server/code-ux-server.ts index 58eca226f8..d7f4a0a863 100644 --- a/src/server/code-ux-server.ts +++ b/src/server/code-ux-server.ts @@ -92,6 +92,7 @@ import { disposeCommandSpawner, shutdownGitHelperPool } from "../shared/subproce import { LocalMcpCliConfigService } from "../services/local-mcp-cli-config-service.js"; import type { McpConnectionInfo } from "../contracts/mcp-connection-types.js"; import type { ChatProviderIngressService } from "../services/chat-provider-ingress-service.js"; +import { ProjectInitializationStateService } from "../services/project-initialization-state-service.js"; function detectMergeConflictMessage(message: string | null | undefined): boolean { const normalized = String(message || "").trim().toLowerCase(); @@ -153,6 +154,7 @@ export class CodeUxServer { private appDbStorage: AppDbStorage; private settingsRepository: SettingsRepository; private projectManagementRepository: ProjectManagementRepository; + private projectInitializationStateService: ProjectInitializationStateService; private projectRuntimeRepository: ProjectRuntimeRepository; private connectionChatRepository: ConnectionChatRepository; private chatProviderRepository: ChatProviderRepository; @@ -236,6 +238,9 @@ export class CodeUxServer { this.appDbStorage = deps.appDbStorage; this.settingsRepository = deps.settingsRepository; this.projectManagementRepository = deps.projectManagementRepository; + this.projectInitializationStateService = new ProjectInitializationStateService( + (projectId) => this.projectManagementRepository.getProject(projectId), + ); this.projectRuntimeRepository = deps.projectRuntimeRepository; this.connectionChatRepository = deps.connectionChatRepository; this.chatProviderRepository = deps.chatProviderRepository; @@ -1341,6 +1346,7 @@ export class CodeUxServer { appDbStorage: this.appDbStorage, settingsRepository: this.settingsRepository, projectManagementRepository: this.projectManagementRepository, + projectInitializationStateService: this.projectInitializationStateService, projectRuntimeRepository: this.projectRuntimeRepository, executionRepository: this.executionRepository, connectionChatRepository: this.connectionChatRepository, diff --git a/src/server/dashboard-server.ts b/src/server/dashboard-server.ts index 7b88fdb128..19b0a38b2c 100644 --- a/src/server/dashboard-server.ts +++ b/src/server/dashboard-server.ts @@ -86,6 +86,7 @@ import type { ImprovePromptInput, PlanSprintOptions, ProjectCollectionResponse, + ProjectInitializationState, ProjectSetupRequestInput, ProjectSetupResult, ProjectSetupStartResult, @@ -235,6 +236,7 @@ export interface DashboardServerOptions { setupProject?: (projectId: string, input?: ProjectSetupRequestInput, signal?: AbortSignal) => Promise; startProjectSetup?: (projectId: string, input?: ProjectSetupRequestInput) => Promise; getProject: (projectId: string) => ProjectSummary | null; + getProjectInitializationState?: (projectId: string) => Promise; updateProject: (projectId: string, input: UpdateProjectInput) => ProjectSummary; deleteProject: (projectId: string) => void; selectProject: (projectId: string | null) => string | null; diff --git a/src/server/project-routes.ts b/src/server/project-routes.ts index cf8cbe82b9..d1f3e0a57c 100644 --- a/src/server/project-routes.ts +++ b/src/server/project-routes.ts @@ -76,6 +76,42 @@ export function registerProjectRoutes(router: Express, deps: DashboardDependenci res.json(project); })); + router.get("/api/projects/:projectId/initialization-state", asyncRoute(async (req, res) => { + const projectId = requireTrimmedString(req.params.projectId, "projectId"); + const project = deps.getProject(projectId); + if (!project) { + res.status(404).json({ + error: `Project not found: ${projectId}`, + projectId, + initializationMode: "existing", + repositoryState: "unavailable", + canCreateInitialAppQuickactions: false, + }); + return; + } + + if (!deps.getProjectInitializationState) { + res.json({ + projectId, + initializationMode: project.initializationMode, + repositoryState: "unavailable", + canCreateInitialAppQuickactions: false, + }); + return; + } + + try { + res.json(await deps.getProjectInitializationState(projectId)); + } catch { + res.json({ + projectId, + initializationMode: project.initializationMode, + repositoryState: "unavailable", + canCreateInitialAppQuickactions: false, + }); + } + })); + router.get("/api/projects/:projectId/settings", syncRoute((req, res) => { try { res.json(deps.getProjectSettings(requireTrimmedString(req.params.projectId, "projectId"))); diff --git a/src/services/project-initialization-state-service.ts b/src/services/project-initialization-state-service.ts new file mode 100644 index 0000000000..913369ce49 --- /dev/null +++ b/src/services/project-initialization-state-service.ts @@ -0,0 +1,116 @@ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import type { + ProjectInitializationState, + ProjectSummary, +} from "../contracts/project-management-types.js"; +import { runCommandStrict } from "./cli-process-runner.js"; + +const INSPECTION_TIMEOUT_MS = 5_000; +const EXPECTED_TRACKED_FILES = [".gitignore", "README.md"]; + +type ProjectLookup = Pick; + +export class ProjectInitializationStateService { + constructor( + private readonly getProject: (projectId: string) => ProjectLookup | null, + ) {} + + async getProjectInitializationState(projectId: string): Promise { + const project = this.getProject(projectId); + if (!project) { + return unavailableState(projectId, "existing"); + } + + if (project.initializationMode === "existing") { + return unavailableState(project.id, project.initializationMode); + } + + try { + const repositoryState = await inspectRepository(project.baseDir); + return { + projectId: project.id, + initializationMode: project.initializationMode, + repositoryState, + canCreateInitialAppQuickactions: repositoryState === "initial", + }; + } catch { + return unavailableState(project.id, project.initializationMode); + } + } +} + +function unavailableState( + projectId: string, + initializationMode: ProjectInitializationState["initializationMode"], +): ProjectInitializationState { + return { + projectId, + initializationMode, + repositoryState: "unavailable", + canCreateInitialAppQuickactions: false, + }; +} + +async function inspectRepository(repoPath: string): Promise<"initial" | "modified"> { + const resolvedRepoPath = path.resolve(repoPath); + const root = (await runGit(resolvedRepoPath, ["rev-parse", "--show-toplevel"])).trim(); + if (path.resolve(root) !== resolvedRepoPath) { + return "modified"; + } + + const status = await runGit(resolvedRepoPath, ["status", "--porcelain=v1", "-z", "--untracked-files=all"]); + if (status.length > 0) { + return "modified"; + } + + const commitCount = Number.parseInt( + (await runGit(resolvedRepoPath, ["rev-list", "--count", "HEAD"])).trim(), + 10, + ); + if (commitCount !== 1) { + return "modified"; + } + + const trackedFiles = splitNullDelimited( + await runGit(resolvedRepoPath, ["ls-tree", "-r", "-z", "--name-only", "HEAD"]), + ).sort(); + if (!sameStringArray(trackedFiles, EXPECTED_TRACKED_FILES)) { + return "modified"; + } + + const rootEntries = (await fs.readdir(resolvedRepoPath)).filter((entry) => entry !== ".git").sort(); + if (!sameStringArray(rootEntries, EXPECTED_TRACKED_FILES)) { + return "modified"; + } + + const readme = await runGit(resolvedRepoPath, ["show", "HEAD:README.md"]); + if (!/^# [^\r\n]+\r?\n\r?\nInitialized with Code UX\.\r?\n?$/.test(readme)) { + return "modified"; + } + + const gitignore = await runGit(resolvedRepoPath, ["show", "HEAD:.gitignore"]); + const gitignoreEntries = gitignore + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith("#")); + return gitignoreEntries.length === 1 && (gitignoreEntries[0] === ".code-ux/" || gitignoreEntries[0] === ".code-ux") + ? "initial" + : "modified"; +} + +async function runGit(repoPath: string, args: string[]): Promise { + const result = await runCommandStrict("git", args, repoPath, process.env, { + timeout: INSPECTION_TIMEOUT_MS, + trimOutput: false, + }); + return result.stdout; +} + +function splitNullDelimited(value: string): string[] { + return value.split("\0").filter(Boolean); +} + +function sameStringArray(left: string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} diff --git a/tests/backend/domain/projects/project-initializer.test.ts b/tests/backend/domain/projects/project-initializer.test.ts index ddcd8a58bd..8736d9f030 100644 --- a/tests/backend/domain/projects/project-initializer.test.ts +++ b/tests/backend/domain/projects/project-initializer.test.ts @@ -38,6 +38,7 @@ describe("initializeProject validation", () => { ); expect(createProject).toHaveBeenCalledWith(expect.objectContaining({ + initMode: "existing", settingsOverrides: expect.objectContaining({ agents: expect.objectContaining({ routing: expect.objectContaining({ @@ -83,7 +84,7 @@ describe("initializeProject validation", () => { expect(createProject).toHaveBeenCalledWith(expect.objectContaining({ sourceType: "local", sourceRef: path.join(os.homedir(), "valid-local-repo"), - initMode: undefined, + initMode: "new-local", })); }); @@ -163,7 +164,7 @@ describe("initializeProject validation", () => { sourceType: "git", sourceRef: "https://github.com/a/b", cloneDir: expectedCloneRoot, - initMode: undefined, + initMode: "new-remote", settingsOverrides: expect.objectContaining({ designGuidance: expect.objectContaining({ selectedStyleguideId: CODE_UX_AWARD_WINNING_STYLEGUIDE_ID, diff --git a/tests/backend/infrastructure/git/remote-repo-creator.test.ts b/tests/backend/infrastructure/git/remote-repo-creator.test.ts index 54ee20b953..9ed0c7117d 100644 --- a/tests/backend/infrastructure/git/remote-repo-creator.test.ts +++ b/tests/backend/infrastructure/git/remote-repo-creator.test.ts @@ -6,6 +6,7 @@ const validateNonEmptyDir = vi.fn((dir: string) => dir); const runCommandStrict = vi.fn(async () => ({ stdout: "", stderr: "", code: 0 })); const buildGitHttpAuthEnvWithFallbacks = vi.fn(async () => ({ GIT_TOKEN: "x" })); const mkdirSync = vi.fn(); +const writeFileSync = vi.fn(); const ensureCodeUxGitignoreEntry = vi.fn(async () => true); vi.mock("../../../../src/utils/path-validator.js", () => ({ @@ -24,6 +25,7 @@ vi.mock("../../../../src/services/git-http-auth.js", () => ({ vi.mock("fs", () => ({ mkdirSync: (...a: unknown[]) => mkdirSync(...a), + writeFileSync: (...a: unknown[]) => writeFileSync(...a), })); vi.mock("../../../../src/infrastructure/git/code-ux-gitignore.js", () => ({ @@ -49,7 +51,7 @@ describe("createGitHubRepo", () => { expect(url).toBe("https://api.github.com/user/repos"); expect(init?.method).toBe("POST"); const body = JSON.parse(String(init?.body)); - expect(body).toEqual({ name: "my-repo", private: true, auto_init: true }); + expect(body).toEqual({ name: "my-repo", private: true, auto_init: false }); return new Response(JSON.stringify({ clone_url: "https://github.com/me/my-repo.git" }), { status: 201 }); }); vi.stubGlobal("fetch", fetchMock); @@ -73,9 +75,10 @@ describe("createGitHubRepo", () => { { GIT_TOKEN: "x" }, ); expect(ensureCodeUxGitignoreEntry).toHaveBeenCalledWith("/tmp/parent/my-repo"); - expect(runCommandStrict).toHaveBeenCalledWith("git", ["add", ".gitignore"], "/tmp/parent/my-repo"); - expect(runCommandStrict).toHaveBeenCalledWith("git", ["commit", "-m", "chore: ignore Code UX runtime files"], "/tmp/parent/my-repo"); - expect(runCommandStrict).toHaveBeenCalledWith("git", ["push", "origin", "HEAD"], "/tmp/parent/my-repo", { GIT_TOKEN: "x" }); + expect(runCommandStrict).toHaveBeenCalledWith("git", ["checkout", "-B", "main"], "/tmp/parent/my-repo"); + expect(runCommandStrict).toHaveBeenCalledWith("git", ["add", "README.md", ".gitignore"], "/tmp/parent/my-repo"); + expect(runCommandStrict).toHaveBeenCalledWith("git", ["commit", "-m", "Initial commit"], "/tmp/parent/my-repo"); + expect(runCommandStrict).toHaveBeenCalledWith("git", ["push", "-u", "origin", "HEAD"], "/tmp/parent/my-repo", { GIT_TOKEN: "x" }); }); it("rejects when no host token is provided", async () => { @@ -129,7 +132,7 @@ describe("createGitHubRepo", () => { }); describe("createGitLabRepo", () => { - it("creates the project with the readme + optional default branch and clones it", async () => { + it("creates an empty project and seeds the requested default branch locally", async () => { const fetchMock = vi.fn(async (url: string, init?: RequestInit) => { expect(url).toBe("https://gitlab.com/api/v4/projects"); const body = JSON.parse(String(init?.body)); @@ -137,9 +140,9 @@ describe("createGitLabRepo", () => { name: "proj", path: "proj", visibility: "private", - initialize_with_readme: true, - default_branch: "trunk", + initialize_with_readme: false, }); + expect(body).not.toHaveProperty("default_branch"); return new Response(JSON.stringify({ http_url_to_repo: "https://gitlab.com/me/proj.git" }), { status: 201 }); }); vi.stubGlobal("fetch", fetchMock); @@ -154,6 +157,7 @@ describe("createGitLabRepo", () => { expect(result.remoteUrl).toBe("https://gitlab.com/me/proj.git"); expect(result.localPath).toBe("/tmp/parent/proj"); + expect(runCommandStrict).toHaveBeenCalledWith("git", ["checkout", "-B", "trunk"], "/tmp/parent/proj"); }); it("omits default_branch when not provided and defaults to public visibility", async () => { diff --git a/tests/backend/repositories/project-management-repository.test.ts b/tests/backend/repositories/project-management-repository.test.ts index 3d652d61b6..3bd994b213 100644 --- a/tests/backend/repositories/project-management-repository.test.ts +++ b/tests/backend/repositories/project-management-repository.test.ts @@ -36,6 +36,48 @@ afterEach(async () => { }); describe("ProjectManagementRepository", () => { + it("defaults legacy and imported projects to existing provenance", async () => { + const { storage, repository } = await createRepository(); + const imported = repository.createProject({ + name: "Imported Project", + sourceType: "local", + sourceRef: "/workspace/imported-project", + }); + expect(imported.initializationMode).toBe("existing"); + + const db = storage.getDatabase(); + const now = new Date().toISOString(); + db.prepare(` + INSERT INTO projects (id, slug, name, base_dir, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run("legacy-project", "legacy-project", "Legacy Project", "/workspace/legacy-project", "idle", now, now); + db.prepare(` + INSERT INTO project_sources (id, project_id, source_type, source_ref, created_at) + VALUES (?, ?, ?, ?, ?) + `).run("legacy-source", "legacy-project", "git", "https://example.test/legacy.git", now); + + expect(repository.getProject("legacy-project")?.initializationMode).toBe("existing"); + }); + + it("persists new local and remote initialization provenance", async () => { + const { repository } = await createRepository(); + const local = repository.createProject({ + name: "New Local Project", + sourceType: "local", + sourceRef: "/workspace/new-local-project", + initMode: "new-local", + }); + const remote = repository.createProject({ + name: "New Remote Project", + sourceType: "git", + sourceRef: "https://example.test/new-remote.git", + initMode: "new-remote", + }); + + expect(repository.getProject(local.id)?.initializationMode).toBe("new-local"); + expect(repository.getProject(remote.id)?.initializationMode).toBe("new-remote"); + }); + it("creates untitled sprints with deterministic generated names and slugs", async () => { const { repository } = await createRepository(); const project = repository.createProject({ diff --git a/tests/backend/server/project-initialization-state-routes.test.ts b/tests/backend/server/project-initialization-state-routes.test.ts new file mode 100644 index 0000000000..e37e15f0f8 --- /dev/null +++ b/tests/backend/server/project-initialization-state-routes.test.ts @@ -0,0 +1,64 @@ +import express from "express"; +import request from "supertest"; +import { describe, expect, it, vi } from "vitest"; +import type { DashboardDependencies } from "../../../src/server/dashboard-server.js"; +import { registerProjectRoutes } from "../../../src/server/project-routes.js"; + +function createApp(overrides: Partial) { + const app = express(); + app.use(express.json()); + registerProjectRoutes(app, { + listProjects: vi.fn(), + getProject: vi.fn(), + ...overrides, + } as DashboardDependencies); + return app; +} + +describe("project initialization state route", () => { + it("returns the lightweight initialization state", async () => { + const state = { + projectId: "project-1", + initializationMode: "new-local" as const, + repositoryState: "initial" as const, + canCreateInitialAppQuickactions: true, + }; + const app = createApp({ + getProject: vi.fn(() => ({ id: "project-1", initializationMode: "new-local" } as never)), + getProjectInitializationState: vi.fn(async () => state), + }); + + const response = await request(app).get("/api/projects/project-1/initialization-state"); + expect(response.status).toBe(200); + expect(response.body).toEqual(state); + }); + + it("fails closed when inspection is unavailable or the project does not exist", async () => { + const unavailableApp = createApp({ + getProject: vi.fn(() => ({ id: "project-1", initializationMode: "new-remote" } as never)), + }); + const unavailable = await request(unavailableApp).get("/api/projects/project-1/initialization-state"); + expect(unavailable.status).toBe(200); + expect(unavailable.body).toMatchObject({ + initializationMode: "new-remote", + repositoryState: "unavailable", + canCreateInitialAppQuickactions: false, + }); + + const failedInspectionApp = createApp({ + getProject: vi.fn(() => ({ id: "project-1", initializationMode: "new-local" } as never)), + getProjectInitializationState: vi.fn(async () => { throw new Error("git failed"); }), + }); + const failedInspection = await request(failedInspectionApp).get("/api/projects/project-1/initialization-state"); + expect(failedInspection.status).toBe(200); + expect(failedInspection.body).toMatchObject({ + repositoryState: "unavailable", + canCreateInitialAppQuickactions: false, + }); + + const missingApp = createApp({ getProject: vi.fn(() => null) }); + const missing = await request(missingApp).get("/api/projects/missing/initialization-state"); + expect(missing.status).toBe(404); + expect(missing.body.canCreateInitialAppQuickactions).toBe(false); + }); +}); diff --git a/tests/backend/services/project-initialization-state-service.test.ts b/tests/backend/services/project-initialization-state-service.test.ts new file mode 100644 index 0000000000..67bb94e8cc --- /dev/null +++ b/tests/backend/services/project-initialization-state-service.test.ts @@ -0,0 +1,92 @@ +import { afterEach, describe, expect, it } from "vitest"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { ProjectInitMode, ProjectSummary } from "../../../src/contracts/project-management-types.js"; +import { runCommandStrict } from "../../../src/services/cli-process-runner.js"; +import { ProjectInitializationStateService } from "../../../src/services/project-initialization-state-service.js"; + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); + +async function createSeededRepository(): Promise { + const repoPath = await fs.mkdtemp(path.join(os.tmpdir(), "code-ux-initial-state-")); + tempDirs.push(repoPath); + await runCommandStrict("git", ["init", "-b", "main"], repoPath); + await fs.writeFile(path.join(repoPath, "README.md"), "# Seeded\n\nInitialized with Code UX.\n"); + await fs.writeFile(path.join(repoPath, ".gitignore"), ".code-ux/\n"); + await runCommandStrict("git", ["add", "README.md", ".gitignore"], repoPath); + await runCommandStrict("git", ["-c", "user.name=Code UX", "-c", "user.email=codeux@example.test", "commit", "-m", "Initial commit"], repoPath); + return repoPath; +} + +function createService(projectId: string, baseDir: string, initializationMode: ProjectInitMode) { + return new ProjectInitializationStateService((id) => id === projectId ? ({ + id: projectId, + baseDir, + initializationMode, + } as Pick) : null); +} + +describe("ProjectInitializationStateService", () => { + it.each(["new-local", "new-remote"] as const)("allows a clean one-commit %s seed", async (mode) => { + const repoPath = await createSeededRepository(); + const state = await createService("project-1", repoPath, mode).getProjectInitializationState("project-1"); + + expect(state).toEqual({ + projectId: "project-1", + initializationMode: mode, + repositoryState: "initial", + canCreateInitialAppQuickactions: true, + }); + }); + + it("rejects extra tracked files", async () => { + const repoPath = await createSeededRepository(); + await fs.writeFile(path.join(repoPath, "package.json"), "{}\n"); + await runCommandStrict("git", ["add", "package.json"], repoPath); + await runCommandStrict("git", ["-c", "user.name=Code UX", "-c", "user.email=codeux@example.test", "commit", "--amend", "--no-edit"], repoPath); + + const state = await createService("project-1", repoPath, "new-local").getProjectInitializationState("project-1"); + expect(state.repositoryState).toBe("modified"); + expect(state.canCreateInitialAppQuickactions).toBe(false); + }); + + it("rejects dirty repositories and ignored setup artifacts", async () => { + const repoPath = await createSeededRepository(); + await fs.writeFile(path.join(repoPath, "README.md"), "dirty\n"); + let state = await createService("project-1", repoPath, "new-local").getProjectInitializationState("project-1"); + expect(state.repositoryState).toBe("modified"); + + await runCommandStrict("git", ["restore", "README.md"], repoPath); + await fs.mkdir(path.join(repoPath, ".code-ux")); + state = await createService("project-1", repoPath, "new-local").getProjectInitializationState("project-1"); + expect(state.repositoryState).toBe("modified"); + expect(state.canCreateInitialAppQuickactions).toBe(false); + }); + + it("rejects repositories with additional commits", async () => { + const repoPath = await createSeededRepository(); + await fs.writeFile(path.join(repoPath, "README.md"), "# Changed\n"); + await runCommandStrict("git", ["add", "README.md"], repoPath); + await runCommandStrict("git", ["-c", "user.name=Code UX", "-c", "user.email=codeux@example.test", "commit", "-m", "Change README"], repoPath); + + const state = await createService("project-1", repoPath, "new-local").getProjectInitializationState("project-1"); + expect(state.repositoryState).toBe("modified"); + expect(state.canCreateInitialAppQuickactions).toBe(false); + }); + + it("fails closed for existing projects, missing checkouts, and unknown projects", async () => { + const existing = await createService("project-1", "/not/inspected", "existing").getProjectInitializationState("project-1"); + expect(existing).toMatchObject({ repositoryState: "unavailable", canCreateInitialAppQuickactions: false }); + + const missing = await createService("project-1", "/definitely/missing", "new-local").getProjectInitializationState("project-1"); + expect(missing).toMatchObject({ repositoryState: "unavailable", canCreateInitialAppQuickactions: false }); + + const unknown = await createService("project-1", "/unused", "new-local").getProjectInitializationState("other"); + expect(unknown).toMatchObject({ initializationMode: "existing", repositoryState: "unavailable", canCreateInitialAppQuickactions: false }); + }); +}); diff --git a/tests/dashboard/v2/use-project-initialization-state.test.tsx b/tests/dashboard/v2/use-project-initialization-state.test.tsx new file mode 100644 index 0000000000..4a3799789e --- /dev/null +++ b/tests/dashboard/v2/use-project-initialization-state.test.tsx @@ -0,0 +1,62 @@ +/** @vitest-environment happy-dom */ +import { renderHook } from "@testing-library/preact"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useRealtimeResource } from "../../../dashboard/src/hooks/use-realtime-resource.js"; +import { + shouldRefreshProjectInitializationState, + useProjectInitializationState, +} from "../../../dashboard/src/v2/hooks/use-project-initialization-state.js"; + +vi.mock("../../../dashboard/src/hooks/use-realtime-resource.js", () => ({ + useRealtimeResource: vi.fn(() => ({ + data: null, + loading: false, + error: null, + refetch: vi.fn(), + })), +})); + +describe("useProjectInitializationState", () => { + beforeEach(() => vi.clearAllMocks()); + + it("refreshes on project and git realtime changes", () => { + renderHook(() => useProjectInitializationState("project-1")); + + expect(useRealtimeResource).toHaveBeenCalledWith(expect.objectContaining({ + realtime: expect.objectContaining({ + scopes: ["project:project-1", "project:project-1:git"], + shouldRefetch: shouldRefreshProjectInitializationState, + }), + })); + expect(shouldRefreshProjectInitializationState({ type: "snapshot_required", reason: "gap" })).toBe(true); + expect(shouldRefreshProjectInitializationState({ + type: "event", + event: { eventType: "project.git.updated" }, + } as never)).toBe(true); + expect(shouldRefreshProjectInitializationState({ + type: "event", + event: { eventType: "conversation.message.created" }, + } as never)).toBe(false); + }); + + it("masks stale eligible data when the current request fails", () => { + vi.mocked(useRealtimeResource).mockReturnValueOnce({ + data: { + projectId: "project-1", + initializationMode: "new-local", + repositoryState: "initial", + canCreateInitialAppQuickactions: true, + }, + loading: false, + error: "inspection failed", + refetch: vi.fn(), + } as never); + + const { result } = renderHook(() => useProjectInitializationState("project-1")); + expect(result.current.data).toMatchObject({ + projectId: "project-1", + repositoryState: "unavailable", + canCreateInitialAppQuickactions: false, + }); + }); +}); From b11aeebb1d638277442fd2790897d8b5b09e5df5 Mon Sep 17 00:00:00 2001 From: Code UX Date: Sat, 11 Jul 2026 03:40:16 +0000 Subject: [PATCH 3/8] feat(task T03): implement via codex --- docs-web/content/docs/user-dashboard-chat.mdx | 2 + .../content/docs/user-dashboard-projects.mdx | 2 +- docs-web/user/dashboard/chat.md | 2 + docs-web/user/dashboard/projects.md | 2 +- docs/architecture/chat-thread-runtime.md | 7 +- docs/dashboard/project-initialization.md | 4 +- src/contracts/connection-chat-types.ts | 2 + src/contracts/project-management-types.ts | 6 + .../chat/create-app-quickaction-catalog.ts | 6 +- src/services/chat-thread-runtime-service.ts | 61 ++++-- src/services/planning-agent-service.ts | 28 ++- .../chat-thread-runtime-service.test.ts | 174 +++++++++++++++++- .../services/planning-agent-service.test.ts | 37 +++- .../services/quicksprint-service.test.ts | 4 + 14 files changed, 305 insertions(+), 32 deletions(-) diff --git a/docs-web/content/docs/user-dashboard-chat.mdx b/docs-web/content/docs/user-dashboard-chat.mdx index 45db843b3f..0056a174e8 100644 --- a/docs-web/content/docs/user-dashboard-chat.mdx +++ b/docs-web/content/docs/user-dashboard-chat.mdx @@ -53,6 +53,8 @@ Use **Create Web App**, **Create Desktop App**, **Create Onlineshop**, **Create Clicking a quickaction starts immediately. You do not need to type composer text first, and Code UX does not show a confirmation step. If there is no active thread yet, the dashboard creates one, posts a short visible message such as `Create a web app`, and starts the matching quicksprint in detached `Plan & Start` mode while you stay in Chat. Its catalog-selected experience guidance applies to that plan only and does not change saved project settings. +**Create Web App** and **Create Desktop App** are limited to eligible initial projects, and the server verifies eligibility again when launching. **Create Onlineshop**, **Create Portfolio**, and **Create Game** can launch from normal project-scoped chat paths. Invalid template or guidance metadata fails safely instead of starting a different sprint. + The quickaction carries the active project's effective techstack into planning: the selected catalog entry when assigned, or the catalog default when the project is unassigned. Stack item labels become suggestion tags, so the progress widget and planner begin from the same stack context visible in the dashboard. The transcript then shows an app progress widget instead of raw status data. The widget reports: diff --git a/docs-web/content/docs/user-dashboard-projects.mdx b/docs-web/content/docs/user-dashboard-projects.mdx index 71094c777c..f34af7d7f2 100644 --- a/docs-web/content/docs/user-dashboard-projects.mdx +++ b/docs-web/content/docs/user-dashboard-projects.mdx @@ -16,7 +16,7 @@ Click **Add Project** to import an existing local checkout or Git URL. Imported Click **New Project** on the Projects page to initialize a new repository through the same modal. New project initialization does not scaffold application source files in the dashboard; it sends `new-local` or `new-remote` initialization data to the backend repository creation flow. -New project creation always writes an explicit project techstack override. New local projects additionally receive `git.githubMode: LOCAL`; new remote projects do not. To set up a web or desktop app inside an existing project, use **Create Web App** or **Create Desktop App** in Chat Threads beside the composer. Those quickactions operate in the selected project, create a chat thread when needed, and launch the matching detached quicksprint without opening the new-project modal. +New project creation always writes an explicit project techstack override. New local projects additionally receive `git.githubMode: LOCAL`; new remote projects do not. To set up a web or desktop app in an eligible initial project, use **Create Web App** or **Create Desktop App** in Chat Threads beside the composer. Those quickactions operate in the selected project, create a chat thread when needed, and launch the matching detached quicksprint without opening the new-project modal. Code UX persists whether a project was imported or initialized as a new local/remote repository. Initial-app quickactions are eligible only while a persisted new project is still a clean, one-commit seed containing exactly `README.md` and the Code UX `.gitignore`. Additional files, commits, setup artifacts, dirty state, missing checkouts, or inspection failures disable eligibility; imported and legacy projects are never inferred to be new from their source type or age. diff --git a/docs-web/user/dashboard/chat.md b/docs-web/user/dashboard/chat.md index 45db843b3f..0056a174e8 100644 --- a/docs-web/user/dashboard/chat.md +++ b/docs-web/user/dashboard/chat.md @@ -53,6 +53,8 @@ Use **Create Web App**, **Create Desktop App**, **Create Onlineshop**, **Create Clicking a quickaction starts immediately. You do not need to type composer text first, and Code UX does not show a confirmation step. If there is no active thread yet, the dashboard creates one, posts a short visible message such as `Create a web app`, and starts the matching quicksprint in detached `Plan & Start` mode while you stay in Chat. Its catalog-selected experience guidance applies to that plan only and does not change saved project settings. +**Create Web App** and **Create Desktop App** are limited to eligible initial projects, and the server verifies eligibility again when launching. **Create Onlineshop**, **Create Portfolio**, and **Create Game** can launch from normal project-scoped chat paths. Invalid template or guidance metadata fails safely instead of starting a different sprint. + The quickaction carries the active project's effective techstack into planning: the selected catalog entry when assigned, or the catalog default when the project is unassigned. Stack item labels become suggestion tags, so the progress widget and planner begin from the same stack context visible in the dashboard. The transcript then shows an app progress widget instead of raw status data. The widget reports: diff --git a/docs-web/user/dashboard/projects.md b/docs-web/user/dashboard/projects.md index 02bee38286..018c6e6551 100644 --- a/docs-web/user/dashboard/projects.md +++ b/docs-web/user/dashboard/projects.md @@ -16,7 +16,7 @@ Click **Add Project** to import an existing local checkout or Git URL. Imported Click **New Project** on the Projects page to initialize a new repository through the same modal. New project initialization does not scaffold application source files in the dashboard; it sends `new-local` or `new-remote` initialization data to the backend repository creation flow. -New project creation always writes an explicit project techstack override. New local projects additionally receive `git.githubMode: LOCAL`; new remote projects do not. To set up a web or desktop app inside an existing project, use **Create Web App** or **Create Desktop App** in Chat Threads beside the composer. Those quickactions operate in the selected project, create a chat thread when needed, and launch the matching detached quicksprint without opening the new-project modal. +New project creation always writes an explicit project techstack override. New local projects additionally receive `git.githubMode: LOCAL`; new remote projects do not. To set up a web or desktop app in an eligible initial project, use **Create Web App** or **Create Desktop App** in Chat Threads beside the composer. Those quickactions operate in the selected project, create a chat thread when needed, and launch the matching detached quicksprint without opening the new-project modal. Code UX persists whether a project was imported or initialized as a new local/remote repository. Initial-app quickactions are eligible only while a persisted new project is still a clean, one-commit seed containing exactly `README.md` and the Code UX `.gitignore`. Additional files, commits, setup artifacts, dirty state, missing checkouts, or inspection failures disable eligibility; imported and legacy projects are never inferred to be new from their source type or age. diff --git a/docs/architecture/chat-thread-runtime.md b/docs/architecture/chat-thread-runtime.md index 6103f85580..c67c0b6ef8 100644 --- a/docs/architecture/chat-thread-runtime.md +++ b/docs/architecture/chat-thread-runtime.md @@ -64,14 +64,17 @@ Structured dashboard replies parse provider output defensively. Some CLI provide Create-app dashboard quickactions are the narrow exception to normal routed provider replies. The dashboard posts a short visible user message first, then attaches structured metadata: - `metadata.quickaction.type = "create_app"` -- canonical `kind` of `web_app` or `desktop_app` +- canonical `kind` of `web_app`, `desktop_app`, `online_shop`, `portfolio`, or `game` (legacy Web/Desktop aliases remain accepted) - stable `requestId` - quicksprint `templateId` +- optional ID-only `designGuidance` selection - optional task count, stack summary, and suggestion tags The dashboard builds the stack summary and suggestion tags from the selected project's effective settings before posting the message. It uses the assigned techstack catalog entry when present, falls back to the catalog default when the project is unassigned, and forwards the stack item labels as suggestion tags so detached planning and the `app_progress` widget start from the same context the dashboard displays. -`ChatThreadRuntimeService.postMessage` detects this metadata after the message is stored and before the normal in-flight provider turn is created. Valid create-app quickactions do not ask for confirmation, do not route through the dashboard reply provider, and do not create a `dashboard_reply` invocation. Instead, the runtime launches `QuicksprintService.launchDetachedQuicksprint` with `submitMode: "plan_and_start"` and passes the quickaction `requestId` as the planning `clientRequestId`. +`ChatThreadRuntimeService.postMessage` detects this metadata after the message is stored and before the normal in-flight provider turn is created. The runtime resolves the action through the create-app catalog and rejects mismatched template or guidance IDs. Missing guidance metadata from older clients is normalized to the catalog selection. Web App and Desktop App additionally re-check initial-project eligibility at this runtime boundary; Online Shop, Portfolio, and Game remain available on normal project-scoped chat paths. + +Valid create-app quickactions do not ask for confirmation, do not route through the dashboard reply provider, and do not create a `dashboard_reply` invocation. Instead, the runtime launches `QuicksprintService.launchDetachedQuicksprint` with `submitMode: "plan_and_start"`, passes the quickaction `requestId` as the planning `clientRequestId`, and forwards the catalog selection as an ID-only planning override. `PlanningAgentService` resolves those IDs against the effective design-guidance catalog and overlays them only for the prompt sent to `PlanningPromptBuilder`. It never accepts instruction Markdown through this override and never writes the selection back to project settings. The detached launch creates the sprint synchronously and returns the planning request plus a completion promise while the planner continues in the background. The chat runtime then marks the quickaction message processed, posts an `app_progress` system message, and stores this slice on the thread: diff --git a/docs/dashboard/project-initialization.md b/docs/dashboard/project-initialization.md index 36c40ee00e..36510233f6 100644 --- a/docs/dashboard/project-initialization.md +++ b/docs/dashboard/project-initialization.md @@ -8,9 +8,9 @@ Project Initialization runs a repository-specific setup pass through the `Projec - Imported local projects save only a project-level `git.githubMode: LOCAL` override. The same dashboard git-mode updater synchronizes internal `git_manager`, `git_manager_local`, and `git_manager_remote` skills so local imports start with repo-local git behavior. - Imported Git URL projects do not receive git-mode or techstack overrides. They continue to inherit the remote git and unassigned techstack defaults unless the operator explicitly changes project or sprint settings or runs setup techstack detection. - `New Project` reuses the same Add Project modal with the `new_project` source selected. The modal exposes `Local Repo` / `Remote Repo` init modes instead of setup scope controls. -- Chat includes `Create Web App` and `Create Desktop App` setup quickactions for the currently selected project. Threads mode exposes them beside the composer, including empty threads, and 3D Chat keeps its idle setup chips. The thread quickactions post typed `create_app` metadata that launches the matching detached quicksprint; they do not open the new-project modal or create/import a Code UX project. +- Chat includes five create-app quickactions for the currently selected project. `Create Web App` and `Create Desktop App` are initial-project setup actions; `Create Onlineshop`, `Create Portfolio`, and `Create Game` are also available on normal project-scoped chat paths. The thread quickactions post typed `create_app` metadata that launches the matching detached quicksprint; they do not open the new-project modal or create/import a Code UX project. - Project persistence records whether creation used `existing`, `new-local`, or `new-remote`. Imported and pre-migration projects default to `existing`; source type and creation time are never used to infer new-project provenance. -- Initial-app quickactions use `GET /api/projects/:projectId/initialization-state`. Eligibility requires persisted new-project provenance, a clean checkout with exactly one commit, and exactly the seeded `README.md` plus Code UX `.gitignore`; missing repositories, setup artifacts, additional files or commits, and inspection failures return unavailable or modified state with eligibility disabled. +- Initial Web/Desktop quickactions use `GET /api/projects/:projectId/initialization-state`, and the chat runtime repeats the same eligibility check before detached launch. Eligibility requires persisted new-project provenance, a clean checkout with exactly one commit, and exactly the seeded `README.md` plus Code UX `.gitignore`; missing repositories, setup artifacts, additional files or commits, and inspection failures return unavailable or modified state with eligibility disabled. - The initialization-state dashboard resource listens to both project structure and dedicated git realtime scopes. Repository inspection remains outside project-list loading, so the project collection does not carry or compute git status payloads. - All `new_project` submissions, local or remote, include an explicit project `techstack` override. New local projects also include `git.githubMode: LOCAL`; new remote projects do not. - The `new_project` branch hides the Project Setup Agent section entirely and routes creation through the backend `initMode` fields. diff --git a/src/contracts/connection-chat-types.ts b/src/contracts/connection-chat-types.ts index b00ac816ea..30737d0a53 100644 --- a/src/contracts/connection-chat-types.ts +++ b/src/contracts/connection-chat-types.ts @@ -1,4 +1,5 @@ import type { WorkerTaskDispatchClaim } from "./execution-types.js"; +import type { PlanningDesignGuidanceSelection } from "./project-management-types.js"; export type McpConnectionRole = "project_manager" | "worker" | "listener"; export type McpConnectionStatus = "connected" | "listening" | "idle" | "paused" | "stale" | "offline"; @@ -111,6 +112,7 @@ export interface DashboardCreateAppQuickactionPayload extends JsonObject { kind: DashboardCreateAppQuickactionKind; requestId: string; templateId: string; + designGuidance?: PlanningDesignGuidanceSelection & JsonObject; taskCount?: number; stackSummary?: DashboardCreateAppQuickactionStackSummary | null; suggestionTags?: string[]; diff --git a/src/contracts/project-management-types.ts b/src/contracts/project-management-types.ts index 85f16366b2..a212f9107c 100644 --- a/src/contracts/project-management-types.ts +++ b/src/contracts/project-management-types.ts @@ -385,6 +385,12 @@ export interface PlanningOverrides { planningAgentPresetId?: string; agentRoutingMode?: AgentRoutingMode; workerAgentPresetId?: string; + designGuidance?: PlanningDesignGuidanceSelection; +} + +export interface PlanningDesignGuidanceSelection { + selectedTechStackId: string; + selectedStyleguideId: string; } export interface ImprovePromptInput { diff --git a/src/domain/chat/create-app-quickaction-catalog.ts b/src/domain/chat/create-app-quickaction-catalog.ts index 5ffe43b88c..daab46cf13 100644 --- a/src/domain/chat/create-app-quickaction-catalog.ts +++ b/src/domain/chat/create-app-quickaction-catalog.ts @@ -1,4 +1,5 @@ import type { DashboardCreateAppQuickactionKind } from "../../contracts/connection-chat-types.js"; +import type { PlanningDesignGuidanceSelection } from "../../contracts/project-management-types.js"; import { CODE_UX_AWARD_WINNING_STYLEGUIDE_ID, CODE_UX_PRODUCT_TECH_STACK_ID, @@ -8,10 +9,7 @@ import { MARKETING_SITE_STYLEGUIDE_ID, } from "../settings/design-guidance-catalog.js"; -export interface CreateAppQuickactionDesignGuidanceSelection { - selectedTechStackId: string; - selectedStyleguideId: string; -} +export type CreateAppQuickactionDesignGuidanceSelection = PlanningDesignGuidanceSelection; export interface CreateAppQuickactionSpec { kind: DashboardCreateAppQuickactionKind; diff --git a/src/services/chat-thread-runtime-service.ts b/src/services/chat-thread-runtime-service.ts index 8f7be1b669..d7e6438e60 100644 --- a/src/services/chat-thread-runtime-service.ts +++ b/src/services/chat-thread-runtime-service.ts @@ -26,6 +26,7 @@ import type { UpdateConversationThreadInput, UpdateConversationThreadRouteInput, } from "../contracts/connection-chat-types.js"; +import type { PlanningDesignGuidanceSelection, ProjectInitializationState } from "../contracts/project-management-types.js"; import { DASHBOARD_APP_PROGRESS_WIDGET_TYPE, DASHBOARD_CREATE_APP_QUICKACTION_KINDS, @@ -52,7 +53,7 @@ import type { AgentMcpAccessConfig } from "../contracts/agent-preset-types.js"; import { dashboardReplyAgentMcpAccess, isSchedulerOnlyAgentMcpAccess } from "./agent-mcp-access.js"; import { buildProviderInvocationWorkspaceOptions } from "../infrastructure/providers/cli/invocation-workspace-preparer.js"; import { getCreateAppQuickactionSpec } from "../domain/chat/create-app-quickaction-catalog.js"; -import { getDesignGuidanceCatalog } from "../domain/settings/design-guidance-catalog.js"; +import { ProjectInitializationStateService } from "./project-initialization-state-service.js"; interface ChatThreadRuntimeServiceDependencies { connectionChatRepository: ConnectionChatRepository; @@ -70,6 +71,7 @@ interface ChatThreadRuntimeServiceDependencies { getMcpConnectionInfo?: () => McpConnectionInfo | null; getMcpApprovalTracker?: () => McpApprovalTracker; runDueSchedulerEntriesAfterReply?: () => Promise; + getProjectInitializationState?: (projectId: string) => Promise; logger?: Logger; } @@ -81,6 +83,7 @@ interface NormalizedCreateAppQuickaction { kind: DashboardCreateAppQuickactionKind; requestId: string; templateId: string; + designGuidance: PlanningDesignGuidanceSelection; taskCount: number; stackSummary: DashboardCreateAppQuickactionStackSummary | null; suggestionTags: string[]; @@ -238,6 +241,27 @@ function readPositiveInteger(value: unknown, fallback: number): number { return Math.floor(value); } +function readCreateAppDesignGuidance( + value: unknown, + expected: PlanningDesignGuidanceSelection, +): PlanningDesignGuidanceSelection { + if (value === undefined) { + return { ...expected }; + } + const selection = readRecord(value); + const selectedTechStackId = readString(selection?.selectedTechStackId); + const selectedStyleguideId = readString(selection?.selectedStyleguideId); + if ( + !selectedTechStackId + || !selectedStyleguideId + || selectedTechStackId !== expected.selectedTechStackId + || selectedStyleguideId !== expected.selectedStyleguideId + ) { + throw new Error("Create app quickaction design guidance does not match its catalog entry."); + } + return { selectedTechStackId, selectedStyleguideId }; +} + function parseCreateAppQuickactionMetadata(metadata: Record | null | undefined): NormalizedCreateAppQuickaction | null { const root = readRecord(metadata); if (!root) { @@ -273,6 +297,10 @@ function parseCreateAppQuickactionMetadata(metadata: Record | n kind, requestId, templateId: spec.templateId, + designGuidance: readCreateAppDesignGuidance( + quickaction.designGuidance ?? quickaction.guidanceSelection ?? root.designGuidance ?? root.guidanceSelection, + spec.designGuidance, + ), taskCount: readPositiveInteger(quickaction.taskCount ?? root.taskCount, 5), stackSummary: readCreateAppStackSummary(quickaction.stackSummary ?? root.stackSummary, kind), suggestionTags: readStringList(quickaction.suggestionTags ?? root.suggestionTags), @@ -307,9 +335,14 @@ async function writeThreadSessionTitleFile(repoPath: string, threadId: string, t export class ChatThreadRuntimeService { private readonly inFlightTurns = new Map(); + private readonly projectInitializationStateService: ProjectInitializationStateService; private quicksprintLauncher: ChatCreateAppQuicksprintLauncher | null = null; - constructor(private readonly deps: ChatThreadRuntimeServiceDependencies) {} + constructor(private readonly deps: ChatThreadRuntimeServiceDependencies) { + this.projectInitializationStateService = new ProjectInitializationStateService( + (projectId) => this.deps.projectManagementRepository.getProject(projectId), + ); + } public setQuicksprintLauncher(launcher: ChatCreateAppQuicksprintLauncher): void { this.quicksprintLauncher = launcher; @@ -692,11 +725,23 @@ export class ChatThreadRuntimeService { throw new Error("Create-app quickactions are not available until quicksprint launch is initialized."); } + if (quickaction.kind === "web_app" || quickaction.kind === "desktop_app") { + const initializationState = this.deps.getProjectInitializationState + ? await this.deps.getProjectInitializationState(projectId) + : await this.projectInitializationStateService.getProjectInitializationState(projectId); + if (!initializationState.canCreateInitialAppQuickactions) { + throw new Error(`${getCreateAppQuickactionSpec(quickaction.kind).displayLabel} is only available for an eligible initial project.`); + } + } + const launch = await this.quicksprintLauncher.launchDetachedQuicksprint(projectId, { templateId: quickaction.templateId, taskCount: quickaction.taskCount, submitMode: "plan_and_start", clientRequestId: quickaction.requestId, + planningOverrides: { + designGuidance: quickaction.designGuidance, + }, additionalPrompt: this.buildCreateAppAdditionalPrompt(userMessage.bodyMarkdown, quickaction), }); @@ -1083,16 +1128,8 @@ export class ChatThreadRuntimeService { private buildCreateAppAdditionalPrompt(bodyMarkdown: string, quickaction: NormalizedCreateAppQuickaction): string { const spec = getCreateAppQuickactionSpec(quickaction.kind); - const appLabel = spec.kind === "web_app" || spec.kind === "desktop_app" - ? `${spec.appKindLabel.slice(0, -3).toLowerCase()}application` - : spec.appKindLabel.toLowerCase(); + const appLabel = spec.appKindLabel.toLowerCase(); const appArticle = /^[aeiou]/i.test(appLabel) ? "an" : "a"; - const guidanceCatalog = getDesignGuidanceCatalog(); - const techStackGuidance = guidanceCatalog.techStacks.find((entry) => entry.id === spec.designGuidance.selectedTechStackId); - const styleguideGuidance = guidanceCatalog.styleguides.find((entry) => entry.id === spec.designGuidance.selectedStyleguideId); - if (!techStackGuidance || !styleguideGuidance) { - throw new Error(`Create app quickaction guidance is unavailable for ${quickaction.kind}.`); - } const stackLines = this.formatCreateAppStackSummary(quickaction.stackSummary); const suggestionLine = quickaction.suggestionTags.length > 0 ? `Suggestion tags from the dashboard: ${quickaction.suggestionTags.join(", ")}.` @@ -1102,8 +1139,6 @@ export class ChatThreadRuntimeService { "Dashboard create-app quickaction.", `Quickaction request id: ${quickaction.requestId}.`, `Create an app sprint for ${appArticle} ${appLabel}.`, - `Apply tech-stack guidance \`${techStackGuidance.id}\` (${techStackGuidance.name}):\n${techStackGuidance.instructionMarkdown}`, - `Apply styleguide guidance \`${styleguideGuidance.id}\` (${styleguideGuidance.name}):\n${styleguideGuidance.instructionMarkdown}`, stackLines ? `Suggested stack summary:\n${stackLines}` : "No suggested stack summary was provided; infer the right stack from the selected project before planning.", suggestionLine, `Original dashboard message:\n${bodyMarkdown.trim()}`, diff --git a/src/services/planning-agent-service.ts b/src/services/planning-agent-service.ts index 8196f94f6f..45da7a98be 100644 --- a/src/services/planning-agent-service.ts +++ b/src/services/planning-agent-service.ts @@ -1,7 +1,7 @@ import { randomUUID } from "crypto"; import type { AgentPresetRecord } from "../contracts/agent-preset-types.js"; import type { MemoryService } from "./memory-service.js"; -import type { CliWorkflowSettings, DashboardSettings, ProviderId, QwenModelProviderSettings, Subtask } from "../contracts/app-types.js"; +import type { CliWorkflowSettings, DashboardSettings, DesignGuidanceSettings, ProviderId, QwenModelProviderSettings, Subtask } from "../contracts/app-types.js"; import type { TaskExecutorType, TaskPriority, @@ -40,6 +40,7 @@ import { waitUntil } from "../shared/polling/wait-until.js"; import { LEARNINGS_FILENAME } from "../contracts/memory-types.js"; import * as PlanningPromptBuilder from "./planning-prompt-builder.js"; import { buildRelevantMemoryInjectionContext } from "./memory-injection-context.js"; +import { getDesignGuidanceCatalog } from "../domain/settings/design-guidance-catalog.js"; interface PlanningAgentServiceDeps { projectManagementRepository: ProjectManagementRepository; @@ -199,7 +200,7 @@ export class PlanningAgentService { planningAgent, sprintName: input.name, goal: input.goal, - designGuidance: runtime.settings.designGuidance, + designGuidance: this.resolveEffectiveDesignGuidance(runtime.settings.designGuidance, input.overrides), memoryContext, learningsInstruction, }); @@ -377,7 +378,7 @@ export class PlanningAgentService { sprintName: sprint.name, canSetSprintTitle: sprint.isGeneratedName, goal: sprint.goal, - designGuidance: runtime.settings.designGuidance, + designGuidance: this.resolveEffectiveDesignGuidance(runtime.settings.designGuidance, options.overrides), memoryContext, learningsInstruction, }); @@ -579,6 +580,27 @@ export class PlanningAgentService { }; } + private resolveEffectiveDesignGuidance( + persisted: DesignGuidanceSettings, + overrides?: PlanningOverrides, + ): DesignGuidanceSettings { + const selection = overrides?.designGuidance; + if (!selection) { + return persisted; + } + const catalog = getDesignGuidanceCatalog(persisted); + const hasTechStack = catalog.techStacks.some((entry) => entry.id === selection.selectedTechStackId); + const hasStyleguide = catalog.styleguides.some((entry) => entry.id === selection.selectedStyleguideId); + if (!hasTechStack || !hasStyleguide) { + throw new Error("Planning design guidance selection is not available in the effective catalog."); + } + return { + ...persisted, + selectedTechStackId: selection.selectedTechStackId, + selectedStyleguideId: selection.selectedStyleguideId, + }; + } + private getTaskCodingRoutingMode(settings: DashboardSettings, overrides?: PlanningOverrides): "MANUAL" | "ORCHESTRATOR" { return overrides?.agentRoutingMode || settings.agents?.routing?.taskCoding?.mode || "MANUAL"; } diff --git a/tests/backend/services/chat-thread-runtime-service.test.ts b/tests/backend/services/chat-thread-runtime-service.test.ts index 2c6c086b57..3ca8ca993a 100644 --- a/tests/backend/services/chat-thread-runtime-service.test.ts +++ b/tests/backend/services/chat-thread-runtime-service.test.ts @@ -4,6 +4,7 @@ import * as os from "os"; import * as path from "path"; import { ChatThreadRuntimeService } from "../../../src/services/chat-thread-runtime-service.js"; import { codeUxAgentMcpAccess, dashboardReplyAgentMcpAccess } from "../../../src/services/agent-mcp-access.js"; +import { CREATE_APP_QUICKACTION_CATALOG } from "../../../src/domain/chat/create-app-quickaction-catalog.js"; describe("ChatThreadRuntimeService", () => { let deps: any; @@ -54,6 +55,12 @@ describe("ChatThreadRuntimeService", () => { chatProviderOutboundService: { deliverReply: vi.fn().mockResolvedValue(null), }, + getProjectInitializationState: vi.fn().mockResolvedValue({ + projectId: "p1", + initializationMode: "new-local", + repositoryState: "initial", + canCreateInitialAppQuickactions: true, + }), }; service = new ChatThreadRuntimeService(deps); }); @@ -116,6 +123,10 @@ describe("ChatThreadRuntimeService", () => { kind: "web_app", requestId: "quickaction-web-1", templateId: "qs-create-web-app", + designGuidance: { + selectedTechStackId: "code-ux-product-stack", + selectedStyleguideId: "code-ux-award-winning", + }, taskCount: 6, stackSummary: { techstackId: "preact-fullstack", @@ -177,7 +188,13 @@ describe("ChatThreadRuntimeService", () => { taskCount: 6, submitMode: "plan_and_start", clientRequestId: "quickaction-web-1", - additionalPrompt: expect.stringContaining("Create an app sprint for a web application."), + planningOverrides: { + designGuidance: { + selectedTechStackId: "code-ux-product-stack", + selectedStyleguideId: "code-ux-award-winning", + }, + }, + additionalPrompt: expect.stringContaining("Create an app sprint for a web app."), })); const launchInput = quicksprintLauncher.launchDetachedQuicksprint.mock.calls[0][1]; expect(launchInput.additionalPrompt).toContain("answer quickly"); @@ -218,6 +235,161 @@ describe("ChatThreadRuntimeService", () => { expect(deps.chatManagementActionService.processManagementAction).not.toHaveBeenCalled(); }); + it.each(CREATE_APP_QUICKACTION_CATALOG)( + "launches $kind with its catalog template, scoped guidance, and progress label", + async (spec) => { + const requestId = `quickaction-${spec.kind}-catalog`; + const metadata = { + quickaction: { + type: "create_app", + kind: spec.kind, + requestId, + templateId: spec.templateId, + designGuidance: spec.designGuidance, + }, + }; + const quicksprintLauncher = { + launchDetachedQuicksprint: vi.fn().mockResolvedValue({ + sprint: { id: `sprint-${spec.kind}`, name: `QS: ${spec.displayLabel}` }, + planningRequest: { + projectId: "p1", + sprintId: `sprint-${spec.kind}`, + templateId: spec.templateId, + submitMode: "plan_and_start", + clientRequestId: requestId, + planOptions: { autoStart: true, replan: false, clientRequestId: requestId }, + }, + planningPromise: new Promise(() => undefined), + }), + }; + service.setQuicksprintLauncher(quicksprintLauncher); + deps.connectionChatRepository.postDashboardMessage.mockReturnValue({ + id: `msg-${spec.kind}`, + threadId: "t-app", + bodyMarkdown: spec.displayLabel, + metadata, + }); + deps.connectionChatRepository.getThread.mockReturnValue({ + id: "t-app", + projectId: "p1", + connectionId: null, + title: spec.displayLabel, + runtimeState: {}, + }); + + await service.postMessage("p1", { bodyMarkdown: spec.displayLabel, metadata }); + + expect(quicksprintLauncher.launchDetachedQuicksprint).toHaveBeenCalledWith("p1", expect.objectContaining({ + templateId: spec.templateId, + submitMode: "plan_and_start", + clientRequestId: requestId, + planningOverrides: { designGuidance: spec.designGuidance }, + })); + expect(deps.connectionChatRepository.postSystemMessage).toHaveBeenCalledWith("p1", expect.objectContaining({ + bodyMarkdown: expect.stringContaining(`Started a ${spec.appKindLabel.toLowerCase()} sprint`), + metadata: { + widget_metadata: expect.objectContaining({ appKind: spec.kind, quickactionRequestId: requestId }), + }, + })); + }, + ); + + it.each(["web_app", "desktop_app"] as const)( + "fails %s safely when the project is not eligible for an initial app", + async (kind) => { + const spec = CREATE_APP_QUICKACTION_CATALOG.find((entry) => entry.kind === kind)!; + const metadata = { + quickaction: { type: "create_app", kind, requestId: `request-${kind}`, templateId: spec.templateId }, + }; + const quicksprintLauncher = { launchDetachedQuicksprint: vi.fn() }; + service.setQuicksprintLauncher(quicksprintLauncher); + deps.getProjectInitializationState.mockResolvedValue({ + projectId: "p1", + initializationMode: "existing", + repositoryState: "unavailable", + canCreateInitialAppQuickactions: false, + }); + deps.connectionChatRepository.postDashboardMessage.mockReturnValue({ + id: `msg-${kind}`, + threadId: "t-app", + bodyMarkdown: spec.displayLabel, + metadata, + }); + deps.connectionChatRepository.getThread.mockReturnValue({ + id: "t-app", + projectId: "p1", + connectionId: null, + runtimeState: {}, + }); + + const result = await service.postMessage("p1", { bodyMarkdown: spec.displayLabel, metadata }); + + expect(result.deliveryStatus).toBe("failed"); + expect(quicksprintLauncher.launchDetachedQuicksprint).not.toHaveBeenCalled(); + expect(deps.connectionChatRepository.postSystemMessage).toHaveBeenCalledWith("p1", expect.objectContaining({ + bodyMarkdown: expect.stringContaining(`${spec.displayLabel} is only available for an eligible initial project.`), + })); + }, + ); + + it.each(CREATE_APP_QUICKACTION_CATALOG.filter((spec) => !["web_app", "desktop_app"].includes(spec.kind)))( + "allows $kind on a normal project chat path without the initial-project guard", + async (spec) => { + const metadata = { + quickaction: { type: "create_app", kind: spec.kind, requestId: `request-${spec.kind}`, templateId: spec.templateId }, + }; + const quicksprintLauncher = { + launchDetachedQuicksprint: vi.fn().mockResolvedValue({ + sprint: { id: `sprint-${spec.kind}`, name: `QS: ${spec.displayLabel}` }, + planningRequest: { clientRequestId: `request-${spec.kind}` }, + planningPromise: new Promise(() => undefined), + }), + }; + service.setQuicksprintLauncher(quicksprintLauncher); + deps.connectionChatRepository.postDashboardMessage.mockReturnValue({ + id: `msg-${spec.kind}`, + threadId: "t-app", + bodyMarkdown: spec.displayLabel, + metadata, + }); + deps.connectionChatRepository.getThread.mockReturnValue({ id: "t-app", projectId: "p1", runtimeState: {} }); + + await service.postMessage("p1", { bodyMarkdown: spec.displayLabel, metadata }); + + expect(deps.getProjectInitializationState).not.toHaveBeenCalled(); + expect(quicksprintLauncher.launchDetachedQuicksprint).toHaveBeenCalledTimes(1); + }, + ); + + it.each([ + ["template", { templateId: "qs-create-game" }], + ["guidance", { designGuidance: { selectedTechStackId: "client-markdown", selectedStyleguideId: "code-ux-award-winning" } }], + ] as const)("rejects invalid create-app %s metadata", async (_label, override) => { + const metadata = { + quickaction: { + type: "create_app", + kind: "web_app", + requestId: "invalid-request", + templateId: "qs-create-web-app", + ...override, + }, + }; + const quicksprintLauncher = { launchDetachedQuicksprint: vi.fn() }; + service.setQuicksprintLauncher(quicksprintLauncher); + deps.connectionChatRepository.postDashboardMessage.mockReturnValue({ + id: "msg-invalid", + threadId: "t-app", + bodyMarkdown: "Create a web app", + metadata, + }); + deps.connectionChatRepository.getThread.mockReturnValue({ id: "t-app", projectId: "p1", runtimeState: {} }); + + const result = await service.postMessage("p1", { bodyMarkdown: "Create a web app", metadata }); + + expect(result.deliveryStatus).toBe("failed"); + expect(quicksprintLauncher.launchDetachedQuicksprint).not.toHaveBeenCalled(); + }); + it("queues normal chat follow-ups while create-app planning has no tasks yet", async () => { const runtimeState = { createAppQuickaction: { diff --git a/tests/backend/services/planning-agent-service.test.ts b/tests/backend/services/planning-agent-service.test.ts index 960c9f54d4..b85a910dc5 100644 --- a/tests/backend/services/planning-agent-service.test.ts +++ b/tests/backend/services/planning-agent-service.test.ts @@ -350,7 +350,15 @@ describe("PlanningAgentService", () => { expect(improved.goal).toBe("Virtual worker improved sprint prompt."); expect(improved.workerConnectionId).toBeNull(); - const planned = await service.planSprint(project.id, sprint.id, { autoStart: true }); + const planned = await service.planSprint(project.id, sprint.id, { + autoStart: true, + overrides: { + designGuidance: { + selectedTechStackId: "code-ux-product-stack", + selectedStyleguideId: "game-experience", + }, + }, + }); expect(planned.createdTaskIds).toHaveLength(1); expect(executionControlService.orchestrateSprint).toHaveBeenCalledWith(project.id, sprint.id); @@ -362,10 +370,14 @@ describe("PlanningAgentService", () => { expect(planPrompt).toContain("## Example Output B"); expect(planPrompt).toContain("## Objective\\n...\\n\\n## Scope"); expect(planPrompt).toContain("## Project Guidance"); - expect(planPrompt).toContain("Name: Planning Service Stack"); - expect(planPrompt).toContain("Prefer typed service boundaries and Vitest verification."); - expect(planPrompt).toContain("Name: Planning Product Style"); - expect(planPrompt).toContain("Preserve existing tokens, focus states, and responsive layouts."); + expect(planPrompt).toContain("Name: Code UX Stack"); + expect(planPrompt).toContain("Name: Game Experience"); + expect(planPrompt).not.toContain("Prefer typed service boundaries and Vitest verification."); + expect(planPrompt).not.toContain("Preserve existing tokens, focus states, and responsive layouts."); + expect(settingsRepository.resolveProjectDashboardSettings(project.id).settings.designGuidance).toMatchObject({ + selectedTechStackId: "planning-stack", + selectedStyleguideId: "planning-style", + }); const createdTasks = projectRepository.listTasks(project.id, sprint.id); expect(createdTasks).toHaveLength(1); expect(createdTasks[0]?.title).toBe("Plan via virtual worker"); @@ -415,6 +427,21 @@ describe("PlanningAgentService", () => { }), }), ])); + + const invalidGuidanceSprint = projectRepository.createSprint(project.id, { + name: "Invalid Guidance Sprint", + goal: "Do not accept client-authored guidance IDs.", + }); + await expect(service.planSprint(project.id, invalidGuidanceSprint.id, { + autoStart: false, + overrides: { + designGuidance: { + selectedTechStackId: "client-instruction-markdown", + selectedStyleguideId: "game-experience", + }, + }, + })).rejects.toThrow("Planning design guidance selection is not available in the effective catalog."); + expect(providerRunner.runProviderForText).toHaveBeenCalledTimes(2); }); it("retries virtual planning on rate limit and records invocation error metadata", async () => { diff --git a/tests/backend/services/quicksprint-service.test.ts b/tests/backend/services/quicksprint-service.test.ts index 5ad3dd0f21..14a33b4ece 100644 --- a/tests/backend/services/quicksprint-service.test.ts +++ b/tests/backend/services/quicksprint-service.test.ts @@ -404,6 +404,10 @@ describe("QuicksprintService", () => { const planningOverrides = { virtualProvider: "codex", virtualModel: "gpt-5", + designGuidance: { + selectedTechStackId: "code-ux-product-stack", + selectedStyleguideId: "game-experience", + }, } as const; await service.launchDetachedQuicksprint(projectId, { From 3adb1e833713f9cfce59f835a71576999ef45dc6 Mon Sep 17 00:00:00 2001 From: Code UX Date: Sat, 11 Jul 2026 03:51:59 +0000 Subject: [PATCH 4/8] feat(task T04): implement via codex --- .../src/v2/hooks/use-chat-thread-data.ts | 7 +- .../src/v2/lib/chat-widget-view-models.ts | 46 +++---- .../content/docs/user-dashboard-sprints.mdx | 2 +- docs-web/user/dashboard/sprints.md | 2 +- docs/dashboard/dashboard-guide.md | 2 +- docs/dashboard/design-system-chat.md | 6 +- .../v2/chat-message-bubbles.test.tsx | 57 +++++++++ .../dashboard/v2/use-chat-page-data.test.tsx | 114 +++++++++++++----- 8 files changed, 175 insertions(+), 61 deletions(-) diff --git a/dashboard/src/v2/hooks/use-chat-thread-data.ts b/dashboard/src/v2/hooks/use-chat-thread-data.ts index c736863be5..213efdc1a7 100644 --- a/dashboard/src/v2/hooks/use-chat-thread-data.ts +++ b/dashboard/src/v2/hooks/use-chat-thread-data.ts @@ -216,6 +216,7 @@ const buildCreateAppQuickactionMetadata = ( kind, requestId: createQuickactionRequestId(kind), templateId: spec.templateId, + designGuidance: { ...spec.designGuidance }, stackSummary: buildCreateAppStackSummary(kind, id, entry), suggestionTags: uniqueStackSuggestionTags(entry), }, @@ -956,9 +957,13 @@ export const useChatThreadData = (options: { const appKindLabel = getCreateAppQuickactionSpec(kind).appKindLabel.toLowerCase(); const bodyMarkdown = `Create ${kind === "online_shop" ? "an" : "a"} ${appKindLabel}`; + const composerDraft = inputRef.current; setSending(true); try { const thread = selectedThread || await createThreadForCompose(); + if (!selectedThread) { + setInput(composerDraft); + } const created = await postConversationMessage(selectedProject.id, { threadId: thread.id, bodyMarkdown, @@ -979,7 +984,7 @@ export const useChatThreadData = (options: { } finally { setSending(false); } - }, [cache, composerRef, createThreadForCompose, dashboardSettings, onMessageSent, selectedProject, selectedThread, sending, setMessagesSnapshot]); + }, [cache, composerRef, createThreadForCompose, dashboardSettings, onMessageSent, selectedProject, selectedThread, sending, setInput, setMessagesSnapshot]); const handleDeleteThread = useCallback(async (threadId: string): Promise => { const nextThreads = removeThread(cache.getThreads(selectedProject?.id || "") || threadsRef.current, threadId); diff --git a/dashboard/src/v2/lib/chat-widget-view-models.ts b/dashboard/src/v2/lib/chat-widget-view-models.ts index cf5f416544..5fb0262be7 100644 --- a/dashboard/src/v2/lib/chat-widget-view-models.ts +++ b/dashboard/src/v2/lib/chat-widget-view-models.ts @@ -1,4 +1,10 @@ -import type { ChatMessageRecord, ConversationRuntimeState, ExecutionInvocationMessageRecord, Task } from "../types.js"; +import type { + ChatMessageRecord, + ConversationRuntimeState, + DashboardCreateAppQuickactionKind, + ExecutionInvocationMessageRecord, + Task, +} from "../types.js"; import type { ExecutionDashboardSnapshot, ExecutionSprintRunSummary, @@ -6,6 +12,10 @@ import type { import type { ExecutionStatus } from "../components/chat/widgets/ChatWidgetFrame.js"; import { formatChatTime } from "./chat-time.js"; import { buildLiveSessionTasks } from "./live-session-task-structure.js"; +import { + CREATE_APP_QUICKACTION_CATALOG, + getCreateAppQuickactionSpec, +} from "../../../../src/domain/chat/create-app-quickaction-catalog.js"; export type ChatWidgetType = "planning" | "app_creation_progress" | "external_reference" | "none"; @@ -31,7 +41,7 @@ export interface ChatWidgetState { suppressBodyMarkdown?: boolean; } -export type AppCreationKind = "web_app" | "desktop_app" | "online_shop" | "portfolio" | "game" | "unknown"; +export type AppCreationKind = DashboardCreateAppQuickactionKind | "unknown"; export type AppCreationProgressStageStatus = "pending" | "running" | "completed" | "failed"; @@ -493,40 +503,24 @@ const normalizeWidgetExecutionStatus = (value: unknown, fallback: ExecutionStatu const normalizeAppCreationKind = (value: unknown): AppCreationKind => { const normalized = readString(value)?.toLowerCase().replace(/[\s-]+/g, "_") ?? ""; - if (normalized === "web_app" || normalized === "web") { + const catalogKind = CREATE_APP_QUICKACTION_CATALOG.find(({ kind }) => kind === normalized)?.kind; + if (catalogKind) { + return catalogKind; + } + if (normalized === "web") { return "web_app"; } - if (normalized === "desktop_app" || normalized === "desktop") { + if (normalized === "desktop") { return "desktop_app"; } - if (normalized === "online_shop" || normalized === "shop" || normalized === "online_store") { + if (normalized === "shop" || normalized === "online_store") { return "online_shop"; } - if (normalized === "portfolio") { - return "portfolio"; - } - if (normalized === "game") { - return "game"; - } return "unknown"; }; const formatAppCreationKindLabel = (kind: AppCreationKind): string => { - switch (kind) { - case "web_app": - return "Web app"; - case "desktop_app": - return "Desktop app"; - case "online_shop": - return "Online shop"; - case "portfolio": - return "Portfolio"; - case "game": - return "Game"; - case "unknown": - default: - return "App"; - } + return kind === "unknown" ? "App" : getCreateAppQuickactionSpec(kind).appKindLabel; }; const formatAppCreationStatusLabel = (status: ExecutionStatus, appKindLabel: string): string => { diff --git a/docs-web/content/docs/user-dashboard-sprints.mdx b/docs-web/content/docs/user-dashboard-sprints.mdx index eef692f6ec..1bb2bb5d5c 100644 --- a/docs-web/content/docs/user-dashboard-sprints.mdx +++ b/docs-web/content/docs/user-dashboard-sprints.mdx @@ -73,7 +73,7 @@ The plan is persisted as markdown files at `/.code-ux/sprints/sprint-/< ## Chat-created app sprints -The Chat page can create app-building sprints through **Create Web App** and **Create Desktop App** quickactions. Those quickactions start a detached `Plan & Start` quicksprint from the selected project and show planning progress in the chat transcript while the sprint appears here like any other sprint. +The Chat page can create app-building sprints through **Create Web App**, **Create Desktop App**, **Create Onlineshop**, **Create Portfolio**, and **Create Game** quickactions. Each action starts its shared-catalog detached `Plan & Start` quicksprint with request-scoped experience guidance and shows planning progress in the chat transcript while the sprint appears here like any other sprint. If you send more direction in the same chat thread while planning is still running, Code UX applies that text to the sprint goal under `Additional direction from chat` after planning finishes. Once the sprint already has tasks, new chat follow-ups are appended to the sprint goal immediately. The generated task prompts are left unchanged, so use the sprint goal as the record of later direction and re-plan only when you want subtasks regenerated. diff --git a/docs-web/user/dashboard/sprints.md b/docs-web/user/dashboard/sprints.md index 83f248a710..0a5751aa53 100644 --- a/docs-web/user/dashboard/sprints.md +++ b/docs-web/user/dashboard/sprints.md @@ -73,7 +73,7 @@ The plan is persisted as markdown files at `/.code-ux/sprints/sprint-/< ## Chat-created app sprints -The Chat page can create app-building sprints through **Create Web App** and **Create Desktop App** quickactions. Those quickactions start a detached `Plan & Start` quicksprint from the selected project and show planning progress in the chat transcript while the sprint appears here like any other sprint. +The Chat page can create app-building sprints through **Create Web App**, **Create Desktop App**, **Create Onlineshop**, **Create Portfolio**, and **Create Game** quickactions. Each action starts its shared-catalog detached `Plan & Start` quicksprint with request-scoped experience guidance and shows planning progress in the chat transcript while the sprint appears here like any other sprint. If you send more direction in the same chat thread while planning is still running, Code UX applies that text to the sprint goal under `Additional direction from chat` after planning finishes. Once the sprint already has tasks, new chat follow-ups are appended to the sprint goal immediately. The generated task prompts are left unchanged, so use the sprint goal as the record of later direction and re-plan only when you want subtasks regenerated. diff --git a/docs/dashboard/dashboard-guide.md b/docs/dashboard/dashboard-guide.md index 9cb0c8040a..3c39814968 100644 --- a/docs/dashboard/dashboard-guide.md +++ b/docs/dashboard/dashboard-guide.md @@ -124,7 +124,7 @@ Project management: - Reads or saves the current dashboard user's unsent chat composer draft for a context key such as `new-thread` or `thread:`; the dashboard also keeps a scoped browser-local fallback so recent keystrokes survive refresh before the backend write completes - `POST /api/projects/:projectId/conversations/messages` - Stores a dashboard-authored message and queues it for a listener - - Create-app quickactions use the same endpoint with `metadata.quickaction.type = "create_app"`, `kind` (`web_app` or `desktop_app`), `requestId`, `templateId`, stack summary, and suggestion tags. The server persists the dashboard message, bypasses normal provider reply routing, launches the matching detached quicksprint in `Plan & Start` mode, posts an `app_progress` widget, and tracks follow-up handling in thread `runtimeState.createAppQuickaction`. + - Create-app quickactions use the same endpoint with `metadata.quickaction.type = "create_app"`, a shared-catalog `kind` (`web_app`, `desktop_app`, `online_shop`, `portfolio`, or `game`), `requestId`, `templateId`, request-scoped design guidance, stack summary, and suggestion tags. The server persists the dashboard message, bypasses normal provider reply routing, launches the matching detached quicksprint in `Plan & Start` mode, posts an `app_progress` widget, and tracks follow-up handling in thread `runtimeState.createAppQuickaction`. - Stores a dashboard-authored message and queues it for a listener or provider-backed dashboard reply - Chat message posts update the selected thread message cache from the returned `ConversationMessageRecord`; the Chat invocation rail remains backed by `GET /api/projects/:projectId/execution/invocations` snapshots and realtime refreshes, so invocation rows are server-created and the browser does not create a frontend-only optimistic invocation placeholder while the backend record is still being persisted. - Threads now remain explicitly `unassigned` until the dashboard targets a connection or a real listener claims them diff --git a/docs/dashboard/design-system-chat.md b/docs/dashboard/design-system-chat.md index 32700722d8..a503b32a47 100644 --- a/docs/dashboard/design-system-chat.md +++ b/docs/dashboard/design-system-chat.md @@ -20,8 +20,6 @@ The chat and invocation design system for the Code UX dashboard defines the layo - **Project-manager asides**: Assistant prose bubbles may append a short italic project-manager thought below the provider markdown. The aside first uses safe explicit metadata (`metadata.moodComment`, `metadata.thinkingLine`, or `metadata.pmAside`), then falls back to a deterministic entry from the shared `mood` humor catalog. It is an adjunct only: provider text, widgets, timestamps, status badges, avatars, and delivery state remain unchanged. User, system, tool, reasoning, tool-call, and tool-result cards never render these asides. - **Widgets**: specialized components (Routing, Planning, Container) embedded within the stream to provide rich status and execution context without cluttering the text transcript. They use a unified visual language (`ChatWidgetFrame`). - **Planning status**: planning widgets prefer live project state when available. The view-model resolves a sprint from message metadata (`sprintId`, `sprintRunId`, or planning widget metadata) or from the active execution run, then combines project task records with the execution snapshot's dispatches and runtime events. The card shows sprint key/name, request/task/run materialization, a real `progressbar`, queued/completed counts, and per-task status labels. Both project task records and the execution snapshot must report loaded for the active project before the live card is rendered; otherwise the widget falls back to the generic planning card instead of rendering partial or invented progress. - - **App creation progress**: create-app quickactions render `app_progress` system metadata through `AppCreationProgressWidget` instead of raw JSON. The widget sits inside the normal message bubble slot with `ChatWidgetFrame`, uses the runtime status tone from metadata, and keeps a compact header containing the app kind and sprint status. Its body shows the sprint label, a visible status pill, a two-column stack summary when fields are present, a stage rail, and optional suggestion tags. The stage rail always has stable Planning, Plan, Showing each Task, Start, and Finish rows; backend-supplied stages override matching rows and custom stages are preserved before Finish. Unknown or malformed fields fall back to readable defaults, and stage correctness comes only from metadata rather than timers. - - **App creation accessibility**: `AppCreationProgressWidget` exposes the stage rail as an ordered list labelled with the app kind, marks the running stage with `aria-current="step"`, uses text labels next to every icon/color state, and disables spinner animation under reduced motion. Suggestion tags are grouped under the label "Suggested follow-up directions" and must wrap without clipping. - **Live entity status**: rich chat widgets can render live sprint/task status cards when transcript text or metadata resolves to real selected-project records. `LiveEntityStatusWidget` is the reusable presentational card set for those references. It consumes `ChatLiveEntityWidget[]` from the pure resolver, wraps content in `ChatWidgetFrame`, uses `StatusDot` plus explicit text labels, and keeps each linked sprint/task accessible by announcing kind, key/title, and live status. Sprint cards link by sprint key when available; task cards link by sprint id plus task record id. The component is intentionally data-fetch free; `ChatPage` resolves widgets from project-scoped live sprint/task records before passing them into thread and invocation bubbles. - **App creation progress**: create-app quickactions render `app_progress` system metadata through `AppCreationProgressWidget` instead of raw JSON. The widget sits inside the normal message bubble slot with `ChatWidgetFrame`, uses the runtime status tone from metadata, and keeps a compact header containing the app kind and sprint status. Its body shows the sprint label, a visible status pill, a two-column stack summary when fields are present, a stage rail, and optional suggestion tags. The stage rail always has stable Planning, Plan, Showing each Task, Start, and Finish rows; backend-supplied stages override matching rows and custom stages are preserved before Finish. Unknown or malformed fields fall back to readable defaults, and stage correctness comes only from metadata rather than timers. - **App creation accessibility**: `AppCreationProgressWidget` exposes the stage rail as an ordered list labelled with the app kind, marks the running stage with `aria-current="step"`, uses text labels next to every icon/color state, and disables spinner animation under reduced motion. Suggestion tags are grouped under the label "Suggested follow-up directions" and must wrap without clipping. @@ -55,7 +53,7 @@ The chat page opens in a cinematic "3D Chat" stage (`components/chat/cinematic/C - **Floating composer**: a bottom-center glass pill shared with the Threads data flow — Enter sends, first send auto-creates the thread, ArrowUp/Down recalls history. - **Expressions catalog**: the avatar vocabulary (SVG + WebGL, kept in sync) now includes `curious`, `thinking`, `excited`, `laughing`, `surprised`, `wink`, `dance`, and `proud` in addition to the original eight. - **Work tools**: while the runtime is in the working phase the bot pulls an animated 3D tool from its toolbox beside itself — cordless screwdriver (spinning bit), jackhammer (hammering chisel + judder), wrench (ratcheting swing), welding torch (flickering glow) — swapping every ~7s (`tool` prop on `AgentAvatarScene`; pin one for design review with `/chat?stageTool=`). -- **Idle quick actions**: six glass chips float around the idle bot (web app setup, desktop app setup, status report, sprint progress, what's failing, plan next steps). Clicking **sends the prompt immediately** via `handleSend(overrideText)` — no composer round-trip. Web and desktop app quickactions can attach `create_app` metadata with a stable request id, app kind, quicksprint template id, stack summary, and suggestion tags; the backend marks the chat message processed, launches the matching detached quicksprint with `submitMode: "plan_and_start"`, and posts an `app_progress` system widget while planning continues. Plain follow-up chat in the same thread is queued until planning finishes if no tasks exist yet, then appended to the sprint-level goal under `Additional direction from chat`; once tasks exist, follow-ups append immediately and the backend posts a confirmation. The backend re-reads the thread before writing queued follow-ups or planning-completion state, and re-checks task count after queue writes, so follow-ups posted as planning resolves or as tasks materialize are preserved and appended. The widget metadata moves to `completed` or `failed` when detached planning settles. Non-app quickactions still use the normal routed chat reply path. They hide while sending/working/error and on small screens. +- **Idle quick actions**: six glass chips float around the idle bot (web app setup, desktop app setup, status report, sprint progress, what's failing, plan next steps). Clicking **sends the prompt immediately** via `handleSend(overrideText)` — no composer round-trip. The original Web/Desktop setup prompts remain compatible with normal thread sends; the catalog-driven Threads controls use typed `create_app` metadata for all five app kinds with a stable request id, quicksprint template id, request-scoped design guidance, stack summary, and suggestion tags. The backend marks a create-app message processed, launches the matching detached quicksprint with `submitMode: "plan_and_start"`, and posts an `app_progress` system widget while planning continues. Plain follow-up chat in the same thread is queued until planning finishes if no tasks exist yet, then appended to the sprint-level goal under `Additional direction from chat`; once tasks exist, follow-ups append immediately and the backend posts a confirmation. The backend re-reads the thread before writing queued follow-ups or planning-completion state, and re-checks task count after queue writes, so follow-ups posted as planning resolves or as tasks materialize are preserved and appended. The widget metadata moves to `completed` or `failed` when detached planning settles. Non-app quickactions still use the normal routed chat reply path. They hide while sending/working/error and on small screens. - **Reduced motion**: the scene falls back to the static SVG bot; aurora, thinking dots, quick-action float, and drift animations are disabled via `prefers-reduced-motion`. ### Stage widget vocabulary (agent-facing) @@ -70,7 +68,7 @@ Agent replies are ordinary markdown; embedding a fenced block renders a designed ## Interaction - Seamless mode switching between the cinematic "3D Chat" stage, standard "Threads" (user-facing chat), and "Invocations" (runtime debugging transcript). - Consistent padding and gap spacing to prevent layout jitter during these transitions. -- Threads mode keeps **Create Desktop App** and **Create Web App** quickactions next to the composer, including the empty-thread state. These controls are plain keyboard-reachable buttons with visible labels and lucide icons (`Monitor`, `Globe2`), disable while a send is in flight or no project is selected, and post short visible messages with `create_app` metadata instead of opening confirmation dialogs or switching to Invocations. Each button uses its visible label as the accessible name, points `aria-describedby` to a hidden per-action description and a shared polite status region, and keeps stable dimensions so the composer area does not jump when disabled or when the thread is created on demand. +- Threads mode renders the shared catalog's **Create Web App**, **Create Desktop App**, **Create Onlineshop**, **Create Portfolio**, and **Create Game** quickactions next to the composer, including the empty-thread state. The original Web/Desktop controls remain the same typed two-item subset of this protocol. These controls are plain keyboard-reachable buttons with visible labels and lucide icons, disable while a send is in flight or no project is selected, and post short visible messages with `create_app` metadata instead of opening confirmation dialogs or switching to Invocations. Each button uses its visible label as the accessible name, points `aria-describedby` to a hidden per-action description and a shared polite status region, and keeps stable dimensions so the composer area does not jump when disabled or when the thread is created on demand. - Threads mode includes the shared `SpeechInputButton` beside the send control in the composer action row. Successful transcripts insert into the current draft at the textarea caret when the selection is available, otherwise they append to the draft with a single whitespace boundary when needed, then focus and resize the textarea to its content height. Speech recording disables while sending or when no project-scoped composer is available. Invocation transcripts remain read-only and never show microphone controls. - The active composer draft is persisted through `/api/projects/:projectId/conversations/draft` after the draft context is restored, while each `setInput` update writes a scoped localStorage fallback synchronously. Switching threads or returning to `/chat` loads only the matching user/project/context draft, so draft text from another project or browser user is never reused. - Recent composer history is read from `/api/projects/:projectId/conversations/message-history` with the same dashboard user header as drafts and updated after successful sends. A fresh dashboard user id starts with empty history, and history rows from another project or user are never used for recall. diff --git a/tests/dashboard/v2/chat-message-bubbles.test.tsx b/tests/dashboard/v2/chat-message-bubbles.test.tsx index e97c18ab24..4b32f4fe81 100644 --- a/tests/dashboard/v2/chat-message-bubbles.test.tsx +++ b/tests/dashboard/v2/chat-message-bubbles.test.tsx @@ -478,10 +478,67 @@ describe("Chat Message Bubbles", () => { expect(view.getByText("Showing each Task")).toBeInTheDocument(); expect(view.getByText("auth")).toBeInTheDocument(); expect(view.getByText("dashboard")).toBeInTheDocument(); + expect(view.getByText("Started a web app sprint.")).toBeInTheDocument(); expect(container.textContent).not.toContain("appKind"); expect(container.textContent).not.toContain("stackSummary"); }); + it.each([ + ["online_shop", "Online shop", "Online shop sprint is being planned."], + ["portfolio", "Portfolio", "Portfolio sprint is being planned."], + ["game", "Game", "Game sprint is being planned."], + ] as const)("renders a readable %s app creation progress label", (appKind, appKindLabel, statusLabel) => { + const message = createChatMessage({ + id: `msg_app_progress_${appKind}`, + authorType: "system", + authorConnectionId: null, + bodyMarkdown: `Started ${appKindLabel.toLowerCase()} planning.`, + metadata: { + widget_metadata: { + type: "app_progress", + status: "running", + appKind, + sprintName: `Create ${appKindLabel}`, + planningStages: [], + suggestionTags: [], + }, + }, + }); + + const { container } = render(); + const view = within(container); + + expect(view.getByText(statusLabel)).toBeInTheDocument(); + expect(view.getByLabelText(`${appKindLabel} sprint stages`)).toBeInTheDocument(); + expect(view.getByText(message.bodyMarkdown)).toBeInTheDocument(); + }); + + it("falls back to generic app progress labels for malformed app-kind metadata", () => { + const message = createChatMessage({ + id: "msg_app_progress_malformed_kind", + authorType: "system", + authorConnectionId: null, + bodyMarkdown: "App planning is still visible.", + metadata: { + widget_metadata: { + type: "app_progress", + status: "running", + appKind: { invalid: true }, + planningStages: "invalid", + suggestionTags: "invalid", + }, + }, + }); + + const { container } = render(); + const view = within(container); + + expect(view.getByText("App sprint is being planned.")).toBeInTheDocument(); + expect(view.getByLabelText("App sprint stages")).toBeInTheDocument(); + expect(view.getByText("App planning is still visible.")).toBeInTheDocument(); + expect(view.getByText("Project stack defaults")).toBeInTheDocument(); + }); + it("renders completed app creation progress without inventing raw metadata", () => { const message: ChatMessageRecord = { id: "msg_app_progress_completed", diff --git a/tests/dashboard/v2/use-chat-page-data.test.tsx b/tests/dashboard/v2/use-chat-page-data.test.tsx index 8b18afd5f1..f701e08748 100644 --- a/tests/dashboard/v2/use-chat-page-data.test.tsx +++ b/tests/dashboard/v2/use-chat-page-data.test.tsx @@ -965,40 +965,100 @@ describe("useChatPageResources integration", () => { }); await act(async () => { - await result.current.threadData.handleCreateAppQuickaction("web_app"); + result.current.threadData.setInput("Keep this composer draft"); }); + const quickactions = [ + { + kind: "web_app", + bodyMarkdown: "Create a web app", + templateId: "qs-create-web-app", + designGuidance: { + selectedTechStackId: "code-ux-product-stack", + selectedStyleguideId: "code-ux-award-winning", + }, + }, + { + kind: "desktop_app", + bodyMarkdown: "Create a desktop app", + templateId: "qs-create-desktop-app", + designGuidance: { + selectedTechStackId: "electron-desktop-app", + selectedStyleguideId: "code-ux-award-winning", + }, + }, + { + kind: "online_shop", + bodyMarkdown: "Create an online shop", + templateId: "qs-create-online-shop", + designGuidance: { + selectedTechStackId: "code-ux-product-stack", + selectedStyleguideId: "ecommerce", + }, + }, + { + kind: "portfolio", + bodyMarkdown: "Create a portfolio", + templateId: "qs-create-portfolio", + designGuidance: { + selectedTechStackId: "code-ux-product-stack", + selectedStyleguideId: "marketing-site", + }, + }, + { + kind: "game", + bodyMarkdown: "Create a game", + templateId: "qs-create-game", + designGuidance: { + selectedTechStackId: "code-ux-product-stack", + selectedStyleguideId: "game-experience", + }, + }, + ] as const; + + for (const quickaction of quickactions) { + await act(async () => { + await result.current.threadData.handleCreateAppQuickaction(quickaction.kind); + }); + } + + expect(createConversationThread).toHaveBeenCalledTimes(1); expect(createConversationThread).toHaveBeenCalledWith("proj-1", expect.objectContaining({ title: expect.stringContaining("Project Chat"), })); - expect(postConversationMessage).toHaveBeenCalledWith("proj-1", expect.objectContaining({ - threadId: "thread-new", - bodyMarkdown: "Create a web app", - metadata: { - quickaction: expect.objectContaining({ - type: "create_app", - kind: "web_app", - requestId: expect.stringMatching(/^dashboard-create-app-web_app-/), - templateId: "qs-create-web-app", - stackSummary: { - techstackId: "react-saas", - techstackName: "React SaaS", - applicationKind: "web_app", - language: "TypeScript", - framework: "React", - runtime: "Node.js", - packageManager: "pnpm", - styling: "Tailwind", - testFramework: "Vitest", + expect(postConversationMessage).toHaveBeenCalledTimes(5); + quickactions.forEach((quickaction, index) => { + expect(postConversationMessage).toHaveBeenNthCalledWith(index + 1, "proj-1", { + threadId: "thread-new", + bodyMarkdown: quickaction.bodyMarkdown, + metadata: { + quickaction: { + type: "create_app", + kind: quickaction.kind, + requestId: expect.stringMatching(new RegExp(`^dashboard-create-app-${quickaction.kind}-`)), + templateId: quickaction.templateId, + designGuidance: quickaction.designGuidance, + stackSummary: { + techstackId: "react-saas", + techstackName: "React SaaS", + applicationKind: quickaction.kind, + language: "TypeScript", + framework: "React", + runtime: "Node.js", + packageManager: "pnpm", + styling: "Tailwind", + testFramework: "Vitest", + }, + suggestionTags: ["TypeScript", "React", "Node.js", "pnpm", "Tailwind", "Vitest"], }, - suggestionTags: ["TypeScript", "React", "Node.js", "pnpm", "Tailwind", "Vitest"], - }), - }, - })); - const postedMetadata = vi.mocked(postConversationMessage).mock.calls[0]?.[1].metadata; + }, + }); + }); expect(onMessageSending).not.toHaveBeenCalled(); - expect(result.current.threadData.input).toBe(""); - expect(result.current.threadData.messages[0]?.metadata).toEqual(postedMetadata); + expect(result.current.threadData.input).toBe("Keep this composer draft"); + expect(result.current.threadData.messages[0]?.metadata).toEqual( + vi.mocked(postConversationMessage).mock.calls[0]?.[1].metadata, + ); expect(recordConversationMessageHistory).not.toHaveBeenCalled(); await act(async () => { From 9295de45821d4bdd0287a5186afa3068cd575fd0 Mon Sep 17 00:00:00 2001 From: Code UX Date: Sat, 11 Jul 2026 04:04:24 +0000 Subject: [PATCH 5/8] feat(task T05): implement via codex --- dashboard/src/v2/ChatPage.tsx | 22 +++- .../chat/ChatCreateAppQuickActions.tsx | 10 +- .../__tests__/ChatPage.accessibility.test.tsx | 99 ++++++++++++-- .../chat/cinematic/CinematicStage.tsx | 124 +++++++++--------- .../__tests__/cinematic-quick-actions.test.ts | 65 +++++++++ .../src/v2/lib/cinematic-quick-actions.ts | 114 ++++++++++++++++ docs-web/content/docs/user-dashboard-chat.mdx | 6 +- docs-web/user/dashboard/chat.md | 6 +- docs/dashboard/design-system-chat.md | 6 +- tests/dashboard/v2/chat-page-shell.test.tsx | 27 +++- 10 files changed, 383 insertions(+), 96 deletions(-) create mode 100644 dashboard/src/v2/lib/__tests__/cinematic-quick-actions.test.ts create mode 100644 dashboard/src/v2/lib/cinematic-quick-actions.ts diff --git a/dashboard/src/v2/ChatPage.tsx b/dashboard/src/v2/ChatPage.tsx index 2a94f6336b..380802cfa9 100644 --- a/dashboard/src/v2/ChatPage.tsx +++ b/dashboard/src/v2/ChatPage.tsx @@ -278,6 +278,8 @@ export const ChatPage: FunctionComponent = () => { threadIndex, invocationIndex, selectedProject, + projectInitializationStateLoading, + canCreateInitialAppQuickactions, agentPresets, feedback, clearFeedback, @@ -308,6 +310,7 @@ export const ChatPage: FunctionComponent = () => { () => invocations.filter((invocation) => invocation.status === "running").length, [invocations], ); + const showInitialCreateActions = !projectInitializationStateLoading && canCreateInitialAppQuickactions; const widgetLiveData = useMemo(() => ({ projectId: selectedProject?.id ?? null, projectTasks, @@ -716,6 +719,9 @@ export const ChatPage: FunctionComponent = () => { input={input} setInput={setInput} handleSend={handleSend} + handleCreateAppQuickaction={handleCreateAppQuickaction} + initialEligibilityLoaded={!projectInitializationStateLoading} + canCreateInitialAppQuickactions={canCreateInitialAppQuickactions} navigateHistory={navigateHistory} composerRef={composerRef} activeConnection={activeConnection} @@ -797,13 +803,15 @@ export const ChatPage: FunctionComponent = () => {
-
- void handleCreateAppQuickaction(kind)} - /> -
+ {selectedProject && !hasWorkingReply && runningInvocationCount === 0 && !sending && !error && ( +
+ void handleCreateAppQuickaction(kind)} + /> +
+ )}