Unverified 提交 7ca82352 authored 作者: keppo-bot[bot]'s avatar keppo-bot[bot] 提交者: GitHub

Persist chat mode per chat (#3249)

## Summary - Adds chat mode persistence to chats so Ask, Build, and Plan are remembered per conversation. - Resolves effective chat mode for chat creation and streaming paths, including fallback metadata for the UI. - Updates chat mode hooks, selectors, tab flows, and coverage for persistence behavior. ## Test plan - npm run fmt && npm run lint:fix && npm run ts - npm test - npm run build - PLAYWRIGHT_HTML_OPEN=never npm run e2e -- e2e-tests/chat_mode.spec.ts Generated with Codex <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/3249" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open in Devin Review"> </picture> </a> <!-- devin-review-badge-end --> --------- Co-authored-by: 's avatarWill Chen <7344640+wwwillchen@users.noreply.github.com>
上级 a5a735e8
......@@ -5,7 +5,7 @@ description: Root-cause flaky or failing E2E tests from a specific CI run by dow
# Deflake E2E Tests from a CI Run
Use this skill when the user points you at a specific failing CI run (e.g. `https://github.com/dyad-sh/dyad/actions/runs/<id>`) and asks you to root-cause the E2E failures. Unlike `deflake-e2e`, this skill does NOT rebuild and re-run tests — it reads the already-recorded Playwright report from the run's artifacts, which is faster and gives you the *exact* failure state CI saw.
Use this skill when the user points you at a specific failing CI run (e.g. `https://github.com/dyad-sh/dyad/actions/runs/<id>`) and asks you to root-cause the E2E failures. Unlike `deflake-e2e`, this skill does NOT rebuild and re-run tests — it reads the already-recorded Playwright report from the run's artifacts, which is faster and gives you the _exact_ failure state CI saw.
## Arguments
......@@ -60,7 +60,7 @@ Group by error shape. If every failure shares the same locator / error ("element
if obj.get('type') == 'before' and obj.get('class') == 'Test':
print(round(obj['startTime']/1000, 2), obj.get('method'), obj.get('title','')[:200])
```
Look for the last few actions before the timeout — that tells you *which call hung and what its locator resolved to*.
Look for the last few actions before the timeout — that tells you _which call hung and what its locator resolved to_.
4. Correlate with app logs. Electron `console.log`/`console.error` lands in `stderr`/`stdout` trace events:
```python
for line in open('/tmp/trace-extract/test.trace'):
......@@ -81,7 +81,7 @@ Group by error shape. If every failure shares the same locator / error ("element
Common patterns and what they mean:
- **"element is not enabled" on a button after fill()** → React render race between URL/atom state updates and the editor's onChange. The fill runs, onChange writes under the *old* key, next render clears the editor for the new context. Fix: wrap fill+click in `expect.toPass()` and assert editor content + button enabled before clicking. See `ChatActions.sendPrompt()`.
- **"element is not enabled" on a button after fill()** → React render race between URL/atom state updates and the editor's onChange. The fill runs, onChange writes under the _old_ key, next render clears the editor for the new context. Fix: wrap fill+click in `expect.toPass()` and assert editor content + button enabled before clicking. See `ChatActions.sendPrompt()`.
- **"locator.click timeout"** with multiple matching elements → stale component still in DOM during a transition. Fix: scope the locator tighter (`getChatInputContainer().locator(...)`) or add a visibility assertion on the stable target first.
- **Assertion flakes right after navigation** → atom/URL mismatch during a single render cycle. Either wait for a post-navigation signal (e.g. a data-loaded state) or wrap the assertion in `toPass` with a bounded timeout.
- **Different error on retry vs. first attempt** → test is mutating shared state. Look for missing teardown or cross-test singletons.
......@@ -92,7 +92,7 @@ Prefer fixing the test over the app unless the race would actually bite a real u
1. Make the minimal change — usually in `e2e-tests/helpers/page-objects/` since many specs share the same helper.
2. `npm run fmt && npm run lint && npm run ts`.
3. Skip local `npm run build && npm run e2e` unless you're genuinely unsure — the CI loop is ~15min and this analysis path is for *obvious* root causes. If you're guessing, stop guessing and run it locally instead.
3. Skip local `npm run build && npm run e2e` unless you're genuinely unsure — the CI loop is ~15min and this analysis path is for _obvious_ root causes. If you're guessing, stop guessing and run it locally instead.
4. Use `/dyad:pr-push` or commit + `gh pr create` directly. The PR body MUST include:
- A link to the failing run.
- The root-cause narrative (what raced, in concrete terms — not "timing issue").
......@@ -101,7 +101,7 @@ Prefer fixing the test over the app unless the race would actually bite a real u
## Gotchas
- `gh run download` needs `-R <owner>/<repo>` if you're not in a cwd with matching origin.
- `results.json` paths inside `attachments[]` are *CI-side*; only use them to match hashes, never to read files.
- `results.json` paths inside `attachments[]` are _CI-side_; only use them to match hashes, never to read files.
- A fork PR's artifacts live on the fork's run, not the upstream's. Make sure `run_id` is on the right repo.
- Many traces unpack to the same `/tmp/trace-extract/` — clean between extractions or use unique subdirs.
- The `html-report` is the *merged* report across shards. Individual shard artifacts (`blob-report-*`, `flakiness-report-*`) are usually unnecessary for root-causing.
- The `html-report` is the _merged_ report across shards. Individual shard artifacts (`blob-report-*`, `flakiness-report-*`) are usually unnecessary for root-causing.
ALTER TABLE `chats` ADD `chat_mode` text;
\ No newline at end of file
{
"version": "6",
"dialect": "sqlite",
"id": "d94d2fa4-c0a3-4820-8a47-949b1b2d864b",
"prevId": "debcac95-faa1-4a6c-8bf6-0f04d2cd8291",
"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
},
"neon_active_branch_id": {
"name": "neon_active_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())"
},
"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
},
"chat_mode": {
"name": "chat_mode",
"type": "text",
"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
......@@ -197,6 +197,13 @@
"when": 1774487675535,
"tag": "0027_unusual_scalphunter",
"breakpoints": true
},
{
"idx": 28,
"version": "6",
"when": 1776728360068,
"tag": "0028_icy_veda",
"breakpoints": true
}
]
}
\ No newline at end of file
import { test } from "./helpers/test_helper";
import { expect } from "@playwright/test";
test("chat mode selector - default build mode", async ({ po }) => {
await po.setUp({ autoApprove: true });
......@@ -23,6 +24,37 @@ test("chat mode selector - ask mode", async ({ po }) => {
await po.snapshotMessages({ replaceDumpPath: true });
});
test("chat mode selector - mode persists per chat", async ({ po }) => {
await po.setUp({ autoApprove: true });
await po.importApp("minimal");
const selector = po.page.getByTestId("chat-mode-selector");
await po.sendPrompt("[dump] first chat setup");
await po.chatActions.waitForChatCompletion();
await po.chatActions.selectChatMode("ask");
await expect(selector).toContainText("Ask");
await po.chatActions.clickNewChat();
await expect(selector).not.toContainText("Ask");
await po.chatActions.selectChatMode("plan");
await expect(selector).toContainText("Plan");
const inactiveTab = po.page
.locator("div[draggable]")
.filter({ hasNot: po.page.locator('button[aria-current="page"]') });
await inactiveTab.locator("button").first().click();
await expect(selector).toContainText("Ask");
const inactiveTab2 = po.page
.locator("div[draggable]")
.filter({ hasNot: po.page.locator('button[aria-current="page"]') });
await inactiveTab2.locator("button").first().click();
await expect(selector).toContainText("Plan");
});
test.skip("dyadwrite edit and save - basic flow", async ({ po }) => {
await po.setUp({ autoApprove: true });
await po.importApp("minimal");
......
......@@ -25,7 +25,9 @@ testSkipIfWindows(
await po.sendPrompt("tc=local-agent/simple-response");
// Verify the compaction status indicator is visible
await expect(po.page.getByText("Conversation compacted")).toBeVisible({
await expect(
po.page.getByText("Conversation compacted").first(),
).toBeVisible({
timeout: Timeout.MEDIUM,
});
......@@ -35,10 +37,10 @@ testSkipIfWindows(
// Verify key compaction elements are present (order-independent checks
// since compaction restructures messages non-deterministically)
await expect(
po.page.getByRole("button", { name: "Conversation compacted" }),
po.page.getByRole("button", { name: "Conversation compacted" }).first(),
).toBeVisible();
await expect(
po.page.getByRole("heading", { name: "Key Decisions Made" }),
po.page.getByRole("heading", { name: "Key Decisions Made" }).first(),
).toBeVisible();
await expect(
po.page.getByText(
......@@ -62,7 +64,9 @@ testSkipIfWindows(
await po.sendPrompt("tc=local-agent/compaction-mid-turn");
// Mid-turn compaction summary should be visible after a single prompt.
await expect(po.page.getByText("Conversation compacted")).toBeVisible({
await expect(
po.page.getByText("Conversation compacted").first(),
).toBeVisible({
timeout: Timeout.MEDIUM,
});
......@@ -77,10 +81,10 @@ testSkipIfWindows(
// Verify key compaction elements are present (order-independent checks
// since compaction restructures messages non-deterministically)
await expect(
po.page.getByRole("button", { name: "Conversation compacted" }),
po.page.getByRole("button", { name: "Conversation compacted" }).first(),
).toBeVisible();
await expect(
po.page.getByRole("heading", { name: "Key Decisions Made" }),
po.page.getByRole("heading", { name: "Key Decisions Made" }).first(),
).toBeVisible();
await expect(po.page.getByText("END OF COMPACTED TURN.")).toBeVisible();
},
......
......@@ -56,15 +56,21 @@ testSkipIfWindows(
po.page.getByRole("button", { name: "Switch back to Build mode" }),
).toBeVisible();
// 6. Try to send an 11th message - should be blocked with error
// 6. Try to send an 11th message - the app should fall back to Build mode
// instead of attempting another Basic Agent request.
await po.sendPrompt("tc=local-agent/simple-response message 11");
// Verify error message appears indicating quota exceeded
await expect(po.page.getByTestId("chat-error-box")).toBeVisible({
await expect(po.page.getByTestId("chat-error-box")).not.toBeVisible({
timeout: 1000,
});
await expect(
po.page
.getByText(
"Hello! I understand your request. This is a simple response from the Basic Agent mode.",
)
.last(),
).toBeVisible({
timeout: Timeout.MEDIUM,
});
await expect(po.page.getByTestId("chat-error-box")).toContainText(
"You have used all 10 free Agent messages for today",
);
// 8. Click "Switch back to Build mode" and verify mode changes
await po.page
......
......@@ -101,11 +101,12 @@ export class ChatActions {
await expect(async () => {
await chatInput.click();
await chatInput.fill(prompt);
await expect(chatInput).toContainText(prompt);
const visiblePrompt = prompt.replace(/@app:/g, "@");
expect(await chatInput.textContent()).toContain(visiblePrompt);
await expect(sendButton).toBeEnabled();
await sendButton.click();
}).toPass({ timeout: Timeout.MEDIUM });
await sendButton.click();
if (!skipWaitForCompletion) {
await this.waitForChatCompletion({ timeout });
}
......
......@@ -18,7 +18,7 @@
- img
- text: less than a minute ago
- img
- text: wrote 1 file(s)
- text: "Version 2: wrote 1 file(s)"
- button "Undo":
- img
- text: ""
......
......@@ -16,7 +16,7 @@
- img
- text: less than a minute ago
- img
- text: wrote 1 file(s)
- text: "Version 2: wrote 1 file(s)"
- paragraph: "[dump]"
- 'button "Expand image: logo.png"':
- img "logo.png"
......
......@@ -16,7 +16,7 @@
- img
- text: less than a minute ago
- img
- text: wrote 1 file(s)
- text: "Version 2: wrote 1 file(s)"
- paragraph: "[dump]"
- 'button "Expand image: logo.png"':
- img "logo.png"
......
......@@ -18,7 +18,7 @@
- img
- text: less than a minute ago
- img
- text: wrote 1 file(s)
- text: "Version 2: wrote 1 file(s)"
- button "Undo":
- img
- text: ""
......
......@@ -66,7 +66,7 @@
- img
- text: less than a minute ago
- img
- text: wrote 3 file(s)
- text: "Version 2: wrote 3 file(s)"
- paragraph: "Fix all of the following errors:"
- list:
- listitem: First error in Index
......@@ -89,7 +89,7 @@
- img
- text: less than a minute ago
- img
- text: wrote 1 file(s)
- text: "Version 3: wrote 1 file(s)"
- button "Undo":
- img
- text: ""
......
......@@ -17,7 +17,7 @@
- img
- text: less than a minute ago
- img
- text: wrote 1 file(s)
- text: "Version 2: wrote 1 file(s)"
- paragraph:
- text: "Fix error: Error Line 6 error Stack trace: Index ("
- link /http:\/\/localhost:\d+\/src\/pages\/Index\.tsx:6:6/:
......@@ -43,7 +43,7 @@
- img
- text: less than a minute ago
- img
- text: wrote 1 file(s)
- text: "Version 3: wrote 1 file(s)"
- button "Undo":
- img
- text: ""
......
......@@ -16,7 +16,7 @@
- img
- text: less than a minute ago
- img
- text: (1 files changed)
- text: "Version 2: (1 files changed)"
- button "Copy Request ID":
- img
- text: ""
......@@ -34,9 +34,13 @@
- button "Copy":
- img
- img
- text: Approved
- img
- text: claude-opus-4-5
- img
- text: less than a minute ago
- img
- text: "Version 3: (1 files changed)"
- button "Copy Request ID":
- img
- text: ""
......
......@@ -16,7 +16,7 @@
- img
- text: less than a minute ago
- img
- text: (1 files changed)
- text: "Version 2: (1 files changed)"
- button "Copy Request ID":
- img
- text: ""
......@@ -33,6 +33,8 @@
- button "Copy":
- img
- img
- text: Approved
- img
- text: claude-opus-4-5
- img
- text: less than a minute ago
......
......@@ -16,7 +16,7 @@
- img
- text: less than a minute ago
- img
- text: (1 files changed)
- text: "Version 2: (1 files changed)"
- button "Copy Request ID":
- img
- text: ""
......@@ -40,7 +40,7 @@
- img
- text: less than a minute ago
- img
- text: (1 files changed)
- text: "Version 3: (1 files changed)"
- button "Copy Request ID":
- img
- text: ""
......@@ -67,9 +67,13 @@
- button "Copy":
- img
- img
- text: Approved
- img
- text: claude-opus-4-5
- img
- text: less than a minute ago
- img
- text: "Version 4: (2 files changed)"
- button "Copy Request ID":
- img
- text: ""
......
......@@ -16,7 +16,7 @@
- img
- text: less than a minute ago
- img
- text: (1 files changed)
- text: "Version 2: (1 files changed)"
- button "Copy Request ID":
- img
- text: ""
......@@ -171,6 +171,156 @@
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- img
- text: /Paused after \d+ tool calls/
- button "Continue":
......@@ -200,6 +350,8 @@
- button "Copy":
- img
- img
- text: Approved
- img
- text: claude-opus-4-5
- img
- text: less than a minute ago
......
......@@ -16,7 +16,7 @@
- img
- text: less than a minute ago
- img
- text: (1 files changed)
- text: "Version 2: (1 files changed)"
- button "Copy Request ID":
- img
- text: ""
......@@ -30,6 +30,8 @@
- button "Copy":
- img
- img
- text: Approved
- img
- text: claude-opus-4-5
- img
- text: less than a minute ago
......
......@@ -53,9 +53,15 @@
- code: "`src/config/aws.ts`"
- text: ","
- code: "`src/services/s3-uploader.ts`"
- button "file1.txt file1.txt Edit"
- button "file1.txt file1.txt Edit":
- img
- text: ""
- button "Edit":
- img
- text: ""
- img
- paragraph: More EOM
- button:
- button "Copy":
- img
- img
- text: Approved
......@@ -64,8 +70,10 @@
- img
- text: less than a minute ago
- img
- text: wrote 1 file(s)
- text: "Version 2: wrote 1 file(s)"
- button "Undo":
- img
- text: ""
- button "Retry":
- img
- text: ""
\ No newline at end of file
......@@ -24,9 +24,15 @@
- strong: Relevant Files
- text: ":"
- code: "`src/api/users.ts`"
- button "file1.txt file1.txt Edit"
- button "file1.txt file1.txt Edit":
- img
- text: ""
- button "Edit":
- img
- text: ""
- img
- paragraph: More EOM
- button:
- button "Copy":
- img
- img
- text: Approved
......@@ -35,8 +41,10 @@
- img
- text: less than a minute ago
- img
- text: wrote 1 file(s)
- text: "Version 2: wrote 1 file(s)"
- button "Undo":
- img
- text: ""
- button "Retry":
- img
- text: ""
\ No newline at end of file
......@@ -33,7 +33,7 @@
- img
- text: less than a minute ago
- img
- text: wrote 1 file(s)
- text: "Version 2: wrote 1 file(s)"
- button "Copy Request ID":
- img
- text: ""
......
# Per-Chat Mode Persistence
> Generated by swarm planning session on 2026-04-20
## Summary
Persist a chat's mode (build / ask / local-agent / plan) on the chat row itself, so that returning to a chat restores its mode automatically. New chats snapshot the user's current effective default mode at creation. Existing chats migrate as NULL and continue to follow the current default until the user explicitly changes their mode.
## Problem Statement
Chat mode is currently a **global** user setting (`selectedChatMode` in `user-settings.json`). If a user is in `plan` mode in Chat A, switches to Chat B and changes to `build`, then returns to Chat A, Chat A is silently in `build` too. Their next message runs through the wrong pipeline — different system prompt, different tool access, different stream routing. The failure mode is silent (no error, just a wrong-shaped response) and violates the product principle "no unexpected behavior." Each chat should own its own mode.
## Scope
### In Scope (MVP)
- Add nullable `chatMode` column to the `chats` table.
- On chat creation: snapshot `getEffectiveDefaultChatMode()` into the chat row.
- On mode switch from inside a chat (dropdown or keyboard shortcut): persist the new mode to that chat's row.
- On chat load: if `chatMode` is set, use it; if NULL, fall back to `getEffectiveDefaultChatMode()`.
- Existing chats keep `chatMode` NULL — no backfill. They behave the same as today (inherit the current default) until the user explicitly picks a mode for them.
- Server resolves the effective mode per turn and echoes `effectiveChatMode` back in the first stream event; client reconciles.
- `Cmd/Ctrl+.` keyboard shortcut continues to work, persists to the current chat row.
- Unavailable stored mode (Pro lapsed, quota exhausted, provider missing): silent fallback to effective default + toast. User can send their message immediately. The fallback is **read-side only** — the stored `chatMode` is NOT overwritten, so the chat auto-recovers if the user becomes Pro / refills quota / re-adds the provider.
- Deprecated `agent` value: migrate to `build` on read via `StoredChatModeSchema`, same pattern as settings.
- Forked/compacted chats inherit the parent chat's `chatMode`.
### Out of Scope (Follow-up)
- Backfilling existing chats with the current global mode at migration time.
- Renaming the `defaultChatMode` settings label to "Default mode for new chats."
- Per-chat mode icon in the chat list.
- Generalizing the mode-change toast with an Undo for all modes (current local-agent warning toast stays as-is).
- Disabling the mode selector while a response is streaming.
- Per-app "last used" mode default.
- Explicit per-chat mode lock (protect from accidental changes).
- Bulk mode change across all chats in an app.
- Multi-window same-chat live sync (broadcast / pub-sub over IPC).
- Persistent in-chat banner for unavailable modes (beyond the one-shot toast).
## User Stories
- As a power user juggling a planning chat and a build chat in the same app, I want each chat to remember its mode so that I don't send a planning prompt through the build pipeline.
- As a user with a mix of ask-mode research chats and build-mode work chats, I want switching between them to Just Work so that I don't have to flip the selector every time.
- As a returning user opening a months-old chat, I want it to behave sensibly (fall back to the current default) so that nothing breaks silently after upgrade.
- As a user who changes mode mid-chat, I want that choice to stick when I come back so that my intent is preserved.
- As a free user whose stored mode was `local-agent` but who's out of quota, I want a toast explaining the fallback so that I understand why my next reply is different.
## UX Design
### User Flow
1. User opens a chat — header `ChatModeSelector` shows the chat's persisted mode (or the effective default if NULL for existing chats).
2. User switches modes via the dropdown or `Cmd/Ctrl+.` — the new mode is persisted to this chat's row (optimistic client update, IPC write).
3. User navigates to a different chat — the selector updates to that chat's mode.
4. User creates a new chat via `HomeChatInput``createChat` is called with `initialChatMode = getEffectiveDefaultChatMode()`, so the new row has a concrete mode from the start.
5. User returns to any earlier chat — the header again reflects that chat's mode.
### Key States
- **Default**: selector shows the chat's current mode; behavior identical to today's selector, just sourced from the chat row instead of global settings.
- **Existing pre-migration chat (chatMode NULL)**: selector shows the resolved effective default. On first mode change, the row transitions from NULL to the chosen mode.
- **Loading**: selector shows the last-known mode for the chat (from react-query cache) during navigation — no skeleton unless the chat itself is loading.
- **Empty (no chat open / home)**: selector still lives in `HomeChatInput` and reads/writes `settings.selectedChatMode`. Cmd/Ctrl+. continues to mutate the global setting while on home — out-of-scope to change.
- **Unavailable stored mode**: server falls back to the effective default; response stream includes the effective mode plus a `fallbackReason`. Client shows a toast with the specific cause — e.g. "Agent v2 unavailable (Pro required). Using Build mode.", "Quota exhausted. Using Build mode.", or "No provider configured. Using Build mode." — with a [Switch mode] action. The toast re-fires each fresh time the user opens a chat whose stored mode is unavailable (bounded because the toast self-dismisses). The selector keeps showing the _stored_ mode (not the fallback) so the chat auto-recovers if the unavailability resolves.
### Interaction Details
- Selector persists on commit (close / keyboard fire), not on hover preview.
- Selector write happens even before the chat has any messages — a freshly-created empty chat with a mode change keeps that mode on return.
- `Cmd/Ctrl+.` in a chat cycles the chat's mode and persists. It does not touch `settings.selectedChatMode`. On home (no chat open), behavior is unchanged.
- Existing local-agent warning toast (mid-chat switch to local-agent) remains as the only persistent mode-switch toast. Other mode switches are silent (status quo).
- When server returns `effectiveChatMode` that differs from the client's request (unavailability fallback), client shows the availability toast and invalidates the chat's query so the selector shows the effective mode.
### Accessibility
- Selector's accessible name should be dynamic: `"Chat mode: Build"` so a screen reader focus on the button reveals the current chat's mode.
- Do not announce the header change on chat navigation (would be fatigue-inducing). Changes are announced only when user-initiated.
- Touch targets remain ≥ 44px (no visual change to the selector).
- Availability toast remains keyboard-reachable for at least 8s; matches today's toast behavior.
## Technical Design
### Architecture
The server is the source of truth for the effective chat mode on each turn. The flow:
1. Client calls `streamMessage` with `requestedChatMode` (read from the chat row via react-query).
2. Server loads the chat, computes `resolvedChatMode = resolveChatMode(chat, settings, env, quota)`:
- If `chat.chatMode` is non-null and that mode is still available → use it.
- Else → fall back to `getEffectiveDefaultChatMode(settings, env, quota)`.
3. Server emits an early `effectiveChatMode` event in the stream (with an optional `fallbackReason: "pro-required" | "quota-exhausted" | "no-provider"` when resolution fell back) so the client can reconcile the selector and show a specific fallback toast when applicable.
4. Server constructs the system prompt and routes to the appropriate stream using `resolvedChatMode`, replacing ~15 current reads of `settings.selectedChatMode`.
`settings.selectedChatMode` remains in v1 as the home-page new-chat seed — out of scope to remove.
### Components Affected
- `src/db/schema.ts` — add `chatMode: text("chat_mode")` to `chats` (nullable, no default).
- `drizzle/` — auto-generated migration: `ALTER TABLE chats ADD COLUMN chat_mode TEXT;`
- `src/lib/schemas.ts` — add `chatMode` to `ChatSchema`; reuse `StoredChatModeSchema` at DB-read boundary so legacy `agent``build` migrates transparently.
- `src/ipc/handlers/chat_handlers.ts``getChat`, `getChats`, `updateChat`, `createChat` all gain the `chatMode` field. `updateChat` accepts `chatMode: ChatMode | null`. `createChat` accepts optional `initialChatMode`.
- `src/ipc/handlers/chat_stream_handlers.ts` — introduce `resolveChatMode(chat, settings, env, quota)`; replace all `settings.selectedChatMode` reads (~15 sites) with the resolved value; add an `effectiveChatMode` event at the start of the stream.
- `src/ipc/ipc_client.ts` / contracts — extend types for `getChat`, `getChats`, `updateChat`, `createChat`, `streamMessage`.
- `src/components/ChatModeSelector.tsx` — when on `/chat?id=…`, read/write chat row via a new `useChatMode(chatId)` hook (react-query mutation on `updateChat`). When on home, keep existing `settings.selectedChatMode` behavior.
- `src/hooks/useChatModeToggle.ts` — branch on active chatId: chat → chat-row mutation; no chat → settings.
- `src/components/chat/HomeChatInput.tsx` — when creating a new chat, pass `initialChatMode = getEffectiveDefaultChatMode()` to `createChat`.
- `src/ipc/handlers/fork_chat.ts` (or wherever chat duplication lives) — copy parent's `chatMode` to the new row.
### Data Model Changes
```ts
// src/db/schema.ts
export const chats = sqliteTable("chats", {
// ...existing columns...
chatMode: text("chat_mode"), // nullable; null = inherit default
});
```
Migration: nullable column, no data backfill. Existing rows have `chatMode = NULL` after the ALTER.
On read, the boundary schema (`StoredChatModeSchema`) maps legacy `"agent"` values to `"build"` (if any ever leaked into a future import path). NULL passes through unchanged.
### API Changes
- `getChat` response: adds `chatMode: ChatMode | null`.
- `getChats` response: each chat includes `chatMode: ChatMode | null` in its columns selection.
- `updateChat` request: adds optional `chatMode: ChatMode | null` (passing `null` resets to default-resolving behavior).
- `createChat` request: adds optional `initialChatMode: ChatMode`. Default: resolved via `getEffectiveDefaultChatMode()` server-side if omitted.
- `streamMessage` request: adds `requestedChatMode?: ChatMode` (client hint).
- `streamMessage` stream: adds an early event `{ type: "effectiveChatMode", mode: ChatMode, fallbackReason?: "pro-required" | "quota-exhausted" | "no-provider" }` before content starts. Additive; older clients ignore the field.
## Implementation Plan
### Phase 1: Data layer
- [ ] Add `chatMode` column to `src/db/schema.ts` and generate the drizzle migration.
- [ ] Extend `ChatSchema` in `src/lib/schemas.ts` with `chatMode: StoredChatModeSchema.nullable()`.
- [ ] Update IPC contracts for `getChat`, `getChats`, `updateChat`, `createChat` to include `chatMode`.
- [ ] Unit test: schema migration against a snapshot of an existing SQLite DB (existing rows get NULL, queries still succeed).
### Phase 2: Server-side resolution
- [ ] Implement `resolveChatMode(chat, settings, env, quota): ChatMode` with table-driven tests: null / pinned-and-available / pinned-unavailable / legacy "agent" / lapsed Pro.
- [ ] Wire `resolveChatMode` into `chat_stream_handlers.ts` at every current `settings.selectedChatMode` read site.
- [ ] Add the `effectiveChatMode` stream event emitted before system-prompt content.
- [ ] Handler for `updateChat({ chatMode })` round-trip.
### Phase 3: Client selector + toggle
- [ ] `useChatMode(chatId)` hook with react-query mutation; optimistic update with rollback on error.
- [ ] `ChatModeSelector`: branch on `selectedChatId`; when a chat is active, read/write the chat row; when not, keep current settings behavior.
- [ ] `useChatModeToggle`: same branching for the keyboard shortcut.
- [ ] Fork path: pass parent's `chatMode` when duplicating a chat.
### Phase 4: New chat seeding
- [ ] `HomeChatInput` passes `initialChatMode = getEffectiveDefaultChatMode(...)` when creating a new chat.
- [ ] Server-side default in `createChat`: if `initialChatMode` is omitted, resolve from settings at the server side.
### Phase 5: Availability fallback UX
- [ ] Client subscribes to the `effectiveChatMode` stream event. When `fallbackReason` is present (and thus the effective mode differs from the stored mode), show a cause-specific toast: `pro-required` → "Agent v2 unavailable (Pro required). Using Build mode.", `quota-exhausted` → "Quota exhausted. Using Build mode.", `no-provider` → "No provider configured. Using Build mode." Each toast has a [Switch mode] action that opens/focuses the selector.
- [ ] Also fire the toast on fresh chat load when the stored mode is already known to be unavailable client-side (via existing signals: `isDyadProEnabled`, `useFreeAgentQuota`, `isOpenAIOrAnthropicSetup`), so users see the fallback explanation before they type.
- [ ] Do NOT persist the fallback back to the chat row — leave `chat.chatMode` untouched so the chat recovers automatically if the unavailability resolves.
### Phase 6: Tests + docs
- [ ] E2E: switch mode in chat A, switch to chat B (different mode), return to A — A's mode is preserved.
- [ ] E2E: new chat created from home respects the current default.
- [ ] E2E: change default in settings — existing chats with NULL pick it up on next send; chats with a pinned non-null mode do not.
- [ ] E2E: Cmd/Ctrl+. inside a chat persists; on home it mutates `settings.selectedChatMode` (status quo).
- [ ] Release notes: brief mention that chat mode is now per-chat, with an emphasis on "existing chats keep working; pick a mode inside a chat to make it sticky."
## Testing Strategy
- Unit tests for `resolveChatMode` (table-driven).
- Unit test for the schema migration against an older DB snapshot (existing rows get NULL, queries pass, fork inherits mode).
- Unit test that `useChatModeToggle` branches correctly on `selectedChatIdAtom`.
- IPC integration test: `updateChat({ chatMode })` round-trips; setting `null` resets to fallback resolution.
- E2E (Playwright): the four scenarios in Phase 6.
- Regression: existing `readSettings` tests stay green; add an equivalent for the per-chat read path.
## Risks & Mitigations
| Risk | Likelihood | Impact | Mitigation |
| ---------------------------------------------------------------------------------------- | ---------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Existing chat's NULL mode silently shifts when user changes `defaultChatMode` | M | M | Documented as expected v1 behavior: existing chats inherit the current default until explicitly set. Users who want stability explicitly pick a mode. |
| Mid-stream mode toggle produces confusion about which mode the running response is using | L | L | Server locks mode at turn start; echoed via `effectiveChatMode` event. Client changes affect next turn only. Explicitly deferred UI guard (disable during stream) to follow-up. |
| Two renderer windows on the same chat desync | L | L | Documented limitation; refresh to reconcile. Not worth IPC pub/sub in v1. |
| Pinned `local-agent` mode + Pro lapsed silently runs `build` without the user noticing | M | M | Server always emits `effectiveChatMode` with a `fallbackReason`; client shows a cause-specific toast each fresh time the user opens a chat whose stored mode is unavailable. Stored `chatMode` is not overwritten, so the chat auto-recovers on re-entitlement. Per-chat inline banner is a follow-up if complaints arrive. |
| Server/client race: client's `requestedChatMode` was just-changed but not yet persisted | L | L | Server reads the chat row as the source of truth; `requestedChatMode` is a hint only. Optimistic client update reconciles to server via `effectiveChatMode`. |
| `~15` read sites in `chat_stream_handlers.ts` miss one | M | L | Treat `resolveChatMode` as the only way to obtain the turn's mode; remove/deprecate direct `settings.selectedChatMode` reads in that file. Search `selectedChatMode` should return zero matches in the stream handler after the refactor. |
## Open Questions
- **Remove `settings.selectedChatMode` entirely?** After the refactor, its only remaining purpose is seeding new chats from home. Engineering lead flagged this as a potential cleanup — doable in v1 as a follow-up commit if the dust settles cleanly, but out of v1 scope as currently specified.
- **Should we later add a persistent in-chat banner for unavailable modes?** The user explicitly chose toast-only for v1. Revisit if fallback cases become confusing in practice.
- **Should we later show a mode icon in the chat list?** Deferred; re-evaluate after per-chat mode adoption is measured.
- **`Cmd/Ctrl+.` on home** currently mutates `settings.selectedChatMode`. Could plausibly be rerouted to `defaultChatMode` in a follow-up if we remove `selectedChatMode` — out of v1 scope.
## Decision Log
- **Migration: leave existing chats NULL, resolve at load.** User choice. Team ultimately leaned toward "backfill with current global mode at first-run" for more determinism, but the user opted for the zero-upgrade-day-impact path. Consequence: existing chats continue to follow the user's current default until the user explicitly changes their mode in that chat.
- **New chats: snapshot the effective default at creation (copy-at-write).** User choice; PM and Eng agreed in final debate. The chat's mode is fixed for its lifetime unless the user changes it. Consistent with how other per-chat fields (`initialCommitHash`) work.
- **Unavailable-mode behavior: silent fallback + one-shot toast.** User choice. UX advocated for a persistent inline banner / disabled composer, citing that toasts dismiss and the next session won't know about the fallback. User decided the simpler fallback + toast is sufficient for v1; a persistent banner can be added later if complaints surface.
- **No "disable selector during streaming" for v1.** User explicitly deselected. Server locks mode at turn start, which provides correctness; UI-side guard is a polish item.
- **No chat-list mode indicator for v1.** User deselected. The header selector remains the primary locus of the mode decision.
- **No rename of "Default chat mode" setting.** User deselected. Copy stays as-is; can revisit if confusion arises.
- **No neutral "Chat switched · Undo" toast generalization.** User deselected. Current local-agent warning toast remains the only mode-switch toast.
- **Server is authoritative for resolved mode; client sends a hint; server echoes back via `effectiveChatMode` event.** Engineering design decision. Prevents client-server drift and gives a clean hook for fallback UX.
- **Cmd/Ctrl+. inside a chat persists to the chat row; on home it mutates `settings.selectedChatMode` (status quo).** Keeps the keyboard shortcut's semantics consistent with the visible selector on the same route. Avoids the "one keybind does two things in different contexts" concern UX raised and then withdrew.
- **Forked/compacted chats inherit the parent chat's mode.** A fork is a continuation; mode should follow.
- **Two-window live sync deferred.** Low incidence; IPC pub/sub adds complexity disproportionate to the benefit for v1.
---
_Generated by dyad:swarm-to-plan_
......@@ -109,6 +109,10 @@ If this happens:
2. Re-run the same `npm run e2e -- e2e-tests/<spec>` command outside the sandbox before treating it as an app regression.
3. If the test passes outside the sandbox, treat the sandbox launch failure as environmental rather than a product bug.
## Native rebuild Python issues during E2E builds
If `npm run build` fails while rebuilding native modules with `ImportError` from Homebrew Python 3.14's `pyexpat` (for example `Symbol not found: _XML_SetAllocTrackerActivationThreshold`), rerun the build with the system Python: `PYTHON=/usr/bin/python3 npm run build`.
## Common flaky test patterns and fixes
- **After `po.importApp(...)`**: Some imports trigger an initial assistant turn (for example `minimal` generating `AI_RULES.md`) that can leave a visible `Retry` button in the chat. If the test is about a later prompt, first wait for that import-time turn to finish, then start a new chat before calling `sendPrompt()`, or helper methods that wait on `Retry` visibility may return too early.
......
......@@ -6,6 +6,8 @@ The pre-commit hook runs `tsgo` (via `npm run ts`), which is stricter than `tsc
`tsgo` is a Go binary, **not** an npm package — running `npx tsgo` fails with `npm error 404 Not Found - GET https://registry.npmjs.org/tsgo` because it is not in the npm registry. It is installed by the project's `npm install` step via a local package. If node_modules is missing or `npm install` fails (e.g., because the environment runs Node.js < 24, which the project requires), skip the `npm run ts` check and note that CI will verify types instead.
If `npm run ts` fails because installed dependency types are missing APIs the repo already uses (for example `@neondatabase/api-client` missing `getNeonAuth` or `BetterAuth`), run `npm install` before editing source. Stale `node_modules` can lag behind the lockfile even when `package.json` is unchanged.
## ES2020 target limitations
The project's `tsconfig.app.json` targets ES2020 with `lib: ["ES2020"]`. Methods introduced in ES2021+ (like `String.prototype.replaceAll`) are not available on the `string` type. If code uses `replaceAll`, it needs an `as any` cast to avoid `TS2550: Property 'replaceAll' does not exist on type 'string'`. Do not remove these casts without updating the tsconfig target.
......
import { describe, expect, it } from "vitest";
import { normalizeStoredChatMode, resolveChatMode } from "@/lib/chatMode";
import type { UserSettings } from "@/lib/schemas";
function makeSettings(overrides: Partial<UserSettings> = {}): UserSettings {
return {
selectedModel: { provider: "auto", name: "auto" },
providerSettings: {},
selectedTemplateId: "react",
enableAutoUpdate: true,
releaseChannel: "stable",
...overrides,
} as UserSettings;
}
describe("chat mode resolution", () => {
it("migrates deprecated agent mode to build", () => {
expect(normalizeStoredChatMode("agent")).toBe("build");
});
it("uses the effective default when a chat has no stored mode", () => {
const settings = makeSettings({ defaultChatMode: "ask" });
expect(
resolveChatMode({
storedChatMode: null,
settings,
envVars: {},
}),
).toEqual({ mode: "ask" });
});
it("uses a stored mode when it is available", () => {
const settings = makeSettings({ defaultChatMode: "build" });
expect(
resolveChatMode({
storedChatMode: "plan",
settings,
envVars: {},
}),
).toEqual({ mode: "plan" });
});
it("falls back when stored local-agent mode has no provider", () => {
const settings = makeSettings({ defaultChatMode: "build" });
expect(
resolveChatMode({
storedChatMode: "local-agent",
settings,
envVars: {},
freeAgentQuotaAvailable: true,
}),
).toEqual({ mode: "build", fallbackReason: "no-provider" });
});
it("falls back when stored local-agent mode is out of quota", () => {
const settings = makeSettings({
defaultChatMode: "build",
providerSettings: {
openai: { apiKey: { value: "test-key" } },
},
});
expect(
resolveChatMode({
storedChatMode: "local-agent",
settings,
envVars: {},
freeAgentQuotaAvailable: false,
}),
).toEqual({ mode: "build", fallbackReason: "quota-exhausted" });
});
it("does not treat unknown quota as exhausted", () => {
const settings = makeSettings({
defaultChatMode: "build",
providerSettings: {
openai: { apiKey: { value: "test-key" } },
},
});
expect(
resolveChatMode({
storedChatMode: "local-agent",
settings,
envVars: {},
freeAgentQuotaAvailable: undefined,
}),
).toEqual({ mode: "local-agent" });
});
it("allows basic agent mode when Pro is enabled without a key but free quota is available", () => {
const settings = makeSettings({
enableDyadPro: true,
defaultChatMode: "build",
providerSettings: {
openai: { apiKey: { value: "test-key" } },
},
});
expect(
resolveChatMode({
storedChatMode: "local-agent",
settings,
envVars: {},
freeAgentQuotaAvailable: true,
}),
).toEqual({ mode: "local-agent" });
});
it("reports quota exhausted before Pro required when a provider is configured", () => {
const settings = makeSettings({
enableDyadPro: true,
defaultChatMode: "build",
providerSettings: {
openai: { apiKey: { value: "test-key" } },
},
});
expect(
resolveChatMode({
storedChatMode: "local-agent",
settings,
envVars: {},
freeAgentQuotaAvailable: false,
}),
).toEqual({ mode: "build", fallbackReason: "quota-exhausted" });
});
it("allows stored local-agent mode for Pro users", () => {
const settings = makeSettings({
enableDyadPro: true,
providerSettings: {
auto: { apiKey: { value: "dyad-key" } },
},
});
expect(
resolveChatMode({
storedChatMode: "local-agent",
settings,
envVars: {},
freeAgentQuotaAvailable: false,
}),
).toEqual({ mode: "local-agent" });
});
});
......@@ -27,6 +27,7 @@ function chat(id: number, appId = 1): ChatSummary {
appId,
title: `Chat ${id}`,
createdAt: new Date(),
chatMode: null,
};
}
......
......@@ -5,6 +5,8 @@ import { ChatModeSelector } from "./ChatModeSelector";
import { McpToolsPicker } from "@/components/McpToolsPicker";
import { useSettings } from "@/hooks/useSettings";
import { useMcp } from "@/hooks/useMcp";
import { useChatMode } from "@/hooks/useChatMode";
import { useRouterState } from "@tanstack/react-router";
export function ChatInputControls({
showContextFilesPicker = false,
......@@ -12,6 +14,12 @@ export function ChatInputControls({
showContextFilesPicker?: boolean;
}) {
const { settings } = useSettings();
const routerState = useRouterState();
const chatId =
routerState.location.pathname === "/chat"
? (routerState.location.search.id as number | undefined)
: null;
const { selectedMode } = useChatMode(chatId);
const { servers } = useMcp();
const enabledMcpServersCount = servers.filter((s) => s.enabled).length;
......@@ -20,7 +28,7 @@ export function ChatInputControls({
// 2. Mode is "build" AND there are enabled MCP servers
const showMcpToolsPicker =
!!settings?.enableMcpServersForBuildMode &&
settings?.selectedChatMode === "build" &&
selectedMode === "build" &&
enabledMcpServersCount > 0;
return (
......
......@@ -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,7 @@ 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 initialChatMode = useInitialChatMode();
const { chats, loading, invalidateChats } = useChats(selectedAppId);
const routerState = useRouterState();
......@@ -109,19 +106,10 @@ export function ChatList({ show }: { show?: boolean }) {
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 });
}
const chatId = await ipc.chat.createChat({
appId: selectedAppId,
initialChatMode,
});
// Refresh the chat list first so the new chat is in the cache
// before selectChat adds it to the tab bar
......
......@@ -11,10 +11,16 @@ import {
TooltipContent,
} from "@/components/ui/tooltip";
import { useSettings } from "@/hooks/useSettings";
import { useChatMode } from "@/hooks/useChatMode";
import { useFreeAgentQuota } from "@/hooks/useFreeAgentQuota";
import { useMcp } from "@/hooks/useMcp";
import type { ChatMode } from "@/lib/schemas";
import { isDyadProEnabled } from "@/lib/schemas";
import {
getChatModeFallbackToastId,
getChatModeDisplayName,
showChatModeFallbackToast,
} from "@/lib/chatModeToast";
import { cn } from "@/lib/utils";
import { detectIsMac } from "@/hooks/useChatModeToggle";
import { useRouterState } from "@tanstack/react-router";
......@@ -23,26 +29,58 @@ import { LocalAgentNewChatToast } from "./LocalAgentNewChatToast";
import { useAtomValue } from "jotai";
import { chatMessagesByIdAtom } from "@/atoms/chatAtoms";
import { Hammer, Bot, MessageCircle, Lightbulb } from "lucide-react";
import { useEffect, useRef } from "react";
export function ChatModeSelector() {
const { settings, updateSettings } = useSettings();
const { updateSettings } = useSettings();
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) ?? []) : [];
const {
selectedMode,
effectiveMode,
storedChatMode,
fallbackReason,
setChatMode,
settings,
} = useChatMode(isChatRoute ? chatId : null);
const fallbackToastKeyRef = useRef<string | null>(null);
// 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 } =
useFreeAgentQuota();
const { servers } = useMcp();
const enabledMcpServersCount = servers.filter((s) => s.enabled).length;
useEffect(() => {
if (!chatId || !fallbackReason || !storedChatMode) {
fallbackToastKeyRef.current = null;
return;
}
const toastKey = getChatModeFallbackToastId({
chatId,
reason: fallbackReason,
effectiveMode,
});
if (fallbackToastKeyRef.current === toastKey) {
return;
}
fallbackToastKeyRef.current = toastKey;
showChatModeFallbackToast({
reason: fallbackReason,
effectiveMode,
isPro: isProEnabled,
toastId: toastKey,
});
}, [chatId, effectiveMode, fallbackReason, isProEnabled, storedChatMode]);
const handleModeChange = (value: string) => {
const newMode = value as ChatMode;
updateSettings({ selectedChatMode: newMode });
void setChatMode(newMode).catch(() => {});
// 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.
......@@ -73,19 +111,7 @@ export function ChatModeSelector() {
};
const getModeDisplayName = (mode: ChatMode) => {
switch (mode) {
case "build":
return "Build";
case "ask":
return "Ask";
case "local-agent":
// Show "Basic Agent" for non-Pro users, "Agent" for Pro users
return isProEnabled ? "Agent" : "Basic Agent";
case "plan":
return "Plan";
default:
return "Build";
}
return getChatModeDisplayName(mode, isProEnabled);
};
const getModeIcon = (mode: ChatMode) => {
......@@ -115,6 +141,7 @@ export function ChatModeSelector() {
render={
<MiniSelectTrigger
data-testid="chat-mode-selector"
aria-label={`Chat mode: ${getModeDisplayName(selectedMode)}`}
className={cn(
"cursor-pointer w-fit px-2 py-0 text-xs font-medium border-none shadow-none gap-1 rounded-lg transition-colors",
selectedMode === "build" || selectedMode === "local-agent"
......
......@@ -24,7 +24,8 @@ import {
import { ArrowDown } from "lucide-react";
import { useSettings } from "@/hooks/useSettings";
import { useFreeAgentQuota } from "@/hooks/useFreeAgentQuota";
import { isBasicAgentMode } from "@/lib/schemas";
import { useChatMode } from "@/hooks/useChatMode";
import { isDyadProEnabled } from "@/lib/schemas";
interface ChatPanelProps {
chatId?: number;
......@@ -44,10 +45,14 @@ export function ChatPanel({
const [error, setError] = useState<string | null>(null);
const streamCountById = useAtomValue(chatStreamCountByIdAtom);
const isStreamingById = useAtomValue(isStreamingByIdAtom);
const { settings, updateSettings } = useSettings();
const { settings } = useSettings();
const { selectedMode, setChatMode } = useChatMode(chatId);
const { isQuotaExceeded } = useFreeAgentQuota();
const showFreeAgentQuotaBanner =
settings && isBasicAgentMode(settings) && isQuotaExceeded;
settings &&
!isDyadProEnabled(settings) &&
selectedMode === "local-agent" &&
isQuotaExceeded;
const messagesEndRef = useRef<HTMLDivElement | null>(null);
const messagesContainerRef = useRef<HTMLDivElement | null>(null);
......@@ -223,7 +228,7 @@ export function ChatPanel({
{showFreeAgentQuotaBanner && (
<FreeAgentQuotaBanner
onSwitchToBuildMode={() =>
updateSettings({ selectedChatMode: "build" })
void setChatMode("build").catch(() => {})
}
/>
)}
......
......@@ -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;
......@@ -53,6 +54,7 @@ export function ChatHeader({
const { invalidateChats } = useChats(appId);
const { selectChat } = useSelectChat();
const { isStreaming } = useStreamChat();
const initialChatMode = useInitialChatMode();
const isAnyCheckoutVersionInProgress = useAtomValue(
isAnyCheckoutVersionInProgressAtom,
);
......@@ -88,7 +90,10 @@ export function ChatHeader({
const handleNewChat = async () => {
if (appId) {
try {
const chatId = await ipc.chat.createChat(appId);
const chatId = await ipc.chat.createChat({
appId,
initialChatMode,
});
await invalidateChats();
selectChat({ chatId, appId });
} catch (error) {
......
......@@ -105,6 +105,8 @@ import { showError as showErrorToast } from "@/lib/toast";
import { cn } from "@/lib/utils";
import { useVoiceToText } from "@/hooks/useVoiceToText";
import { isDyadProEnabled } from "@/lib/schemas";
import { useChatMode } from "@/hooks/useChatMode";
import { useInitialChatMode } from "@/hooks/useInitialChatMode";
const showTokenBarAtom = atom(false);
......@@ -113,6 +115,12 @@ export function ChatInput({ chatId }: { chatId?: number }) {
const posthog = usePostHog();
const [inputValue, setInputValue] = useAtom(chatInputValueAtom);
const { settings } = useSettings();
const {
selectedMode: chatMode,
effectiveMode,
isLoading: isChatModeLoading,
} = useChatMode(chatId);
const initialChatMode = useInitialChatMode();
const appId = useAtomValue(selectedAppIdAtom);
const { refreshVersions } = useVersions(appId);
const {
......@@ -231,7 +239,7 @@ export function ChatInput({ chatId }: { chatId?: number }) {
const lastMessage = (chatId ? (messagesById.get(chatId) ?? []) : []).at(-1);
const disableSendButton =
settings?.selectedChatMode !== "local-agent" &&
effectiveMode !== "local-agent" &&
lastMessage?.role === "assistant" &&
!lastMessage.approvalState &&
!!proposal &&
......@@ -269,10 +277,23 @@ export function ChatInput({ chatId }: { chatId?: number }) {
);
// Detect transition to plan mode from another mode in a chat with messages
const prevModeRef = useRef(settings?.selectedChatMode);
const prevModeRef = useRef(chatMode);
const prevModeChatIdRef = useRef(chatId);
const hasInitializedModeRef = useRef(false);
useEffect(() => {
if (isChatModeLoading) return;
if (
!hasInitializedModeRef.current ||
prevModeChatIdRef.current !== chatId
) {
hasInitializedModeRef.current = true;
prevModeChatIdRef.current = chatId;
prevModeRef.current = chatMode;
return;
}
const prevMode = prevModeRef.current;
const currentMode = settings?.selectedChatMode;
const currentMode = chatMode;
prevModeRef.current = currentMode;
if (prevMode && prevMode !== "plan" && currentMode === "plan") {
......@@ -281,7 +302,13 @@ export function ChatInput({ chatId }: { chatId?: number }) {
setNeedsFreshPlanChat(true);
}
}
}, [settings?.selectedChatMode, chatId, messagesById, setNeedsFreshPlanChat]);
}, [
chatMode,
chatId,
isChatModeLoading,
messagesById,
setNeedsFreshPlanChat,
]);
// Token counting for context limit banner
const { result: tokenCountResult } = useCountTokens(
......@@ -483,11 +510,14 @@ export function ChatInput({ chatId }: { chatId?: number }) {
// If switching to plan mode from another mode in a chat with messages,
// create a new chat for a clean context.
if (needsFreshPlanChat && settings?.selectedChatMode === "plan" && appId) {
if (needsFreshPlanChat && chatMode === "plan" && appId) {
setInputValue("");
setNeedsFreshPlanChat(false);
const newChatId = await ipc.chat.createChat(appId);
const newChatId = await ipc.chat.createChat({
appId,
initialChatMode: "plan",
});
setSelectedChatId(newChatId);
navigate({ to: "/chat", search: { id: newChatId } });
queryClient.invalidateQueries({ queryKey: queryKeys.chats.all });
......@@ -498,9 +528,10 @@ export function ChatInput({ chatId }: { chatId?: number }) {
chatId: newChatId,
attachments,
redo: false,
requestedChatMode: "plan",
});
clearAttachments();
posthog.capture("chat:submit", { chatMode: settings?.selectedChatMode });
posthog.capture("chat:submit", { chatMode });
return;
}
......@@ -569,9 +600,10 @@ export function ChatInput({ chatId }: { chatId?: number }) {
attachments,
redo: false,
selectedComponents: componentsToSend,
requestedChatMode: isChatModeLoading ? null : chatMode,
});
clearAttachments();
posthog.capture("chat:submit", { chatMode: settings?.selectedChatMode });
posthog.capture("chat:submit", { chatMode });
};
const handleCancel = () => {
......@@ -598,7 +630,10 @@ 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,
initialChatMode,
});
setSelectedChatId(newChatId);
navigate({
to: "/chat",
......@@ -798,8 +833,8 @@ export function ChatInput({ chatId }: { chatId?: number }) {
{!pendingAgentConsent &&
proposal &&
proposalResult?.chatId === chatId &&
settings.selectedChatMode !== "ask" &&
settings.selectedChatMode !== "local-agent" && (
effectiveMode !== "ask" &&
effectiveMode !== "local-agent" && (
<ChatInputActions
proposal={proposal}
onApprove={handleApprove}
......
......@@ -22,7 +22,11 @@ export function useSummarizeInNewChat() {
return;
}
try {
const newChatId = await ipc.chat.createChat(appId);
const sourceChat = await ipc.chat.getChat(chatId);
const newChatId = await ipc.chat.createChat({
appId,
initialChatMode: sourceChat.chatMode ?? undefined,
});
// navigate to new chat
await navigate({ to: "/chat", search: { id: newChatId } });
await streamMessage({
......
......@@ -18,7 +18,7 @@ import { previewModeAtom } from "@/atoms/appAtoms";
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
import { useStreamChat } from "@/hooks/useStreamChat";
import { usePlan } from "@/hooks/usePlan";
import { useSettings } from "@/hooks/useSettings";
import { useChatMode } from "@/hooks/useChatMode";
import { SelectionCommentButton } from "./plan/SelectionCommentButton";
import { CommentsFloatingButton } from "./plan/CommentsFloatingButton";
import { CommentPopover } from "./plan/CommentPopover";
......@@ -34,7 +34,7 @@ export const PlanPanel: React.FC = () => {
const setPreviewMode = useSetAtom(previewModeAtom);
const { streamMessage, isStreaming } = useStreamChat();
const { savedPlan } = usePlan();
const { settings } = useSettings();
const { selectedMode } = useChatMode(chatId);
const annotations = useAtomValue(planAnnotationsAtom);
const planContentRef = useRef<HTMLDivElement>(null);
......@@ -154,7 +154,7 @@ export const PlanPanel: React.FC = () => {
const handleAccept = () => {
if (!chatId) return;
if (settings?.selectedChatMode !== "plan") return;
if (selectedMode !== "plan") return;
if (isSubmitting) return;
setIsSubmitting(true);
......
......@@ -2,6 +2,7 @@ import { sql } from "drizzle-orm";
import { integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
import { relations } from "drizzle-orm";
import type { ModelMessage } from "ai";
import type { StoredChatMode } from "@/lib/schemas";
export const AI_MESSAGES_SDK_VERSION = "ai@v6" as const;
......@@ -82,6 +83,7 @@ export const chats = sqliteTable("chats", {
compactedAt: integer("compacted_at", { mode: "timestamp" }),
compactionBackupPath: text("compaction_backup_path"),
pendingCompaction: integer("pending_compaction", { mode: "boolean" }),
chatMode: text("chat_mode").$type<StoredChatMode | null>(),
});
export const messages = sqliteTable("messages", {
......
import { useCallback, useMemo } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ipc, type Chat } from "@/ipc/types";
import type { ChatSummary } from "@/lib/schemas";
import {
getEffectiveDefaultChatMode,
type ChatMode,
type UserSettings,
} from "@/lib/schemas";
import {
getUnavailableChatModeReason,
type ChatModeFallbackReason,
} from "@/lib/chatMode";
import { queryKeys } from "@/lib/queryKeys";
import { useSettings } from "./useSettings";
import { useFreeAgentQuota } from "./useFreeAgentQuota";
type ChatModeMutationContext = {
previousChat?: Chat;
previousLists: [readonly unknown[], ChatSummary[] | undefined][];
};
const chatListQueryFilter = {
predicate: (query: { queryKey: readonly unknown[] }) =>
query.queryKey[0] === "chats" && query.queryKey.length === 2,
};
export function useChatMode(chatId: number | null | undefined) {
const queryClient = useQueryClient();
const { settings, envVars, updateSettings } = useSettings();
const { isQuotaExceeded, isLoading: isQuotaLoading } = useFreeAgentQuota();
const activeChatId = chatId ?? null;
const chatQuery = useQuery({
queryKey: queryKeys.chats.detail({ chatId: activeChatId }),
queryFn: () => ipc.chat.getChat(activeChatId!),
enabled: activeChatId !== null,
});
const freeAgentQuotaAvailable = isQuotaLoading ? undefined : !isQuotaExceeded;
const effectiveDefaultMode = settings
? getEffectiveDefaultChatMode(settings, envVars, freeAgentQuotaAvailable)
: "build";
const storedChatMode = chatQuery.data?.chatMode ?? null;
const selectedMode = activeChatId
? (storedChatMode ?? effectiveDefaultMode)
: (settings?.selectedChatMode ?? "build");
const fallbackReason = useMemo<ChatModeFallbackReason | undefined>(() => {
if (!settings || !activeChatId || !storedChatMode) {
return undefined;
}
return getUnavailableChatModeReason({
mode: storedChatMode,
settings,
envVars,
freeAgentQuotaAvailable,
});
}, [
activeChatId,
envVars,
freeAgentQuotaAvailable,
settings,
storedChatMode,
]);
const effectiveMode =
activeChatId && fallbackReason ? effectiveDefaultMode : selectedMode;
const updateChatModeMutation = useMutation<
void,
Error,
ChatMode | null,
ChatModeMutationContext
>({
mutationFn: async (chatMode) => {
if (activeChatId === null) {
return;
}
await ipc.chat.updateChat({
chatId: activeChatId,
chatMode,
});
},
onMutate: async (chatMode) => {
if (activeChatId === null) {
return { previousLists: [] };
}
await queryClient.cancelQueries({
queryKey: queryKeys.chats.detail({ chatId: activeChatId }),
});
await queryClient.cancelQueries(chatListQueryFilter);
const previousChat = queryClient.getQueryData<Chat>(
queryKeys.chats.detail({ chatId: activeChatId }),
);
const previousLists =
queryClient.getQueriesData<ChatSummary[]>(chatListQueryFilter);
queryClient.setQueryData<Chat>(
queryKeys.chats.detail({ chatId: activeChatId }),
(old) => (old ? { ...old, chatMode } : old),
);
queryClient.setQueriesData<ChatSummary[]>(chatListQueryFilter, (old) =>
old?.map((chat) =>
chat.id === activeChatId ? { ...chat, chatMode } : chat,
),
);
return { previousChat, previousLists };
},
onError: (_error, _chatMode, context) => {
if (activeChatId !== null && context?.previousChat) {
queryClient.setQueryData(
queryKeys.chats.detail({ chatId: activeChatId }),
context.previousChat,
);
}
for (const [queryKey, data] of context?.previousLists ?? []) {
queryClient.setQueryData(queryKey, data);
}
},
onSettled: () => {
if (activeChatId !== null) {
queryClient.invalidateQueries({
queryKey: queryKeys.chats.detail({ chatId: activeChatId }),
});
queryClient.invalidateQueries({ queryKey: queryKeys.chats.all });
}
},
meta: { showErrorToast: true },
});
const setChatMode = useCallback(
async (mode: ChatMode | null) => {
if (activeChatId !== null) {
await updateChatModeMutation.mutateAsync(mode);
return;
}
if (mode !== null) {
await updateSettings({ selectedChatMode: mode });
}
},
[activeChatId, updateChatModeMutation, updateSettings],
);
return {
chat: chatQuery.data ?? null,
isLoading: chatQuery.isLoading,
storedChatMode,
selectedMode,
effectiveMode,
effectiveDefaultMode,
fallbackReason,
setChatMode,
isUpdating: updateChatModeMutation.isPending,
settings: settings as UserSettings | null,
};
}
import { useCallback, useMemo } from "react";
import { useSettings } from "./useSettings";
import { useShortcut } from "./useShortcut";
import { usePostHog } from "posthog-js/react";
import { ChatModeSchema } from "../lib/schemas";
import { useChatMode } from "./useChatMode";
import { useRouterState } from "@tanstack/react-router";
export function useChatModeToggle() {
const { settings, updateSettings } = useSettings();
const routerState = useRouterState();
const routeChatId =
routerState.location.pathname === "/chat"
? (routerState.location.search.id as number | undefined)
: null;
const { selectedMode, setChatMode, settings } = useChatMode(routeChatId);
const posthog = usePostHog();
// Detect if user is on mac
......@@ -22,21 +28,21 @@ export function useChatModeToggle() {
// Function to toggle between chat modes
const toggleChatMode = useCallback(() => {
if (!settings || !settings.selectedChatMode) return;
if (!settings || !selectedMode) return;
const currentMode = settings.selectedChatMode;
const currentMode = selectedMode;
// Migration on read ensures currentMode is never "agent"
const modes = ChatModeSchema.options;
const currentIndex = modes.indexOf(currentMode);
const newMode = modes[(currentIndex + 1) % modes.length];
updateSettings({ selectedChatMode: newMode });
void setChatMode(newMode).catch(() => {});
posthog.capture("chat:mode_toggle", {
from: currentMode,
to: newMode,
trigger: "keyboard_shortcut",
});
}, [settings, updateSettings, posthog]);
}, [selectedMode, setChatMode, settings, posthog]);
// Add keyboard shortcut with memoized modifiers
useShortcut(
......
import { useMemo } from "react";
import { getEffectiveDefaultChatMode, type ChatMode } from "@/lib/schemas";
import { useFreeAgentQuota } from "./useFreeAgentQuota";
import { useSettings } from "./useSettings";
export function useInitialChatMode(): ChatMode | undefined {
const { settings, envVars } = useSettings();
const { isQuotaExceeded, isLoading: isQuotaLoading } = useFreeAgentQuota();
return useMemo(() => {
if (!settings) {
return undefined;
}
if (settings.selectedChatMode) {
return settings.selectedChatMode;
}
if (isQuotaLoading) {
return undefined;
}
return getEffectiveDefaultChatMode(settings, envVars, !isQuotaExceeded);
}, [envVars, isQuotaExceeded, isQuotaLoading, settings]);
}
......@@ -37,7 +37,7 @@ export function usePlanEvents() {
const setSelectedChatId = useSetAtom(selectedChatIdAtom);
const navigate = useNavigate();
const queryClient = useQueryClient();
const { settings, updateSettings } = useSettings();
const { settings } = useSettings();
// Use refs for values accessed in event handlers to avoid stale closures
const planStateRef = useRef(planState);
......@@ -113,11 +113,6 @@ export function usePlanEvents() {
const currentState = planStateRef.current;
const planData = currentState.plansByChatId.get(payload.chatId);
// Switch chat mode to local-agent for implementation (only if currently in plan mode)
if (settingsRef.current?.selectedChatMode === "plan") {
updateSettings({ selectedChatMode: "local-agent" });
}
// Switch preview back to preview mode
setPreviewMode("preview");
......@@ -146,7 +141,10 @@ export function usePlanEvents() {
}
try {
const newChatId = await ipc.chat.createChat(selectedAppIdRef.current);
const newChatId = await ipc.chat.createChat({
appId: selectedAppIdRef.current,
initialChatMode: "local-agent",
});
// Navigate to the new chat
setSelectedChatId(newChatId);
......@@ -205,7 +203,6 @@ export function usePlanEvents() {
}, [
setPlanState,
setPreviewMode,
updateSettings,
setPendingPlanImplementation,
setPendingQuestionnaire,
setSelectedChatId,
......
......@@ -7,6 +7,8 @@ import {
chatErrorByIdAtom,
} from "@/atoms/chatAtoms";
import { ipc } from "@/ipc/types";
import { useSettings } from "./useSettings";
import { handleEffectiveChatModeChunk } from "@/lib/chatModeStream";
/**
* Hook to handle starting plan implementation when a plan is accepted.
......@@ -20,6 +22,7 @@ export function usePlanImplementation() {
const setIsStreamingById = useSetAtom(isStreamingByIdAtom);
const setMessagesById = useSetAtom(chatMessagesByIdAtom);
const setErrorById = useSetAtom(chatErrorByIdAtom);
const { settings } = useSettings();
// Track if we've already triggered implementation for this pending plan
const hasTriggeredRef = useRef(false);
......@@ -102,9 +105,21 @@ export function usePlanImplementation() {
messages: updatedMessages,
streamingMessageId,
streamingContent,
effectiveChatMode,
chatModeFallbackReason,
}) => {
if (!isMountedRef.current) return;
if (
handleEffectiveChatModeChunk(
{ effectiveChatMode, chatModeFallbackReason },
settings,
chatId,
)
) {
return;
}
if (updatedMessages) {
// Full messages update (initial load, post-compaction, etc.)
setMessagesById((prev) => {
......@@ -177,5 +192,6 @@ export function usePlanImplementation() {
setIsStreamingById,
setMessagesById,
setErrorById,
settings,
]);
}
......@@ -9,7 +9,9 @@ import {
} from "@/atoms/chatAtoms";
import { useStreamChat } from "./useStreamChat";
import { usePostHog } from "posthog-js/react";
import { useSettings } from "./useSettings";
import { useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/queryKeys";
import type { Chat } from "@/ipc/types";
/**
* Root-level hook that processes queued messages for any chat,
......@@ -25,7 +27,7 @@ export function useQueueProcessor() {
const [queuePausedById] = useAtom(queuePausedByIdAtom);
const [isStreamingById] = useAtom(isStreamingByIdAtom);
const posthog = usePostHog();
const { settings } = useSettings();
const queryClient = useQueryClient();
useEffect(() => {
// Find any chatId that has both completed successfully and has queued messages
......@@ -68,9 +70,11 @@ export function useQueueProcessor() {
if (!messageToSend) return;
posthog.capture("chat:submit", {
chatMode: settings?.selectedChatMode,
});
const chatMode = queryClient.getQueryData<Chat>(
queryKeys.chats.detail({ chatId }),
)?.chatMode;
posthog.capture("chat:submit", { chatMode });
streamMessage({
prompt: messageToSend.prompt,
......@@ -78,6 +82,7 @@ export function useQueueProcessor() {
redo: false,
attachments: messageToSend.attachments,
selectedComponents: messageToSend.selectedComponents,
requestedChatMode: chatMode,
});
// Only process one chatId per effect run
......@@ -92,6 +97,6 @@ export function useQueueProcessor() {
setQueuedMessagesById,
setStreamCompletedSuccessfullyById,
posthog,
settings?.selectedChatMode,
queryClient,
]);
}
......@@ -12,6 +12,8 @@ import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { showError } from "@/lib/toast";
import { useChats } from "@/hooks/useChats";
import { useLoadApp } from "@/hooks/useLoadApp";
import { useSettings } from "@/hooks/useSettings";
import { handleEffectiveChatModeChunk } from "@/lib/chatModeStream";
interface UseResolveMergeConflictsWithAIProps {
appId: number;
......@@ -38,6 +40,7 @@ export function useResolveMergeConflictsWithAI({
const isResolvingRef = useRef(false);
const { invalidateChats } = useChats(appId);
const { refreshApp } = useLoadApp(appId);
const { settings } = useSettings();
const resolveWithAI = useCallback(async () => {
if (!appId) {
......@@ -58,7 +61,10 @@ export function useResolveMergeConflictsWithAI({
let chatId: number | null = null;
try {
// Create a new chat for conflict resolution
const newChatId = await ipc.chat.createChat(appId);
const newChatId = await ipc.chat.createChat({
appId,
initialChatMode: "build",
});
chatId = newChatId;
// Clear conflicts state after successful chat creation
......@@ -97,7 +103,23 @@ For each file, review the conflict markers (<<<<<<<, =======, >>>>>>>) and choos
prompt,
},
{
onChunk: ({ messages, streamingMessageId, streamingContent }) => {
onChunk: ({
messages,
streamingMessageId,
streamingContent,
effectiveChatMode,
chatModeFallbackReason,
}) => {
if (
handleEffectiveChatModeChunk(
{ effectiveChatMode, chatModeFallbackReason },
settings,
newChatId,
)
) {
return;
}
if (!hasIncrementedStreamCount) {
setStreamCountById((prev) => {
const next = new Map(prev);
......@@ -183,6 +205,7 @@ For each file, review the conflict markers (<<<<<<<, =======, >>>>>>>) and choos
navigate,
invalidateChats,
refreshApp,
settings,
]);
return { resolveWithAI, isResolving };
......
......@@ -18,7 +18,7 @@ import {
} from "@/atoms/chatAtoms";
import { ipc } from "@/ipc/types";
import { isPreviewOpenAtom } from "@/atoms/viewAtoms";
import type { ChatResponseEnd, App } from "@/ipc/types";
import type { ChatResponseEnd, App, Chat } from "@/ipc/types";
import type { ChatSummary } from "@/lib/schemas";
import { useChats } from "./useChats";
import { useLoadApp } from "./useLoadApp";
......@@ -35,6 +35,7 @@ import { useSettings } from "./useSettings";
import { useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/queryKeys";
import { applyCancellationNoticeToLastAssistantMessage } from "@/shared/chatCancellation";
import { handleEffectiveChatModeChunk } from "@/lib/chatModeStream";
export function getRandomNumberId() {
return Math.floor(Math.random() * 1_000_000_000_000_000);
......@@ -90,6 +91,7 @@ export function useStreamChat({
redo,
attachments,
selectedComponents,
requestedChatMode,
onSettled,
}: {
prompt: string;
......@@ -97,6 +99,7 @@ export function useStreamChat({
redo?: boolean;
attachments?: FileAttachment[];
selectedComponents?: ComponentSelection[];
requestedChatMode?: Chat["chatMode"] | null;
onSettled?: (result: { success: boolean }) => void;
}) => {
if (
......@@ -167,6 +170,13 @@ export function useStreamChat({
let hasIncrementedStreamCount = false;
try {
const cachedChat =
requestedChatMode === null
? undefined
: queryClient.getQueryData<Chat>(
queryKeys.chats.detail({ chatId }),
);
ipc.chatStream.start(
{
chatId,
......@@ -174,13 +184,34 @@ export function useStreamChat({
redo,
attachments: convertedAttachments,
selectedComponents: selectedComponents ?? [],
requestedChatMode:
requestedChatMode === null
? undefined
: (requestedChatMode ?? cachedChat?.chatMode ?? undefined),
},
{
onChunk: ({
messages: updatedMessages,
streamingMessageId,
streamingContent,
effectiveChatMode,
chatModeFallbackReason,
}) => {
if (
handleEffectiveChatModeChunk(
{ effectiveChatMode, chatModeFallbackReason },
settings,
chatId,
)
) {
if (chatModeFallbackReason) {
queryClient.invalidateQueries({
queryKey: queryKeys.chats.detail({ chatId }),
});
}
return;
}
if (!hasIncrementedStreamCount) {
setStreamCountById((prev) => {
const next = new Map(prev);
......@@ -323,6 +354,10 @@ export function useStreamChat({
// that may only be finalized at stream completion.
try {
const latestChat = await ipc.chat.getChat(chatId);
queryClient.setQueryData(
queryKeys.chats.detail({ chatId }),
latestChat,
);
setMessagesById((prev) => {
const next = new Map(prev);
next.set(chatId, latestChat.messages);
......
......@@ -64,6 +64,7 @@ import {
uploadCloudSandboxFiles,
} from "../utils/cloud_sandbox_provider";
import { createFromTemplate } from "./createFromTemplate";
import { getInitialChatModeForNewChat } from "./chat_mode_resolution";
import {
gitCommit,
gitAdd,
......@@ -1205,11 +1206,16 @@ export function registerAppHandlers() {
})
.returning();
const initialChatMode = await getInitialChatModeForNewChat(
params.initialChatMode,
);
// Create an initial chat for this app
const [chat] = await db
.insert(chats)
.values({
appId: app.id,
chatMode: initialChatMode,
})
.returning();
......
......@@ -9,11 +9,20 @@ import { getDyadAppPath } from "../../paths/paths";
import { getCurrentCommitHash } from "../utils/git_utils";
import { createTypedHandler } from "./base";
import { chatContracts } from "../types/chat";
import {
getInitialChatModeForNewChat,
normalizeStoredChatMode,
} from "./chat_mode_resolution";
const logger = log.scope("chat_handlers");
export function registerChatHandlers() {
createTypedHandler(chatContracts.createChat, async (_, appId) => {
createTypedHandler(chatContracts.createChat, async (_, input) => {
const { appId, initialChatMode } =
typeof input === "number"
? { appId: input, initialChatMode: undefined }
: input;
// Get the app's path first
const app = await db.query.apps.findFirst({
where: eq(apps.id, appId),
......@@ -37,12 +46,15 @@ export function registerChatHandlers() {
// Continue without the git revision
}
const chatMode = await getInitialChatModeForNewChat(initialChatMode);
// Create a new chat
const [chat] = await db
.insert(chats)
.values({
appId,
initialCommitHash,
chatMode,
})
.returning();
logger.info(
......@@ -73,6 +85,7 @@ export function registerChatHandlers() {
return {
...chat,
title: chat.title ?? "",
chatMode: normalizeStoredChatMode(chat.chatMode),
messages: chat.messages.map((m) => ({
...m,
role: m.role as "user" | "assistant",
......@@ -90,6 +103,7 @@ export function registerChatHandlers() {
title: true,
createdAt: true,
appId: true,
chatMode: true,
},
orderBy: [desc(chats.createdAt)],
})
......@@ -99,12 +113,16 @@ export function registerChatHandlers() {
title: true,
createdAt: true,
appId: true,
chatMode: true,
},
orderBy: [desc(chats.createdAt)],
});
const allChats = await query;
return allChats as ChatSummary[];
return allChats.map((chat) => ({
...chat,
chatMode: normalizeStoredChatMode(chat.chatMode),
})) satisfies ChatSummary[];
});
createTypedHandler(chatContracts.deleteChat, async (_, chatId) => {
......@@ -112,8 +130,18 @@ export function registerChatHandlers() {
});
createTypedHandler(chatContracts.updateChat, async (_, params) => {
const { chatId, title } = params;
await db.update(chats).set({ title }).where(eq(chats.id, chatId));
const { chatId, title, chatMode } = params;
const updates: Partial<typeof chats.$inferInsert> = {};
if (title !== undefined) {
updates.title = title;
}
if (chatMode !== undefined) {
updates.chatMode = chatMode;
}
if (Object.keys(updates).length === 0) {
return;
}
await db.update(chats).set(updates).where(eq(chats.id, chatId));
});
createTypedHandler(chatContracts.deleteMessages, async (_, chatId) => {
......
import {
getEffectiveDefaultChatMode,
isDyadProEnabled,
type ChatMode,
type UserSettings,
} from "@/lib/schemas";
import {
normalizeStoredChatMode,
resolveChatMode,
type ChatModeResolution,
} from "@/lib/chatMode";
import { readSettings } from "@/main/settings";
import { PROVIDER_TO_ENV_VAR } from "@/ipc/shared/language_model_constants";
import { getEnvVar } from "@/ipc/utils/read_env";
import { getFreeAgentQuotaStatus } from "./free_agent_quota_handlers";
export { normalizeStoredChatMode };
export async function resolveChatModeForTurn({
storedChatMode,
requestedChatMode,
settings = readSettings(),
}: {
storedChatMode: string | null | undefined;
requestedChatMode?: ChatMode;
settings?: UserSettings;
}): Promise<ChatModeResolution & { settings: UserSettings }> {
const modeForTurn = requestedChatMode ?? storedChatMode;
const normalizedChatMode = normalizeStoredChatMode(modeForTurn);
const envVars = getChatModeEnvVars();
const freeAgentQuotaAvailable = await getFreeAgentQuotaAvailableIfNeeded(
settings,
normalizedChatMode,
);
return {
...resolveChatMode({
storedChatMode: modeForTurn,
settings,
envVars,
freeAgentQuotaAvailable,
}),
settings,
};
}
export async function getInitialChatModeForNewChat(
initialChatMode?: ChatMode,
): Promise<ChatMode> {
if (initialChatMode) {
return initialChatMode;
}
const settings = readSettings();
if (settings.selectedChatMode) {
return settings.selectedChatMode;
}
const envVars = getChatModeEnvVars();
const freeAgentQuotaAvailable = await getFreeAgentQuotaAvailableIfNeeded(
settings,
null,
);
return getEffectiveDefaultChatMode(
settings,
envVars,
freeAgentQuotaAvailable,
);
}
function getChatModeEnvVars(): Record<string, string | undefined> {
const openAiEnvVar = PROVIDER_TO_ENV_VAR.openai;
const anthropicEnvVar = PROVIDER_TO_ENV_VAR.anthropic;
return {
[openAiEnvVar]: getEnvVar(openAiEnvVar),
[anthropicEnvVar]: getEnvVar(anthropicEnvVar),
};
}
async function getFreeAgentQuotaAvailableIfNeeded(
settings: UserSettings,
chatMode: ChatMode | null,
): Promise<boolean | undefined> {
if (isDyadProEnabled(settings)) {
return undefined;
}
const defaultMayUseLocalAgent =
!settings.defaultChatMode || settings.defaultChatMode === "local-agent";
const needsQuota =
chatMode === "local-agent" ||
(chatMode === null && defaultMayUseLocalAgent);
if (!needsQuota) {
return undefined;
}
const quotaStatus = await getFreeAgentQuotaStatus();
return !quotaStatus.isQuotaExceeded;
}
......@@ -30,7 +30,6 @@ import {
import { buildNeonPromptForApp } from "../../neon_admin/neon_prompt_context";
import { getDyadAppPath } from "../../paths/paths";
import { buildDyadMediaUrl } from "../../lib/dyadMediaUrl";
import { readSettings } from "../../main/settings";
import type { ChatResponseEnd, ChatStreamParams } from "@/ipc/types";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import {
......@@ -104,6 +103,7 @@ import {
isSupabaseConnected,
isTurboEditsV2Enabled,
} from "@/lib/schemas";
import { resolveChatModeForTurn } from "./chat_mode_resolution";
import {
getFreeAgentQuotaStatus,
markMessageAsUsingFreeAgentQuota,
......@@ -116,6 +116,7 @@ import {
VersionedFiles,
} from "../utils/versioned_codebase_context";
import { getAiMessagesJsonIfWithinLimit } from "../utils/ai_messages_utils";
import { readSettings } from "@/main/settings";
type AsyncIterableStream<T> = AsyncIterable<T> & ReadableStream<T>;
......@@ -536,7 +537,23 @@ ${componentSnippet}
})
.returning({ id: messages.id });
const userMessageId = insertedUserMessage.id;
const settings = readSettings();
const {
settings: storedSettings,
mode: selectedChatMode,
fallbackReason: chatModeFallbackReason,
} = await resolveChatModeForTurn({
storedChatMode: chat.chatMode,
requestedChatMode: req.requestedChatMode,
});
const settings = {
...storedSettings,
selectedChatMode,
};
safeSend(event.sender, "chat:response:chunk", {
chatId: req.chatId,
effectiveChatMode: selectedChatMode,
chatModeFallbackReason,
});
// Only Dyad Pro requests have request ids.
if (settings.enableDyadPro) {
// Generate requestId early so it can be saved with the message
......@@ -653,8 +670,7 @@ ${componentSnippet}
updatedChat.app.id, // Exclude current app
);
const willUseLocalAgentStream =
(settings.selectedChatMode === "local-agent" ||
settings.selectedChatMode === "ask") &&
(selectedChatMode === "local-agent" || selectedChatMode === "ask") &&
!mentionedAppsCodebases.length;
const isDeepContextEnabled =
......@@ -772,7 +788,7 @@ ${componentSnippet}
// Migration on read converts "agent" to "build", so no need to check for it here
let systemPrompt = constructSystemPrompt({
aiRules,
chatMode: settings.selectedChatMode,
chatMode: selectedChatMode,
enableTurboEditsV2: isTurboEditsV2Enabled(settings),
themePrompt,
basicAgentMode: isBasicAgentMode(settings),
......@@ -822,7 +838,7 @@ ${componentSnippet}
getSupabaseAvailableSystemPrompt(supabaseClientCode) +
"\n\n" +
// For local agent, we will explicitly fetch the database context when needed.
(settings.selectedChatMode === "local-agent"
(selectedChatMode === "local-agent"
? ""
: await getSupabaseContext({
supabaseProjectId: updatedChat.app.supabaseProjectId,
......@@ -838,12 +854,12 @@ ${componentSnippet}
neonProjectId: updatedChat.app.neonProjectId!,
neonActiveBranchId: updatedChat.app.neonActiveBranchId,
neonDevelopmentBranchId: updatedChat.app.neonDevelopmentBranchId,
selectedChatMode: settings.selectedChatMode ?? "",
selectedChatMode,
})) +
"\n\n";
} else if (
// In local agent mode, we will suggest integrations as part of the add-integration tool
settings.selectedChatMode !== "local-agent" &&
selectedChatMode !== "local-agent" &&
// If in security review mode, we don't need to mention integrations are available.
!isSecurityReviewIntent
) {
......@@ -873,7 +889,7 @@ ${componentSnippet}
// print out the dyad-write tags.
// Usually, AI models will want to use the image as reference to generate code (e.g. UI mockups) anyways, so
// it's not that critical to include the image analysis instructions.
const isAskMode = settings.selectedChatMode === "ask";
const isAskMode = selectedChatMode === "ask";
if (hasUploadedAttachments) {
if (willUseLocalAgentStream && !isAskMode) {
systemPrompt += `
......@@ -946,7 +962,7 @@ This conversation includes one or more image attachments. When the user uploads
// Thinking tags are generally not critical for the context
// and eats up extra tokens.
content:
settings.selectedChatMode === "ask"
selectedChatMode === "ask"
? removeDyadTags(removeNonEssentialTags(msg.content))
: removeNonEssentialTags(msg.content),
providerOptions: {
......@@ -1164,10 +1180,7 @@ This conversation includes one or more image attachments. When the user uploads
// Handle ask mode: use local-agent in read-only mode
// This gives users access to code reading tools while in ask mode
// Ask mode does not consume free agent quota
if (
settings.selectedChatMode === "ask" &&
!mentionedAppsCodebases.length
) {
if (selectedChatMode === "ask" && !mentionedAppsCodebases.length) {
// Reconstruct system prompt for local-agent read-only mode
const readOnlySystemPrompt = constructSystemPrompt({
aiRules,
......@@ -1196,6 +1209,7 @@ This conversation includes one or more image attachments. When the user uploads
dyadRequestId: dyadRequestId ?? "[no-request-id]",
readOnly: true,
messageOverride: isSummarizeIntent ? chatMessages : undefined,
settingsOverride: settings,
},
);
if (!streamSuccess) {
......@@ -1208,10 +1222,7 @@ This conversation includes one or more image attachments. When the user uploads
// Handle plan mode: use local-agent with plan tools only
// Plan mode is for requirements gathering and creating implementation plans
if (
settings.selectedChatMode === "plan" &&
!mentionedAppsCodebases.length
) {
if (selectedChatMode === "plan" && !mentionedAppsCodebases.length) {
// Reconstruct system prompt for plan mode
const planModeSystemPrompt = constructSystemPrompt({
aiRules,
......@@ -1226,6 +1237,7 @@ This conversation includes one or more image attachments. When the user uploads
dyadRequestId: dyadRequestId ?? "[no-request-id]",
planModeOnly: true,
messageOverride: isSummarizeIntent ? chatMessages : undefined,
settingsOverride: settings,
});
return;
}
......@@ -1234,7 +1246,7 @@ This conversation includes one or more image attachments. When the user uploads
// Mentioned apps can't be handled by the local agent (defer to balanced smart context
// in build mode)
if (
settings.selectedChatMode === "local-agent" &&
selectedChatMode === "local-agent" &&
!mentionedAppsCodebases.length
) {
// Check quota for Basic Agent mode (non-Pro users)
......@@ -1271,6 +1283,7 @@ This conversation includes one or more image attachments. When the user uploads
systemPrompt,
dyadRequestId: dyadRequestId ?? "[no-request-id]",
messageOverride: isSummarizeIntent ? chatMessages : undefined,
settingsOverride: settings,
},
);
} finally {
......@@ -1288,7 +1301,7 @@ This conversation includes one or more image attachments. When the user uploads
// 2. Mode is "build" AND there are enabled MCP servers
if (
settings.enableMcpServersForBuildMode &&
settings.selectedChatMode === "build"
selectedChatMode === "build"
) {
const tools = await getMcpTools(event);
const hasEnabledMcpServers = Object.keys(tools).length > 0;
......@@ -1355,10 +1368,7 @@ This conversation includes one or more image attachments. When the user uploads
});
fullResponse = result.fullResponse;
if (
settings.selectedChatMode !== "ask" &&
isTurboEditsV2Enabled(settings)
) {
if (selectedChatMode !== "ask" && isTurboEditsV2Enabled(settings)) {
let issues = await dryRunSearchReplace({
fullResponse,
appPath: getDyadAppPath(updatedChat.app.path),
......@@ -1457,7 +1467,7 @@ ${formattedSearchReplaceIssues}`,
if (
!abortController.signal.aborted &&
settings.selectedChatMode !== "ask" &&
selectedChatMode !== "ask" &&
hasUnclosedDyadWrite(fullResponse)
) {
let continuationAttempts = 0;
......@@ -1511,7 +1521,7 @@ ${formattedSearchReplaceIssues}`,
// installed yet.
addDependencies.length === 0 &&
settings.enableAutoFixProblems &&
settings.selectedChatMode !== "ask"
selectedChatMode !== "ask"
) {
try {
// IF auto-fix is enabled
......@@ -1695,11 +1705,8 @@ ${problemReport.problems
.update(messages)
.set({ content: fullResponse })
.where(eq(messages.id, placeholderAssistantMessage.id));
const settings = readSettings();
if (
settings.autoApproveChanges &&
settings.selectedChatMode !== "ask"
) {
const latestSettings = readSettings();
if (latestSettings.autoApproveChanges && selectedChatMode !== "ask") {
const status = await processFullResponseActions(
fullResponse,
req.chatId,
......
......@@ -13,6 +13,7 @@ import { ImportAppParams, ImportAppResult } from "@/ipc/types";
import { copyDirectoryRecursive } from "../utils/file_utils";
import { gitCommit, gitAdd, gitInit } from "../utils/git_utils";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import { getInitialChatModeForNewChat } from "./chat_mode_resolution";
const logger = log.scope("import-handlers");
const handle = createLoggedHandler(logger);
......@@ -152,11 +153,14 @@ export function registerImportHandlers() {
})
.returning();
const initialChatMode = await getInitialChatModeForNewChat();
// Create an initial chat for this app
const [chat] = await db
.insert(chats)
.values({
appId: app.id,
chatMode: initialChatMode,
})
.returning();
return { appId: app.id, chatId: chat.id };
......
......@@ -34,6 +34,7 @@ import { createLoggedHandler } from "./safe_handle";
import { ApproveProposalResult } from "@/ipc/types";
import { validateChatContext } from "../utils/context_paths_utils";
import { readSettings } from "@/main/settings";
import { resolveChatModeForTurn } from "./chat_mode_resolution";
const logger = log.scope("proposal_handlers");
const handle = createLoggedHandler(logger);
......@@ -338,7 +339,15 @@ const approveProposalHandler = async (
{ chatId, messageId }: { chatId: number; messageId: number },
): Promise<ApproveProposalResult> => {
const settings = readSettings();
if (settings.selectedChatMode === "ask") {
const chat = await db.query.chats.findFirst({
where: eq(chats.id, chatId),
columns: { chatMode: true },
});
const { mode: selectedChatMode } = await resolveChatModeForTurn({
storedChatMode: chat?.chatMode ?? null,
settings,
});
if (selectedChatMode === "ask") {
throw new Error(
"Ask mode is not supported for proposal approval. Please switch to build mode.",
);
......
......@@ -28,6 +28,7 @@ import { extractMentionedAppsCodebases } from "../utils/mention_apps";
import { parseAppMentions } from "@/shared/parse_mention_apps";
import { isTurboEditsV2Enabled } from "@/lib/schemas";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import { resolveChatModeForTurn } from "./chat_mode_resolution";
const logger = log.scope("token_count_handlers");
......@@ -63,7 +64,15 @@ export function registerTokenCountHandlers() {
// Count input tokens
const inputTokens = estimateTokens(req.input);
const settings = readSettings();
const storedSettings = readSettings();
const { mode: selectedChatMode } = await resolveChatModeForTurn({
storedChatMode: chat.chatMode,
settings: storedSettings,
});
const settings = {
...storedSettings,
selectedChatMode,
};
// Parse app mentions from the input
const mentionedAppNames = parseAppMentions(req.input);
......@@ -74,9 +83,7 @@ export function registerTokenCountHandlers() {
let systemPrompt = constructSystemPrompt({
aiRules: await readAiRules(getDyadAppPath(chat.app.path)),
chatMode:
settings.selectedChatMode === "local-agent"
? "build"
: settings.selectedChatMode,
selectedChatMode === "local-agent" ? "build" : selectedChatMode,
enableTurboEditsV2: isTurboEditsV2Enabled(settings),
themePrompt,
});
......@@ -101,7 +108,7 @@ export function registerTokenCountHandlers() {
neonProjectId: chat.app.neonProjectId!,
neonActiveBranchId: chat.app.neonActiveBranchId,
neonDevelopmentBranchId: chat.app.neonDevelopmentBranchId,
selectedChatMode: settings.selectedChatMode ?? "",
selectedChatMode,
}));
} else {
// Neon projects don't need Supabase (already handled above).
......
import { z } from "zod";
import { defineContract, createClient } from "../contracts/core";
import { APP_FRAMEWORK_TYPES } from "../../lib/framework_constants";
import { ChatModeSchema } from "../../lib/schemas";
// =============================================================================
// App Schemas
......@@ -54,6 +55,7 @@ export type App = z.infer<typeof AppSchema>;
*/
export const CreateAppParamsSchema = z.object({
name: z.string().min(1),
initialChatMode: ChatModeSchema.optional(),
});
/**
......
......@@ -5,6 +5,12 @@ import {
createClient,
createStreamClient,
} from "../contracts/core";
import {
ChatModeSchema,
StoredChatModeSchema,
migrateStoredChatMode,
type ChatMode,
} from "../../lib/schemas";
// =============================================================================
// Chat Schemas
......@@ -29,6 +35,10 @@ export const MessageSchema = z.object({
export type Message = z.infer<typeof MessageSchema>;
export const NullableChatModeSchema = StoredChatModeSchema.nullable().transform(
(mode): ChatMode | null => migrateStoredChatMode(mode ?? undefined) ?? null,
);
/**
* Schema for a Chat object.
*/
......@@ -38,6 +48,7 @@ export const ChatSchema = z.object({
messages: z.array(MessageSchema),
initialCommitHash: z.string().nullable().optional(),
dbTimestamp: z.string().nullable().optional(),
chatMode: NullableChatModeSchema,
});
export type Chat = z.infer<typeof ChatSchema>;
......@@ -86,6 +97,7 @@ export const ChatStreamParamsSchema = z.object({
redo: z.boolean().optional(),
attachments: z.array(ChatAttachmentSchema).optional(),
selectedComponents: z.array(ComponentSelectionSchema).optional(),
requestedChatMode: ChatModeSchema.optional(),
});
export type ChatStreamParams = z.infer<typeof ChatStreamParamsSchema>;
......@@ -104,8 +116,14 @@ export const ChatResponseChunkSchema = z.object({
messages: z.array(MessageSchema).optional(),
streamingMessageId: z.number().optional(),
streamingContent: z.string().optional(),
effectiveChatMode: ChatModeSchema.optional(),
chatModeFallbackReason: z
.enum(["pro-required", "quota-exhausted", "no-provider"])
.optional(),
});
export type ChatResponseChunk = z.infer<typeof ChatResponseChunkSchema>;
/**
* Schema for chat response end event.
*/
......@@ -143,7 +161,8 @@ export const CreateChatResultSchema = z.number();
*/
export const UpdateChatParamsSchema = z.object({
chatId: z.number(),
title: z.string(),
title: z.string().optional(),
chatMode: ChatModeSchema.nullable().optional(),
});
export type UpdateChatParams = z.infer<typeof UpdateChatParamsSchema>;
......@@ -194,13 +213,20 @@ export const chatContracts = {
appId: z.number(),
title: z.string().nullable(),
createdAt: z.date(),
chatMode: NullableChatModeSchema,
}),
),
}),
createChat: defineContract({
channel: "create-chat",
input: z.number(), // appId
input: z.union([
z.number(), // appId (legacy shape)
z.object({
appId: z.number(),
initialChatMode: ChatModeSchema.optional(),
}),
]),
output: CreateChatResultSchema,
}),
......
......@@ -124,6 +124,7 @@ export type {
FileAttachment,
ChatAttachment,
ChatStreamParams,
ChatResponseChunk,
ChatResponseEnd,
UpdateChatParams,
TokenCountParams,
......
import { isOpenAIOrAnthropicSetup } from "./providerUtils";
import {
getEffectiveDefaultChatMode,
hasDyadProKey,
isDyadProEnabled,
migrateStoredChatMode,
StoredChatModeSchema,
type ChatMode,
type UserSettings,
} from "./schemas";
export type ChatModeFallbackReason =
| "pro-required"
| "quota-exhausted"
| "no-provider";
export interface ChatModeResolution {
mode: ChatMode;
fallbackReason?: ChatModeFallbackReason;
}
export function normalizeStoredChatMode(
mode: string | null | undefined,
): ChatMode | null {
if (!mode) {
return null;
}
const parsed = StoredChatModeSchema.safeParse(mode);
if (!parsed.success) {
return null;
}
return migrateStoredChatMode(parsed.data) ?? null;
}
export function getUnavailableChatModeReason({
mode,
settings,
envVars,
freeAgentQuotaAvailable,
}: {
mode: ChatMode | null | undefined;
settings: UserSettings;
envVars: Record<string, string | undefined>;
freeAgentQuotaAvailable?: boolean;
}): ChatModeFallbackReason | undefined {
if (mode !== "local-agent") {
return undefined;
}
if (isDyadProEnabled(settings)) {
return undefined;
}
if (isOpenAIOrAnthropicSetup(settings, envVars)) {
if (freeAgentQuotaAvailable === false) {
return "quota-exhausted";
}
return undefined;
}
if (settings.enableDyadPro === true && !hasDyadProKey(settings)) {
return "pro-required";
}
return "no-provider";
}
export function resolveChatMode({
storedChatMode,
settings,
envVars,
freeAgentQuotaAvailable,
}: {
storedChatMode: string | null | undefined;
settings: UserSettings;
envVars: Record<string, string | undefined>;
freeAgentQuotaAvailable?: boolean;
}): ChatModeResolution {
const chatMode = normalizeStoredChatMode(storedChatMode);
const effectiveDefault = getEffectiveDefaultChatMode(
settings,
envVars,
freeAgentQuotaAvailable,
);
if (!chatMode) {
return { mode: effectiveDefault };
}
const fallbackReason = getUnavailableChatModeReason({
mode: chatMode,
settings,
envVars,
freeAgentQuotaAvailable,
});
if (fallbackReason && effectiveDefault !== chatMode) {
return { mode: effectiveDefault, fallbackReason };
}
return { mode: chatMode };
}
import type { ChatMode, UserSettings } from "./schemas";
import { isDyadProEnabled } from "./schemas";
import type { ChatModeFallbackReason } from "./chatMode";
import {
getChatModeFallbackToastId,
showChatModeFallbackToast,
} from "./chatModeToast";
export function handleEffectiveChatModeChunk(
chunk: {
effectiveChatMode?: ChatMode;
chatModeFallbackReason?: ChatModeFallbackReason;
},
settings: UserSettings | null | undefined,
chatId?: number,
): boolean {
if (!chunk.effectiveChatMode) {
return false;
}
if (chunk.chatModeFallbackReason) {
showChatModeFallbackToast({
reason: chunk.chatModeFallbackReason,
effectiveMode: chunk.effectiveChatMode,
isPro: settings ? isDyadProEnabled(settings) : false,
toastId: getChatModeFallbackToastId({
chatId,
reason: chunk.chatModeFallbackReason,
effectiveMode: chunk.effectiveChatMode,
}),
});
}
return true;
}
import { toast } from "sonner";
import type { ChatMode } from "./schemas";
import type { ChatModeFallbackReason } from "./chatMode";
export function getChatModeDisplayName(mode: ChatMode, isPro: boolean): string {
switch (mode) {
case "build":
return "Build";
case "ask":
return "Ask";
case "local-agent":
return isPro ? "Agent" : "Basic Agent";
case "plan":
return "Plan";
}
}
export function getChatModeFallbackToastId({
chatId,
reason,
effectiveMode,
}: {
chatId?: number;
reason: ChatModeFallbackReason;
effectiveMode: ChatMode;
}) {
return chatId
? `chat-mode-fallback:${chatId}:${reason}:${effectiveMode}`
: `chat-mode-fallback:${reason}:${effectiveMode}`;
}
export function showChatModeFallbackToast({
reason,
effectiveMode,
isPro,
toastId,
}: {
reason: ChatModeFallbackReason;
effectiveMode: ChatMode;
isPro: boolean;
toastId?: string;
}) {
const modeName = getChatModeDisplayName(effectiveMode, isPro);
const message =
reason === "pro-required"
? `Agent v2 unavailable (Pro required). Using ${modeName} mode.`
: reason === "quota-exhausted"
? `Quota exhausted. Using ${modeName} mode.`
: `No provider configured. Using ${modeName} mode.`;
toast.warning(message, {
id: toastId,
duration: 8000,
action: {
label: "Switch mode",
onClick: () => {
const trigger = document.querySelector<HTMLElement>(
'[data-testid="chat-mode-selector"]',
);
if (trigger) {
trigger.focus();
trigger.click();
return;
}
if (toastId) {
toast.dismiss(toastId);
}
toast.info("Open a chat to switch modes.", { duration: 5000 });
},
},
});
}
......@@ -50,6 +50,8 @@ export const queryKeys = {
chats: {
all: ["chats"] as const,
list: ({ appId }: { appId: number | null }) => ["chats", appId] as const,
detail: ({ chatId }: { chatId: number | null }) =>
["chats", "detail", chatId] as const,
search: ({ appId, query }: { appId: number | null; query: string }) =>
["chats", "search", appId, query] as const,
},
......
......@@ -15,6 +15,7 @@ export const ChatSummarySchema = z.object({
appId: z.number(),
title: z.string().nullable(),
createdAt: z.date(),
chatMode: z.enum(["build", "ask", "local-agent", "plan"]).nullable(),
});
/**
......
......@@ -44,6 +44,7 @@ import {
} from "@/components/ProBanner";
import { hasDyadProKey, getEffectiveDefaultChatMode } from "@/lib/schemas";
import { useFreeAgentQuota } from "@/hooks/useFreeAgentQuota";
import { useInitialChatMode } from "@/hooks/useInitialChatMode";
// Track whether we've already checked release notes this session (module-scoped
// so it persists across component unmount/remount cycles).
......@@ -63,6 +64,7 @@ export default function HomePage() {
const { refreshApps } = useLoadApps();
const { settings, updateSettings, envVars } = useSettings();
const { isQuotaExceeded, isLoading: isQuotaLoading } = useFreeAgentQuota();
const initialChatMode = useInitialChatMode();
const setIsPreviewOpen = useSetAtom(isPreviewOpenAtom);
const { selectChat } = useSelectChat();
......@@ -184,15 +186,18 @@ export default function HomePage() {
let chatId: number;
let appId: number;
if (selectedApp) {
// Existing app flow: create a new chat in the selected app
chatId = await ipc.chat.createChat(selectedApp.id);
chatId = await ipc.chat.createChat({
appId: selectedApp.id,
initialChatMode,
});
appId = selectedApp.id;
} else {
// New app flow (default behavior)
const result = await ipc.app.createApp({
name: generateCuteAppName(),
initialChatMode,
});
chatId = result.chatId;
appId = result.app.id;
......@@ -221,6 +226,7 @@ export default function HomePage() {
prompt: inputValue,
chatId,
attachments,
requestedChatMode: initialChatMode,
});
await new Promise((resolve) =>
setTimeout(resolve, settings?.isTestMode ? 0 : 2000),
......
......@@ -18,7 +18,11 @@ import { db } from "@/db";
import { chats, messages } from "@/db/schema";
import { eq } from "drizzle-orm";
import { isDyadProEnabled, isBasicAgentMode } from "@/lib/schemas";
import {
isDyadProEnabled,
isBasicAgentMode,
type UserSettings,
} from "@/lib/schemas";
import { readSettings } from "@/main/settings";
import { getDyadAppPath } from "@/paths/paths";
import { detectFrameworkType } from "@/ipc/utils/framework_utils";
......@@ -267,6 +271,7 @@ export async function handleLocalAgentStream(
readOnly = false,
planModeOnly = false,
messageOverride,
settingsOverride,
}: {
placeholderMessageId: number;
systemPrompt: string;
......@@ -286,9 +291,10 @@ export async function handleLocalAgentStream(
* Used for summarization where messages need to be transformed.
*/
messageOverride?: ModelMessage[];
settingsOverride?: UserSettings;
},
): Promise<boolean> {
const settings = readSettings();
const settings = settingsOverride ?? readSettings();
const maxToolCallSteps =
settings.maxToolCallSteps ?? DEFAULT_MAX_TOOL_CALL_STEPS;
let fullResponse = "";
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论