diff --git a/drizzle/0027_luxuriant_redwing.sql b/drizzle/0027_luxuriant_redwing.sql new file mode 100644 index 0000000000..54f76c6e34 --- /dev/null +++ b/drizzle/0027_luxuriant_redwing.sql @@ -0,0 +1 @@ +ALTER TABLE `chats` ADD `chat_mode` text; \ No newline at end of file diff --git a/drizzle/meta/0027_snapshot.json b/drizzle/meta/0027_snapshot.json new file mode 100644 index 0000000000..931f0b6eaa --- /dev/null +++ b/drizzle/meta/0027_snapshot.json @@ -0,0 +1,913 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "886e0ca2-c54c-4d21-9e59-277e628e6fda", + "prevId": "80dda8f1-cd0c-411a-8d8c-1e9c32c485e3", + "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 + }, + "chat_mode": { + "name": "chat_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "compacted_at": { + "name": "compacted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "compaction_backup_path": { + "name": "compaction_backup_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pending_compaction": { + "name": "pending_compaction", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "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 + }, + "headers_json": { + "name": "headers_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 + }, + "using_free_agent_mode_quota": { + "name": "using_free_agent_mode_quota", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_compaction_summary": { + "name": "is_compaction_summary", + "type": "integer", + "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 + }, + "slug": { + "name": "slug", + "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": { + "prompts_slug_unique": { + "name": "prompts_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "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 diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index ab8b0f0d79..3abad680a5 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -190,6 +190,13 @@ "when": 1771025337064, "tag": "0026_lying_joseph", "breakpoints": true + }, + { + "idx": 27, + "version": "6", + "when": 1774703616778, + "tag": "0027_luxuriant_redwing", + "breakpoints": true } ] } \ No newline at end of file diff --git a/e2e-tests/chat_mode_persistence.spec.ts b/e2e-tests/chat_mode_persistence.spec.ts new file mode 100644 index 0000000000..6267346862 --- /dev/null +++ b/e2e-tests/chat_mode_persistence.spec.ts @@ -0,0 +1,56 @@ +import { test } from "./helpers/test_helper"; +import { expect } from "@playwright/test"; + +test("chat mode persists when switching between chats", async ({ po }) => { + await po.setUp({ autoApprove: true }); + await po.importApp("minimal"); + + await po.sendPrompt("[dump] chat mode test 1"); + await po.chatActions.waitForChatCompletion(); + await po.chatActions.selectChatMode("ask"); + + await po.page.waitForTimeout(500); + let modeText = await po.chatActions.getChatMode(); + expect(modeText).toContain("Ask"); + + await po.chatActions.clickNewChat(); + + await po.page.waitForTimeout(300); + await po.sendPrompt("[dump] chat mode test 2"); + await po.chatActions.waitForChatCompletion(); + await po.chatActions.selectChatMode("plan"); + + await po.page.waitForTimeout(500); + modeText = await po.chatActions.getChatMode(); + expect(modeText).toContain("Plan"); + + const allTabs = po.page.locator("div[draggable]"); + const tabCount = await allTabs.count(); + + expect(tabCount).toBeGreaterThanOrEqual(2); + + const inactiveTabs = po.page + .locator("div[draggable]") + .filter({ hasNot: po.page.locator('button[aria-current="page"]') }); + const inactiveCount = await inactiveTabs.count(); + let foundAskTab = false; + + for (let i = 0; i < inactiveCount; i++) { + await inactiveTabs.nth(i).locator("button").first().click(); + const currentMode = await po.chatActions.getChatMode(); + if (currentMode.includes("Ask")) { + foundAskTab = true; + break; + } + } + + expect(foundAskTab).toBe(true); + + const messagesList = po.page.getByTestId("messages-list"); + await expect(messagesList).toBeVisible({ timeout: 5000 }); + await po.page.waitForTimeout(300); + + await expect + .poll(async () => po.chatActions.getChatMode(), { timeout: 5000 }) + .toContain("Ask"); +}); diff --git a/e2e-tests/helpers/page-objects/components/ChatActions.ts b/e2e-tests/helpers/page-objects/components/ChatActions.ts index 4755d03a5b..7c93832c2c 100644 --- a/e2e-tests/helpers/page-objects/components/ChatActions.ts +++ b/e2e-tests/helpers/page-objects/components/ChatActions.ts @@ -38,6 +38,16 @@ export class ChatActions { }).toPass({ timeout: Timeout.SHORT }); } + async dismissFloatingOverlays() { + const tooltipOverlay = this.page.locator( + '[data-slot="tooltip-content"][data-open]', + ); + if (await tooltipOverlay.count()) { + await this.page.keyboard.press("Escape"); + await expect(tooltipOverlay).toHaveCount(0, { timeout: Timeout.SHORT }); + } + } + /** * Opens the chat history menu by clearing the input and pressing ArrowUp. * Uses toPass() for resilience since the Lexical editor may need time to @@ -99,27 +109,39 @@ export class ChatActions { async selectChatMode( mode: "build" | "ask" | "agent" | "local-agent" | "basic-agent" | "plan", ) { - await this.page.getByTestId("chat-mode-selector").click(); - const mapping: Record = { - build: "Build Generate and edit code", - ask: "Ask Ask", - agent: "Build with MCP", - "local-agent": "Agent v2", - "basic-agent": "Basic Agent", // For free users - plan: "Plan.*Design before you build", + await this.dismissFloatingOverlays(); + const selector = this.page.getByTestId("chat-mode-selector"); + await expect(selector).toBeVisible({ timeout: Timeout.MEDIUM }); + await selector.click({ force: true }); + const mapping: Record = { + build: /^Build/, + ask: /^Ask/, + agent: /^Agent/, + "local-agent": /^Agent/, + "basic-agent": /Basic Agent/, + plan: /^Plan/, }; const optionName = mapping[mode]; - await this.page - .getByRole("option", { - name: new RegExp(optionName), - }) - .click(); + + const option = this.page.getByRole("option", { + name: optionName, + }); + + await expect(option).toBeVisible({ timeout: Timeout.MEDIUM }); + await option.click({ force: true }); + // Dismiss any open tooltips after mode selection + await this.page.keyboard.press("Escape"); } async selectLocalAgentMode() { await this.selectChatMode("local-agent"); } + async getChatMode(): Promise { + const modeButton = this.page.getByTestId("chat-mode-selector"); + return (await modeButton.textContent()) || ""; + } + async snapshotChatInputContainer() { await expect(this.getChatInputContainer()).toMatchAriaSnapshot(); } diff --git a/package-lock.json b/package-lock.json index 4df18dfa29..eeb4c32df6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dyad", - "version": "0.43.0", + "version": "0.43.0-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dyad", - "version": "0.43.0", + "version": "0.43.0-beta.1", "license": "MIT", "dependencies": { "@ai-sdk/amazon-bedrock": "^4.0.46", diff --git a/src/__tests__/chat_tabs.test.ts b/src/__tests__/chat_tabs.test.ts index d58ca5ea53..84496e3108 100644 --- a/src/__tests__/chat_tabs.test.ts +++ b/src/__tests__/chat_tabs.test.ts @@ -26,6 +26,7 @@ function chat(id: number, appId = 1): ChatSummary { id, appId, title: `Chat ${id}`, + chatMode: null, createdAt: new Date(), }; } diff --git a/src/__tests__/local_agent_handler.test.ts b/src/__tests__/local_agent_handler.test.ts index cf49b5ec26..92f578838c 100644 --- a/src/__tests__/local_agent_handler.test.ts +++ b/src/__tests__/local_agent_handler.test.ts @@ -340,6 +340,7 @@ describe("handleLocalAgentStream", () => { placeholderMessageId: 10, systemPrompt: "You are helpful", dyadRequestId, + effectiveStreamMode: "build", }, ); @@ -369,6 +370,7 @@ describe("handleLocalAgentStream", () => { placeholderMessageId: 10, systemPrompt: "You are helpful", dyadRequestId, + effectiveStreamMode: "build", }, ); @@ -395,6 +397,7 @@ describe("handleLocalAgentStream", () => { placeholderMessageId: 10, systemPrompt: "You are helpful", dyadRequestId, + effectiveStreamMode: "local-agent", }, ), ).rejects.toThrow("Chat not found: 999"); @@ -416,6 +419,7 @@ describe("handleLocalAgentStream", () => { placeholderMessageId: 10, systemPrompt: "You are helpful", dyadRequestId, + effectiveStreamMode: "local-agent", }, ), ).rejects.toThrow("Chat not found: 1"); @@ -457,6 +461,7 @@ describe("handleLocalAgentStream", () => { placeholderMessageId: 10, systemPrompt: "You are helpful", dyadRequestId, + effectiveStreamMode: "local-agent", }, ); @@ -491,6 +496,7 @@ describe("handleLocalAgentStream", () => { placeholderMessageId: 10, systemPrompt: "You are helpful", dyadRequestId, + effectiveStreamMode: "local-agent", }, ); @@ -607,6 +613,7 @@ describe("handleLocalAgentStream", () => { placeholderMessageId: 10, systemPrompt: "You are helpful", dyadRequestId, + effectiveStreamMode: "local-agent", }, ); @@ -836,6 +843,7 @@ describe("handleLocalAgentStream", () => { placeholderMessageId: 10, systemPrompt: "You are helpful", dyadRequestId, + effectiveStreamMode: "local-agent", }, ); @@ -873,6 +881,7 @@ describe("handleLocalAgentStream", () => { placeholderMessageId: 10, systemPrompt: "You are helpful", dyadRequestId, + effectiveStreamMode: "local-agent", }, ); @@ -947,6 +956,7 @@ describe("handleLocalAgentStream", () => { placeholderMessageId: 10, systemPrompt: "You are helpful", dyadRequestId, + effectiveStreamMode: "local-agent", }, ); @@ -1040,6 +1050,7 @@ describe("handleLocalAgentStream", () => { placeholderMessageId: 10, systemPrompt: "You are helpful", dyadRequestId, + effectiveStreamMode: "local-agent", }, ); @@ -1091,8 +1102,11 @@ describe("handleLocalAgentStream", () => { if (attemptCount === 1) { return { - fullStream: (async function* () { - yield* []; + fullStream: (async function* (): AsyncGenerator< + { type: string; [key: string]: unknown }, + void, + unknown + > { throw { type: "error", sequence_number: 0, @@ -1102,10 +1116,12 @@ describe("handleLocalAgentStream", () => { message: "The server had an error processing your request.", }, }; + // This yield is never reached, but required for generator signature. + yield { type: "error" }; })(), response: Promise.resolve({ messages: [] }), steps: Promise.resolve([]), - }; + } as unknown as FakeStreamResult; } return { @@ -1133,6 +1149,7 @@ describe("handleLocalAgentStream", () => { placeholderMessageId: 10, systemPrompt: "You are helpful", dyadRequestId, + effectiveStreamMode: "local-agent", }, ); @@ -1181,6 +1198,7 @@ describe("handleLocalAgentStream", () => { placeholderMessageId: 10, systemPrompt: "You are helpful", dyadRequestId, + effectiveStreamMode: "local-agent", }, ); @@ -1218,6 +1236,7 @@ describe("handleLocalAgentStream", () => { placeholderMessageId: 10, systemPrompt: "You are helpful", dyadRequestId, + effectiveStreamMode: "local-agent", }, ); @@ -1333,6 +1352,7 @@ describe("handleLocalAgentStream", () => { placeholderMessageId: 10, systemPrompt: "You are helpful", dyadRequestId, + effectiveStreamMode: "local-agent", }, ); @@ -1399,6 +1419,7 @@ describe("handleLocalAgentStream", () => { placeholderMessageId: 10, systemPrompt: "You are helpful", dyadRequestId, + effectiveStreamMode: "local-agent", }, ); @@ -1443,6 +1464,7 @@ describe("handleLocalAgentStream", () => { placeholderMessageId: 10, systemPrompt: "You are helpful", dyadRequestId, + effectiveStreamMode: "local-agent", }, ); @@ -1476,6 +1498,7 @@ describe("handleLocalAgentStream", () => { placeholderMessageId: 10, systemPrompt: "You are helpful", dyadRequestId, + effectiveStreamMode: "local-agent", }, ); @@ -1505,6 +1528,7 @@ describe("handleLocalAgentStream", () => { placeholderMessageId: 10, systemPrompt: "You are helpful", dyadRequestId, + effectiveStreamMode: "local-agent", }, ); diff --git a/src/__tests__/readSettings.test.ts b/src/__tests__/readSettings.test.ts index 0dabb2ffb2..15e7ee10f4 100644 --- a/src/__tests__/readSettings.test.ts +++ b/src/__tests__/readSettings.test.ts @@ -79,7 +79,6 @@ describe("readSettings", () => { "lastKnownPerformance": undefined, "providerSettings": {}, "releaseChannel": "stable", - "selectedChatMode": "build", "selectedModel": { "name": "auto", "provider": "auto", @@ -471,7 +470,6 @@ describe("readSettings", () => { "lastKnownPerformance": undefined, "providerSettings": {}, "releaseChannel": "stable", - "selectedChatMode": "build", "selectedModel": { "name": "auto", "provider": "auto", diff --git a/src/components/ChatList.tsx b/src/components/ChatList.tsx index 5720714e13..b1ad566cb1 100644 --- a/src/components/ChatList.tsx +++ b/src/components/ChatList.tsx @@ -14,9 +14,7 @@ import { selectedAppIdAtom } from "@/atoms/appAtoms"; import { dropdownOpenAtom } from "@/atoms/uiAtoms"; import { ipc } from "@/ipc/types"; import { showError, showSuccess } from "@/lib/toast"; -import { useSettings } from "@/hooks/useSettings"; -import { getEffectiveDefaultChatMode } from "@/lib/schemas"; -import { useFreeAgentQuota } from "@/hooks/useFreeAgentQuota"; +import { useInitialChatMode } from "@/hooks/useInitialChatMode"; import { SidebarGroup, SidebarGroupContent, @@ -44,8 +42,6 @@ export function ChatList({ show }: { show?: boolean }) { const [selectedChatId, setSelectedChatId] = useAtom(selectedChatIdAtom); const [selectedAppId] = useAtom(selectedAppIdAtom); const [, setIsDropdownOpen] = useAtom(dropdownOpenAtom); - const { settings, updateSettings, envVars } = useSettings(); - const { isQuotaExceeded, isLoading: isQuotaLoading } = useFreeAgentQuota(); const { chats, loading, invalidateChats } = useChats(selectedAppId); const routerState = useRouterState(); @@ -89,6 +85,9 @@ export function ChatList({ show }: { show?: boolean }) { ensureRecentViewedChatId, ]); + // Call hook before any early returns (Rules of Hooks) + const initialChatMode = useInitialChatMode(); + if (!show) { return; } @@ -96,11 +95,13 @@ export function ChatList({ show }: { show?: boolean }) { const handleChatClick = ({ chatId, appId, + chatMode, }: { chatId: number; appId: number; + chatMode?: "ask" | "build" | "local-agent" | "plan" | null; }) => { - selectChat({ chatId, appId }); + selectChat({ chatId, appId, chatMode: chatMode || undefined }); setIsSearchDialogOpen(false); }; @@ -108,27 +109,24 @@ export function ChatList({ show }: { show?: boolean }) { // Only create a new chat if an app is selected if (selectedAppId) { try { - // Create a new chat with an empty title for now - const chatId = await ipc.chat.createChat(selectedAppId); - - // Set the default chat mode for the new chat - // Only consider quota available if it has finished loading and is not exceeded - if (settings) { - const freeAgentQuotaAvailable = !isQuotaLoading && !isQuotaExceeded; - const effectiveDefaultMode = getEffectiveDefaultChatMode( - settings, - envVars, - freeAgentQuotaAvailable, - ); - updateSettings({ selectedChatMode: effectiveDefaultMode }); - } + // Create a new chat with the initial mode persisted to the database + const chatId = await ipc.chat.createChat({ + appId: selectedAppId, + ...(initialChatMode && { + initialChatMode, + }), + }); // Refresh the chat list first so the new chat is in the cache // before selectChat adds it to the tab bar await invalidateChats(); // Navigate to the new chat (use selectChat so it appears at front of tab bar) - selectChat({ chatId, appId: selectedAppId }); + selectChat({ + chatId, + appId: selectedAppId, + chatMode: initialChatMode, + }); } catch (error) { // DO A TOAST showError(t("failedCreateChat", { error: (error as any).toString() })); @@ -236,6 +234,7 @@ export function ChatList({ show }: { show?: boolean }) { handleChatClick({ chatId: chat.id, appId: chat.appId, + chatMode: chat.chatMode, }) } className={`justify-start w-full text-left py-3 pr-1 hover:bg-sidebar-accent/80 ${ diff --git a/src/components/ChatModeSelector.tsx b/src/components/ChatModeSelector.tsx index 3789eeb3ac..c6083c4925 100644 --- a/src/components/ChatModeSelector.tsx +++ b/src/components/ChatModeSelector.tsx @@ -1,3 +1,5 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { MiniSelectTrigger, Select, @@ -14,109 +16,206 @@ import { useSettings } from "@/hooks/useSettings"; import { useFreeAgentQuota } from "@/hooks/useFreeAgentQuota"; import { useMcp } from "@/hooks/useMcp"; import type { ChatMode } from "@/lib/schemas"; -import { isDyadProEnabled } from "@/lib/schemas"; +import { + isChatModeAllowed, + isDyadProEnabled, + getEffectiveDefaultChatMode, +} from "@/lib/schemas"; import { cn } from "@/lib/utils"; -import { detectIsMac } from "@/hooks/useChatModeToggle"; -import { useRouterState } from "@tanstack/react-router"; +import { + getLocalAgentUnavailableReasonKey, + getChatModeLabelKey, +} from "@/lib/chatModeUtils"; import { toast } from "sonner"; import { LocalAgentNewChatToast } from "./LocalAgentNewChatToast"; import { useAtomValue } from "jotai"; +import { selectedAppIdAtom } from "@/atoms/appAtoms"; +import { Hammer, Bot, MessageCircle, Lightbulb, Loader2 } from "lucide-react"; +import { useChats } from "@/hooks/useChats"; +import { usePersistChatMode } from "@/hooks/usePersistChatMode"; +import { useCurrentChatIdFromRoute } from "@/hooks/useCurrentChatIdFromRoute"; +import { useIsMac } from "@/hooks/useChatModeToggle"; +import { useRouterState } from "@tanstack/react-router"; import { chatMessagesByIdAtom } from "@/atoms/chatAtoms"; -import { Hammer, Bot, MessageCircle, Lightbulb } from "lucide-react"; export function ChatModeSelector() { - const { settings, updateSettings } = useSettings(); + const { t } = useTranslation("chat"); + const { settings, updateSettings, envVars } = useSettings(); + const getCurrentChatId = useCurrentChatIdFromRoute(); + const chatId = getCurrentChatId(); + const selectedAppId = useAtomValue(selectedAppIdAtom); + const { invalidateChats } = useChats(selectedAppId); + const [isPersisting, setIsPersisting] = useState(false); + const { persistChatMode } = usePersistChatMode(); + + const isMac = useIsMac(); const routerState = useRouterState(); const isChatRoute = routerState.location.pathname === "/chat"; const messagesById = useAtomValue(chatMessagesByIdAtom); - const chatId = routerState.location.search.id as number | undefined; const currentChatMessages = chatId ? (messagesById.get(chatId) ?? []) : []; - // Migration happens on read, so selectedChatMode will never be "agent" - const selectedMode = settings?.selectedChatMode || "build"; - const isProEnabled = settings ? isDyadProEnabled(settings) : false; - const { messagesRemaining, messagesLimit, isQuotaExceeded } = + const { isQuotaExceeded, messagesRemaining, messagesLimit } = useFreeAgentQuota(); + const freeAgentQuotaAvailable = !isQuotaExceeded; + + const effectiveDefaultMode = settings + ? getEffectiveDefaultChatMode(settings, envVars, freeAgentQuotaAvailable) + : "build"; + const selectedMode = settings?.selectedChatMode || effectiveDefaultMode; + const isProEnabled = settings ? isDyadProEnabled(settings) : false; const { servers } = useMcp(); const enabledMcpServersCount = servers.filter((s) => s.enabled).length; + const isLocalAgentAllowed = + !!settings && + isChatModeAllowed( + "local-agent", + settings, + envVars, + freeAgentQuotaAvailable, + ); - const handleModeChange = (value: string) => { - const newMode = value as ChatMode; - updateSettings({ selectedChatMode: newMode }); - - // We want to show a toast when user is switching to the new agent mode - // because they might weird results mixing Build and Agent mode in the same chat. - // - // Only show toast if: - // - User is switching to the new agent mode - // - User is on the chat (not home page) with existing messages - // - User has not explicitly disabled the toast - if ( - newMode === "local-agent" && - isChatRoute && - currentChatMessages.length > 0 && - !settings?.hideLocalAgentNewChatToast - ) { - toast.custom( - (t) => ( - { - updateSettings({ hideLocalAgentNewChatToast: true }); - }} - /> - ), - // Make the toast shorter in test mode for faster tests. - { duration: settings?.isTestMode ? 50 : 8000 }, - ); - } + const getLocalAgentUnavailableMessage = () => { + const reasonKey = getLocalAgentUnavailableReasonKey(isQuotaExceeded); + return t(reasonKey, { + defaultValue: + reasonKey === "chatMode.agentUnavailableQuota" + ? "Agent mode unavailable — free quota exceeded" + : "Agent mode requires an OpenAI or Anthropic provider", + }); }; const getModeDisplayName = (mode: ChatMode) => { + return t(getChatModeLabelKey(mode, { isProEnabled }), { + defaultValue: + mode === "local-agent" + ? isProEnabled + ? "Agent" + : "Basic Agent" + : mode, + }); + }; + + const handleModeChange = (value: ChatMode | null) => { + if (!value || value === selectedMode) { + return; + } + + const performChange = async () => { + if (value === "local-agent" && !isLocalAgentAllowed) { + toast.error(getLocalAgentUnavailableMessage()); + return; + } + + setIsPersisting(true); + + try { + if (chatId && selectedAppId) { + const result = await persistChatMode({ + chatId, + appId: selectedAppId, + chatMode: value, + optimistic: true, + onPersistSuccess: () => { + invalidateChats(); + }, + onPersistError: () => { + toast.error( + t("chatMode.persistFailed", { + defaultValue: "Failed to save chat mode to database", + }), + ); + }, + }); + + if (!result.success || !result.sameRoute) { + return; + } + } else { + await updateSettings({ selectedChatMode: value }); + } + + if ( + value === "local-agent" && + isChatRoute && + currentChatMessages.length > 0 && + (!settings || !settings.hideLocalAgentNewChatToast) + ) { + toast.custom( + (t_toast) => ( + + updateSettings({ hideLocalAgentNewChatToast: true }) + } + /> + ), + { + duration: settings?.isTestMode ? 50 : 8000, + }, + ); + } + } finally { + setIsPersisting(false); + } + }; + + void performChange(); + }; + + const getIconForMode = (mode: ChatMode) => { switch (mode) { case "build": - return "Build"; + return ; case "ask": - return "Ask"; + return ; case "local-agent": - // Show "Basic Agent" for non-Pro users, "Agent" for Pro users - return isProEnabled ? "Agent" : "Basic Agent"; + return ; case "plan": - return "Plan"; + return ; default: - return "Build"; + return null; } }; - const getModeIcon = (mode: ChatMode) => { + const getModeTooltip = (mode: ChatMode) => { switch (mode) { case "build": - return ; + return t("chatMode.buildDesc", { + defaultValue: "Build: Best for coding and generating code.", + }); case "ask": - return ; + return t("chatMode.askDesc", { + defaultValue: "Ask: Best for answering questions about your code.", + }); case "local-agent": - return ; + return t("chatMode.agentDesc", { + defaultValue: + "Agent: Best for complex tasks that require multiple steps.", + }); case "plan": - return ; + return t("chatMode.planDesc", { + defaultValue: "Plan: Best for planning out a new feature.", + }); default: - return ; + return ""; } }; - const isMac = detectIsMac(); return ( -
- - - {getModeIcon(selectedMode)} + + {isPersisting && ( + + {t("chatMode.persisting", { + defaultValue: "Saving...", + })} + + )} - {`Open mode menu (${isMac ? "\u2318 + ." : "Ctrl + ."} to toggle)`} + {getModeTooltip(selectedMode)} ({isMac ? "⌘" : "Ctrl"} + . to + toggle) - - {isProEnabled && ( - -
-
- - Agent v2 -
- - Better at bigger tasks and debugging - -
-
- )} - -
-
- - Plan -
- - Design before you build - -
-
- {!isProEnabled && ( - -
-
- - Basic Agent - - {`(${isQuotaExceeded ? "0" : messagesRemaining}/${messagesLimit} remaining for today)`} - -
- - {isQuotaExceeded - ? "Daily limit reached" - : "Try our AI agent for free"} - -
-
- )} - -
-
- - Build -
- - Generate and edit code - -
-
- -
-
- - Ask -
- - Ask questions about the app - -
-
+ + } + label="Ask" + description="Best for asking questions" + isProEnabled={isProEnabled} + t={t} + /> + } + label="Build" + description="Best for coding" + isProEnabled={isProEnabled} + t={t} + /> + } + label={isProEnabled ? "Agent" : "Basic Agent"} + description="Best for multi-step tasks" + disabled={!isLocalAgentAllowed} + disabledReason={getLocalAgentUnavailableMessage()} + isProEnabled={isProEnabled} + t={t} + badge={ + [ + enabledMcpServersCount > 0 + ? t("chatMode.mcpCount", { + count: enabledMcpServersCount, + }) + : null, + !isProEnabled + ? t("chatMode.messagesRemaining", { + remaining: isQuotaExceeded ? 0 : messagesRemaining, + limit: messagesLimit, + }) + : null, + ] + .filter(Boolean) + .join(" · ") || undefined + } + /> + } + label="Plan" + description="Best for feature planning" + isProEnabled={isProEnabled} + t={t} + /> - {selectedMode === "build" && }
); } -function McpChip({ count }: { count: number }) { - if (count === 0) return null; - return ( - - - } - > - {count} MCP - - - - {count} MCP server{count !== 1 ? "s" : ""} enabled +function ModeOption({ + mode, + icon, + label, + description, + disabled = false, + disabledReason, + isProEnabled, + t, + badge, +}: { + mode: ChatMode; + icon: React.ReactNode; + label: string; + description: string; + isProEnabled: boolean; + disabled?: boolean; + disabledReason?: string; + t: any; + badge?: string; +}) { + const content = ( + +
+ {icon} + + {t(getChatModeLabelKey(mode, { isProEnabled }), { + defaultValue: label, + })} - - + {badge && ( + + {badge} + + )} +
+ + {t(`chatMode.${mode}DescMini`, { defaultValue: description })} + + {disabled && disabledReason && ( + + {disabledReason} + + )} +
); + + return content; } diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx index da5012a0ca..4be420239f 100644 --- a/src/components/ChatPanel.tsx +++ b/src/components/ChatPanel.tsx @@ -6,7 +6,11 @@ import { chatStreamCountByIdAtom, isStreamingByIdAtom, } from "../atoms/chatAtoms"; +import { selectedAppIdAtom } from "@/atoms/appAtoms"; import { ipc } from "@/ipc/types"; +import { toast } from "sonner"; +import { useQueryClient } from "@tanstack/react-query"; +import { queryKeys } from "@/lib/queryKeys"; import { ChatHeader } from "./chat/ChatHeader"; import { MessagesList } from "./chat/MessagesList"; @@ -21,10 +25,13 @@ import { TooltipTrigger, TooltipContent, } from "@/components/ui/tooltip"; -import { ArrowDown } from "lucide-react"; +import { ArrowDown, Loader } from "lucide-react"; import { useSettings } from "@/hooks/useSettings"; +import { isDyadProEnabled } from "@/lib/schemas"; import { useFreeAgentQuota } from "@/hooks/useFreeAgentQuota"; -import { isBasicAgentMode } from "@/lib/schemas"; +import { useChats } from "@/hooks/useChats"; +import { usePersistChatMode } from "@/hooks/usePersistChatMode"; +import { useRestoreChatMode } from "@/hooks/useRestoreChatMode"; interface ChatPanelProps { chatId?: number; @@ -44,10 +51,20 @@ export function ChatPanel({ const [error, setError] = useState(null); const streamCountById = useAtomValue(chatStreamCountByIdAtom); const isStreamingById = useAtomValue(isStreamingByIdAtom); - const { settings, updateSettings } = useSettings(); + const { settings, envVars, updateSettings } = useSettings(); + const selectedAppId = useAtomValue(selectedAppIdAtom); const { isQuotaExceeded } = useFreeAgentQuota(); + const { chats } = useChats(selectedAppId); + const currentChat = chats.find((c) => c.id === chatId); + const effectiveChatMode = + currentChat?.chatMode ?? settings?.selectedChatMode ?? "build"; const showFreeAgentQuotaBanner = - settings && isBasicAgentMode(settings) && isQuotaExceeded; + settings && + !isDyadProEnabled(settings) && + effectiveChatMode === "local-agent" && + isQuotaExceeded; + const queryClient = useQueryClient(); + const { persistChatMode } = usePersistChatMode(); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); @@ -85,6 +102,15 @@ export function ChatPanel({ // Track previous chatId to detect chat switches const prevChatIdRef = useRef(undefined); + const { isRestoringMode } = useRestoreChatMode({ + chatId, + appId: selectedAppId, + settings, + envVars, + isQuotaExceeded, + updateSettings, + }); + useEffect(() => { const isChatSwitch = prevChatIdRef.current !== chatId; prevChatIdRef.current = chatId; @@ -130,9 +156,35 @@ export function ChatPanel({ fetchChatMessages(); }, [fetchChatMessages]); - const isStreaming = chatId ? (isStreamingById.get(chatId) ?? false) : false; + const switchToBuildMode = useCallback(() => { + if (!selectedAppId || !chatId) { + toast.error( + t("chatMode.noAppSelected", { + defaultValue: "No app selected — can't change chat mode", + }), + ); + return; + } + + void persistChatMode({ + chatId, + appId: selectedAppId, + chatMode: "build", + optimistic: true, + onPersistSuccess: () => + queryClient.invalidateQueries({ queryKey: queryKeys.chats.all }), + onPersistError: () => { + toast.error( + t("chatMode.persistFailed", { + defaultValue: "Failed to save chat mode to database", + }), + { id: "switch-to-build-error" }, + ); + }, + }); + }, [chatId, persistChatMode, queryClient, selectedAppId, t]); - // Scroll to bottom when streaming completes to ensure footer content is visible, + const isStreaming = chatId ? (isStreamingById.get(chatId) ?? false) : false; // but only if the user was following (at bottom) during the stream. useEffect(() => { const wasStreaming = prevIsStreamingRef.current; @@ -221,14 +273,24 @@ export function ChatPanel({ setError(null)} /> {showFreeAgentQuotaBanner && ( - - updateSettings({ selectedChatMode: "build" }) - } - /> + )} - + {isRestoringMode && ( +
+
+ + {t("chatMode.restoringChatMode", { + defaultValue: "Restoring chat mode...", + })} +
+
+ )} + )} void; - onSelectChat: ({ chatId, appId }: { chatId: number; appId: number }) => void; + onSelectChat: ({ + chatId, + appId, + chatMode, + }: { + chatId: number; + appId: number; + chatMode?: ChatMode | null; + }) => void; appId: number | null; allChats: ChatSummary[]; }; @@ -129,7 +137,11 @@ export function ChatSearchDialog({ - onSelectChat({ chatId: chat.id, appId: chat.appId }) + onSelectChat({ + chatId: chat.id, + appId: chat.appId, + chatMode: chat.chatMode, + }) } value={ (chat.title || "Untitled Chat") + diff --git a/src/components/DyadAppMediaFolder.tsx b/src/components/DyadAppMediaFolder.tsx index 0c0ff4075c..ae3fa3f105 100644 --- a/src/components/DyadAppMediaFolder.tsx +++ b/src/components/DyadAppMediaFolder.tsx @@ -38,6 +38,7 @@ import { getFileExtension, } from "./media-library/media-folder-utils"; import { MediaFolderOpen } from "./media-library/MediaFolderOpen"; +import { useInitialChatMode } from "@/hooks/useInitialChatMode"; import { ImageLightbox } from "./chat/ImageLightbox"; import { buildDyadMediaUrl } from "@/lib/dyadMediaUrl"; import { AppSearchSelect } from "./AppSearchSelect"; @@ -84,6 +85,7 @@ export function DyadAppMediaFolder({ const [previewFile, setPreviewFile] = useState(null); const queryClient = useQueryClient(); const { selectChat } = useSelectChat(); + const initialChatMode = useInitialChatMode(); const moveTargetApps = useMemo( () => allApps.filter((app) => app.id !== appId), @@ -100,11 +102,15 @@ export function DyadAppMediaFolder({ const handleStartNewChatWithImage = async (file: MediaFile) => { setIsStartingChat(true); try { - const chatId = await ipc.chat.createChat(file.appId); + const chatId = await ipc.chat.createChat({ + appId: file.appId, + ...(initialChatMode && { initialChatMode }), + }); await queryClient.invalidateQueries({ queryKey: queryKeys.chats.all }); selectChat({ chatId, appId: file.appId, + chatMode: initialChatMode, prefillInput: `@media:${encodeURIComponent(file.fileName)} `, }); } catch (error) { diff --git a/src/components/ImportAppDialog.tsx b/src/components/ImportAppDialog.tsx index 5a299ee48a..942dfdd55f 100644 --- a/src/components/ImportAppDialog.tsx +++ b/src/components/ImportAppDialog.tsx @@ -18,8 +18,10 @@ import { Input } from "@/components/ui/input"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Checkbox } from "@/components/ui/checkbox"; import { Label } from "@/components/ui/label"; -import { useNavigate } from "@tanstack/react-router"; import { useStreamChat } from "@/hooks/useStreamChat"; +import { useSettings } from "@/hooks/useSettings"; +import { useInitialChatMode } from "@/hooks/useInitialChatMode"; +import { useSelectChat } from "@/hooks/useSelectChat"; import type { GithubRepository } from "@/ipc/types"; import { useGithubRepos } from "@/hooks/useGithubRepos"; @@ -33,7 +35,6 @@ import { AccordionTrigger, } from "./ui/accordion"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { useSettings } from "@/hooks/useSettings"; import { UnconnectedGitHubConnector } from "@/components/GitHubConnector"; interface ImportAppDialogProps { @@ -52,11 +53,11 @@ export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) { const [installCommand, setInstallCommand] = useState(""); const [startCommand, setStartCommand] = useState(""); const [copyToDyadApps, setCopyToDyadApps] = useState(true); - const navigate = useNavigate(); const { streamMessage } = useStreamChat({ hasChatId: false }); const { refreshApps } = useLoadApps(); const setSelectedAppId = useSetAtom(selectedAppIdAtom); - // GitHub import state + const initialChatMode = useInitialChatMode(); + const { selectChat } = useSelectChat(); const [url, setUrl] = useState(""); const [importing, setImporting] = useState(false); const { settings, refreshSettings } = useSettings(); @@ -75,7 +76,6 @@ export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) { } }, [isOpen]); - // Re-check app name when copyToDyadApps changes useEffect(() => { if (customAppName.trim() && selectedPath) { checkAppName({ name: customAppName, skipCopy: !copyToDyadApps }); @@ -106,6 +106,7 @@ export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) { const match = url.match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?\/?$/); return match ? match[2] : null; }; + const handleImportFromUrl = async () => { setImporting(true); try { @@ -125,8 +126,11 @@ export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) { } setSelectedAppId(result.app.id); showSuccess(t("home:successfullyImported", { name: result.app.name })); - const chatId = await ipc.chat.createChat(result.app.id); - navigate({ to: "/chat", search: { id: chatId } }); + const chatId = await ipc.chat.createChat({ + appId: result.app.id, + initialChatMode, + }); + selectChat({ chatId, appId: result.app.id, chatMode: initialChatMode }); if (!result.hasAiRules) { streamMessage({ prompt: AI_RULES_PROMPT, @@ -161,8 +165,11 @@ export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) { } setSelectedAppId(result.app.id); showSuccess(t("home:successfullyImported", { name: result.app.name })); - const chatId = await ipc.chat.createChat(result.app.id); - navigate({ to: "/chat", search: { id: chatId } }); + const chatId = await ipc.chat.createChat({ + appId: result.app.id, + initialChatMode, + }); + selectChat({ chatId, appId: result.app.id, chatMode: initialChatMode }); if (!result.hasAiRules) { streamMessage({ prompt: AI_RULES_PROMPT, @@ -225,7 +232,6 @@ export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) { mutationFn: async () => { const result = await ipc.system.selectAppFolder(); if (!result.path || !result.name) { - // User cancelled the folder selection dialog return null; } const aiRulesCheck = await ipc.import.checkAiRules({ @@ -233,9 +239,7 @@ export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) { }); setHasAiRules(aiRulesCheck.exists); setSelectedPath(result.path); - // Use the folder name from the IPC response setCustomAppName(result.name); - // Check if the app name already exists await checkAppName({ name: result.name, skipCopy: !copyToDyadApps }); return result; }, @@ -253,6 +257,7 @@ export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) { installCommand: installCommand || undefined, startCommand: startCommand || undefined, skipCopy: !copyToDyadApps, + initialChatMode, }); }, onSuccess: async (result) => { @@ -261,7 +266,11 @@ export function ImportAppDialog({ isOpen, onClose }: ImportAppDialogProps) { ); onClose(); - navigate({ to: "/chat", search: { id: result.chatId } }); + selectChat({ + chatId: result.chatId, + appId: result.appId, + chatMode: initialChatMode, + }); if (!hasAiRules) { streamMessage({ prompt: AI_RULES_PROMPT, diff --git a/src/components/chat/ChatHeader.tsx b/src/components/chat/ChatHeader.tsx index f1a86cb6ef..5843bd4725 100644 --- a/src/components/chat/ChatHeader.tsx +++ b/src/components/chat/ChatHeader.tsx @@ -31,6 +31,7 @@ import { useRenameBranch } from "@/hooks/useRenameBranch"; import { isAnyCheckoutVersionInProgressAtom } from "@/store/appAtoms"; import { LoadingBar } from "../ui/LoadingBar"; import { UncommittedFilesBanner } from "./UncommittedFilesBanner"; +import { useInitialChatMode } from "@/hooks/useInitialChatMode"; interface ChatHeaderProps { isVersionPaneOpen: boolean; @@ -56,6 +57,7 @@ export function ChatHeader({ const isAnyCheckoutVersionInProgress = useAtomValue( isAnyCheckoutVersionInProgressAtom, ); + const initialChatMode = useInitialChatMode(); const { branchInfo, @@ -88,9 +90,12 @@ export function ChatHeader({ const handleNewChat = async () => { if (appId) { try { - const chatId = await ipc.chat.createChat(appId); + const chatId = await ipc.chat.createChat({ + appId, + initialChatMode: initialChatMode ?? "build", + }); await invalidateChats(); - selectChat({ chatId, appId }); + selectChat({ chatId, appId, chatMode: initialChatMode ?? "build" }); } catch (error) { showError(t("failedCreateChat", { error: (error as any).toString() })); } diff --git a/src/components/chat/ChatInput.tsx b/src/components/chat/ChatInput.tsx index 0df1f4b177..17bca0fa44 100644 --- a/src/components/chat/ChatInput.tsx +++ b/src/components/chat/ChatInput.tsx @@ -108,7 +108,13 @@ import { isDyadProEnabled } from "@/lib/schemas"; const showTokenBarAtom = atom(false); -export function ChatInput({ chatId }: { chatId?: number }) { +export function ChatInput({ + chatId, + effectiveChatMode, +}: { + chatId?: number; + effectiveChatMode?: string; +}) { const { t } = useTranslation("chat"); const posthog = usePostHog(); const [inputValue, setInputValue] = useAtom(chatInputValueAtom); @@ -231,12 +237,14 @@ export function ChatInput({ chatId }: { chatId?: number }) { const lastMessage = (chatId ? (messagesById.get(chatId) ?? []) : []).at(-1); const disableSendButton = - settings?.selectedChatMode !== "local-agent" && - lastMessage?.role === "assistant" && - !lastMessage.approvalState && - !!proposal && - proposal.type === "code-proposal" && - messageId === lastMessage.id; + (effectiveChatMode !== "local-agent" && effectiveChatMode !== undefined) || + (settings?.selectedChatMode !== "local-agent" && + effectiveChatMode === undefined && + lastMessage?.role === "assistant" && + !lastMessage.approvalState && + !!proposal && + proposal.type === "code-proposal" && + messageId === lastMessage.id); // Extract user message history for terminal-style navigation const userMessageHistory = useMemo(() => { @@ -487,7 +495,7 @@ export function ChatInput({ chatId }: { chatId?: number }) { setInputValue(""); setNeedsFreshPlanChat(false); - const newChatId = await ipc.chat.createChat(appId); + const newChatId = await ipc.chat.createChat({ appId }); setSelectedChatId(newChatId); navigate({ to: "/chat", search: { id: newChatId } }); queryClient.invalidateQueries({ queryKey: queryKeys.chats.all }); @@ -496,6 +504,7 @@ export function ChatInput({ chatId }: { chatId?: number }) { await streamMessage({ prompt: promptWithImages, chatId: newChatId, + chatMode: settings?.selectedChatMode, attachments, redo: false, }); @@ -566,6 +575,7 @@ export function ChatInput({ chatId }: { chatId?: number }) { await streamMessage({ prompt: currentInput, chatId, + chatMode: settings?.selectedChatMode, attachments, redo: false, selectedComponents: componentsToSend, @@ -598,7 +608,7 @@ export function ChatInput({ chatId }: { chatId?: number }) { const handleNewChat = async () => { if (appId) { try { - const newChatId = await ipc.chat.createChat(appId); + const newChatId = await ipc.chat.createChat({ appId }); setSelectedChatId(newChatId); navigate({ to: "/chat", diff --git a/src/components/chat/ChatTabs.tsx b/src/components/chat/ChatTabs.tsx index 80d9e1906a..776feaa839 100644 --- a/src/components/chat/ChatTabs.tsx +++ b/src/components/chat/ChatTabs.tsx @@ -412,10 +412,12 @@ export function ChatTabs({ selectedChatId }: ChatTabsProps) { clearNotification(chat.id); + // Pass the current chat's persisted mode so settings.selectedChatMode stays in sync. selectChat({ chatId: chat.id, appId: chat.appId, preserveTabOrder: true, + chatMode: chat.chatMode ?? undefined, }); }; @@ -445,6 +447,7 @@ export function ChatTabs({ selectedChatId }: ChatTabsProps) { chatId: fallbackTab.id, appId: fallbackTab.appId, preserveTabOrder: true, + chatMode: fallbackTab.chatMode || undefined, }); }; @@ -473,6 +476,7 @@ export function ChatTabs({ selectedChatId }: ChatTabsProps) { chatId: fallbackTab.id, appId: fallbackTab.appId, preserveTabOrder: true, + chatMode: fallbackTab.chatMode || undefined, }); } } diff --git a/src/components/chat/ContextLimitBanner.tsx b/src/components/chat/ContextLimitBanner.tsx index d785cef9a1..0937238a6b 100644 --- a/src/components/chat/ContextLimitBanner.tsx +++ b/src/components/chat/ContextLimitBanner.tsx @@ -36,7 +36,7 @@ export function ContextLimitBanner({ totalTokens, contextWindow, }: ContextLimitBannerProps) { - const { handleSummarize } = useSummarizeInNewChat(); + const { handleSummarize, isChatModeLoading } = useSummarizeInNewChat(); if (!shouldShowContextLimitBanner({ totalTokens, contextWindow })) { return null; @@ -62,6 +62,7 @@ export function ContextLimitBanner({ render={