Unverified 提交 dc9acbd6 authored 作者: Mohamed Aziz Mejri's avatar Mohamed Aziz Mejri 提交者: GitHub

Custom theme generator (#2182)

<!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Prototype custom theme generator that lets users create themes manually or generate prompts from images, manage them from a new Themes page, and apply them in chat. Themes are global and used in streaming and token counting. - **New Features** - Added custom_themes table. - Implemented IPC and hooks to list/create/update/delete themes with query cache invalidation. - New CustomThemeDialog with manual prompt entry and prompt generation from uploaded images and optional keywords; uses the selected model via Dyad Pro and requires Dyad Pro enabled. - New Themes page with CRUD, EditThemeDialog, and a sidebar link. - Updated chat Themes menu to show built-in plus recent custom themes, with “New Theme” and a “More themes” dialog; newly created themes auto-select and selection persists per app. - **Refactors** - Replaced getThemePrompt with async getThemePromptById to support custom theme IDs (custom:<id>); integrated in chat_stream and token_count handlers. - Whitelisted new IPC channels in preload. <sup>Written for commit 37d9e5f0c477e2bb0847df506450b45a25ab4874. Summary will update on new commits.</sup> <!-- End of auto-generated description by cubic. --> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Adds end-to-end custom theme support, including storage, IPC, UI, and chat/system-prompt integration. > > - New `custom_themes` table (+ migration `0022_loving_wendigo`) and Drizzle schema `customThemes` > - IPC: moved/expanded theme handlers to `pro/main/ipc/handlers/themes_handlers.ts` with endpoints for `get/set-app-theme`, `get/create/update/delete` custom themes, image save/cleanup, and `generate-theme-prompt`; whitelisted channels in `preload` > - Hooks and client: `useCustomThemes` CRUD/generation hooks; `IpcClient` methods for custom themes, image handling, and generation > - UI: new `ThemesPage` with cards, `CustomThemeDialog` (AI + manual), `AIGeneratorTab` (image upload, model/mode, Pro-gated), `EditThemeDialog`, improved `DeleteConfirmationDialog`; added `LibraryList` and updated `app-sidebar` default Library route > - Chat: `AuxiliaryActionsMenu` shows built-in and recent custom themes, "New Theme" and "More themes"; auto-select newly created theme > - Prompt resolution: replaced `getThemePrompt` with async `getThemePromptById` (supports `custom:<id>`) in chat stream and token count handlers > - Routing: added `/themes` route; e2e tests for themes CRUD, AI generator flow/limits, and prompt library navigation > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 37d9e5f0c477e2bb0847df506450b45a25ab4874. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
上级 b49e43ec
CREATE TABLE `custom_themes` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`description` text,
`prompt` text NOT NULL,
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
);
{
"version": "6",
"dialect": "sqlite",
"id": "ce28ff48-ebcb-4c8e-90aa-623ebe456839",
"prevId": "fe227e2c-ae93-4d47-bdd6-61caf215311e",
"tables": {
"apps": {
"name": "apps",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"path": {
"name": "path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"github_org": {
"name": "github_org",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"github_repo": {
"name": "github_repo",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"github_branch": {
"name": "github_branch",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"supabase_project_id": {
"name": "supabase_project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"supabase_parent_project_id": {
"name": "supabase_parent_project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"supabase_organization_slug": {
"name": "supabase_organization_slug",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"neon_project_id": {
"name": "neon_project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"neon_development_branch_id": {
"name": "neon_development_branch_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"neon_preview_branch_id": {
"name": "neon_preview_branch_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"vercel_project_id": {
"name": "vercel_project_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"vercel_project_name": {
"name": "vercel_project_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"vercel_team_id": {
"name": "vercel_team_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"vercel_deployment_url": {
"name": "vercel_deployment_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"install_command": {
"name": "install_command",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"start_command": {
"name": "start_command",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"chat_context": {
"name": "chat_context",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_favorite": {
"name": "is_favorite",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "0"
},
"theme_id": {
"name": "theme_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"chats": {
"name": "chats",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"app_id": {
"name": "app_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"initial_commit_hash": {
"name": "initial_commit_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {
"chats_app_id_apps_id_fk": {
"name": "chats_app_id_apps_id_fk",
"tableFrom": "chats",
"tableTo": "apps",
"columnsFrom": [
"app_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"custom_themes": {
"name": "custom_themes",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"prompt": {
"name": "prompt",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"language_model_providers": {
"name": "language_model_providers",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"api_base_url": {
"name": "api_base_url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"env_var_name": {
"name": "env_var_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"language_models": {
"name": "language_models",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"display_name": {
"name": "display_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"api_name": {
"name": "api_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"builtin_provider_id": {
"name": "builtin_provider_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"custom_provider_id": {
"name": "custom_provider_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"max_output_tokens": {
"name": "max_output_tokens",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"context_window": {
"name": "context_window",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {
"language_models_custom_provider_id_language_model_providers_id_fk": {
"name": "language_models_custom_provider_id_language_model_providers_id_fk",
"tableFrom": "language_models",
"tableTo": "language_model_providers",
"columnsFrom": [
"custom_provider_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"mcp_servers": {
"name": "mcp_servers",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"transport": {
"name": "transport",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"command": {
"name": "command",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"args": {
"name": "args",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"env_json": {
"name": "env_json",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"enabled": {
"name": "enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "0"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"mcp_tool_consents": {
"name": "mcp_tool_consents",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"server_id": {
"name": "server_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"tool_name": {
"name": "tool_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"consent": {
"name": "consent",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'ask'"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {
"uniq_mcp_consent": {
"name": "uniq_mcp_consent",
"columns": [
"server_id",
"tool_name"
],
"isUnique": true
}
},
"foreignKeys": {
"mcp_tool_consents_server_id_mcp_servers_id_fk": {
"name": "mcp_tool_consents_server_id_mcp_servers_id_fk",
"tableFrom": "mcp_tool_consents",
"tableTo": "mcp_servers",
"columnsFrom": [
"server_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"messages": {
"name": "messages",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"chat_id": {
"name": "chat_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"approval_state": {
"name": "approval_state",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"source_commit_hash": {
"name": "source_commit_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"commit_hash": {
"name": "commit_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"request_id": {
"name": "request_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"max_tokens_used": {
"name": "max_tokens_used",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"model": {
"name": "model",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ai_messages_json": {
"name": "ai_messages_json",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {
"messages_chat_id_chats_id_fk": {
"name": "messages_chat_id_chats_id_fk",
"tableFrom": "messages",
"tableTo": "chats",
"columnsFrom": [
"chat_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"prompts": {
"name": "prompts",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"versions": {
"name": "versions",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"app_id": {
"name": "app_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"commit_hash": {
"name": "commit_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"neon_db_timestamp": {
"name": "neon_db_timestamp",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch())"
}
},
"indexes": {
"versions_app_commit_unique": {
"name": "versions_app_commit_unique",
"columns": [
"app_id",
"commit_hash"
],
"isUnique": true
}
},
"foreignKeys": {
"versions_app_id_apps_id_fk": {
"name": "versions_app_id_apps_id_fk",
"tableFrom": "versions",
"tableTo": "apps",
"columnsFrom": [
"app_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
\ No newline at end of file
...@@ -155,6 +155,13 @@ ...@@ -155,6 +155,13 @@
"when": 1768167214574, "when": 1768167214574,
"tag": "0021_kind_luckman", "tag": "0021_kind_luckman",
"breakpoints": true "breakpoints": true
},
{
"idx": 22,
"version": "6",
"when": 1768924462154,
"tag": "0022_loving_wendigo",
"breakpoints": true
} }
] ]
} }
\ No newline at end of file
...@@ -4,6 +4,7 @@ import { expect } from "@playwright/test"; ...@@ -4,6 +4,7 @@ import { expect } from "@playwright/test";
test("create and edit prompt", async ({ po }) => { test("create and edit prompt", async ({ po }) => {
await po.setUp(); await po.setUp();
await po.goToLibraryTab(); await po.goToLibraryTab();
await po.page.getByRole("link", { name: "Prompts" }).click();
await po.createPrompt({ await po.createPrompt({
title: "title1", title: "title1",
description: "desc", description: "desc",
...@@ -24,6 +25,7 @@ test("create and edit prompt", async ({ po }) => { ...@@ -24,6 +25,7 @@ test("create and edit prompt", async ({ po }) => {
test("delete prompt", async ({ po }) => { test("delete prompt", async ({ po }) => {
await po.setUp(); await po.setUp();
await po.goToLibraryTab(); await po.goToLibraryTab();
await po.page.getByRole("link", { name: "Prompts" }).click();
await po.createPrompt({ await po.createPrompt({
title: "title1", title: "title1",
description: "desc", description: "desc",
...@@ -39,6 +41,7 @@ test("delete prompt", async ({ po }) => { ...@@ -39,6 +41,7 @@ test("delete prompt", async ({ po }) => {
test("use prompt", async ({ po }) => { test("use prompt", async ({ po }) => {
await po.setUp(); await po.setUp();
await po.goToLibraryTab(); await po.goToLibraryTab();
await po.page.getByRole("link", { name: "Prompts" }).click();
await po.createPrompt({ await po.createPrompt({
title: "title1", title: "title1",
description: "desc", description: "desc",
......
import { test } from "./helpers/test_helper";
import { expect } from "@playwright/test";
test("themes management - CRUD operations", async ({ po }) => {
await po.setUp();
// Navigate to Themes page via Library sidebar
await po.goToLibraryTab();
await po.page.getByRole("link", { name: "Themes" }).click();
await expect(po.page.getByRole("heading", { name: "Themes" })).toBeVisible();
// Verify no themes exist initially
await expect(
po.page.getByText("No custom themes yet. Create one to get started."),
).toBeVisible();
// === CREATE ===
// Click New Theme button
await po.page.getByRole("button", { name: "New Theme" }).click();
// Wait for dialog to open
await expect(
po.page.getByRole("dialog").getByText("Create Custom Theme"),
).toBeVisible();
// Switch to Manual tab
await po.page.getByRole("tab", { name: "Manual Configuration" }).click();
// Fill in manual configuration form
await po.page.getByLabel("Theme Name").fill("My Test Theme");
await po.page
.getByLabel("Description (optional)")
.fill("A test theme description");
await po.page
.getByLabel("Theme Prompt")
.fill("Use blue colors and modern styling");
// Save the theme
await po.page.getByRole("button", { name: "Save Theme" }).click();
// Verify dialog closes and theme card appears
await expect(po.page.getByRole("dialog")).not.toBeVisible();
await expect(po.page.getByTestId("theme-card")).toBeVisible();
await expect(po.page.getByText("My Test Theme")).toBeVisible();
await expect(po.page.getByText("A test theme description")).toBeVisible();
// === UPDATE ===
// Click edit button on the theme card
await po.page.getByTestId("edit-theme-button").click();
// Wait for edit dialog to open
await expect(
po.page.getByRole("dialog").getByText("Edit Theme"),
).toBeVisible();
// Update the theme details
await po.page.getByLabel("Theme Name").clear();
await po.page.getByLabel("Theme Name").fill("Updated Theme");
await po.page
.getByLabel("Description (optional)")
.fill("Updated description");
await po.page.getByLabel("Theme Prompt").clear();
await po.page.getByLabel("Theme Prompt").fill("Updated prompt content");
// Save changes
await po.page.getByRole("button", { name: "Save" }).click();
// Verify dialog closes and updated content appears
await expect(po.page.getByRole("dialog")).not.toBeVisible();
await expect(po.page.getByText("Updated Theme")).toBeVisible();
await expect(po.page.getByText("Updated description")).toBeVisible();
await expect(po.page.getByText("Updated prompt content")).toBeVisible();
// Verify old name is gone
await expect(po.page.getByText("My Test Theme")).not.toBeVisible();
// === DELETE ===
// Click delete button on the theme card
await po.page.getByTestId("delete-prompt-button").click();
// Verify delete confirmation dialog appears
await expect(po.page.getByRole("alertdialog")).toBeVisible();
await expect(po.page.getByText("Delete Theme")).toBeVisible();
await expect(
po.page.getByText('Are you sure you want to delete "Updated Theme"?'),
).toBeVisible();
// Confirm deletion
await po.page.getByRole("button", { name: "Delete" }).click();
// Verify dialog closes and theme is removed
await expect(po.page.getByRole("alertdialog")).not.toBeVisible();
await expect(po.page.getByText("Updated Theme")).not.toBeVisible();
// Verify empty state is shown again
await expect(
po.page.getByText("No custom themes yet. Create one to get started."),
).toBeVisible();
});
test("themes management - create theme from chat input", async ({ po }) => {
await po.setUp();
// Open the auxiliary actions menu
await po
.getHomeChatInputContainer()
.getByTestId("auxiliary-actions-menu")
.click();
// Hover over Themes submenu
await po.page.getByRole("menuitem", { name: "Themes" }).hover();
// Click "New Theme" option
await po.page.getByRole("menuitem", { name: "New Theme" }).click();
// Wait for dialog to open
await expect(
po.page.getByRole("dialog").getByText("Create Custom Theme"),
).toBeVisible();
// Switch to Manual tab (AI tab is now default)
await po.page.getByRole("tab", { name: "Manual Configuration" }).click();
// Fill in manual configuration form
await po.page.getByLabel("Theme Name").fill("Chat Input Theme");
await po.page
.getByLabel("Description (optional)")
.fill("Created from chat input");
await po.page
.getByLabel("Theme Prompt")
.fill("Use dark mode with purple accents");
// Save the theme
await po.page.getByRole("button", { name: "Save Theme" }).click();
// Verify dialog closes
await expect(po.page.getByRole("dialog")).not.toBeVisible();
// Verify the newly created theme is auto-selected
// Re-open the menu to verify
await po
.getHomeChatInputContainer()
.getByTestId("auxiliary-actions-menu")
.click();
await po.page.getByRole("menuitem", { name: "Themes" }).hover();
// The custom theme should be visible and selected (has bg-primary class)
await expect(po.page.getByTestId("theme-option-custom:1")).toHaveClass(
/bg-primary/,
);
});
test("themes management - AI generator image upload limit", async ({ po }) => {
await po.setUpDyadPro();
// Navigate to Themes page via Library sidebar
await po.goToLibraryTab();
await po.page.getByRole("link", { name: "Themes" }).click();
await expect(po.page.getByRole("heading", { name: "Themes" })).toBeVisible();
// Click New Theme button
await po.page.getByRole("button", { name: "New Theme" }).click();
// Wait for dialog to open
await expect(
po.page.getByRole("dialog").getByText("Create Custom Theme"),
).toBeVisible();
// Verify AI-Powered Generator tab is active by default
const aiTab = po.page.getByRole("tab", { name: "AI-Powered Generator" });
await expect(aiTab).toHaveAttribute("data-state", "active");
// Verify upload area is visible
const uploadArea = po.page.getByText("Click to upload images");
await expect(uploadArea).toBeVisible();
// Set up file chooser listener BEFORE clicking the upload area
const fileChooserPromise = po.page.waitForEvent("filechooser");
// Click the upload area to trigger file picker
await uploadArea.click();
// Handle the file chooser dialog - select the same image 7 times (exceeds 5 limit)
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles([
"e2e-tests/fixtures/images/logo.png",
"e2e-tests/fixtures/images/logo.png",
"e2e-tests/fixtures/images/logo.png",
"e2e-tests/fixtures/images/logo.png",
"e2e-tests/fixtures/images/logo.png",
"e2e-tests/fixtures/images/logo.png",
"e2e-tests/fixtures/images/logo.png",
]);
// Verify that only 5 images were uploaded (max limit)
await expect(po.page.getByText("5 / 5 images")).toBeVisible();
await expect(po.page.getByText("Maximum reached")).toBeVisible();
// Verify error toast appeared about skipped images
await expect(po.page.getByText(/files? (was|were) skipped/)).toBeVisible();
});
test("themes management - AI generator flow", async ({ po }) => {
await po.setUp();
// Navigate to Themes page via Library sidebar
await po.goToLibraryTab();
await po.page.getByRole("link", { name: "Themes" }).click();
await expect(po.page.getByRole("heading", { name: "Themes" })).toBeVisible();
// Verify no themes exist initially
await expect(
po.page.getByText("No custom themes yet. Create one to get started."),
).toBeVisible();
// Click New Theme button
await po.page.getByRole("button", { name: "New Theme" }).click();
// Wait for dialog to open
await expect(
po.page.getByRole("dialog").getByText("Create Custom Theme"),
).toBeVisible();
// Verify AI-Powered Generator tab is active by default
const aiTab = po.page.getByRole("tab", { name: "AI-Powered Generator" });
await expect(aiTab).toHaveAttribute("data-state", "active");
// Verify upload area is visible
const uploadArea = po.page.getByText("Click to upload images");
await expect(uploadArea).toBeVisible();
// Verify Generate button is disabled before uploading images
const generateButton = po.page.getByRole("button", {
name: "Generate Theme Prompt",
});
await expect(generateButton).toBeDisabled();
// Fill in theme details
await po.page.getByLabel("Theme Name").fill("AI Generated Theme");
await po.page
.getByLabel("Description (optional)")
.fill("Created via AI generator");
// Upload an image
const fileChooserPromise = po.page.waitForEvent("filechooser");
await uploadArea.click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(["e2e-tests/fixtures/images/logo.png"]);
// Verify image counter shows 1 image
await expect(po.page.getByText("1 / 5 images")).toBeVisible();
// Verify Generate button is now enabled
await expect(generateButton).toBeEnabled();
// Click Generate to get mock theme prompt (test mode returns mock response)
await generateButton.click();
// Wait for generation to complete - the generated prompt textarea should appear
await expect(po.page.locator("#ai-prompt")).toBeVisible({ timeout: 10000 });
// Verify the mock theme content is displayed
await expect(po.page.getByText("Test Mode Theme")).toBeVisible();
// Save the theme
await po.page.getByRole("button", { name: "Save Theme" }).click();
// Verify dialog closes and theme card appears
await expect(po.page.getByRole("dialog")).not.toBeVisible();
await expect(po.page.getByTestId("theme-card")).toBeVisible();
await expect(po.page.getByText("AI Generated Theme")).toBeVisible();
await expect(po.page.getByText("Created via AI generator")).toBeVisible();
});
import { useState, useCallback, useRef, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Loader2, Upload, X, Sparkles, Lock } from "lucide-react";
import { useGenerateThemePrompt } from "@/hooks/useCustomThemes";
import { IpcClient } from "@/ipc/ipc_client";
import { showError } from "@/lib/toast";
import { toast } from "sonner";
import { useUserBudgetInfo } from "@/hooks/useUserBudgetInfo";
import { AiAccessBanner } from "./ProBanner";
import type {
ThemeGenerationMode,
ThemeGenerationModel,
} from "@/ipc/ipc_types";
// Image upload constants
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB per image (raw file size)
const MAX_IMAGES = 5;
// Default model for AI theme generation
const DEFAULT_THEME_GENERATION_MODEL: ThemeGenerationModel = "gemini-3-pro";
// Image stored with file path (for IPC) and blob URL (for preview)
interface ThemeImage {
path: string; // File path in temp directory
preview: string; // Blob URL for displaying thumbnail
}
interface AIGeneratorTabProps {
aiName: string;
setAiName: (name: string) => void;
aiDescription: string;
setAiDescription: (desc: string) => void;
aiGeneratedPrompt: string;
setAiGeneratedPrompt: (prompt: string) => void;
onSave: () => Promise<void>;
isSaving: boolean;
isDialogOpen: boolean;
}
export function AIGeneratorTab({
aiName,
setAiName,
aiDescription,
setAiDescription,
aiGeneratedPrompt,
setAiGeneratedPrompt,
onSave,
isSaving,
isDialogOpen,
}: AIGeneratorTabProps) {
const [aiImages, setAiImages] = useState<ThemeImage[]>([]);
const [aiKeywords, setAiKeywords] = useState("");
const [aiGenerationMode, setAiGenerationMode] =
useState<ThemeGenerationMode>("inspired");
const [aiSelectedModel, setAiSelectedModel] = useState<ThemeGenerationModel>(
DEFAULT_THEME_GENERATION_MODEL,
);
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Track if dialog is open to prevent orphaned uploads from adding images after close
const isDialogOpenRef = useRef(isDialogOpen);
const generatePromptMutation = useGenerateThemePrompt();
const isGenerating = generatePromptMutation.isPending;
const { userBudget } = useUserBudgetInfo();
// Cleanup function to revoke blob URLs and delete temp files
const cleanupImages = useCallback(
async (images: ThemeImage[], showErrors = false) => {
// Revoke blob URLs to free memory
images.forEach((img) => {
URL.revokeObjectURL(img.preview);
});
// Delete temp files via IPC
const paths = images.map((img) => img.path);
if (paths.length > 0) {
try {
await IpcClient.getInstance().cleanupThemeImages({ paths });
} catch {
if (showErrors) {
showError("Failed to cleanup temporary image files");
}
}
}
},
[],
);
// Keep ref in sync with isDialogOpen prop
useEffect(() => {
isDialogOpenRef.current = isDialogOpen;
}, [isDialogOpen]);
// Cleanup images when dialog closes
useEffect(() => {
if (!isDialogOpen && aiImages.length > 0) {
cleanupImages(aiImages);
setAiImages([]);
setAiKeywords("");
setAiGenerationMode("inspired");
setAiSelectedModel(DEFAULT_THEME_GENERATION_MODEL);
}
}, [isDialogOpen, aiImages, cleanupImages]);
const handleImageUpload = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files) return;
const availableSlots = MAX_IMAGES - aiImages.length;
if (availableSlots <= 0) {
showError(`Maximum ${MAX_IMAGES} images allowed`);
return;
}
const filesToProcess = Array.from(files).slice(0, availableSlots);
const skippedCount = files.length - filesToProcess.length;
if (skippedCount > 0) {
showError(
`Only ${availableSlots} image${availableSlots === 1 ? "" : "s"} can be added. ${skippedCount} file${skippedCount === 1 ? " was" : "s were"} skipped.`,
);
}
setIsUploading(true);
try {
const newImages: ThemeImage[] = [];
for (const file of filesToProcess) {
// Validate file type
if (!file.type.startsWith("image/")) {
showError(
`Please upload only image files. "${file.name}" is not a valid image.`,
);
continue;
}
// Validate file size (raw file size)
if (file.size > MAX_FILE_SIZE) {
const sizeMB = (file.size / (1024 * 1024)).toFixed(1);
showError(`File "${file.name}" exceeds 10MB limit (${sizeMB}MB)`);
continue;
}
try {
// Read file as base64 for upload
const base64Data = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error("Failed to read file"));
reader.onload = () => {
const base64 = reader.result as string;
const data = base64.split(",")[1];
if (!data) {
reject(new Error("Failed to extract image data"));
return;
}
resolve(data);
};
reader.readAsDataURL(file);
});
// Save to temp file via IPC
const result = await IpcClient.getInstance().saveThemeImage({
data: base64Data,
filename: file.name,
});
// Create blob URL for preview (much more memory efficient than base64 in DOM)
const preview = URL.createObjectURL(file);
newImages.push({
path: result.path,
preview,
});
} catch (err) {
showError(
`Error processing "${file.name}": ${err instanceof Error ? err.message : "Unknown error"}`,
);
}
}
if (newImages.length > 0) {
// Check if dialog was closed while upload was in progress
if (!isDialogOpenRef.current) {
// Dialog closed - cleanup orphaned images immediately
await cleanupImages(newImages);
return;
}
setAiImages((prev) => {
// Double-check limit in case of race conditions
const remaining = MAX_IMAGES - prev.length;
return [...prev, ...newImages.slice(0, remaining)];
});
}
} finally {
setIsUploading(false);
// Reset input
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
},
[aiImages.length, cleanupImages],
);
const handleRemoveImage = useCallback(
async (index: number) => {
const imageToRemove = aiImages[index];
if (imageToRemove) {
// Cleanup the removed image - show errors since this is a user action
await cleanupImages([imageToRemove], true);
}
setAiImages((prev) => prev.filter((_, i) => i !== index));
},
[aiImages, cleanupImages],
);
const handleGenerate = useCallback(async () => {
if (aiImages.length === 0) {
showError("Please upload at least one image");
return;
}
try {
const result = await generatePromptMutation.mutateAsync({
imagePaths: aiImages.map((img) => img.path),
keywords: aiKeywords,
generationMode: aiGenerationMode,
model: aiSelectedModel,
});
setAiGeneratedPrompt(result.prompt);
toast.success("Theme prompt generated successfully");
} catch (error) {
showError(
`Failed to generate theme: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}, [
aiImages,
aiKeywords,
aiGenerationMode,
aiSelectedModel,
generatePromptMutation,
setAiGeneratedPrompt,
]);
// Show Pro-only locked state for non-Pro users
if (!userBudget) {
return (
<div className="space-y-4 mt-4">
<div className="flex flex-col items-center justify-center py-8 px-4 border-2 border-dashed border-muted-foreground/25 rounded-lg bg-muted/10">
<Lock className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold text-center mb-2">
AI Theme Generator
</h3>
<p className="text-sm text-muted-foreground text-center max-w-md">
Upload screenshots and let AI generate a custom theme prompt
tailored to your design style.
</p>
<p className="text-xs text-muted-foreground/70 mt-2">
Pro-only feature
</p>
</div>
<AiAccessBanner />
</div>
);
}
return (
<div className="space-y-4 mt-4">
<div className="space-y-2">
<Label htmlFor="ai-name">Theme Name</Label>
<Input
id="ai-name"
placeholder="My AI-Generated Theme"
value={aiName}
onChange={(e) => setAiName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ai-description">Description (optional)</Label>
<Input
id="ai-description"
placeholder="A brief description of your theme"
value={aiDescription}
onChange={(e) => setAiDescription(e.target.value)}
/>
</div>
{/* Image Upload Section */}
<div className="space-y-2">
<Label>Reference Images</Label>
<div
className={`border-2 border-dashed border-muted-foreground/25 rounded-lg p-4 text-center cursor-pointer hover:border-muted-foreground/50 transition-colors ${isUploading ? "opacity-50 pointer-events-none" : ""}`}
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handleImageUpload}
disabled={isUploading}
/>
{isUploading ? (
<Loader2 className="h-8 w-8 mx-auto text-muted-foreground mb-2 animate-spin" />
) : (
<Upload className="h-8 w-8 mx-auto text-muted-foreground mb-2" />
)}
<p className="text-sm text-muted-foreground">
{isUploading ? "Uploading..." : "Click to upload images"}
</p>
<p className="text-xs text-muted-foreground/70 mt-1">
Upload UI screenshots to inspire your theme
</p>
</div>
{/* Image counter */}
<p className="text-xs text-muted-foreground mt-2 text-center">
{aiImages.length} / {MAX_IMAGES} images
{aiImages.length >= MAX_IMAGES && (
<span className="text-destructive ml-2">• Maximum reached</span>
)}
</p>
{/* Image Preview */}
{aiImages.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{aiImages.map((img, index) => (
<div key={img.path} className="relative group">
<img
src={img.preview}
alt={`Upload ${index + 1}`}
className="h-16 w-16 object-cover rounded-md border"
/>
<button
onClick={() => handleRemoveImage(index)}
className="absolute -top-2 -right-2 bg-destructive text-white rounded-full p-1 opacity-0 group-hover:opacity-100 transition-opacity"
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
</div>
{/* Keywords Input */}
<div className="space-y-2">
<Label htmlFor="ai-keywords">Keywords (optional)</Label>
<Input
id="ai-keywords"
placeholder="modern, minimal, dark mode, glassmorphism..."
value={aiKeywords}
onChange={(e) => setAiKeywords(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Add keywords or reference designs to guide the generation
</p>
</div>
{/* Generation Mode Selection */}
<div className="space-y-3">
<Label>Generation Mode</Label>
<div className="grid grid-cols-2 gap-4">
<button
type="button"
onClick={() => setAiGenerationMode("inspired")}
className={`flex flex-col items-start rounded-lg border p-3 text-left transition-colors ${
aiGenerationMode === "inspired"
? "border-primary bg-primary/5"
: "hover:bg-muted/50"
}`}
>
<span className="font-medium">Inspired</span>
<span className="text-xs text-muted-foreground mt-1">
Extracts an abstract, reusable design system. Does not replicate
the original UI.
</span>
</button>
<button
type="button"
onClick={() => setAiGenerationMode("high-fidelity")}
className={`flex flex-col items-start rounded-lg border p-3 text-left transition-colors ${
aiGenerationMode === "high-fidelity"
? "border-primary bg-primary/5"
: "hover:bg-muted/50"
}`}
>
<span className="font-medium">High Fidelity</span>
<span className="text-xs text-muted-foreground mt-1">
Recreates the visual system from the image as closely as possible.
</span>
</button>
</div>
</div>
{/* Model Selection */}
<div className="space-y-3">
<Label>Model Selection</Label>
<div className="grid grid-cols-3 gap-3">
<button
type="button"
onClick={() => setAiSelectedModel("gemini-3-pro")}
className={`flex flex-col items-center rounded-lg border p-3 text-center transition-colors ${
aiSelectedModel === "gemini-3-pro"
? "border-primary bg-primary/5"
: "hover:bg-muted/50"
}`}
>
<span className="font-medium text-sm">Gemini 3 Pro</span>
<span className="text-xs text-muted-foreground mt-1">
Most capable
</span>
</button>
<button
type="button"
onClick={() => setAiSelectedModel("claude-opus-4.5")}
className={`flex flex-col items-center rounded-lg border p-3 text-center transition-colors ${
aiSelectedModel === "claude-opus-4.5"
? "border-primary bg-primary/5"
: "hover:bg-muted/50"
}`}
>
<span className="font-medium text-sm">Claude Opus 4.5</span>
<span className="text-xs text-muted-foreground mt-1">
Creative & detailed
</span>
</button>
<button
type="button"
onClick={() => setAiSelectedModel("gpt-5.2")}
className={`flex flex-col items-center rounded-lg border p-3 text-center transition-colors ${
aiSelectedModel === "gpt-5.2"
? "border-primary bg-primary/5"
: "hover:bg-muted/50"
}`}
>
<span className="font-medium text-sm">GPT 5.2</span>
<span className="text-xs text-muted-foreground mt-1">
Latest OpenAI
</span>
</button>
</div>
</div>
{/* Generate Button */}
<Button
onClick={handleGenerate}
disabled={isGenerating || aiImages.length === 0}
variant="secondary"
className="w-full"
>
{isGenerating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Generating prompt...
</>
) : (
<>
<Sparkles className="mr-2 h-4 w-4" />
Generate Theme Prompt
</>
)}
</Button>
{/* Generated Prompt Display */}
<div className="space-y-2">
<Label htmlFor="ai-prompt">Generated Prompt</Label>
{aiGeneratedPrompt ? (
<Textarea
id="ai-prompt"
className="min-h-[200px] font-mono text-sm"
value={aiGeneratedPrompt}
onChange={(e) => setAiGeneratedPrompt(e.target.value)}
placeholder="Generated prompt will appear here..."
/>
) : (
<div className="min-h-[100px] border rounded-md p-4 flex items-center justify-center text-muted-foreground text-sm">
No prompt generated yet. Upload images and click "Generate" to
create a theme prompt.
</div>
)}
</div>
{/* Save Button - only show when prompt is generated */}
{aiGeneratedPrompt && (
<Button
onClick={onSave}
disabled={isSaving || !aiName.trim()}
className="w-full"
>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
"Save Theme"
)}
</Button>
)}
</div>
);
}
import { useState, useCallback } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Loader2, Sparkles, PenLine } from "lucide-react";
import { useCreateCustomTheme } from "@/hooks/useCustomThemes";
import { showError } from "@/lib/toast";
import { toast } from "sonner";
import { AIGeneratorTab } from "./AIGeneratorTab";
interface CustomThemeDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onThemeCreated?: (themeId: number) => void; // callback when theme is created
}
export function CustomThemeDialog({
open,
onOpenChange,
onThemeCreated,
}: CustomThemeDialogProps) {
const [activeTab, setActiveTab] = useState<"manual" | "ai">("ai");
// Manual tab state
const [manualName, setManualName] = useState("");
const [manualDescription, setManualDescription] = useState("");
const [manualPrompt, setManualPrompt] = useState("");
// AI tab state (shared with AIGeneratorTab)
const [aiName, setAiName] = useState("");
const [aiDescription, setAiDescription] = useState("");
const [aiGeneratedPrompt, setAiGeneratedPrompt] = useState("");
const createThemeMutation = useCreateCustomTheme();
const resetForm = useCallback(() => {
setManualName("");
setManualDescription("");
setManualPrompt("");
setAiName("");
setAiDescription("");
setAiGeneratedPrompt("");
setActiveTab("ai");
}, []);
const handleClose = useCallback(async () => {
resetForm();
onOpenChange(false);
}, [onOpenChange, resetForm]);
const handleSave = useCallback(async () => {
const isManual = activeTab === "manual";
const name = isManual ? manualName : aiName;
const description = isManual ? manualDescription : aiDescription;
const prompt = isManual ? manualPrompt : aiGeneratedPrompt;
if (!name.trim()) {
showError("Please enter a theme name");
return;
}
if (!prompt.trim()) {
showError(
isManual
? "Please enter a theme prompt"
: "Please generate a prompt first",
);
return;
}
try {
const createdTheme = await createThemeMutation.mutateAsync({
name: name.trim(),
description: description.trim() || undefined,
prompt: prompt.trim(),
});
toast.success("Custom theme created successfully");
onThemeCreated?.(createdTheme.id);
await handleClose();
} catch (error) {
showError(
`Failed to create theme: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}, [
activeTab,
manualName,
manualDescription,
manualPrompt,
aiName,
aiDescription,
aiGeneratedPrompt,
createThemeMutation,
onThemeCreated,
handleClose,
]);
const isSaving = createThemeMutation.isPending;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Create Custom Theme</DialogTitle>
<DialogDescription>
Create a custom theme using manual configuration or AI-powered
generation.
</DialogDescription>
</DialogHeader>
<Tabs
value={activeTab}
onValueChange={(v) => setActiveTab(v as "manual" | "ai")}
className="mt-4"
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="ai" className="flex items-center gap-2">
<Sparkles className="h-4 w-4" />
AI-Powered Generator
</TabsTrigger>
<TabsTrigger value="manual" className="flex items-center gap-2">
<PenLine className="h-4 w-4" />
Manual Configuration
</TabsTrigger>
</TabsList>
{/* AI-Powered Generator Tab */}
<TabsContent value="ai">
<AIGeneratorTab
aiName={aiName}
setAiName={setAiName}
aiDescription={aiDescription}
setAiDescription={setAiDescription}
aiGeneratedPrompt={aiGeneratedPrompt}
setAiGeneratedPrompt={setAiGeneratedPrompt}
onSave={handleSave}
isSaving={isSaving}
isDialogOpen={open}
/>
</TabsContent>
{/* Manual Configuration Tab */}
<TabsContent value="manual" className="space-y-4 mt-4">
<div className="space-y-2">
<Label htmlFor="manual-name">Theme Name</Label>
<Input
id="manual-name"
placeholder="My Custom Theme"
value={manualName}
onChange={(e) => setManualName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="manual-description">Description (optional)</Label>
<Input
id="manual-description"
placeholder="A brief description of your theme"
value={manualDescription}
onChange={(e) => setManualDescription(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="manual-prompt">Theme Prompt</Label>
<Textarea
id="manual-prompt"
placeholder="Enter your theme system prompt..."
className="min-h-[200px] font-mono text-sm"
value={manualPrompt}
onChange={(e) => setManualPrompt(e.target.value)}
/>
</div>
<Button
onClick={handleSave}
disabled={isSaving || !manualName.trim() || !manualPrompt.trim()}
className="w-full"
>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
"Save Theme"
)}
</Button>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
}
import React from "react"; import React from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Trash2 } from "lucide-react"; import { Trash2, Loader2 } from "lucide-react";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
...@@ -23,6 +23,7 @@ interface DeleteConfirmationDialogProps { ...@@ -23,6 +23,7 @@ interface DeleteConfirmationDialogProps {
itemType?: string; itemType?: string;
onDelete: () => void | Promise<void>; onDelete: () => void | Promise<void>;
trigger?: React.ReactNode; trigger?: React.ReactNode;
isDeleting?: boolean;
} }
export function DeleteConfirmationDialog({ export function DeleteConfirmationDialog({
...@@ -30,6 +31,7 @@ export function DeleteConfirmationDialog({ ...@@ -30,6 +31,7 @@ export function DeleteConfirmationDialog({
itemType = "item", itemType = "item",
onDelete, onDelete,
trigger, trigger,
isDeleting = false,
}: DeleteConfirmationDialogProps) { }: DeleteConfirmationDialogProps) {
return ( return (
<AlertDialog> <AlertDialog>
...@@ -43,6 +45,7 @@ export function DeleteConfirmationDialog({ ...@@ -43,6 +45,7 @@ export function DeleteConfirmationDialog({
size="icon" size="icon"
variant="ghost" variant="ghost"
data-testid="delete-prompt-button" data-testid="delete-prompt-button"
disabled={isDeleting}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
...@@ -62,8 +65,17 @@ export function DeleteConfirmationDialog({ ...@@ -62,8 +65,17 @@ export function DeleteConfirmationDialog({
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onDelete}>Delete</AlertDialogAction> <AlertDialogAction onClick={onDelete} disabled={isDeleting}>
{isDeleting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Deleting...
</>
) : (
"Delete"
)}
</AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
......
import { useState, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Save, Edit2, Loader2 } from "lucide-react";
import { showError } from "@/lib/toast";
import { toast } from "sonner";
import type { CustomTheme } from "@/ipc/ipc_types";
interface EditThemeDialogProps {
theme: CustomTheme;
onUpdateTheme: (params: {
id: number;
name: string;
description?: string;
prompt: string;
}) => Promise<void>;
trigger?: React.ReactNode;
}
export function EditThemeDialog({
theme,
onUpdateTheme,
trigger,
}: EditThemeDialogProps) {
const [open, setOpen] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [draft, setDraft] = useState({
name: "",
description: "",
prompt: "",
});
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Auto-resize textarea function
const adjustTextareaHeight = () => {
const textarea = textareaRef.current;
if (textarea) {
const currentHeight = textarea.style.height;
textarea.style.height = "auto";
const scrollHeight = textarea.scrollHeight;
const maxHeight = window.innerHeight * 0.5;
const minHeight = 150;
const newHeight = Math.min(Math.max(scrollHeight, minHeight), maxHeight);
if (`${newHeight}px` !== currentHeight) {
textarea.style.height = `${newHeight}px`;
}
}
};
// Initialize draft with theme data
useEffect(() => {
if (open) {
setDraft({
name: theme.name,
description: theme.description || "",
prompt: theme.prompt,
});
}
}, [open, theme]);
// Auto-resize textarea when content changes
useEffect(() => {
adjustTextareaHeight();
}, [draft.prompt]);
// Trigger resize when dialog opens
useEffect(() => {
if (open) {
setTimeout(adjustTextareaHeight, 0);
}
}, [open]);
const handleSave = async () => {
if (!draft.name.trim() || !draft.prompt.trim()) return;
setIsSaving(true);
try {
await onUpdateTheme({
id: theme.id,
name: draft.name.trim(),
description: draft.description.trim() || undefined,
prompt: draft.prompt.trim(),
});
toast.success("Theme updated successfully");
setOpen(false);
} catch (error) {
showError(
`Failed to update theme: ${error instanceof Error ? error.message : "Unknown error"}`,
);
} finally {
setIsSaving(false);
}
};
const handleCancel = () => {
setDraft({
name: theme.name,
description: theme.description || "",
prompt: theme.prompt,
});
setOpen(false);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
{trigger ? (
<DialogTrigger asChild>{trigger}</DialogTrigger>
) : (
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button
size="icon"
variant="ghost"
data-testid="edit-theme-button"
>
<Edit2 className="h-4 w-4" />
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent>
<p>Edit theme</p>
</TooltipContent>
</Tooltip>
)}
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit Theme</DialogTitle>
<DialogDescription>
Modify your custom theme settings and prompt.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 mt-4">
<div className="space-y-2">
<label htmlFor="edit-theme-name" className="text-sm font-medium">
Theme Name
</label>
<Input
id="edit-theme-name"
placeholder="Theme name"
value={draft.name}
onChange={(e) =>
setDraft((d) => ({ ...d, name: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<label
htmlFor="edit-theme-description"
className="text-sm font-medium"
>
Description (optional)
</label>
<Input
id="edit-theme-description"
placeholder="A brief description of your theme"
value={draft.description}
onChange={(e) =>
setDraft((d) => ({ ...d, description: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<label htmlFor="edit-theme-prompt" className="text-sm font-medium">
Theme Prompt
</label>
<Textarea
id="edit-theme-prompt"
ref={textareaRef}
placeholder="Enter your theme system prompt..."
value={draft.prompt}
onChange={(e) => {
setDraft((d) => ({ ...d, prompt: e.target.value }));
requestAnimationFrame(adjustTextareaHeight);
}}
className="resize-none overflow-y-auto font-mono text-sm"
style={{ minHeight: "150px" }}
/>
</div>
</div>
<DialogFooter className="mt-4">
<Button variant="outline" onClick={handleCancel} disabled={isSaving}>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={isSaving || !draft.name.trim() || !draft.prompt.trim()}
>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" /> Save
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { Link, useRouterState } from "@tanstack/react-router";
import { Palette, FileText } from "lucide-react";
type LibrarySection = {
id: string;
label: string;
to: string;
icon: React.ComponentType<{ className?: string }>;
};
const LIBRARY_SECTIONS: LibrarySection[] = [
{ id: "themes", label: "Themes", to: "/themes", icon: Palette },
{ id: "prompts", label: "Prompts", to: "/library", icon: FileText },
];
export function LibraryList({ show }: { show: boolean }) {
const routerState = useRouterState();
const pathname = routerState.location.pathname;
if (!show) {
return null;
}
return (
<div className="flex flex-col h-full">
<div className="flex-shrink-0 p-4">
<h2 className="text-lg font-semibold tracking-tight">Library</h2>
</div>
<ScrollArea className="flex-grow">
<div className="space-y-1 p-4 pt-0">
{LIBRARY_SECTIONS.map((section) => {
const isActive =
section.to === pathname ||
(section.to !== "/" && pathname.startsWith(section.to));
return (
<Link
key={section.id}
to={section.to}
className={cn(
"w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors",
isActive
? "bg-sidebar-accent text-sidebar-accent-foreground font-semibold"
: "hover:bg-sidebar-accent",
)}
>
<section.icon className="h-4 w-4" />
{section.label}
</Link>
);
})}
</div>
</ScrollArea>
</div>
);
}
...@@ -28,6 +28,7 @@ import { ChatList } from "./ChatList"; ...@@ -28,6 +28,7 @@ import { ChatList } from "./ChatList";
import { AppList } from "./AppList"; import { AppList } from "./AppList";
import { HelpDialog } from "./HelpDialog"; // Import the new dialog import { HelpDialog } from "./HelpDialog"; // Import the new dialog
import { SettingsList } from "./SettingsList"; import { SettingsList } from "./SettingsList";
import { LibraryList } from "./LibraryList";
// Menu items. // Menu items.
const items = [ const items = [
...@@ -48,7 +49,7 @@ const items = [ ...@@ -48,7 +49,7 @@ const items = [
}, },
{ {
title: "Library", title: "Library",
to: "/library", to: "/themes",
icon: BookOpen, icon: BookOpen,
}, },
{ {
...@@ -97,6 +98,9 @@ export function AppSidebar() { ...@@ -97,6 +98,9 @@ export function AppSidebar() {
routerState.location.pathname.startsWith("/app-details"); routerState.location.pathname.startsWith("/app-details");
const isChatRoute = routerState.location.pathname === "/chat"; const isChatRoute = routerState.location.pathname === "/chat";
const isSettingsRoute = routerState.location.pathname.startsWith("/settings"); const isSettingsRoute = routerState.location.pathname.startsWith("/settings");
const isLibraryRoute =
routerState.location.pathname.startsWith("/library") ||
routerState.location.pathname.startsWith("/themes");
let selectedItem: string | null = null; let selectedItem: string | null = null;
if (hoverState === "start-hover:app") { if (hoverState === "start-hover:app") {
...@@ -114,6 +118,8 @@ export function AppSidebar() { ...@@ -114,6 +118,8 @@ export function AppSidebar() {
selectedItem = "Chat"; selectedItem = "Chat";
} else if (isSettingsRoute) { } else if (isSettingsRoute) {
selectedItem = "Settings"; selectedItem = "Settings";
} else if (isLibraryRoute) {
selectedItem = "Library";
} }
} }
...@@ -142,6 +148,7 @@ export function AppSidebar() { ...@@ -142,6 +148,7 @@ export function AppSidebar() {
<AppList show={selectedItem === "Apps"} /> <AppList show={selectedItem === "Apps"} />
<ChatList show={selectedItem === "Chat"} /> <ChatList show={selectedItem === "Chat"} />
<SettingsList show={selectedItem === "Settings"} /> <SettingsList show={selectedItem === "Settings"} />
<LibraryList show={selectedItem === "Library"} />
</div> </div>
</div> </div>
</SidebarContent> </SidebarContent>
......
import { useState } from "react"; import { useState, useMemo } from "react";
import { import {
Plus, Plus,
Paperclip, Paperclip,
...@@ -6,6 +6,9 @@ import { ...@@ -6,6 +6,9 @@ import {
Palette, Palette,
Check, Check,
Ban, Ban,
Brush,
PlusCircle,
MoreHorizontal,
} from "lucide-react"; } from "lucide-react";
import { import {
DropdownMenu, DropdownMenu,
...@@ -17,6 +20,12 @@ import { ...@@ -17,6 +20,12 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuItem, DropdownMenuItem,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
...@@ -25,8 +34,10 @@ import { ...@@ -25,8 +34,10 @@ import {
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ContextFilesPicker } from "@/components/ContextFilesPicker"; import { ContextFilesPicker } from "@/components/ContextFilesPicker";
import { FileAttachmentDropdown } from "./FileAttachmentDropdown"; import { FileAttachmentDropdown } from "./FileAttachmentDropdown";
import { CustomThemeDialog } from "@/components/CustomThemeDialog";
import { useThemes } from "@/hooks/useThemes"; import { useThemes } from "@/hooks/useThemes";
import { useAppTheme, APP_THEME_QUERY_KEY } from "@/hooks/useAppTheme"; import { useAppTheme, APP_THEME_QUERY_KEY } from "@/hooks/useAppTheme";
import { useCustomThemes } from "@/hooks/useCustomThemes";
import { useSettings } from "@/hooks/useSettings"; import { useSettings } from "@/hooks/useSettings";
import { IpcClient } from "@/ipc/ipc_client"; import { IpcClient } from "@/ipc/ipc_client";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
...@@ -50,7 +61,10 @@ export function AuxiliaryActionsMenu({ ...@@ -50,7 +61,10 @@ export function AuxiliaryActionsMenu({
appId, appId,
}: AuxiliaryActionsMenuProps) { }: AuxiliaryActionsMenuProps) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [customThemeDialogOpen, setCustomThemeDialogOpen] = useState(false);
const [allThemesDialogOpen, setAllThemesDialogOpen] = useState(false);
const { themes } = useThemes(); const { themes } = useThemes();
const { customThemes } = useCustomThemes();
const { themeId: appThemeId } = useAppTheme(appId); const { themeId: appThemeId } = useAppTheme(appId);
const { settings, updateSettings } = useSettings(); const { settings, updateSettings } = useSettings();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
...@@ -60,8 +74,34 @@ export function AuxiliaryActionsMenu({ ...@@ -60,8 +74,34 @@ export function AuxiliaryActionsMenu({
const currentThemeId = const currentThemeId =
appId != null ? appThemeId : settings?.selectedThemeId || null; appId != null ? appThemeId : settings?.selectedThemeId || null;
// Compute visible custom themes: selected custom theme + up to 3 others
const visibleCustomThemes = useMemo(() => {
const MAX_VISIBLE = 4; // selected + 3 others
// Check if current theme is a custom theme
const selectedCustomTheme = customThemes.find(
(t) => `custom:${t.id}` === currentThemeId,
);
const otherCustomThemes = customThemes.filter(
(t) => `custom:${t.id}` !== currentThemeId,
);
const result = [];
if (selectedCustomTheme) {
result.push(selectedCustomTheme);
}
// Add up to (MAX_VISIBLE - result.length) other custom themes
const remaining = MAX_VISIBLE - result.length;
result.push(...otherCustomThemes.slice(0, remaining));
return result;
}, [customThemes, currentThemeId]);
const hasMoreCustomThemes = customThemes.length > visibleCustomThemes.length;
const handleThemeSelect = async (themeId: string | null) => { const handleThemeSelect = async (themeId: string | null) => {
if (appId) { if (appId != null) {
// Update app-specific theme // Update app-specific theme
await IpcClient.getInstance().setAppTheme({ await IpcClient.getInstance().setAppTheme({
appId, appId,
...@@ -76,118 +116,260 @@ export function AuxiliaryActionsMenu({ ...@@ -76,118 +116,260 @@ export function AuxiliaryActionsMenu({
} }
}; };
const handleCreateCustomTheme = () => {
setIsOpen(false);
setCustomThemeDialogOpen(true);
};
const handleCustomThemeDialogClose = (open: boolean) => {
setCustomThemeDialogOpen(open);
if (!open) {
// Refresh custom themes when dialog closes
queryClient.invalidateQueries({
queryKey: ["custom-themes"],
});
}
};
return ( return (
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}> <>
<DropdownMenuTrigger asChild> <DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<Button <DropdownMenuTrigger asChild>
variant="ghost" <Button
size="sm" variant="ghost"
className="has-[>svg]:px-2 hover:bg-muted bg-primary/10 text-primary cursor-pointer rounded-xl" size="sm"
data-testid="auxiliary-actions-menu" className="has-[>svg]:px-2 hover:bg-muted bg-primary/10 text-primary cursor-pointer rounded-xl"
> data-testid="auxiliary-actions-menu"
<Plus >
size={20} <Plus
className={`transition-transform duration-200 ${isOpen ? "rotate-45" : "rotate-0"}`} size={20}
/> className={`transition-transform duration-200 ${isOpen ? "rotate-45" : "rotate-0"}`}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{/* Codebase Context */}
{!hideContextFilesPicker && <ContextFilesPicker />}
{/* Attach Files Submenu */}
<DropdownMenuSub>
<DropdownMenuSubTrigger className="py-2 px-3">
<Paperclip size={16} className="mr-2" />
Attach files
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<FileAttachmentDropdown
onFileSelect={onFileSelect}
closeMenu={() => setIsOpen(false)}
/> />
</DropdownMenuSubContent> </Button>
</DropdownMenuSub> </DropdownMenuTrigger>
<DropdownMenuContent align="end">
{/* Themes Submenu */} {/* Codebase Context */}
<DropdownMenuSub> {!hideContextFilesPicker && <ContextFilesPicker />}
<DropdownMenuSubTrigger className="py-2 px-3">
<Palette size={16} className="mr-2" /> {/* Attach Files Submenu */}
Themes <DropdownMenuSub>
</DropdownMenuSubTrigger> <DropdownMenuSubTrigger className="py-2 px-3">
<DropdownMenuSubContent> <Paperclip size={16} className="mr-2" />
{/* No Theme option (special frontend-only option) */} Attach files
<DropdownMenuItem </DropdownMenuSubTrigger>
onClick={() => handleThemeSelect(null)} <DropdownMenuSubContent>
className={`py-2 px-3 ${currentThemeId === null ? "bg-primary/10" : ""}`} <FileAttachmentDropdown
data-testid="theme-option-none" onFileSelect={onFileSelect}
> closeMenu={() => setIsOpen(false)}
<div className="flex items-center w-full"> />
<Ban size={16} className="mr-2 text-muted-foreground" /> </DropdownMenuSubContent>
<span className="flex-1">No Theme</span> </DropdownMenuSub>
{currentThemeId === null && (
<Check size={16} className="text-primary ml-2" /> {/* Themes Submenu */}
)} <DropdownMenuSub>
</div> <DropdownMenuSubTrigger className="py-2 px-3">
</DropdownMenuItem> <Palette size={16} className="mr-2" />
Themes
{/* Actual themes from themesData */} </DropdownMenuSubTrigger>
{themes?.map((theme) => { <DropdownMenuSubContent>
const isSelected = currentThemeId === theme.id; <DropdownMenuItem
onClick={() => handleThemeSelect(null)}
className={`py-2 px-3 ${currentThemeId === null ? "bg-primary/10" : ""}`}
data-testid="theme-option-none"
>
<div className="flex items-center w-full">
<Ban size={16} className="mr-2 text-muted-foreground" />
<span className="flex-1">No Theme</span>
{currentThemeId === null && (
<Check size={16} className="text-primary ml-2" />
)}
</div>
</DropdownMenuItem>
{/* Built-in themes from themesData */}
{themes?.map((theme) => {
const isSelected = currentThemeId === theme.id;
return (
<Tooltip key={theme.id}>
<TooltipTrigger asChild>
<DropdownMenuItem
onClick={() => handleThemeSelect(theme.id)}
className={`py-2 px-3 ${isSelected ? "bg-primary/10" : ""}`}
data-testid={`theme-option-${theme.id}`}
>
<div className="flex items-center w-full">
{theme.icon === "palette" && (
<Palette
size={16}
className="mr-2 text-muted-foreground"
/>
)}
<span className="flex-1">{theme.name}</span>
{isSelected && (
<Check size={16} className="text-primary ml-2" />
)}
</div>
</DropdownMenuItem>
</TooltipTrigger>
<TooltipContent side="right">
{theme.description}
</TooltipContent>
</Tooltip>
);
})}
{/* Custom Themes Section (limited) */}
{visibleCustomThemes.length > 0 && (
<>
<DropdownMenuSeparator />
{visibleCustomThemes.map((theme) => {
const themeId = `custom:${theme.id}`;
const isSelected = currentThemeId === themeId;
return (
<Tooltip key={themeId}>
<TooltipTrigger asChild>
<DropdownMenuItem
onClick={() => handleThemeSelect(themeId)}
className={`py-2 px-3 ${isSelected ? "bg-primary/10" : ""}`}
data-testid={`theme-option-${themeId}`}
>
<div className="flex items-center w-full">
<Brush
size={16}
className="mr-2 text-muted-foreground"
/>
<span className="flex-1">{theme.name}</span>
{isSelected && (
<Check
size={16}
className="text-primary ml-2"
/>
)}
</div>
</DropdownMenuItem>
</TooltipTrigger>
<TooltipContent side="right">
{theme.description || "Custom theme"}
</TooltipContent>
</Tooltip>
);
})}
</>
)}
{/* All Custom Themes option */}
{hasMoreCustomThemes && (
<DropdownMenuItem
onClick={() => {
setIsOpen(false);
setAllThemesDialogOpen(true);
}}
className="py-2 px-3"
data-testid="all-custom-themes-option"
>
<div className="flex items-center w-full">
<MoreHorizontal
size={16}
className="mr-2 text-muted-foreground"
/>
<span className="flex-1">More themes</span>
</div>
</DropdownMenuItem>
)}
{/* Create Custom Theme option (always available) */}
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleCreateCustomTheme}
className="py-2 px-3"
data-testid="create-custom-theme"
>
<div className="flex items-center w-full">
<PlusCircle
size={16}
className="mr-2 text-muted-foreground"
/>
<span className="flex-1">New Theme</span>
</div>
</DropdownMenuItem>
</>
</DropdownMenuSubContent>
</DropdownMenuSub>
{toggleShowTokenBar && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={toggleShowTokenBar}
className={`py-2 px-3 group ${showTokenBar ? "bg-primary/10 text-primary" : ""}`}
data-testid="token-bar-toggle"
>
<ChartColumnIncreasing
size={16}
className={
showTokenBar
? "text-primary group-hover:text-accent-foreground"
: ""
}
/>
<span className="flex-1">
{showTokenBar ? "Hide" : "Show"} token usage
</span>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
{/* Custom Theme Dialog */}
<CustomThemeDialog
open={customThemeDialogOpen}
onOpenChange={handleCustomThemeDialogClose}
onThemeCreated={(themeId) => {
// Auto-select the newly created theme
handleThemeSelect(`custom:${themeId}`);
}}
/>
{/* All Custom Themes Dialog */}
<Dialog open={allThemesDialogOpen} onOpenChange={setAllThemesDialogOpen}>
<DialogContent className="max-w-md max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>All Custom Themes</DialogTitle>
</DialogHeader>
<div className="overflow-y-auto flex-1 -mx-6 px-6">
{/* All custom themes list */}
{customThemes.map((theme) => {
const themeId = `custom:${theme.id}`;
const isSelected = currentThemeId === themeId;
return ( return (
<Tooltip key={theme.id}> <div
<TooltipTrigger asChild> key={themeId}
<DropdownMenuItem onClick={() => {
onClick={() => handleThemeSelect(theme.id)} handleThemeSelect(themeId);
className={`py-2 px-3 ${isSelected ? "bg-primary/10" : ""}`} setAllThemesDialogOpen(false);
data-testid={`theme-option-${theme.id}`} }}
> className={`flex items-center p-3 rounded-lg cursor-pointer hover:bg-muted transition-colors ${
<div className="flex items-center w-full"> isSelected ? "bg-primary/10" : ""
{theme.icon === "palette" && ( }`}
<Palette >
size={16} <Brush size={18} className="mr-3 text-muted-foreground" />
className="mr-2 text-muted-foreground" <div className="flex-1">
/> <div className="font-medium">{theme.name}</div>
)} {theme.description && (
<span className="flex-1">{theme.name}</span> <div className="text-sm text-muted-foreground">
{isSelected && ( {theme.description}
<Check size={16} className="text-primary ml-2" />
)}
</div> </div>
</DropdownMenuItem> )}
</TooltipTrigger> </div>
<TooltipContent side="right"> {isSelected && <Check size={18} className="text-primary" />}
{theme.description} </div>
</TooltipContent>
</Tooltip>
); );
})} })}
</DropdownMenuSubContent> </div>
</DropdownMenuSub> </DialogContent>
</Dialog>
{toggleShowTokenBar && ( </>
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={toggleShowTokenBar}
className={`py-2 px-3 group ${showTokenBar ? "bg-primary/10 text-primary" : ""}`}
data-testid="token-bar-toggle"
>
<ChartColumnIncreasing
size={16}
className={
showTokenBar
? "text-primary group-hover:text-accent-foreground"
: ""
}
/>
<span className="flex-1">
{showTokenBar ? "Hide" : "Show"} token usage
</span>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
); );
} }
...@@ -245,3 +245,17 @@ export const mcpToolConsents = sqliteTable( ...@@ -245,3 +245,17 @@ export const mcpToolConsents = sqliteTable(
}, },
(table) => [unique("uniq_mcp_consent").on(table.serverId, table.toolName)], (table) => [unique("uniq_mcp_consent").on(table.serverId, table.toolName)],
); );
// --- Custom Themes table ---
export const customThemes = sqliteTable("custom_themes", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
description: text("description"),
prompt: text("prompt").notNull(),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
});
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { IpcClient } from "@/ipc/ipc_client";
import type {
CustomTheme,
CreateCustomThemeParams,
UpdateCustomThemeParams,
GenerateThemePromptParams,
GenerateThemePromptResult,
} from "@/ipc/ipc_types";
// Query key for custom themes
export const CUSTOM_THEMES_QUERY_KEY = ["custom-themes"];
/**
* Hook to fetch all custom themes.
*/
export function useCustomThemes() {
const query = useQuery({
queryKey: CUSTOM_THEMES_QUERY_KEY,
queryFn: async (): Promise<CustomTheme[]> => {
const ipcClient = IpcClient.getInstance();
return ipcClient.getCustomThemes();
},
meta: {
showErrorToast: true,
},
});
return {
customThemes: query.data ?? [],
isLoading: query.isLoading,
error: query.error,
refetch: query.refetch,
};
}
export function useCreateCustomTheme() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (
params: CreateCustomThemeParams,
): Promise<CustomTheme> => {
const ipcClient = IpcClient.getInstance();
return ipcClient.createCustomTheme(params);
},
onSuccess: () => {
// Invalidate all custom theme queries using prefix matching
queryClient.invalidateQueries({
queryKey: ["custom-themes"],
});
},
});
}
export function useUpdateCustomTheme() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (
params: UpdateCustomThemeParams,
): Promise<CustomTheme> => {
const ipcClient = IpcClient.getInstance();
return ipcClient.updateCustomTheme(params);
},
onSuccess: () => {
// Invalidate all custom theme queries using prefix matching
queryClient.invalidateQueries({
queryKey: ["custom-themes"],
});
},
});
}
export function useDeleteCustomTheme() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: number): Promise<void> => {
const ipcClient = IpcClient.getInstance();
await ipcClient.deleteCustomTheme({ id });
},
onSuccess: () => {
// Invalidate all custom theme queries using prefix matching
queryClient.invalidateQueries({
queryKey: ["custom-themes"],
});
},
});
}
export function useGenerateThemePrompt() {
return useMutation({
mutationFn: async (
params: GenerateThemePromptParams,
): Promise<GenerateThemePromptResult> => {
const ipcClient = IpcClient.getInstance();
return ipcClient.generateThemePrompt(params);
},
});
}
...@@ -20,7 +20,7 @@ import { ...@@ -20,7 +20,7 @@ import {
constructSystemPrompt, constructSystemPrompt,
readAiRules, readAiRules,
} from "../../prompts/system_prompt"; } from "../../prompts/system_prompt";
import { getThemePrompt } from "../../shared/themes"; import { getThemePromptById } from "../utils/theme_utils";
import { import {
getSupabaseAvailableSystemPrompt, getSupabaseAvailableSystemPrompt,
SUPABASE_NOT_AVAILABLE_SYSTEM_PROMPT, SUPABASE_NOT_AVAILABLE_SYSTEM_PROMPT,
...@@ -612,7 +612,7 @@ ${componentSnippet} ...@@ -612,7 +612,7 @@ ${componentSnippet}
const aiRules = await readAiRules(getDyadAppPath(updatedChat.app.path)); const aiRules = await readAiRules(getDyadAppPath(updatedChat.app.path));
// Get theme prompt for the app (null themeId means "no theme") // Get theme prompt for the app (null themeId means "no theme")
const themePrompt = getThemePrompt(updatedChat.app.themeId); const themePrompt = await getThemePromptById(updatedChat.app.themeId);
logger.log( logger.log(
`Theme for app ${updatedChat.app.id}: ${updatedChat.app.themeId ?? "none"}, prompt length: ${themePrompt.length} chars`, `Theme for app ${updatedChat.app.id}: ${updatedChat.app.themeId ?? "none"}, prompt length: ${themePrompt.length} chars`,
); );
......
import { createLoggedHandler } from "./safe_handle";
import log from "electron-log";
import { themesData, type Theme } from "../../shared/themes";
import { db } from "../../db";
import { apps } from "../../db/schema";
import { eq, sql } from "drizzle-orm";
import type { SetAppThemeParams, GetAppThemeParams } from "../ipc_types";
const logger = log.scope("themes_handlers");
const handle = createLoggedHandler(logger);
export function registerThemesHandlers() {
handle("get-themes", async (): Promise<Theme[]> => {
return themesData;
});
handle(
"set-app-theme",
async (_, params: SetAppThemeParams): Promise<void> => {
const { appId, themeId } = params;
// Use raw SQL to properly set NULL when themeId is null (representing "no theme")
if (!themeId) {
await db
.update(apps)
.set({ themeId: sql`NULL` })
.where(eq(apps.id, appId));
} else {
await db.update(apps).set({ themeId }).where(eq(apps.id, appId));
}
},
);
handle(
"get-app-theme",
async (_, params: GetAppThemeParams): Promise<string | null> => {
const app = await db.query.apps.findFirst({
where: eq(apps.id, params.appId),
columns: { themeId: true },
});
return app?.themeId ?? null;
},
);
}
...@@ -5,7 +5,7 @@ import { ...@@ -5,7 +5,7 @@ import {
constructSystemPrompt, constructSystemPrompt,
readAiRules, readAiRules,
} from "../../prompts/system_prompt"; } from "../../prompts/system_prompt";
import { getThemePrompt } from "../../shared/themes"; import { getThemePromptById } from "../utils/theme_utils";
import { import {
getSupabaseAvailableSystemPrompt, getSupabaseAvailableSystemPrompt,
SUPABASE_NOT_AVAILABLE_SYSTEM_PROMPT, SUPABASE_NOT_AVAILABLE_SYSTEM_PROMPT,
...@@ -65,7 +65,7 @@ export function registerTokenCountHandlers() { ...@@ -65,7 +65,7 @@ export function registerTokenCountHandlers() {
const mentionedAppNames = parseAppMentions(req.input); const mentionedAppNames = parseAppMentions(req.input);
// Count system prompt tokens // Count system prompt tokens
const themePrompt = getThemePrompt(chat.app?.themeId ?? null); const themePrompt = await getThemePromptById(chat.app?.themeId ?? null);
let systemPrompt = constructSystemPrompt({ let systemPrompt = constructSystemPrompt({
aiRules: await readAiRules(getDyadAppPath(chat.app.path)), aiRules: await readAiRules(getDyadAppPath(chat.app.path)),
chatMode: chatMode:
......
...@@ -95,6 +95,15 @@ import type { ...@@ -95,6 +95,15 @@ import type {
ConsoleEntry, ConsoleEntry,
SetAppThemeParams, SetAppThemeParams,
GetAppThemeParams, GetAppThemeParams,
CustomTheme,
CreateCustomThemeParams,
UpdateCustomThemeParams,
DeleteCustomThemeParams,
GenerateThemePromptParams,
GenerateThemePromptResult,
SaveThemeImageParams,
SaveThemeImageResult,
CleanupThemeImagesParams,
UncommittedFile, UncommittedFile,
} from "./ipc_types"; } from "./ipc_types";
import type { Template } from "../shared/templates"; import type { Template } from "../shared/templates";
...@@ -1686,6 +1695,46 @@ export class IpcClient { ...@@ -1686,6 +1695,46 @@ export class IpcClient {
return this.ipcRenderer.invoke("get-app-theme", params); return this.ipcRenderer.invoke("get-app-theme", params);
} }
public async getCustomThemes(): Promise<CustomTheme[]> {
return this.ipcRenderer.invoke("get-custom-themes");
}
public async createCustomTheme(
params: CreateCustomThemeParams,
): Promise<CustomTheme> {
return this.ipcRenderer.invoke("create-custom-theme", params);
}
public async updateCustomTheme(
params: UpdateCustomThemeParams,
): Promise<CustomTheme> {
return this.ipcRenderer.invoke("update-custom-theme", params);
}
public async deleteCustomTheme(
params: DeleteCustomThemeParams,
): Promise<void> {
await this.ipcRenderer.invoke("delete-custom-theme", params);
}
public async generateThemePrompt(
params: GenerateThemePromptParams,
): Promise<GenerateThemePromptResult> {
return this.ipcRenderer.invoke("generate-theme-prompt", params);
}
public async saveThemeImage(
params: SaveThemeImageParams,
): Promise<SaveThemeImageResult> {
return this.ipcRenderer.invoke("save-theme-image", params);
}
public async cleanupThemeImages(
params: CleanupThemeImagesParams,
): Promise<void> {
await this.ipcRenderer.invoke("cleanup-theme-images", params);
}
// --- Prompts Library --- // --- Prompts Library ---
public async listPrompts(): Promise<PromptDto[]> { public async listPrompts(): Promise<PromptDto[]> {
return this.ipcRenderer.invoke("prompts:list"); return this.ipcRenderer.invoke("prompts:list");
......
...@@ -28,7 +28,7 @@ import { registerCapacitorHandlers } from "./handlers/capacitor_handlers"; ...@@ -28,7 +28,7 @@ import { registerCapacitorHandlers } from "./handlers/capacitor_handlers";
import { registerProblemsHandlers } from "./handlers/problems_handlers"; import { registerProblemsHandlers } from "./handlers/problems_handlers";
import { registerAppEnvVarsHandlers } from "./handlers/app_env_vars_handlers"; import { registerAppEnvVarsHandlers } from "./handlers/app_env_vars_handlers";
import { registerTemplateHandlers } from "./handlers/template_handlers"; import { registerTemplateHandlers } from "./handlers/template_handlers";
import { registerThemesHandlers } from "./handlers/themes_handlers"; import { registerThemesHandlers } from "../pro/main/ipc/handlers/themes_handlers";
import { registerPortalHandlers } from "./handlers/portal_handlers"; import { registerPortalHandlers } from "./handlers/portal_handlers";
import { registerPromptHandlers } from "./handlers/prompt_handlers"; import { registerPromptHandlers } from "./handlers/prompt_handlers";
import { registerHelpBotHandlers } from "./handlers/help_bot_handlers"; import { registerHelpBotHandlers } from "./handlers/help_bot_handlers";
......
...@@ -779,6 +779,65 @@ export interface GetAppThemeParams { ...@@ -779,6 +779,65 @@ export interface GetAppThemeParams {
appId: number; appId: number;
} }
// --- Custom Theme Types ---
export interface CustomTheme {
id: number;
name: string;
description: string | null;
prompt: string;
createdAt: Date;
updatedAt: Date;
}
export interface CreateCustomThemeParams {
name: string;
description?: string;
prompt: string;
}
export interface UpdateCustomThemeParams {
id: number;
name?: string;
description?: string;
prompt?: string;
}
export interface DeleteCustomThemeParams {
id: number;
}
export type ThemeGenerationMode = "inspired" | "high-fidelity";
export type ThemeGenerationModel =
| "gemini-3-pro"
| "claude-opus-4.5"
| "gpt-5.2";
export interface GenerateThemePromptParams {
imagePaths: string[]; // File paths to images (stored in temp directory)
keywords: string;
generationMode: ThemeGenerationMode; // 'inspired' (abstract design system) or 'high-fidelity' (visual recreation)
model: ThemeGenerationModel; // Model to use for generation
}
export interface GenerateThemePromptResult {
prompt: string;
}
// --- Theme Image File Handling ---
export interface SaveThemeImageParams {
data: string; // Base64 encoded image data
filename: string; // Original filename for extension detection
}
export interface SaveThemeImageResult {
path: string; // Path to the saved temp file
}
export interface CleanupThemeImagesParams {
paths: string[]; // Paths to delete
}
// --- Uncommitted Files Types --- // --- Uncommitted Files Types ---
export type UncommittedFileStatus = export type UncommittedFileStatus =
| "added" | "added"
......
import log from "electron-log";
import { db } from "../../db";
import { customThemes } from "../../db/schema";
import { eq } from "drizzle-orm";
import { themesData, type Theme } from "../../shared/themes";
const logger = log.scope("theme_utils");
/**
* Check if a theme ID refers to a custom theme.
* Custom theme IDs are prefixed with "custom:"
*/
export function isCustomThemeId(themeId: string | null): boolean {
return themeId?.startsWith("custom:") ?? false;
}
/**
* Extract the numeric ID from a custom theme ID.
* e.g., "custom:123" -> 123
*/
export function getCustomThemeNumericId(themeId: string): number | null {
if (!isCustomThemeId(themeId)) return null;
const numericId = parseInt(themeId.replace("custom:", ""), 10);
return isNaN(numericId) ? null : numericId;
}
/**
* Get a built-in theme by ID.
*/
export function getBuiltinThemeById(themeId: string | null): Theme | null {
if (!themeId) return null;
return themesData.find((t) => t.id === themeId) ?? null;
}
/**
* Async function to resolve theme prompt by ID.
* Handles both built-in themes (by ID) and custom themes (prefixed with "custom:")
*/
export async function getThemePromptById(
themeId: string | null,
): Promise<string> {
if (!themeId) {
return "";
}
// Check if it's a custom theme
if (isCustomThemeId(themeId)) {
const numericId = getCustomThemeNumericId(themeId);
if (numericId === null) {
logger.warn(`Invalid custom theme ID: ${themeId}`);
return "";
}
const customTheme = await db.query.customThemes.findFirst({
where: eq(customThemes.id, numericId),
});
if (!customTheme) {
logger.warn(`Custom theme not found: ${themeId}`);
return "";
}
return customTheme.prompt;
}
// It's a built-in theme
const builtinTheme = getBuiltinThemeById(themeId);
return builtinTheme?.prompt ?? "";
}
import { useState } from "react";
import {
useCustomThemes,
useUpdateCustomTheme,
useDeleteCustomTheme,
} from "@/hooks/useCustomThemes";
import { CustomThemeDialog } from "@/components/CustomThemeDialog";
import { EditThemeDialog } from "@/components/EditThemeDialog";
import { DeleteConfirmationDialog } from "@/components/DeleteConfirmationDialog";
import { Button } from "@/components/ui/button";
import { Plus, Palette } from "lucide-react";
import { showError } from "@/lib/toast";
import type { CustomTheme } from "@/ipc/ipc_types";
export default function ThemesPage() {
const { customThemes, isLoading } = useCustomThemes();
const [createDialogOpen, setCreateDialogOpen] = useState(false);
return (
<div className="min-h-screen px-8 py-6">
<div className="max-w-6xl mx-auto">
<div className="flex items-center justify-between mb-6">
<h1 className="text-3xl font-bold mr-4">
<Palette className="inline-block h-8 w-8 mr-2" />
Themes
</h1>
<Button onClick={() => setCreateDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" /> New Theme
</Button>
</div>
{isLoading ? (
<div>Loading...</div>
) : customThemes.length === 0 ? (
<div className="text-muted-foreground">
No custom themes yet. Create one to get started.
</div>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(320px,1fr))] gap-4">
{customThemes.map((theme) => (
<ThemeCard key={theme.id} theme={theme} />
))}
</div>
)}
<CustomThemeDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
/>
</div>
</div>
);
}
function ThemeCard({ theme }: { theme: CustomTheme }) {
const updateThemeMutation = useUpdateCustomTheme();
const deleteThemeMutation = useDeleteCustomTheme();
const isDeleting = deleteThemeMutation.isPending;
const handleUpdate = async (params: {
id: number;
name: string;
description?: string;
prompt: string;
}) => {
await updateThemeMutation.mutateAsync(params);
};
const handleDelete = async () => {
try {
await deleteThemeMutation.mutateAsync(theme.id);
} catch (error) {
showError(
`Failed to delete theme: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
};
return (
<div
data-testid="theme-card"
className="border rounded-lg p-4 bg-(--background-lightest)"
>
<div className="space-y-2">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<Palette className="h-4 w-4 text-muted-foreground shrink-0" />
<h3 className="text-lg font-semibold truncate">{theme.name}</h3>
</div>
{theme.description && (
<p className="text-sm text-muted-foreground mt-1">
{theme.description}
</p>
)}
</div>
<div className="flex gap-1 shrink-0 ml-2">
<EditThemeDialog theme={theme} onUpdateTheme={handleUpdate} />
<DeleteConfirmationDialog
itemName={theme.name}
itemType="Theme"
onDelete={handleDelete}
isDeleting={isDeleting}
/>
</div>
</div>
<pre className="text-sm whitespace-pre-wrap bg-transparent border rounded p-2 max-h-48 overflow-auto">
{theme.prompt}
</pre>
</div>
</div>
);
}
...@@ -173,6 +173,13 @@ const validInvokeChannels = [ ...@@ -173,6 +173,13 @@ const validInvokeChannels = [
"get-themes", "get-themes",
"set-app-theme", "set-app-theme",
"get-app-theme", "get-app-theme",
"get-custom-themes",
"create-custom-theme",
"update-custom-theme",
"delete-custom-theme",
"generate-theme-prompt",
"save-theme-image",
"cleanup-theme-images",
// Test-only channels // Test-only channels
// These should ALWAYS be guarded with IS_TEST_BUILD in the main process. // These should ALWAYS be guarded with IS_TEST_BUILD in the main process.
// We can't detect with IS_TEST_BUILD in the preload script because // We can't detect with IS_TEST_BUILD in the preload script because
......
import { createLoggedHandler } from "../../../../ipc/handlers/safe_handle";
import log from "electron-log";
import path from "path";
import os from "os";
import fs from "fs";
import { readFile, writeFile, unlink, mkdir } from "fs/promises";
import { themesData, type Theme } from "../../../../shared/themes";
import { db } from "../../../../db";
import { apps, customThemes } from "../../../../db/schema";
import { eq, sql } from "drizzle-orm";
import { streamText, TextPart, ImagePart } from "ai";
import { readSettings } from "../../../../main/settings";
import { IS_TEST_BUILD } from "@/ipc/utils/test_utils";
import { getModelClient } from "../../../../ipc/utils/get_model_client";
import type {
SetAppThemeParams,
GetAppThemeParams,
CustomTheme,
CreateCustomThemeParams,
UpdateCustomThemeParams,
DeleteCustomThemeParams,
GenerateThemePromptParams,
GenerateThemePromptResult,
SaveThemeImageParams,
SaveThemeImageResult,
CleanupThemeImagesParams,
} from "@/ipc/ipc_types";
const logger = log.scope("themes_handlers");
const handle = createLoggedHandler(logger);
// Directory for storing temporary theme images
const THEME_IMAGES_TEMP_DIR = path.join(os.tmpdir(), "dyad-theme-images");
// Ensure temp directory exists
if (!fs.existsSync(THEME_IMAGES_TEMP_DIR)) {
fs.mkdirSync(THEME_IMAGES_TEMP_DIR, { recursive: true });
}
// Get mime type from extension
function getMimeTypeFromExtension(
ext: string,
): "image/jpeg" | "image/png" | "image/gif" | "image/webp" {
const mimeMap: Record<
string,
"image/jpeg" | "image/png" | "image/gif" | "image/webp"
> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
};
return mimeMap[ext.toLowerCase()] || "image/png";
}
const THEME_GENERATION_META_PROMPT = `PURPOSE
- Generate a strict SYSTEM PROMPT that extracts a reusable UI DESIGN SYSTEM from provided images.
- This is a visual ruleset, not a website blueprint.
- Extract constraints, scales, and principles — never layouts or compositions.
- You are NOT recreating, cloning, or reverse-engineering a specific website.
- The resulting system must be applicable to unrelated products without visual resemblance.
SCOPE & LIMITATIONS (MANDATORY)
- Do NOT reproduce:
- Page layouts
- Component hierarchies
- Spatial arrangements
- Relative positioning between elements
- Information architecture
- Do NOT describe the original interface.
- Do NOT reference screen structure, sections, or flows.
- The output must remain abstract, systemic, and transferable.
INPUTS
- One or more UI images
- Optional reference name (popular product or known design system)
- Visual input defines stylistic constraints only (tokens, shapes, motion, density)
FIXED TECH STACK
- Assume React + Tailwind CSS + shadcn/ui.
- Hard Rules:
- Never ship default shadcn styles
- No inline styles
- No arbitrary values outside defined scales
- All styling must be token-driven
OUTPUT RULES
- Wrap the entire output in <theme></theme> tags.
- Output exactly ONE SYSTEM PROMPT that:
- Names the inspiration strictly as a stylistic reference, not a target
- Defines enforceable rules, never descriptions
- Uses imperative language only ("must", "never", "always")
- Never mentions images, screenshots, or visual analysis
- Produces a system that cannot recreate the original UI even if followed precisely
REQUIRED STRUCTURE
- Visual Objective (abstract, non-descriptive)
- Layout & Spacing Rules (scales only, no patterns)
- Typography System (roles, hierarchy, constraints)
- Color & Surfaces (tokens, elevation logic)
- Components & Shape Language (geometry, affordances — no layouts)
- Motion & Interaction (timing, intent, limits)
- Forbidden Patterns (explicit anti-cloning rules)
- Self-Check (verifies abstraction & non-replication)
`;
const HIGH_FIDELITY_META_PROMPT = `PURPOSE
- Generate a strict SYSTEM PROMPT that allows an AI to recreate a UI visual system from a provided image.
- This is a visual subsystem. Do not define roles or personas.
- Extract rules, not descriptions.
INPUTS
- One or more UI images
- Optional reference name (popular product / design system)
- Image always takes priority.
FIXED TECH STACK
- Assume React + Tailwind CSS + shadcn/ui.
- Rules:
- Never ship default shadcn styles
- No inline styles
- No arbitrary values outside defined scales
OUTPUT RULES
- Wrap the entire output in <theme></theme> tags.
- Output one SYSTEM PROMPT that:
- Explicitly names the inspiration as a guiding reference
- Uses hard, enforceable rules only
- Is technical and unambiguous
- Never mentions the image
- Avoids vague language ("might", "appears", etc.)
REQUIRED STRUCTURE
- Visual Objective
- Layout & Spacing Rules
- Typography System
- Color & Surfaces
- Components & Shape Language
- Motion & Interaction
- Forbidden Patterns
- Self-Check
`;
export function registerThemesHandlers() {
// Get built-in themes
handle("get-themes", async (): Promise<Theme[]> => {
return themesData;
});
// Set app theme (built-in or custom theme ID)
handle(
"set-app-theme",
async (_, params: SetAppThemeParams): Promise<void> => {
const { appId, themeId } = params;
// Use raw SQL to properly set NULL when themeId is null (representing "no theme")
if (!themeId) {
await db
.update(apps)
.set({ themeId: sql`NULL` })
.where(eq(apps.id, appId));
} else {
await db.update(apps).set({ themeId }).where(eq(apps.id, appId));
}
},
);
// Get app theme
handle(
"get-app-theme",
async (_, params: GetAppThemeParams): Promise<string | null> => {
const app = await db.query.apps.findFirst({
where: eq(apps.id, params.appId),
columns: { themeId: true },
});
return app?.themeId ?? null;
},
);
// Get all custom themes
handle("get-custom-themes", async (): Promise<CustomTheme[]> => {
const themes = await db.query.customThemes.findMany({
orderBy: (themes, { desc }) => [desc(themes.createdAt)],
});
return themes.map((t) => ({
id: t.id,
name: t.name,
description: t.description,
prompt: t.prompt,
createdAt: t.createdAt,
updatedAt: t.updatedAt,
}));
});
// Create custom theme
handle(
"create-custom-theme",
async (_, params: CreateCustomThemeParams): Promise<CustomTheme> => {
// Validate and sanitize inputs
const trimmedName = params.name.trim();
const trimmedDescription = params.description?.trim();
const trimmedPrompt = params.prompt.trim();
// Validate name
if (!trimmedName) {
throw new Error("Theme name is required");
}
if (trimmedName.length > 100) {
throw new Error("Theme name must be less than 100 characters");
}
// Validate description
if (trimmedDescription && trimmedDescription.length > 500) {
throw new Error("Theme description must be less than 500 characters");
}
// Validate prompt
if (!trimmedPrompt) {
throw new Error("Theme prompt is required");
}
if (trimmedPrompt.length > 50000) {
throw new Error("Theme prompt must be less than 50,000 characters");
}
// Check for duplicate theme name (case-insensitive)
const existingTheme = await db.query.customThemes.findFirst({
where: sql`LOWER(${customThemes.name}) = LOWER(${trimmedName})`,
});
if (existingTheme) {
throw new Error(
`A theme named "${trimmedName}" already exists. Please choose a different name.`,
);
}
const result = await db
.insert(customThemes)
.values({
name: trimmedName,
description: trimmedDescription || null,
prompt: trimmedPrompt,
})
.returning();
const theme = result[0];
return {
id: theme.id,
name: theme.name,
description: theme.description,
prompt: theme.prompt,
createdAt: theme.createdAt,
updatedAt: theme.updatedAt,
};
},
);
// Update custom theme
handle(
"update-custom-theme",
async (_, params: UpdateCustomThemeParams): Promise<CustomTheme> => {
const updateData: Partial<{
name: string;
description: string | null;
prompt: string;
updatedAt: Date;
}> = {
updatedAt: new Date(),
};
// Get the current theme to verify it exists
const currentTheme = await db.query.customThemes.findFirst({
where: eq(customThemes.id, params.id),
});
if (!currentTheme) {
throw new Error("Theme not found");
}
// Validate and sanitize name if provided
if (params.name !== undefined) {
const trimmedName = params.name.trim();
if (!trimmedName) {
throw new Error("Theme name is required");
}
if (trimmedName.length > 100) {
throw new Error("Theme name must be less than 100 characters");
}
// Check for duplicate theme name (case-insensitive), excluding current theme
const existingTheme = await db.query.customThemes.findFirst({
where: sql`LOWER(${customThemes.name}) = LOWER(${trimmedName}) AND ${customThemes.id} != ${params.id}`,
});
if (existingTheme) {
throw new Error(
`A theme named "${trimmedName}" already exists. Please choose a different name.`,
);
}
updateData.name = trimmedName;
}
// Validate and sanitize description if provided
if (params.description !== undefined) {
const trimmedDescription = params.description.trim();
if (trimmedDescription.length > 500) {
throw new Error("Theme description must be less than 500 characters");
}
updateData.description = trimmedDescription || null;
}
// Validate and sanitize prompt if provided
if (params.prompt !== undefined) {
const trimmedPrompt = params.prompt.trim();
if (!trimmedPrompt) {
throw new Error("Theme prompt is required");
}
if (trimmedPrompt.length > 50000) {
throw new Error("Theme prompt must be less than 50,000 characters");
}
updateData.prompt = trimmedPrompt;
}
const result = await db
.update(customThemes)
.set(updateData)
.where(eq(customThemes.id, params.id))
.returning();
const theme = result[0];
if (!theme) {
throw new Error("Theme not found");
}
return {
id: theme.id,
name: theme.name,
description: theme.description,
prompt: theme.prompt,
createdAt: theme.createdAt,
updatedAt: theme.updatedAt,
};
},
);
// Delete custom theme
handle(
"delete-custom-theme",
async (_, params: DeleteCustomThemeParams): Promise<void> => {
await db.delete(customThemes).where(eq(customThemes.id, params.id));
},
);
// Save theme image to temp directory
handle(
"save-theme-image",
async (_, params: SaveThemeImageParams): Promise<SaveThemeImageResult> => {
const { data, filename } = params;
// Validate base64 data
if (!data || typeof data !== "string") {
throw new Error("Invalid image data");
}
// Validate and extract extension
const ext = path.extname(filename).toLowerCase();
const validExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp"];
if (!validExtensions.includes(ext)) {
throw new Error(
`Invalid image extension: ${ext}. Supported: ${validExtensions.join(", ")}`,
);
}
// Generate unique filename
const uniqueFilename = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}${ext}`;
const filePath = path.join(THEME_IMAGES_TEMP_DIR, uniqueFilename);
// Validate size (base64 to bytes approximation)
const sizeInBytes = (data.length * 3) / 4;
if (sizeInBytes > 10 * 1024 * 1024) {
throw new Error("Image size exceeds 10MB limit");
}
// Ensure temp directory exists
await mkdir(THEME_IMAGES_TEMP_DIR, { recursive: true });
// Write file
const buffer = Buffer.from(data, "base64");
await writeFile(filePath, buffer);
return { path: filePath };
},
);
// Cleanup theme images from temp directory
handle(
"cleanup-theme-images",
async (_, params: CleanupThemeImagesParams): Promise<void> => {
const { paths } = params;
for (const filePath of paths) {
// Security: only delete files in our temp directory
// Use path.resolve() to normalize and prevent path traversal attacks
const normalizedPath = path.resolve(filePath);
const normalizedTempDir = path.resolve(THEME_IMAGES_TEMP_DIR);
if (!normalizedPath.startsWith(normalizedTempDir + path.sep)) {
throw new Error(
"Invalid path: cannot delete files outside temp directory",
);
}
try {
await unlink(filePath);
logger.log(`Cleaned up theme image: ${filePath}`);
} catch (error) {
// File might already be deleted (ENOENT), that's okay
// But other errors (permissions, etc.) should be reported
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw new Error("Failed to cleanup temporary image file");
}
}
}
},
);
handle(
"generate-theme-prompt",
async (
_,
params: GenerateThemePromptParams,
): Promise<GenerateThemePromptResult> => {
const settings = readSettings();
// Return mock response in test mode
if (IS_TEST_BUILD) {
return {
prompt: `<theme>
# Test Mode Theme
## Visual Objective
Modern dark theme with purple accents for testing.
</theme>`,
};
}
if (!settings.enableDyadPro) {
throw new Error(
"Dyad Pro is required for AI theme generation. Please enable Dyad Pro in Settings.",
);
}
// Validate inputs - image paths are required
if (params.imagePaths.length === 0) {
throw new Error("Please upload at least one image to generate a theme");
}
if (params.imagePaths.length > 5) {
throw new Error("Maximum 5 images allowed");
}
// Validate keywords length
if (params.keywords.length > 500) {
throw new Error("Keywords must be less than 500 characters");
}
// Validate generation mode
if (!["inspired", "high-fidelity"].includes(params.generationMode)) {
throw new Error("Invalid generation mode");
}
// Validate and map model selection
const modelMap: Record<string, { provider: string; name: string }> = {
"gemini-3-pro": { provider: "google", name: "gemini-3-pro-preview" },
"claude-opus-4.5": {
provider: "anthropic",
name: "claude-opus-4-5",
},
"gpt-5.2": { provider: "openai", name: "gpt-5.2" },
};
const selectedModel = modelMap[params.model];
if (!selectedModel) {
throw new Error("Invalid model selection");
}
// Use the selected model for theme generation
const { modelClient } = await getModelClient(selectedModel, settings);
// Select system prompt based on generation mode
const systemPrompt =
params.generationMode === "high-fidelity"
? HIGH_FIDELITY_META_PROMPT
: THEME_GENERATION_META_PROMPT;
// Build the user input prompt
const keywordsPart = params.keywords.trim() || "N/A";
const imagesPart =
params.imagePaths.length > 0
? `${params.imagePaths.length} image(s) attached`
: "N/A";
const userInput = `inspired by: ${keywordsPart}
images: ${imagesPart}`;
// Generate theme with images - read from file paths
try {
const contentParts: (TextPart | ImagePart)[] = [];
// Add user input text first
contentParts.push({ type: "text", text: userInput });
// Read images from file paths and add to content
for (const imagePath of params.imagePaths) {
// Security: validate path is in our temp directory
// Use path.resolve() to normalize and prevent path traversal attacks
const normalizedImagePath = path.resolve(imagePath);
const normalizedTempDir = path.resolve(THEME_IMAGES_TEMP_DIR);
if (!normalizedImagePath.startsWith(normalizedTempDir + path.sep)) {
throw new Error(
"Invalid image path: images must be uploaded through the theme dialog",
);
}
try {
const imageBuffer = await readFile(imagePath);
const base64Data = imageBuffer.toString("base64");
const ext = path.extname(imagePath).toLowerCase();
const mimeType = getMimeTypeFromExtension(ext);
contentParts.push({
type: "image",
image: base64Data,
mimeType,
} as ImagePart);
} catch {
throw new Error(
`Failed to read image file: ${path.basename(imagePath)}`,
);
}
}
const stream = streamText({
model: modelClient.model,
system: systemPrompt,
maxRetries: 1,
messages: [{ role: "user", content: contentParts }],
});
const result = await stream.text;
return { prompt: result };
} catch (error) {
throw new Error(
error instanceof Error
? error.message
: "Failed to process images for theme generation. Please try with fewer or smaller images, or use manual mode.",
);
}
},
);
}
...@@ -7,11 +7,13 @@ import { providerSettingsRoute } from "./routes/settings/providers/$provider"; ...@@ -7,11 +7,13 @@ import { providerSettingsRoute } from "./routes/settings/providers/$provider";
import { appDetailsRoute } from "./routes/app-details"; import { appDetailsRoute } from "./routes/app-details";
import { hubRoute } from "./routes/hub"; import { hubRoute } from "./routes/hub";
import { libraryRoute } from "./routes/library"; import { libraryRoute } from "./routes/library";
import { themesRoute } from "./routes/themes";
const routeTree = rootRoute.addChildren([ const routeTree = rootRoute.addChildren([
homeRoute, homeRoute,
hubRoute, hubRoute,
libraryRoute, libraryRoute,
themesRoute,
chatRoute, chatRoute,
appDetailsRoute, appDetailsRoute,
settingsRoute.addChildren([providerSettingsRoute]), settingsRoute.addChildren([providerSettingsRoute]),
......
import { createRoute } from "@tanstack/react-router";
import { rootRoute } from "./root";
import ThemesPage from "@/pages/themes";
export const themesRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/themes",
component: ThemesPage,
});
...@@ -70,20 +70,3 @@ export const themesData: Theme[] = [ ...@@ -70,20 +70,3 @@ export const themesData: Theme[] = [
prompt: DEFAULT_THEME_PROMPT, prompt: DEFAULT_THEME_PROMPT,
}, },
]; ];
export function getThemeById(themeId: string | null): Theme | null {
// null means "no theme" - return null
if (!themeId) {
return null;
}
return themesData.find((t) => t.id === themeId) ?? null;
}
export function getThemePrompt(themeId: string | null): string {
// null means "no theme" - return empty string (no prompt)
if (!themeId) {
return "";
}
const theme = getThemeById(themeId);
return theme?.prompt ?? "";
}
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论