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 ...@@ -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 # 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 ## Arguments
...@@ -60,7 +60,7 @@ Group by error shape. If every failure shares the same locator / error ("element ...@@ -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': if obj.get('type') == 'before' and obj.get('class') == 'Test':
print(round(obj['startTime']/1000, 2), obj.get('method'), obj.get('title','')[:200]) 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: 4. Correlate with app logs. Electron `console.log`/`console.error` lands in `stderr`/`stdout` trace events:
```python ```python
for line in open('/tmp/trace-extract/test.trace'): 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 ...@@ -81,7 +81,7 @@ Group by error shape. If every failure shares the same locator / error ("element
Common patterns and what they mean: 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. - **"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. - **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. - **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 ...@@ -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. 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`. 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: 4. Use `/dyad:pr-push` or commit + `gh pr create` directly. The PR body MUST include:
- A link to the failing run. - A link to the failing run.
- The root-cause narrative (what raced, in concrete terms — not "timing issue"). - 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 ...@@ -101,7 +101,7 @@ Prefer fixing the test over the app unless the race would actually bite a real u
## Gotchas ## Gotchas
- `gh run download` needs `-R <owner>/<repo>` if you're not in a cwd with matching origin. - `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. - 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. - 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
差异被折叠。
...@@ -197,6 +197,13 @@ ...@@ -197,6 +197,13 @@
"when": 1774487675535, "when": 1774487675535,
"tag": "0027_unusual_scalphunter", "tag": "0027_unusual_scalphunter",
"breakpoints": true "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 { test } from "./helpers/test_helper";
import { expect } from "@playwright/test";
test("chat mode selector - default build mode", async ({ po }) => { test("chat mode selector - default build mode", async ({ po }) => {
await po.setUp({ autoApprove: true }); await po.setUp({ autoApprove: true });
...@@ -23,6 +24,37 @@ test("chat mode selector - ask mode", async ({ po }) => { ...@@ -23,6 +24,37 @@ test("chat mode selector - ask mode", async ({ po }) => {
await po.snapshotMessages({ replaceDumpPath: true }); 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 }) => { test.skip("dyadwrite edit and save - basic flow", async ({ po }) => {
await po.setUp({ autoApprove: true }); await po.setUp({ autoApprove: true });
await po.importApp("minimal"); await po.importApp("minimal");
......
...@@ -25,7 +25,9 @@ testSkipIfWindows( ...@@ -25,7 +25,9 @@ testSkipIfWindows(
await po.sendPrompt("tc=local-agent/simple-response"); await po.sendPrompt("tc=local-agent/simple-response");
// Verify the compaction status indicator is visible // 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, timeout: Timeout.MEDIUM,
}); });
...@@ -35,10 +37,10 @@ testSkipIfWindows( ...@@ -35,10 +37,10 @@ testSkipIfWindows(
// Verify key compaction elements are present (order-independent checks // Verify key compaction elements are present (order-independent checks
// since compaction restructures messages non-deterministically) // since compaction restructures messages non-deterministically)
await expect( await expect(
po.page.getByRole("button", { name: "Conversation compacted" }), po.page.getByRole("button", { name: "Conversation compacted" }).first(),
).toBeVisible(); ).toBeVisible();
await expect( await expect(
po.page.getByRole("heading", { name: "Key Decisions Made" }), po.page.getByRole("heading", { name: "Key Decisions Made" }).first(),
).toBeVisible(); ).toBeVisible();
await expect( await expect(
po.page.getByText( po.page.getByText(
...@@ -62,7 +64,9 @@ testSkipIfWindows( ...@@ -62,7 +64,9 @@ testSkipIfWindows(
await po.sendPrompt("tc=local-agent/compaction-mid-turn"); await po.sendPrompt("tc=local-agent/compaction-mid-turn");
// Mid-turn compaction summary should be visible after a single prompt. // 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, timeout: Timeout.MEDIUM,
}); });
...@@ -77,10 +81,10 @@ testSkipIfWindows( ...@@ -77,10 +81,10 @@ testSkipIfWindows(
// Verify key compaction elements are present (order-independent checks // Verify key compaction elements are present (order-independent checks
// since compaction restructures messages non-deterministically) // since compaction restructures messages non-deterministically)
await expect( await expect(
po.page.getByRole("button", { name: "Conversation compacted" }), po.page.getByRole("button", { name: "Conversation compacted" }).first(),
).toBeVisible(); ).toBeVisible();
await expect( await expect(
po.page.getByRole("heading", { name: "Key Decisions Made" }), po.page.getByRole("heading", { name: "Key Decisions Made" }).first(),
).toBeVisible(); ).toBeVisible();
await expect(po.page.getByText("END OF COMPACTED TURN.")).toBeVisible(); await expect(po.page.getByText("END OF COMPACTED TURN.")).toBeVisible();
}, },
......
...@@ -56,15 +56,21 @@ testSkipIfWindows( ...@@ -56,15 +56,21 @@ testSkipIfWindows(
po.page.getByRole("button", { name: "Switch back to Build mode" }), po.page.getByRole("button", { name: "Switch back to Build mode" }),
).toBeVisible(); ).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"); 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")).not.toBeVisible({
await expect(po.page.getByTestId("chat-error-box")).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, 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 // 8. Click "Switch back to Build mode" and verify mode changes
await po.page await po.page
......
...@@ -101,11 +101,12 @@ export class ChatActions { ...@@ -101,11 +101,12 @@ export class ChatActions {
await expect(async () => { await expect(async () => {
await chatInput.click(); await chatInput.click();
await chatInput.fill(prompt); 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 expect(sendButton).toBeEnabled();
await sendButton.click();
}).toPass({ timeout: Timeout.MEDIUM }); }).toPass({ timeout: Timeout.MEDIUM });
await sendButton.click();
if (!skipWaitForCompletion) { if (!skipWaitForCompletion) {
await this.waitForChatCompletion({ timeout }); await this.waitForChatCompletion({ timeout });
} }
......
...@@ -18,7 +18,7 @@ ...@@ -18,7 +18,7 @@
- img - img
- text: less than a minute ago - text: less than a minute ago
- img - img
- text: wrote 1 file(s) - text: "Version 2: wrote 1 file(s)"
- button "Undo": - button "Undo":
- img - img
- text: "" - text: ""
......
...@@ -16,7 +16,7 @@ ...@@ -16,7 +16,7 @@
- img - img
- text: less than a minute ago - text: less than a minute ago
- img - img
- text: wrote 1 file(s) - text: "Version 2: wrote 1 file(s)"
- paragraph: "[dump]" - paragraph: "[dump]"
- 'button "Expand image: logo.png"': - 'button "Expand image: logo.png"':
- img "logo.png" - img "logo.png"
......
...@@ -16,7 +16,7 @@ ...@@ -16,7 +16,7 @@
- img - img
- text: less than a minute ago - text: less than a minute ago
- img - img
- text: wrote 1 file(s) - text: "Version 2: wrote 1 file(s)"
- paragraph: "[dump]" - paragraph: "[dump]"
- 'button "Expand image: logo.png"': - 'button "Expand image: logo.png"':
- img "logo.png" - img "logo.png"
......
...@@ -18,7 +18,7 @@ ...@@ -18,7 +18,7 @@
- img - img
- text: less than a minute ago - text: less than a minute ago
- img - img
- text: wrote 1 file(s) - text: "Version 2: wrote 1 file(s)"
- button "Undo": - button "Undo":
- img - img
- text: "" - text: ""
......
...@@ -66,7 +66,7 @@ ...@@ -66,7 +66,7 @@
- img - img
- text: less than a minute ago - text: less than a minute ago
- img - img
- text: wrote 3 file(s) - text: "Version 2: wrote 3 file(s)"
- paragraph: "Fix all of the following errors:" - paragraph: "Fix all of the following errors:"
- list: - list:
- listitem: First error in Index - listitem: First error in Index
...@@ -89,7 +89,7 @@ ...@@ -89,7 +89,7 @@
- img - img
- text: less than a minute ago - text: less than a minute ago
- img - img
- text: wrote 1 file(s) - text: "Version 3: wrote 1 file(s)"
- button "Undo": - button "Undo":
- img - img
- text: "" - text: ""
......
...@@ -17,7 +17,7 @@ ...@@ -17,7 +17,7 @@
- img - img
- text: less than a minute ago - text: less than a minute ago
- img - img
- text: wrote 1 file(s) - text: "Version 2: wrote 1 file(s)"
- paragraph: - paragraph:
- text: "Fix error: Error Line 6 error Stack trace: Index (" - text: "Fix error: Error Line 6 error Stack trace: Index ("
- link /http:\/\/localhost:\d+\/src\/pages\/Index\.tsx:6:6/: - link /http:\/\/localhost:\d+\/src\/pages\/Index\.tsx:6:6/:
...@@ -43,7 +43,7 @@ ...@@ -43,7 +43,7 @@
- img - img
- text: less than a minute ago - text: less than a minute ago
- img - img
- text: wrote 1 file(s) - text: "Version 3: wrote 1 file(s)"
- button "Undo": - button "Undo":
- img - img
- text: "" - text: ""
......
...@@ -16,7 +16,7 @@ ...@@ -16,7 +16,7 @@
- img - img
- text: less than a minute ago - text: less than a minute ago
- img - img
- text: (1 files changed) - text: "Version 2: (1 files changed)"
- button "Copy Request ID": - button "Copy Request ID":
- img - img
- text: "" - text: ""
...@@ -34,9 +34,13 @@ ...@@ -34,9 +34,13 @@
- button "Copy": - button "Copy":
- img - img
- img - img
- text: Approved
- img
- text: claude-opus-4-5 - text: claude-opus-4-5
- img - img
- text: less than a minute ago - text: less than a minute ago
- img
- text: "Version 3: (1 files changed)"
- button "Copy Request ID": - button "Copy Request ID":
- img - img
- text: "" - text: ""
......
...@@ -16,7 +16,7 @@ ...@@ -16,7 +16,7 @@
- img - img
- text: less than a minute ago - text: less than a minute ago
- img - img
- text: (1 files changed) - text: "Version 2: (1 files changed)"
- button "Copy Request ID": - button "Copy Request ID":
- img - img
- text: "" - text: ""
...@@ -33,6 +33,8 @@ ...@@ -33,6 +33,8 @@
- button "Copy": - button "Copy":
- img - img
- img - img
- text: Approved
- img
- text: claude-opus-4-5 - text: claude-opus-4-5
- img - img
- text: less than a minute ago - text: less than a minute ago
......
...@@ -16,7 +16,7 @@ ...@@ -16,7 +16,7 @@
- img - img
- text: less than a minute ago - text: less than a minute ago
- img - img
- text: (1 files changed) - text: "Version 2: (1 files changed)"
- button "Copy Request ID": - button "Copy Request ID":
- img - img
- text: "" - text: ""
...@@ -40,7 +40,7 @@ ...@@ -40,7 +40,7 @@
- img - img
- text: less than a minute ago - text: less than a minute ago
- img - img
- text: (1 files changed) - text: "Version 3: (1 files changed)"
- button "Copy Request ID": - button "Copy Request ID":
- img - img
- text: "" - text: ""
...@@ -67,9 +67,13 @@ ...@@ -67,9 +67,13 @@
- button "Copy": - button "Copy":
- img - img
- img - img
- text: Approved
- img
- text: claude-opus-4-5 - text: claude-opus-4-5
- img - img
- text: less than a minute ago - text: less than a minute ago
- img
- text: "Version 4: (2 files changed)"
- button "Copy Request ID": - button "Copy Request ID":
- img - img
- text: "" - text: ""
......
...@@ -16,7 +16,7 @@ ...@@ -16,7 +16,7 @@
- img - img
- text: less than a minute ago - text: less than a minute ago
- img - img
- text: (1 files changed) - text: "Version 2: (1 files changed)"
- button "Copy Request ID": - button "Copy Request ID":
- img - img
- text: "" - text: ""
...@@ -171,6 +171,156 @@ ...@@ -171,6 +171,156 @@
- paragraph: "/Step \\d+: reading file\\./" - paragraph: "/Step \\d+: reading file\\./"
- img - img
- text: Read package.json - 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 - img
- text: /Paused after \d+ tool calls/ - text: /Paused after \d+ tool calls/
- button "Continue": - button "Continue":
...@@ -200,6 +350,8 @@ ...@@ -200,6 +350,8 @@
- button "Copy": - button "Copy":
- img - img
- img - img
- text: Approved
- img
- text: claude-opus-4-5 - text: claude-opus-4-5
- img - img
- text: less than a minute ago - text: less than a minute ago
......
...@@ -16,7 +16,7 @@ ...@@ -16,7 +16,7 @@
- img - img
- text: less than a minute ago - text: less than a minute ago
- img - img
- text: (1 files changed) - text: "Version 2: (1 files changed)"
- button "Copy Request ID": - button "Copy Request ID":
- img - img
- text: "" - text: ""
...@@ -30,6 +30,8 @@ ...@@ -30,6 +30,8 @@
- button "Copy": - button "Copy":
- img - img
- img - img
- text: Approved
- img
- text: claude-opus-4-5 - text: claude-opus-4-5
- img - img
- text: less than a minute ago - text: less than a minute ago
......
...@@ -53,9 +53,15 @@ ...@@ -53,9 +53,15 @@
- code: "`src/config/aws.ts`" - code: "`src/config/aws.ts`"
- text: "," - text: ","
- code: "`src/services/s3-uploader.ts`" - 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 - paragraph: More EOM
- button: - button "Copy":
- img - img
- img - img
- text: Approved - text: Approved
...@@ -64,8 +70,10 @@ ...@@ -64,8 +70,10 @@
- img - img
- text: less than a minute ago - text: less than a minute ago
- img - img
- text: wrote 1 file(s) - text: "Version 2: wrote 1 file(s)"
- button "Undo": - button "Undo":
- img - img
- text: ""
- button "Retry": - button "Retry":
- img - img
- text: ""
\ No newline at end of file
...@@ -24,9 +24,15 @@ ...@@ -24,9 +24,15 @@
- strong: Relevant Files - strong: Relevant Files
- text: ":" - text: ":"
- code: "`src/api/users.ts`" - 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 - paragraph: More EOM
- button: - button "Copy":
- img - img
- img - img
- text: Approved - text: Approved
...@@ -35,8 +41,10 @@ ...@@ -35,8 +41,10 @@
- img - img
- text: less than a minute ago - text: less than a minute ago
- img - img
- text: wrote 1 file(s) - text: "Version 2: wrote 1 file(s)"
- button "Undo": - button "Undo":
- img - img
- text: ""
- button "Retry": - button "Retry":
- img - img
- text: ""
\ No newline at end of file
...@@ -33,7 +33,7 @@ ...@@ -33,7 +33,7 @@
- img - img
- text: less than a minute ago - text: less than a minute ago
- img - img
- text: wrote 1 file(s) - text: "Version 2: wrote 1 file(s)"
- button "Copy Request ID": - button "Copy Request ID":
- img - img
- text: "" - text: ""
......
差异被折叠。
...@@ -109,6 +109,10 @@ If this happens: ...@@ -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. 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. 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 ## 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. - **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 ...@@ -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. `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 ## 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. 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 { ...@@ -27,6 +27,7 @@ function chat(id: number, appId = 1): ChatSummary {
appId, appId,
title: `Chat ${id}`, title: `Chat ${id}`,
createdAt: new Date(), createdAt: new Date(),
chatMode: null,
}; };
} }
......
...@@ -5,6 +5,8 @@ import { ChatModeSelector } from "./ChatModeSelector"; ...@@ -5,6 +5,8 @@ import { ChatModeSelector } from "./ChatModeSelector";
import { McpToolsPicker } from "@/components/McpToolsPicker"; import { McpToolsPicker } from "@/components/McpToolsPicker";
import { useSettings } from "@/hooks/useSettings"; import { useSettings } from "@/hooks/useSettings";
import { useMcp } from "@/hooks/useMcp"; import { useMcp } from "@/hooks/useMcp";
import { useChatMode } from "@/hooks/useChatMode";
import { useRouterState } from "@tanstack/react-router";
export function ChatInputControls({ export function ChatInputControls({
showContextFilesPicker = false, showContextFilesPicker = false,
...@@ -12,6 +14,12 @@ export function ChatInputControls({ ...@@ -12,6 +14,12 @@ export function ChatInputControls({
showContextFilesPicker?: boolean; showContextFilesPicker?: boolean;
}) { }) {
const { settings } = useSettings(); 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 { servers } = useMcp();
const enabledMcpServersCount = servers.filter((s) => s.enabled).length; const enabledMcpServersCount = servers.filter((s) => s.enabled).length;
...@@ -20,7 +28,7 @@ export function ChatInputControls({ ...@@ -20,7 +28,7 @@ export function ChatInputControls({
// 2. Mode is "build" AND there are enabled MCP servers // 2. Mode is "build" AND there are enabled MCP servers
const showMcpToolsPicker = const showMcpToolsPicker =
!!settings?.enableMcpServersForBuildMode && !!settings?.enableMcpServersForBuildMode &&
settings?.selectedChatMode === "build" && selectedMode === "build" &&
enabledMcpServersCount > 0; enabledMcpServersCount > 0;
return ( return (
......
...@@ -14,9 +14,7 @@ import { selectedAppIdAtom } from "@/atoms/appAtoms"; ...@@ -14,9 +14,7 @@ import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { dropdownOpenAtom } from "@/atoms/uiAtoms"; import { dropdownOpenAtom } from "@/atoms/uiAtoms";
import { ipc } from "@/ipc/types"; import { ipc } from "@/ipc/types";
import { showError, showSuccess } from "@/lib/toast"; import { showError, showSuccess } from "@/lib/toast";
import { useSettings } from "@/hooks/useSettings"; import { useInitialChatMode } from "@/hooks/useInitialChatMode";
import { getEffectiveDefaultChatMode } from "@/lib/schemas";
import { useFreeAgentQuota } from "@/hooks/useFreeAgentQuota";
import { import {
SidebarGroup, SidebarGroup,
SidebarGroupContent, SidebarGroupContent,
...@@ -44,8 +42,7 @@ export function ChatList({ show }: { show?: boolean }) { ...@@ -44,8 +42,7 @@ export function ChatList({ show }: { show?: boolean }) {
const [selectedChatId, setSelectedChatId] = useAtom(selectedChatIdAtom); const [selectedChatId, setSelectedChatId] = useAtom(selectedChatIdAtom);
const [selectedAppId] = useAtom(selectedAppIdAtom); const [selectedAppId] = useAtom(selectedAppIdAtom);
const [, setIsDropdownOpen] = useAtom(dropdownOpenAtom); const [, setIsDropdownOpen] = useAtom(dropdownOpenAtom);
const { settings, updateSettings, envVars } = useSettings(); const initialChatMode = useInitialChatMode();
const { isQuotaExceeded, isLoading: isQuotaLoading } = useFreeAgentQuota();
const { chats, loading, invalidateChats } = useChats(selectedAppId); const { chats, loading, invalidateChats } = useChats(selectedAppId);
const routerState = useRouterState(); const routerState = useRouterState();
...@@ -109,19 +106,10 @@ export function ChatList({ show }: { show?: boolean }) { ...@@ -109,19 +106,10 @@ export function ChatList({ show }: { show?: boolean }) {
if (selectedAppId) { if (selectedAppId) {
try { try {
// Create a new chat with an empty title for now // Create a new chat with an empty title for now
const chatId = await ipc.chat.createChat(selectedAppId); const chatId = await ipc.chat.createChat({
appId: selectedAppId,
// Set the default chat mode for the new chat initialChatMode,
// 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 });
}
// Refresh the chat list first so the new chat is in the cache // Refresh the chat list first so the new chat is in the cache
// before selectChat adds it to the tab bar // before selectChat adds it to the tab bar
......
...@@ -11,10 +11,16 @@ import { ...@@ -11,10 +11,16 @@ import {
TooltipContent, TooltipContent,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { useSettings } from "@/hooks/useSettings"; import { useSettings } from "@/hooks/useSettings";
import { useChatMode } from "@/hooks/useChatMode";
import { useFreeAgentQuota } from "@/hooks/useFreeAgentQuota"; import { useFreeAgentQuota } from "@/hooks/useFreeAgentQuota";
import { useMcp } from "@/hooks/useMcp"; import { useMcp } from "@/hooks/useMcp";
import type { ChatMode } from "@/lib/schemas"; import type { ChatMode } from "@/lib/schemas";
import { isDyadProEnabled } from "@/lib/schemas"; import { isDyadProEnabled } from "@/lib/schemas";
import {
getChatModeFallbackToastId,
getChatModeDisplayName,
showChatModeFallbackToast,
} from "@/lib/chatModeToast";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { detectIsMac } from "@/hooks/useChatModeToggle"; import { detectIsMac } from "@/hooks/useChatModeToggle";
import { useRouterState } from "@tanstack/react-router"; import { useRouterState } from "@tanstack/react-router";
...@@ -23,26 +29,58 @@ import { LocalAgentNewChatToast } from "./LocalAgentNewChatToast"; ...@@ -23,26 +29,58 @@ import { LocalAgentNewChatToast } from "./LocalAgentNewChatToast";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { chatMessagesByIdAtom } from "@/atoms/chatAtoms"; import { chatMessagesByIdAtom } from "@/atoms/chatAtoms";
import { Hammer, Bot, MessageCircle, Lightbulb } from "lucide-react"; import { Hammer, Bot, MessageCircle, Lightbulb } from "lucide-react";
import { useEffect, useRef } from "react";
export function ChatModeSelector() { export function ChatModeSelector() {
const { settings, updateSettings } = useSettings(); const { updateSettings } = useSettings();
const routerState = useRouterState(); const routerState = useRouterState();
const isChatRoute = routerState.location.pathname === "/chat"; const isChatRoute = routerState.location.pathname === "/chat";
const messagesById = useAtomValue(chatMessagesByIdAtom); const messagesById = useAtomValue(chatMessagesByIdAtom);
const chatId = routerState.location.search.id as number | undefined; const chatId = routerState.location.search.id as number | undefined;
const currentChatMessages = chatId ? (messagesById.get(chatId) ?? []) : []; 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 isProEnabled = settings ? isDyadProEnabled(settings) : false;
const { messagesRemaining, messagesLimit, isQuotaExceeded } = const { messagesRemaining, messagesLimit, isQuotaExceeded } =
useFreeAgentQuota(); useFreeAgentQuota();
const { servers } = useMcp(); const { servers } = useMcp();
const enabledMcpServersCount = servers.filter((s) => s.enabled).length; 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 handleModeChange = (value: string) => {
const newMode = value as ChatMode; 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 // 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. // because they might weird results mixing Build and Agent mode in the same chat.
...@@ -73,19 +111,7 @@ export function ChatModeSelector() { ...@@ -73,19 +111,7 @@ export function ChatModeSelector() {
}; };
const getModeDisplayName = (mode: ChatMode) => { const getModeDisplayName = (mode: ChatMode) => {
switch (mode) { return getChatModeDisplayName(mode, isProEnabled);
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";
}
}; };
const getModeIcon = (mode: ChatMode) => { const getModeIcon = (mode: ChatMode) => {
...@@ -115,6 +141,7 @@ export function ChatModeSelector() { ...@@ -115,6 +141,7 @@ export function ChatModeSelector() {
render={ render={
<MiniSelectTrigger <MiniSelectTrigger
data-testid="chat-mode-selector" data-testid="chat-mode-selector"
aria-label={`Chat mode: ${getModeDisplayName(selectedMode)}`}
className={cn( className={cn(
"cursor-pointer w-fit px-2 py-0 text-xs font-medium border-none shadow-none gap-1 rounded-lg transition-colors", "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" selectedMode === "build" || selectedMode === "local-agent"
......
...@@ -24,7 +24,8 @@ import { ...@@ -24,7 +24,8 @@ import {
import { ArrowDown } from "lucide-react"; import { ArrowDown } from "lucide-react";
import { useSettings } from "@/hooks/useSettings"; import { useSettings } from "@/hooks/useSettings";
import { useFreeAgentQuota } from "@/hooks/useFreeAgentQuota"; import { useFreeAgentQuota } from "@/hooks/useFreeAgentQuota";
import { isBasicAgentMode } from "@/lib/schemas"; import { useChatMode } from "@/hooks/useChatMode";
import { isDyadProEnabled } from "@/lib/schemas";
interface ChatPanelProps { interface ChatPanelProps {
chatId?: number; chatId?: number;
...@@ -44,10 +45,14 @@ export function ChatPanel({ ...@@ -44,10 +45,14 @@ export function ChatPanel({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const streamCountById = useAtomValue(chatStreamCountByIdAtom); const streamCountById = useAtomValue(chatStreamCountByIdAtom);
const isStreamingById = useAtomValue(isStreamingByIdAtom); const isStreamingById = useAtomValue(isStreamingByIdAtom);
const { settings, updateSettings } = useSettings(); const { settings } = useSettings();
const { selectedMode, setChatMode } = useChatMode(chatId);
const { isQuotaExceeded } = useFreeAgentQuota(); const { isQuotaExceeded } = useFreeAgentQuota();
const showFreeAgentQuotaBanner = const showFreeAgentQuotaBanner =
settings && isBasicAgentMode(settings) && isQuotaExceeded; settings &&
!isDyadProEnabled(settings) &&
selectedMode === "local-agent" &&
isQuotaExceeded;
const messagesEndRef = useRef<HTMLDivElement | null>(null); const messagesEndRef = useRef<HTMLDivElement | null>(null);
const messagesContainerRef = useRef<HTMLDivElement | null>(null); const messagesContainerRef = useRef<HTMLDivElement | null>(null);
...@@ -223,7 +228,7 @@ export function ChatPanel({ ...@@ -223,7 +228,7 @@ export function ChatPanel({
{showFreeAgentQuotaBanner && ( {showFreeAgentQuotaBanner && (
<FreeAgentQuotaBanner <FreeAgentQuotaBanner
onSwitchToBuildMode={() => onSwitchToBuildMode={() =>
updateSettings({ selectedChatMode: "build" }) void setChatMode("build").catch(() => {})
} }
/> />
)} )}
......
...@@ -31,6 +31,7 @@ import { useRenameBranch } from "@/hooks/useRenameBranch"; ...@@ -31,6 +31,7 @@ import { useRenameBranch } from "@/hooks/useRenameBranch";
import { isAnyCheckoutVersionInProgressAtom } from "@/store/appAtoms"; import { isAnyCheckoutVersionInProgressAtom } from "@/store/appAtoms";
import { LoadingBar } from "../ui/LoadingBar"; import { LoadingBar } from "../ui/LoadingBar";
import { UncommittedFilesBanner } from "./UncommittedFilesBanner"; import { UncommittedFilesBanner } from "./UncommittedFilesBanner";
import { useInitialChatMode } from "@/hooks/useInitialChatMode";
interface ChatHeaderProps { interface ChatHeaderProps {
isVersionPaneOpen: boolean; isVersionPaneOpen: boolean;
...@@ -53,6 +54,7 @@ export function ChatHeader({ ...@@ -53,6 +54,7 @@ export function ChatHeader({
const { invalidateChats } = useChats(appId); const { invalidateChats } = useChats(appId);
const { selectChat } = useSelectChat(); const { selectChat } = useSelectChat();
const { isStreaming } = useStreamChat(); const { isStreaming } = useStreamChat();
const initialChatMode = useInitialChatMode();
const isAnyCheckoutVersionInProgress = useAtomValue( const isAnyCheckoutVersionInProgress = useAtomValue(
isAnyCheckoutVersionInProgressAtom, isAnyCheckoutVersionInProgressAtom,
); );
...@@ -88,7 +90,10 @@ export function ChatHeader({ ...@@ -88,7 +90,10 @@ export function ChatHeader({
const handleNewChat = async () => { const handleNewChat = async () => {
if (appId) { if (appId) {
try { try {
const chatId = await ipc.chat.createChat(appId); const chatId = await ipc.chat.createChat({
appId,
initialChatMode,
});
await invalidateChats(); await invalidateChats();
selectChat({ chatId, appId }); selectChat({ chatId, appId });
} catch (error) { } catch (error) {
......
...@@ -105,6 +105,8 @@ import { showError as showErrorToast } from "@/lib/toast"; ...@@ -105,6 +105,8 @@ import { showError as showErrorToast } from "@/lib/toast";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useVoiceToText } from "@/hooks/useVoiceToText"; import { useVoiceToText } from "@/hooks/useVoiceToText";
import { isDyadProEnabled } from "@/lib/schemas"; import { isDyadProEnabled } from "@/lib/schemas";
import { useChatMode } from "@/hooks/useChatMode";
import { useInitialChatMode } from "@/hooks/useInitialChatMode";
const showTokenBarAtom = atom(false); const showTokenBarAtom = atom(false);
...@@ -113,6 +115,12 @@ export function ChatInput({ chatId }: { chatId?: number }) { ...@@ -113,6 +115,12 @@ export function ChatInput({ chatId }: { chatId?: number }) {
const posthog = usePostHog(); const posthog = usePostHog();
const [inputValue, setInputValue] = useAtom(chatInputValueAtom); const [inputValue, setInputValue] = useAtom(chatInputValueAtom);
const { settings } = useSettings(); const { settings } = useSettings();
const {
selectedMode: chatMode,
effectiveMode,
isLoading: isChatModeLoading,
} = useChatMode(chatId);
const initialChatMode = useInitialChatMode();
const appId = useAtomValue(selectedAppIdAtom); const appId = useAtomValue(selectedAppIdAtom);
const { refreshVersions } = useVersions(appId); const { refreshVersions } = useVersions(appId);
const { const {
...@@ -231,7 +239,7 @@ export function ChatInput({ chatId }: { chatId?: number }) { ...@@ -231,7 +239,7 @@ export function ChatInput({ chatId }: { chatId?: number }) {
const lastMessage = (chatId ? (messagesById.get(chatId) ?? []) : []).at(-1); const lastMessage = (chatId ? (messagesById.get(chatId) ?? []) : []).at(-1);
const disableSendButton = const disableSendButton =
settings?.selectedChatMode !== "local-agent" && effectiveMode !== "local-agent" &&
lastMessage?.role === "assistant" && lastMessage?.role === "assistant" &&
!lastMessage.approvalState && !lastMessage.approvalState &&
!!proposal && !!proposal &&
...@@ -269,10 +277,23 @@ export function ChatInput({ chatId }: { chatId?: number }) { ...@@ -269,10 +277,23 @@ export function ChatInput({ chatId }: { chatId?: number }) {
); );
// Detect transition to plan mode from another mode in a chat with messages // 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(() => { 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 prevMode = prevModeRef.current;
const currentMode = settings?.selectedChatMode; const currentMode = chatMode;
prevModeRef.current = currentMode; prevModeRef.current = currentMode;
if (prevMode && prevMode !== "plan" && currentMode === "plan") { if (prevMode && prevMode !== "plan" && currentMode === "plan") {
...@@ -281,7 +302,13 @@ export function ChatInput({ chatId }: { chatId?: number }) { ...@@ -281,7 +302,13 @@ export function ChatInput({ chatId }: { chatId?: number }) {
setNeedsFreshPlanChat(true); setNeedsFreshPlanChat(true);
} }
} }
}, [settings?.selectedChatMode, chatId, messagesById, setNeedsFreshPlanChat]); }, [
chatMode,
chatId,
isChatModeLoading,
messagesById,
setNeedsFreshPlanChat,
]);
// Token counting for context limit banner // Token counting for context limit banner
const { result: tokenCountResult } = useCountTokens( const { result: tokenCountResult } = useCountTokens(
...@@ -483,11 +510,14 @@ export function ChatInput({ chatId }: { chatId?: number }) { ...@@ -483,11 +510,14 @@ export function ChatInput({ chatId }: { chatId?: number }) {
// If switching to plan mode from another mode in a chat with messages, // If switching to plan mode from another mode in a chat with messages,
// create a new chat for a clean context. // create a new chat for a clean context.
if (needsFreshPlanChat && settings?.selectedChatMode === "plan" && appId) { if (needsFreshPlanChat && chatMode === "plan" && appId) {
setInputValue(""); setInputValue("");
setNeedsFreshPlanChat(false); setNeedsFreshPlanChat(false);
const newChatId = await ipc.chat.createChat(appId); const newChatId = await ipc.chat.createChat({
appId,
initialChatMode: "plan",
});
setSelectedChatId(newChatId); setSelectedChatId(newChatId);
navigate({ to: "/chat", search: { id: newChatId } }); navigate({ to: "/chat", search: { id: newChatId } });
queryClient.invalidateQueries({ queryKey: queryKeys.chats.all }); queryClient.invalidateQueries({ queryKey: queryKeys.chats.all });
...@@ -498,9 +528,10 @@ export function ChatInput({ chatId }: { chatId?: number }) { ...@@ -498,9 +528,10 @@ export function ChatInput({ chatId }: { chatId?: number }) {
chatId: newChatId, chatId: newChatId,
attachments, attachments,
redo: false, redo: false,
requestedChatMode: "plan",
}); });
clearAttachments(); clearAttachments();
posthog.capture("chat:submit", { chatMode: settings?.selectedChatMode }); posthog.capture("chat:submit", { chatMode });
return; return;
} }
...@@ -569,9 +600,10 @@ export function ChatInput({ chatId }: { chatId?: number }) { ...@@ -569,9 +600,10 @@ export function ChatInput({ chatId }: { chatId?: number }) {
attachments, attachments,
redo: false, redo: false,
selectedComponents: componentsToSend, selectedComponents: componentsToSend,
requestedChatMode: isChatModeLoading ? null : chatMode,
}); });
clearAttachments(); clearAttachments();
posthog.capture("chat:submit", { chatMode: settings?.selectedChatMode }); posthog.capture("chat:submit", { chatMode });
}; };
const handleCancel = () => { const handleCancel = () => {
...@@ -598,7 +630,10 @@ export function ChatInput({ chatId }: { chatId?: number }) { ...@@ -598,7 +630,10 @@ export function ChatInput({ chatId }: { chatId?: number }) {
const handleNewChat = async () => { const handleNewChat = async () => {
if (appId) { if (appId) {
try { try {
const newChatId = await ipc.chat.createChat(appId); const newChatId = await ipc.chat.createChat({
appId,
initialChatMode,
});
setSelectedChatId(newChatId); setSelectedChatId(newChatId);
navigate({ navigate({
to: "/chat", to: "/chat",
...@@ -798,8 +833,8 @@ export function ChatInput({ chatId }: { chatId?: number }) { ...@@ -798,8 +833,8 @@ export function ChatInput({ chatId }: { chatId?: number }) {
{!pendingAgentConsent && {!pendingAgentConsent &&
proposal && proposal &&
proposalResult?.chatId === chatId && proposalResult?.chatId === chatId &&
settings.selectedChatMode !== "ask" && effectiveMode !== "ask" &&
settings.selectedChatMode !== "local-agent" && ( effectiveMode !== "local-agent" && (
<ChatInputActions <ChatInputActions
proposal={proposal} proposal={proposal}
onApprove={handleApprove} onApprove={handleApprove}
......
...@@ -22,7 +22,11 @@ export function useSummarizeInNewChat() { ...@@ -22,7 +22,11 @@ export function useSummarizeInNewChat() {
return; return;
} }
try { 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 // navigate to new chat
await navigate({ to: "/chat", search: { id: newChatId } }); await navigate({ to: "/chat", search: { id: newChatId } });
await streamMessage({ await streamMessage({
......
...@@ -18,7 +18,7 @@ import { previewModeAtom } from "@/atoms/appAtoms"; ...@@ -18,7 +18,7 @@ import { previewModeAtom } from "@/atoms/appAtoms";
import { selectedChatIdAtom } from "@/atoms/chatAtoms"; import { selectedChatIdAtom } from "@/atoms/chatAtoms";
import { useStreamChat } from "@/hooks/useStreamChat"; import { useStreamChat } from "@/hooks/useStreamChat";
import { usePlan } from "@/hooks/usePlan"; import { usePlan } from "@/hooks/usePlan";
import { useSettings } from "@/hooks/useSettings"; import { useChatMode } from "@/hooks/useChatMode";
import { SelectionCommentButton } from "./plan/SelectionCommentButton"; import { SelectionCommentButton } from "./plan/SelectionCommentButton";
import { CommentsFloatingButton } from "./plan/CommentsFloatingButton"; import { CommentsFloatingButton } from "./plan/CommentsFloatingButton";
import { CommentPopover } from "./plan/CommentPopover"; import { CommentPopover } from "./plan/CommentPopover";
...@@ -34,7 +34,7 @@ export const PlanPanel: React.FC = () => { ...@@ -34,7 +34,7 @@ export const PlanPanel: React.FC = () => {
const setPreviewMode = useSetAtom(previewModeAtom); const setPreviewMode = useSetAtom(previewModeAtom);
const { streamMessage, isStreaming } = useStreamChat(); const { streamMessage, isStreaming } = useStreamChat();
const { savedPlan } = usePlan(); const { savedPlan } = usePlan();
const { settings } = useSettings(); const { selectedMode } = useChatMode(chatId);
const annotations = useAtomValue(planAnnotationsAtom); const annotations = useAtomValue(planAnnotationsAtom);
const planContentRef = useRef<HTMLDivElement>(null); const planContentRef = useRef<HTMLDivElement>(null);
...@@ -154,7 +154,7 @@ export const PlanPanel: React.FC = () => { ...@@ -154,7 +154,7 @@ export const PlanPanel: React.FC = () => {
const handleAccept = () => { const handleAccept = () => {
if (!chatId) return; if (!chatId) return;
if (settings?.selectedChatMode !== "plan") return; if (selectedMode !== "plan") return;
if (isSubmitting) return; if (isSubmitting) return;
setIsSubmitting(true); setIsSubmitting(true);
......
...@@ -2,6 +2,7 @@ import { sql } from "drizzle-orm"; ...@@ -2,6 +2,7 @@ import { sql } from "drizzle-orm";
import { integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core"; import { integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
import { relations } from "drizzle-orm"; import { relations } from "drizzle-orm";
import type { ModelMessage } from "ai"; import type { ModelMessage } from "ai";
import type { StoredChatMode } from "@/lib/schemas";
export const AI_MESSAGES_SDK_VERSION = "ai@v6" as const; export const AI_MESSAGES_SDK_VERSION = "ai@v6" as const;
...@@ -82,6 +83,7 @@ export const chats = sqliteTable("chats", { ...@@ -82,6 +83,7 @@ export const chats = sqliteTable("chats", {
compactedAt: integer("compacted_at", { mode: "timestamp" }), compactedAt: integer("compacted_at", { mode: "timestamp" }),
compactionBackupPath: text("compaction_backup_path"), compactionBackupPath: text("compaction_backup_path"),
pendingCompaction: integer("pending_compaction", { mode: "boolean" }), pendingCompaction: integer("pending_compaction", { mode: "boolean" }),
chatMode: text("chat_mode").$type<StoredChatMode | null>(),
}); });
export const messages = sqliteTable("messages", { 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 { useCallback, useMemo } from "react";
import { useSettings } from "./useSettings";
import { useShortcut } from "./useShortcut"; import { useShortcut } from "./useShortcut";
import { usePostHog } from "posthog-js/react"; import { usePostHog } from "posthog-js/react";
import { ChatModeSchema } from "../lib/schemas"; import { ChatModeSchema } from "../lib/schemas";
import { useChatMode } from "./useChatMode";
import { useRouterState } from "@tanstack/react-router";
export function useChatModeToggle() { 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(); const posthog = usePostHog();
// Detect if user is on mac // Detect if user is on mac
...@@ -22,21 +28,21 @@ export function useChatModeToggle() { ...@@ -22,21 +28,21 @@ export function useChatModeToggle() {
// Function to toggle between chat modes // Function to toggle between chat modes
const toggleChatMode = useCallback(() => { 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" // Migration on read ensures currentMode is never "agent"
const modes = ChatModeSchema.options; const modes = ChatModeSchema.options;
const currentIndex = modes.indexOf(currentMode); const currentIndex = modes.indexOf(currentMode);
const newMode = modes[(currentIndex + 1) % modes.length]; const newMode = modes[(currentIndex + 1) % modes.length];
updateSettings({ selectedChatMode: newMode }); void setChatMode(newMode).catch(() => {});
posthog.capture("chat:mode_toggle", { posthog.capture("chat:mode_toggle", {
from: currentMode, from: currentMode,
to: newMode, to: newMode,
trigger: "keyboard_shortcut", trigger: "keyboard_shortcut",
}); });
}, [settings, updateSettings, posthog]); }, [selectedMode, setChatMode, settings, posthog]);
// Add keyboard shortcut with memoized modifiers // Add keyboard shortcut with memoized modifiers
useShortcut( 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() { ...@@ -37,7 +37,7 @@ export function usePlanEvents() {
const setSelectedChatId = useSetAtom(selectedChatIdAtom); const setSelectedChatId = useSetAtom(selectedChatIdAtom);
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { settings, updateSettings } = useSettings(); const { settings } = useSettings();
// Use refs for values accessed in event handlers to avoid stale closures // Use refs for values accessed in event handlers to avoid stale closures
const planStateRef = useRef(planState); const planStateRef = useRef(planState);
...@@ -113,11 +113,6 @@ export function usePlanEvents() { ...@@ -113,11 +113,6 @@ export function usePlanEvents() {
const currentState = planStateRef.current; const currentState = planStateRef.current;
const planData = currentState.plansByChatId.get(payload.chatId); 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 // Switch preview back to preview mode
setPreviewMode("preview"); setPreviewMode("preview");
...@@ -146,7 +141,10 @@ export function usePlanEvents() { ...@@ -146,7 +141,10 @@ export function usePlanEvents() {
} }
try { 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 // Navigate to the new chat
setSelectedChatId(newChatId); setSelectedChatId(newChatId);
...@@ -205,7 +203,6 @@ export function usePlanEvents() { ...@@ -205,7 +203,6 @@ export function usePlanEvents() {
}, [ }, [
setPlanState, setPlanState,
setPreviewMode, setPreviewMode,
updateSettings,
setPendingPlanImplementation, setPendingPlanImplementation,
setPendingQuestionnaire, setPendingQuestionnaire,
setSelectedChatId, setSelectedChatId,
......
...@@ -7,6 +7,8 @@ import { ...@@ -7,6 +7,8 @@ import {
chatErrorByIdAtom, chatErrorByIdAtom,
} from "@/atoms/chatAtoms"; } from "@/atoms/chatAtoms";
import { ipc } from "@/ipc/types"; 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. * Hook to handle starting plan implementation when a plan is accepted.
...@@ -20,6 +22,7 @@ export function usePlanImplementation() { ...@@ -20,6 +22,7 @@ export function usePlanImplementation() {
const setIsStreamingById = useSetAtom(isStreamingByIdAtom); const setIsStreamingById = useSetAtom(isStreamingByIdAtom);
const setMessagesById = useSetAtom(chatMessagesByIdAtom); const setMessagesById = useSetAtom(chatMessagesByIdAtom);
const setErrorById = useSetAtom(chatErrorByIdAtom); const setErrorById = useSetAtom(chatErrorByIdAtom);
const { settings } = useSettings();
// Track if we've already triggered implementation for this pending plan // Track if we've already triggered implementation for this pending plan
const hasTriggeredRef = useRef(false); const hasTriggeredRef = useRef(false);
...@@ -102,9 +105,21 @@ export function usePlanImplementation() { ...@@ -102,9 +105,21 @@ export function usePlanImplementation() {
messages: updatedMessages, messages: updatedMessages,
streamingMessageId, streamingMessageId,
streamingContent, streamingContent,
effectiveChatMode,
chatModeFallbackReason,
}) => { }) => {
if (!isMountedRef.current) return; if (!isMountedRef.current) return;
if (
handleEffectiveChatModeChunk(
{ effectiveChatMode, chatModeFallbackReason },
settings,
chatId,
)
) {
return;
}
if (updatedMessages) { if (updatedMessages) {
// Full messages update (initial load, post-compaction, etc.) // Full messages update (initial load, post-compaction, etc.)
setMessagesById((prev) => { setMessagesById((prev) => {
...@@ -177,5 +192,6 @@ export function usePlanImplementation() { ...@@ -177,5 +192,6 @@ export function usePlanImplementation() {
setIsStreamingById, setIsStreamingById,
setMessagesById, setMessagesById,
setErrorById, setErrorById,
settings,
]); ]);
} }
...@@ -9,7 +9,9 @@ import { ...@@ -9,7 +9,9 @@ import {
} from "@/atoms/chatAtoms"; } from "@/atoms/chatAtoms";
import { useStreamChat } from "./useStreamChat"; import { useStreamChat } from "./useStreamChat";
import { usePostHog } from "posthog-js/react"; 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, * Root-level hook that processes queued messages for any chat,
...@@ -25,7 +27,7 @@ export function useQueueProcessor() { ...@@ -25,7 +27,7 @@ export function useQueueProcessor() {
const [queuePausedById] = useAtom(queuePausedByIdAtom); const [queuePausedById] = useAtom(queuePausedByIdAtom);
const [isStreamingById] = useAtom(isStreamingByIdAtom); const [isStreamingById] = useAtom(isStreamingByIdAtom);
const posthog = usePostHog(); const posthog = usePostHog();
const { settings } = useSettings(); const queryClient = useQueryClient();
useEffect(() => { useEffect(() => {
// Find any chatId that has both completed successfully and has queued messages // Find any chatId that has both completed successfully and has queued messages
...@@ -68,9 +70,11 @@ export function useQueueProcessor() { ...@@ -68,9 +70,11 @@ export function useQueueProcessor() {
if (!messageToSend) return; if (!messageToSend) return;
posthog.capture("chat:submit", { const chatMode = queryClient.getQueryData<Chat>(
chatMode: settings?.selectedChatMode, queryKeys.chats.detail({ chatId }),
}); )?.chatMode;
posthog.capture("chat:submit", { chatMode });
streamMessage({ streamMessage({
prompt: messageToSend.prompt, prompt: messageToSend.prompt,
...@@ -78,6 +82,7 @@ export function useQueueProcessor() { ...@@ -78,6 +82,7 @@ export function useQueueProcessor() {
redo: false, redo: false,
attachments: messageToSend.attachments, attachments: messageToSend.attachments,
selectedComponents: messageToSend.selectedComponents, selectedComponents: messageToSend.selectedComponents,
requestedChatMode: chatMode,
}); });
// Only process one chatId per effect run // Only process one chatId per effect run
...@@ -92,6 +97,6 @@ export function useQueueProcessor() { ...@@ -92,6 +97,6 @@ export function useQueueProcessor() {
setQueuedMessagesById, setQueuedMessagesById,
setStreamCompletedSuccessfullyById, setStreamCompletedSuccessfullyById,
posthog, posthog,
settings?.selectedChatMode, queryClient,
]); ]);
} }
...@@ -12,6 +12,8 @@ import { selectedAppIdAtom } from "@/atoms/appAtoms"; ...@@ -12,6 +12,8 @@ import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { showError } from "@/lib/toast"; import { showError } from "@/lib/toast";
import { useChats } from "@/hooks/useChats"; import { useChats } from "@/hooks/useChats";
import { useLoadApp } from "@/hooks/useLoadApp"; import { useLoadApp } from "@/hooks/useLoadApp";
import { useSettings } from "@/hooks/useSettings";
import { handleEffectiveChatModeChunk } from "@/lib/chatModeStream";
interface UseResolveMergeConflictsWithAIProps { interface UseResolveMergeConflictsWithAIProps {
appId: number; appId: number;
...@@ -38,6 +40,7 @@ export function useResolveMergeConflictsWithAI({ ...@@ -38,6 +40,7 @@ export function useResolveMergeConflictsWithAI({
const isResolvingRef = useRef(false); const isResolvingRef = useRef(false);
const { invalidateChats } = useChats(appId); const { invalidateChats } = useChats(appId);
const { refreshApp } = useLoadApp(appId); const { refreshApp } = useLoadApp(appId);
const { settings } = useSettings();
const resolveWithAI = useCallback(async () => { const resolveWithAI = useCallback(async () => {
if (!appId) { if (!appId) {
...@@ -58,7 +61,10 @@ export function useResolveMergeConflictsWithAI({ ...@@ -58,7 +61,10 @@ export function useResolveMergeConflictsWithAI({
let chatId: number | null = null; let chatId: number | null = null;
try { try {
// Create a new chat for conflict resolution // 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; chatId = newChatId;
// Clear conflicts state after successful chat creation // Clear conflicts state after successful chat creation
...@@ -97,7 +103,23 @@ For each file, review the conflict markers (<<<<<<<, =======, >>>>>>>) and choos ...@@ -97,7 +103,23 @@ For each file, review the conflict markers (<<<<<<<, =======, >>>>>>>) and choos
prompt, prompt,
}, },
{ {
onChunk: ({ messages, streamingMessageId, streamingContent }) => { onChunk: ({
messages,
streamingMessageId,
streamingContent,
effectiveChatMode,
chatModeFallbackReason,
}) => {
if (
handleEffectiveChatModeChunk(
{ effectiveChatMode, chatModeFallbackReason },
settings,
newChatId,
)
) {
return;
}
if (!hasIncrementedStreamCount) { if (!hasIncrementedStreamCount) {
setStreamCountById((prev) => { setStreamCountById((prev) => {
const next = new Map(prev); const next = new Map(prev);
...@@ -183,6 +205,7 @@ For each file, review the conflict markers (<<<<<<<, =======, >>>>>>>) and choos ...@@ -183,6 +205,7 @@ For each file, review the conflict markers (<<<<<<<, =======, >>>>>>>) and choos
navigate, navigate,
invalidateChats, invalidateChats,
refreshApp, refreshApp,
settings,
]); ]);
return { resolveWithAI, isResolving }; return { resolveWithAI, isResolving };
......
...@@ -18,7 +18,7 @@ import { ...@@ -18,7 +18,7 @@ import {
} from "@/atoms/chatAtoms"; } from "@/atoms/chatAtoms";
import { ipc } from "@/ipc/types"; import { ipc } from "@/ipc/types";
import { isPreviewOpenAtom } from "@/atoms/viewAtoms"; 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 type { ChatSummary } from "@/lib/schemas";
import { useChats } from "./useChats"; import { useChats } from "./useChats";
import { useLoadApp } from "./useLoadApp"; import { useLoadApp } from "./useLoadApp";
...@@ -35,6 +35,7 @@ import { useSettings } from "./useSettings"; ...@@ -35,6 +35,7 @@ import { useSettings } from "./useSettings";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/queryKeys"; import { queryKeys } from "@/lib/queryKeys";
import { applyCancellationNoticeToLastAssistantMessage } from "@/shared/chatCancellation"; import { applyCancellationNoticeToLastAssistantMessage } from "@/shared/chatCancellation";
import { handleEffectiveChatModeChunk } from "@/lib/chatModeStream";
export function getRandomNumberId() { export function getRandomNumberId() {
return Math.floor(Math.random() * 1_000_000_000_000_000); return Math.floor(Math.random() * 1_000_000_000_000_000);
...@@ -90,6 +91,7 @@ export function useStreamChat({ ...@@ -90,6 +91,7 @@ export function useStreamChat({
redo, redo,
attachments, attachments,
selectedComponents, selectedComponents,
requestedChatMode,
onSettled, onSettled,
}: { }: {
prompt: string; prompt: string;
...@@ -97,6 +99,7 @@ export function useStreamChat({ ...@@ -97,6 +99,7 @@ export function useStreamChat({
redo?: boolean; redo?: boolean;
attachments?: FileAttachment[]; attachments?: FileAttachment[];
selectedComponents?: ComponentSelection[]; selectedComponents?: ComponentSelection[];
requestedChatMode?: Chat["chatMode"] | null;
onSettled?: (result: { success: boolean }) => void; onSettled?: (result: { success: boolean }) => void;
}) => { }) => {
if ( if (
...@@ -167,6 +170,13 @@ export function useStreamChat({ ...@@ -167,6 +170,13 @@ export function useStreamChat({
let hasIncrementedStreamCount = false; let hasIncrementedStreamCount = false;
try { try {
const cachedChat =
requestedChatMode === null
? undefined
: queryClient.getQueryData<Chat>(
queryKeys.chats.detail({ chatId }),
);
ipc.chatStream.start( ipc.chatStream.start(
{ {
chatId, chatId,
...@@ -174,13 +184,34 @@ export function useStreamChat({ ...@@ -174,13 +184,34 @@ export function useStreamChat({
redo, redo,
attachments: convertedAttachments, attachments: convertedAttachments,
selectedComponents: selectedComponents ?? [], selectedComponents: selectedComponents ?? [],
requestedChatMode:
requestedChatMode === null
? undefined
: (requestedChatMode ?? cachedChat?.chatMode ?? undefined),
}, },
{ {
onChunk: ({ onChunk: ({
messages: updatedMessages, messages: updatedMessages,
streamingMessageId, streamingMessageId,
streamingContent, streamingContent,
effectiveChatMode,
chatModeFallbackReason,
}) => { }) => {
if (
handleEffectiveChatModeChunk(
{ effectiveChatMode, chatModeFallbackReason },
settings,
chatId,
)
) {
if (chatModeFallbackReason) {
queryClient.invalidateQueries({
queryKey: queryKeys.chats.detail({ chatId }),
});
}
return;
}
if (!hasIncrementedStreamCount) { if (!hasIncrementedStreamCount) {
setStreamCountById((prev) => { setStreamCountById((prev) => {
const next = new Map(prev); const next = new Map(prev);
...@@ -323,6 +354,10 @@ export function useStreamChat({ ...@@ -323,6 +354,10 @@ export function useStreamChat({
// that may only be finalized at stream completion. // that may only be finalized at stream completion.
try { try {
const latestChat = await ipc.chat.getChat(chatId); const latestChat = await ipc.chat.getChat(chatId);
queryClient.setQueryData(
queryKeys.chats.detail({ chatId }),
latestChat,
);
setMessagesById((prev) => { setMessagesById((prev) => {
const next = new Map(prev); const next = new Map(prev);
next.set(chatId, latestChat.messages); next.set(chatId, latestChat.messages);
......
...@@ -64,6 +64,7 @@ import { ...@@ -64,6 +64,7 @@ import {
uploadCloudSandboxFiles, uploadCloudSandboxFiles,
} from "../utils/cloud_sandbox_provider"; } from "../utils/cloud_sandbox_provider";
import { createFromTemplate } from "./createFromTemplate"; import { createFromTemplate } from "./createFromTemplate";
import { getInitialChatModeForNewChat } from "./chat_mode_resolution";
import { import {
gitCommit, gitCommit,
gitAdd, gitAdd,
...@@ -1205,11 +1206,16 @@ export function registerAppHandlers() { ...@@ -1205,11 +1206,16 @@ export function registerAppHandlers() {
}) })
.returning(); .returning();
const initialChatMode = await getInitialChatModeForNewChat(
params.initialChatMode,
);
// Create an initial chat for this app // Create an initial chat for this app
const [chat] = await db const [chat] = await db
.insert(chats) .insert(chats)
.values({ .values({
appId: app.id, appId: app.id,
chatMode: initialChatMode,
}) })
.returning(); .returning();
......
...@@ -9,11 +9,20 @@ import { getDyadAppPath } from "../../paths/paths"; ...@@ -9,11 +9,20 @@ import { getDyadAppPath } from "../../paths/paths";
import { getCurrentCommitHash } from "../utils/git_utils"; import { getCurrentCommitHash } from "../utils/git_utils";
import { createTypedHandler } from "./base"; import { createTypedHandler } from "./base";
import { chatContracts } from "../types/chat"; import { chatContracts } from "../types/chat";
import {
getInitialChatModeForNewChat,
normalizeStoredChatMode,
} from "./chat_mode_resolution";
const logger = log.scope("chat_handlers"); const logger = log.scope("chat_handlers");
export function registerChatHandlers() { 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 // Get the app's path first
const app = await db.query.apps.findFirst({ const app = await db.query.apps.findFirst({
where: eq(apps.id, appId), where: eq(apps.id, appId),
...@@ -37,12 +46,15 @@ export function registerChatHandlers() { ...@@ -37,12 +46,15 @@ export function registerChatHandlers() {
// Continue without the git revision // Continue without the git revision
} }
const chatMode = await getInitialChatModeForNewChat(initialChatMode);
// Create a new chat // Create a new chat
const [chat] = await db const [chat] = await db
.insert(chats) .insert(chats)
.values({ .values({
appId, appId,
initialCommitHash, initialCommitHash,
chatMode,
}) })
.returning(); .returning();
logger.info( logger.info(
...@@ -73,6 +85,7 @@ export function registerChatHandlers() { ...@@ -73,6 +85,7 @@ export function registerChatHandlers() {
return { return {
...chat, ...chat,
title: chat.title ?? "", title: chat.title ?? "",
chatMode: normalizeStoredChatMode(chat.chatMode),
messages: chat.messages.map((m) => ({ messages: chat.messages.map((m) => ({
...m, ...m,
role: m.role as "user" | "assistant", role: m.role as "user" | "assistant",
...@@ -90,6 +103,7 @@ export function registerChatHandlers() { ...@@ -90,6 +103,7 @@ export function registerChatHandlers() {
title: true, title: true,
createdAt: true, createdAt: true,
appId: true, appId: true,
chatMode: true,
}, },
orderBy: [desc(chats.createdAt)], orderBy: [desc(chats.createdAt)],
}) })
...@@ -99,12 +113,16 @@ export function registerChatHandlers() { ...@@ -99,12 +113,16 @@ export function registerChatHandlers() {
title: true, title: true,
createdAt: true, createdAt: true,
appId: true, appId: true,
chatMode: true,
}, },
orderBy: [desc(chats.createdAt)], orderBy: [desc(chats.createdAt)],
}); });
const allChats = await query; const allChats = await query;
return allChats as ChatSummary[]; return allChats.map((chat) => ({
...chat,
chatMode: normalizeStoredChatMode(chat.chatMode),
})) satisfies ChatSummary[];
}); });
createTypedHandler(chatContracts.deleteChat, async (_, chatId) => { createTypedHandler(chatContracts.deleteChat, async (_, chatId) => {
...@@ -112,8 +130,18 @@ export function registerChatHandlers() { ...@@ -112,8 +130,18 @@ export function registerChatHandlers() {
}); });
createTypedHandler(chatContracts.updateChat, async (_, params) => { createTypedHandler(chatContracts.updateChat, async (_, params) => {
const { chatId, title } = params; const { chatId, title, chatMode } = params;
await db.update(chats).set({ title }).where(eq(chats.id, chatId)); 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) => { 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 { ...@@ -30,7 +30,6 @@ import {
import { buildNeonPromptForApp } from "../../neon_admin/neon_prompt_context"; import { buildNeonPromptForApp } from "../../neon_admin/neon_prompt_context";
import { getDyadAppPath } from "../../paths/paths"; import { getDyadAppPath } from "../../paths/paths";
import { buildDyadMediaUrl } from "../../lib/dyadMediaUrl"; import { buildDyadMediaUrl } from "../../lib/dyadMediaUrl";
import { readSettings } from "../../main/settings";
import type { ChatResponseEnd, ChatStreamParams } from "@/ipc/types"; import type { ChatResponseEnd, ChatStreamParams } from "@/ipc/types";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import { import {
...@@ -104,6 +103,7 @@ import { ...@@ -104,6 +103,7 @@ import {
isSupabaseConnected, isSupabaseConnected,
isTurboEditsV2Enabled, isTurboEditsV2Enabled,
} from "@/lib/schemas"; } from "@/lib/schemas";
import { resolveChatModeForTurn } from "./chat_mode_resolution";
import { import {
getFreeAgentQuotaStatus, getFreeAgentQuotaStatus,
markMessageAsUsingFreeAgentQuota, markMessageAsUsingFreeAgentQuota,
...@@ -116,6 +116,7 @@ import { ...@@ -116,6 +116,7 @@ import {
VersionedFiles, VersionedFiles,
} from "../utils/versioned_codebase_context"; } from "../utils/versioned_codebase_context";
import { getAiMessagesJsonIfWithinLimit } from "../utils/ai_messages_utils"; import { getAiMessagesJsonIfWithinLimit } from "../utils/ai_messages_utils";
import { readSettings } from "@/main/settings";
type AsyncIterableStream<T> = AsyncIterable<T> & ReadableStream<T>; type AsyncIterableStream<T> = AsyncIterable<T> & ReadableStream<T>;
...@@ -536,7 +537,23 @@ ${componentSnippet} ...@@ -536,7 +537,23 @@ ${componentSnippet}
}) })
.returning({ id: messages.id }); .returning({ id: messages.id });
const userMessageId = insertedUserMessage.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. // Only Dyad Pro requests have request ids.
if (settings.enableDyadPro) { if (settings.enableDyadPro) {
// Generate requestId early so it can be saved with the message // Generate requestId early so it can be saved with the message
...@@ -653,8 +670,7 @@ ${componentSnippet} ...@@ -653,8 +670,7 @@ ${componentSnippet}
updatedChat.app.id, // Exclude current app updatedChat.app.id, // Exclude current app
); );
const willUseLocalAgentStream = const willUseLocalAgentStream =
(settings.selectedChatMode === "local-agent" || (selectedChatMode === "local-agent" || selectedChatMode === "ask") &&
settings.selectedChatMode === "ask") &&
!mentionedAppsCodebases.length; !mentionedAppsCodebases.length;
const isDeepContextEnabled = const isDeepContextEnabled =
...@@ -772,7 +788,7 @@ ${componentSnippet} ...@@ -772,7 +788,7 @@ ${componentSnippet}
// Migration on read converts "agent" to "build", so no need to check for it here // Migration on read converts "agent" to "build", so no need to check for it here
let systemPrompt = constructSystemPrompt({ let systemPrompt = constructSystemPrompt({
aiRules, aiRules,
chatMode: settings.selectedChatMode, chatMode: selectedChatMode,
enableTurboEditsV2: isTurboEditsV2Enabled(settings), enableTurboEditsV2: isTurboEditsV2Enabled(settings),
themePrompt, themePrompt,
basicAgentMode: isBasicAgentMode(settings), basicAgentMode: isBasicAgentMode(settings),
...@@ -822,7 +838,7 @@ ${componentSnippet} ...@@ -822,7 +838,7 @@ ${componentSnippet}
getSupabaseAvailableSystemPrompt(supabaseClientCode) + getSupabaseAvailableSystemPrompt(supabaseClientCode) +
"\n\n" + "\n\n" +
// For local agent, we will explicitly fetch the database context when needed. // For local agent, we will explicitly fetch the database context when needed.
(settings.selectedChatMode === "local-agent" (selectedChatMode === "local-agent"
? "" ? ""
: await getSupabaseContext({ : await getSupabaseContext({
supabaseProjectId: updatedChat.app.supabaseProjectId, supabaseProjectId: updatedChat.app.supabaseProjectId,
...@@ -838,12 +854,12 @@ ${componentSnippet} ...@@ -838,12 +854,12 @@ ${componentSnippet}
neonProjectId: updatedChat.app.neonProjectId!, neonProjectId: updatedChat.app.neonProjectId!,
neonActiveBranchId: updatedChat.app.neonActiveBranchId, neonActiveBranchId: updatedChat.app.neonActiveBranchId,
neonDevelopmentBranchId: updatedChat.app.neonDevelopmentBranchId, neonDevelopmentBranchId: updatedChat.app.neonDevelopmentBranchId,
selectedChatMode: settings.selectedChatMode ?? "", selectedChatMode,
})) + })) +
"\n\n"; "\n\n";
} else if ( } else if (
// In local agent mode, we will suggest integrations as part of the add-integration tool // 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. // If in security review mode, we don't need to mention integrations are available.
!isSecurityReviewIntent !isSecurityReviewIntent
) { ) {
...@@ -873,7 +889,7 @@ ${componentSnippet} ...@@ -873,7 +889,7 @@ ${componentSnippet}
// print out the dyad-write tags. // 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 // 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. // it's not that critical to include the image analysis instructions.
const isAskMode = settings.selectedChatMode === "ask"; const isAskMode = selectedChatMode === "ask";
if (hasUploadedAttachments) { if (hasUploadedAttachments) {
if (willUseLocalAgentStream && !isAskMode) { if (willUseLocalAgentStream && !isAskMode) {
systemPrompt += ` systemPrompt += `
...@@ -946,7 +962,7 @@ This conversation includes one or more image attachments. When the user uploads ...@@ -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 // Thinking tags are generally not critical for the context
// and eats up extra tokens. // and eats up extra tokens.
content: content:
settings.selectedChatMode === "ask" selectedChatMode === "ask"
? removeDyadTags(removeNonEssentialTags(msg.content)) ? removeDyadTags(removeNonEssentialTags(msg.content))
: removeNonEssentialTags(msg.content), : removeNonEssentialTags(msg.content),
providerOptions: { providerOptions: {
...@@ -1164,10 +1180,7 @@ This conversation includes one or more image attachments. When the user uploads ...@@ -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 // Handle ask mode: use local-agent in read-only mode
// This gives users access to code reading tools while in ask mode // This gives users access to code reading tools while in ask mode
// Ask mode does not consume free agent quota // Ask mode does not consume free agent quota
if ( if (selectedChatMode === "ask" && !mentionedAppsCodebases.length) {
settings.selectedChatMode === "ask" &&
!mentionedAppsCodebases.length
) {
// Reconstruct system prompt for local-agent read-only mode // Reconstruct system prompt for local-agent read-only mode
const readOnlySystemPrompt = constructSystemPrompt({ const readOnlySystemPrompt = constructSystemPrompt({
aiRules, aiRules,
...@@ -1196,6 +1209,7 @@ This conversation includes one or more image attachments. When the user uploads ...@@ -1196,6 +1209,7 @@ This conversation includes one or more image attachments. When the user uploads
dyadRequestId: dyadRequestId ?? "[no-request-id]", dyadRequestId: dyadRequestId ?? "[no-request-id]",
readOnly: true, readOnly: true,
messageOverride: isSummarizeIntent ? chatMessages : undefined, messageOverride: isSummarizeIntent ? chatMessages : undefined,
settingsOverride: settings,
}, },
); );
if (!streamSuccess) { if (!streamSuccess) {
...@@ -1208,10 +1222,7 @@ This conversation includes one or more image attachments. When the user uploads ...@@ -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 // Handle plan mode: use local-agent with plan tools only
// Plan mode is for requirements gathering and creating implementation plans // Plan mode is for requirements gathering and creating implementation plans
if ( if (selectedChatMode === "plan" && !mentionedAppsCodebases.length) {
settings.selectedChatMode === "plan" &&
!mentionedAppsCodebases.length
) {
// Reconstruct system prompt for plan mode // Reconstruct system prompt for plan mode
const planModeSystemPrompt = constructSystemPrompt({ const planModeSystemPrompt = constructSystemPrompt({
aiRules, aiRules,
...@@ -1226,6 +1237,7 @@ This conversation includes one or more image attachments. When the user uploads ...@@ -1226,6 +1237,7 @@ This conversation includes one or more image attachments. When the user uploads
dyadRequestId: dyadRequestId ?? "[no-request-id]", dyadRequestId: dyadRequestId ?? "[no-request-id]",
planModeOnly: true, planModeOnly: true,
messageOverride: isSummarizeIntent ? chatMessages : undefined, messageOverride: isSummarizeIntent ? chatMessages : undefined,
settingsOverride: settings,
}); });
return; return;
} }
...@@ -1234,7 +1246,7 @@ This conversation includes one or more image attachments. When the user uploads ...@@ -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 // Mentioned apps can't be handled by the local agent (defer to balanced smart context
// in build mode) // in build mode)
if ( if (
settings.selectedChatMode === "local-agent" && selectedChatMode === "local-agent" &&
!mentionedAppsCodebases.length !mentionedAppsCodebases.length
) { ) {
// Check quota for Basic Agent mode (non-Pro users) // 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 ...@@ -1271,6 +1283,7 @@ This conversation includes one or more image attachments. When the user uploads
systemPrompt, systemPrompt,
dyadRequestId: dyadRequestId ?? "[no-request-id]", dyadRequestId: dyadRequestId ?? "[no-request-id]",
messageOverride: isSummarizeIntent ? chatMessages : undefined, messageOverride: isSummarizeIntent ? chatMessages : undefined,
settingsOverride: settings,
}, },
); );
} finally { } finally {
...@@ -1288,7 +1301,7 @@ This conversation includes one or more image attachments. When the user uploads ...@@ -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 // 2. Mode is "build" AND there are enabled MCP servers
if ( if (
settings.enableMcpServersForBuildMode && settings.enableMcpServersForBuildMode &&
settings.selectedChatMode === "build" selectedChatMode === "build"
) { ) {
const tools = await getMcpTools(event); const tools = await getMcpTools(event);
const hasEnabledMcpServers = Object.keys(tools).length > 0; const hasEnabledMcpServers = Object.keys(tools).length > 0;
...@@ -1355,10 +1368,7 @@ This conversation includes one or more image attachments. When the user uploads ...@@ -1355,10 +1368,7 @@ This conversation includes one or more image attachments. When the user uploads
}); });
fullResponse = result.fullResponse; fullResponse = result.fullResponse;
if ( if (selectedChatMode !== "ask" && isTurboEditsV2Enabled(settings)) {
settings.selectedChatMode !== "ask" &&
isTurboEditsV2Enabled(settings)
) {
let issues = await dryRunSearchReplace({ let issues = await dryRunSearchReplace({
fullResponse, fullResponse,
appPath: getDyadAppPath(updatedChat.app.path), appPath: getDyadAppPath(updatedChat.app.path),
...@@ -1457,7 +1467,7 @@ ${formattedSearchReplaceIssues}`, ...@@ -1457,7 +1467,7 @@ ${formattedSearchReplaceIssues}`,
if ( if (
!abortController.signal.aborted && !abortController.signal.aborted &&
settings.selectedChatMode !== "ask" && selectedChatMode !== "ask" &&
hasUnclosedDyadWrite(fullResponse) hasUnclosedDyadWrite(fullResponse)
) { ) {
let continuationAttempts = 0; let continuationAttempts = 0;
...@@ -1511,7 +1521,7 @@ ${formattedSearchReplaceIssues}`, ...@@ -1511,7 +1521,7 @@ ${formattedSearchReplaceIssues}`,
// installed yet. // installed yet.
addDependencies.length === 0 && addDependencies.length === 0 &&
settings.enableAutoFixProblems && settings.enableAutoFixProblems &&
settings.selectedChatMode !== "ask" selectedChatMode !== "ask"
) { ) {
try { try {
// IF auto-fix is enabled // IF auto-fix is enabled
...@@ -1695,11 +1705,8 @@ ${problemReport.problems ...@@ -1695,11 +1705,8 @@ ${problemReport.problems
.update(messages) .update(messages)
.set({ content: fullResponse }) .set({ content: fullResponse })
.where(eq(messages.id, placeholderAssistantMessage.id)); .where(eq(messages.id, placeholderAssistantMessage.id));
const settings = readSettings(); const latestSettings = readSettings();
if ( if (latestSettings.autoApproveChanges && selectedChatMode !== "ask") {
settings.autoApproveChanges &&
settings.selectedChatMode !== "ask"
) {
const status = await processFullResponseActions( const status = await processFullResponseActions(
fullResponse, fullResponse,
req.chatId, req.chatId,
......
...@@ -13,6 +13,7 @@ import { ImportAppParams, ImportAppResult } from "@/ipc/types"; ...@@ -13,6 +13,7 @@ import { ImportAppParams, ImportAppResult } from "@/ipc/types";
import { copyDirectoryRecursive } from "../utils/file_utils"; import { copyDirectoryRecursive } from "../utils/file_utils";
import { gitCommit, gitAdd, gitInit } from "../utils/git_utils"; import { gitCommit, gitAdd, gitInit } from "../utils/git_utils";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import { getInitialChatModeForNewChat } from "./chat_mode_resolution";
const logger = log.scope("import-handlers"); const logger = log.scope("import-handlers");
const handle = createLoggedHandler(logger); const handle = createLoggedHandler(logger);
...@@ -152,11 +153,14 @@ export function registerImportHandlers() { ...@@ -152,11 +153,14 @@ export function registerImportHandlers() {
}) })
.returning(); .returning();
const initialChatMode = await getInitialChatModeForNewChat();
// Create an initial chat for this app // Create an initial chat for this app
const [chat] = await db const [chat] = await db
.insert(chats) .insert(chats)
.values({ .values({
appId: app.id, appId: app.id,
chatMode: initialChatMode,
}) })
.returning(); .returning();
return { appId: app.id, chatId: chat.id }; return { appId: app.id, chatId: chat.id };
......
...@@ -34,6 +34,7 @@ import { createLoggedHandler } from "./safe_handle"; ...@@ -34,6 +34,7 @@ import { createLoggedHandler } from "./safe_handle";
import { ApproveProposalResult } from "@/ipc/types"; import { ApproveProposalResult } from "@/ipc/types";
import { validateChatContext } from "../utils/context_paths_utils"; import { validateChatContext } from "../utils/context_paths_utils";
import { readSettings } from "@/main/settings"; import { readSettings } from "@/main/settings";
import { resolveChatModeForTurn } from "./chat_mode_resolution";
const logger = log.scope("proposal_handlers"); const logger = log.scope("proposal_handlers");
const handle = createLoggedHandler(logger); const handle = createLoggedHandler(logger);
...@@ -338,7 +339,15 @@ const approveProposalHandler = async ( ...@@ -338,7 +339,15 @@ const approveProposalHandler = async (
{ chatId, messageId }: { chatId: number; messageId: number }, { chatId, messageId }: { chatId: number; messageId: number },
): Promise<ApproveProposalResult> => { ): Promise<ApproveProposalResult> => {
const settings = readSettings(); 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( throw new Error(
"Ask mode is not supported for proposal approval. Please switch to build mode.", "Ask mode is not supported for proposal approval. Please switch to build mode.",
); );
......
...@@ -28,6 +28,7 @@ import { extractMentionedAppsCodebases } from "../utils/mention_apps"; ...@@ -28,6 +28,7 @@ import { extractMentionedAppsCodebases } from "../utils/mention_apps";
import { parseAppMentions } from "@/shared/parse_mention_apps"; import { parseAppMentions } from "@/shared/parse_mention_apps";
import { isTurboEditsV2Enabled } from "@/lib/schemas"; import { isTurboEditsV2Enabled } from "@/lib/schemas";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error"; import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import { resolveChatModeForTurn } from "./chat_mode_resolution";
const logger = log.scope("token_count_handlers"); const logger = log.scope("token_count_handlers");
...@@ -63,7 +64,15 @@ export function registerTokenCountHandlers() { ...@@ -63,7 +64,15 @@ export function registerTokenCountHandlers() {
// Count input tokens // Count input tokens
const inputTokens = estimateTokens(req.input); 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 // Parse app mentions from the input
const mentionedAppNames = parseAppMentions(req.input); const mentionedAppNames = parseAppMentions(req.input);
...@@ -74,9 +83,7 @@ export function registerTokenCountHandlers() { ...@@ -74,9 +83,7 @@ export function registerTokenCountHandlers() {
let systemPrompt = constructSystemPrompt({ let systemPrompt = constructSystemPrompt({
aiRules: await readAiRules(getDyadAppPath(chat.app.path)), aiRules: await readAiRules(getDyadAppPath(chat.app.path)),
chatMode: chatMode:
settings.selectedChatMode === "local-agent" selectedChatMode === "local-agent" ? "build" : selectedChatMode,
? "build"
: settings.selectedChatMode,
enableTurboEditsV2: isTurboEditsV2Enabled(settings), enableTurboEditsV2: isTurboEditsV2Enabled(settings),
themePrompt, themePrompt,
}); });
...@@ -101,7 +108,7 @@ export function registerTokenCountHandlers() { ...@@ -101,7 +108,7 @@ export function registerTokenCountHandlers() {
neonProjectId: chat.app.neonProjectId!, neonProjectId: chat.app.neonProjectId!,
neonActiveBranchId: chat.app.neonActiveBranchId, neonActiveBranchId: chat.app.neonActiveBranchId,
neonDevelopmentBranchId: chat.app.neonDevelopmentBranchId, neonDevelopmentBranchId: chat.app.neonDevelopmentBranchId,
selectedChatMode: settings.selectedChatMode ?? "", selectedChatMode,
})); }));
} else { } else {
// Neon projects don't need Supabase (already handled above). // Neon projects don't need Supabase (already handled above).
......
import { z } from "zod"; import { z } from "zod";
import { defineContract, createClient } from "../contracts/core"; import { defineContract, createClient } from "../contracts/core";
import { APP_FRAMEWORK_TYPES } from "../../lib/framework_constants"; import { APP_FRAMEWORK_TYPES } from "../../lib/framework_constants";
import { ChatModeSchema } from "../../lib/schemas";
// ============================================================================= // =============================================================================
// App Schemas // App Schemas
...@@ -54,6 +55,7 @@ export type App = z.infer<typeof AppSchema>; ...@@ -54,6 +55,7 @@ export type App = z.infer<typeof AppSchema>;
*/ */
export const CreateAppParamsSchema = z.object({ export const CreateAppParamsSchema = z.object({
name: z.string().min(1), name: z.string().min(1),
initialChatMode: ChatModeSchema.optional(),
}); });
/** /**
......
...@@ -5,6 +5,12 @@ import { ...@@ -5,6 +5,12 @@ import {
createClient, createClient,
createStreamClient, createStreamClient,
} from "../contracts/core"; } from "../contracts/core";
import {
ChatModeSchema,
StoredChatModeSchema,
migrateStoredChatMode,
type ChatMode,
} from "../../lib/schemas";
// ============================================================================= // =============================================================================
// Chat Schemas // Chat Schemas
...@@ -29,6 +35,10 @@ export const MessageSchema = z.object({ ...@@ -29,6 +35,10 @@ export const MessageSchema = z.object({
export type Message = z.infer<typeof MessageSchema>; 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. * Schema for a Chat object.
*/ */
...@@ -38,6 +48,7 @@ export const ChatSchema = z.object({ ...@@ -38,6 +48,7 @@ export const ChatSchema = z.object({
messages: z.array(MessageSchema), messages: z.array(MessageSchema),
initialCommitHash: z.string().nullable().optional(), initialCommitHash: z.string().nullable().optional(),
dbTimestamp: z.string().nullable().optional(), dbTimestamp: z.string().nullable().optional(),
chatMode: NullableChatModeSchema,
}); });
export type Chat = z.infer<typeof ChatSchema>; export type Chat = z.infer<typeof ChatSchema>;
...@@ -86,6 +97,7 @@ export const ChatStreamParamsSchema = z.object({ ...@@ -86,6 +97,7 @@ export const ChatStreamParamsSchema = z.object({
redo: z.boolean().optional(), redo: z.boolean().optional(),
attachments: z.array(ChatAttachmentSchema).optional(), attachments: z.array(ChatAttachmentSchema).optional(),
selectedComponents: z.array(ComponentSelectionSchema).optional(), selectedComponents: z.array(ComponentSelectionSchema).optional(),
requestedChatMode: ChatModeSchema.optional(),
}); });
export type ChatStreamParams = z.infer<typeof ChatStreamParamsSchema>; export type ChatStreamParams = z.infer<typeof ChatStreamParamsSchema>;
...@@ -104,8 +116,14 @@ export const ChatResponseChunkSchema = z.object({ ...@@ -104,8 +116,14 @@ export const ChatResponseChunkSchema = z.object({
messages: z.array(MessageSchema).optional(), messages: z.array(MessageSchema).optional(),
streamingMessageId: z.number().optional(), streamingMessageId: z.number().optional(),
streamingContent: z.string().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. * Schema for chat response end event.
*/ */
...@@ -143,7 +161,8 @@ export const CreateChatResultSchema = z.number(); ...@@ -143,7 +161,8 @@ export const CreateChatResultSchema = z.number();
*/ */
export const UpdateChatParamsSchema = z.object({ export const UpdateChatParamsSchema = z.object({
chatId: z.number(), chatId: z.number(),
title: z.string(), title: z.string().optional(),
chatMode: ChatModeSchema.nullable().optional(),
}); });
export type UpdateChatParams = z.infer<typeof UpdateChatParamsSchema>; export type UpdateChatParams = z.infer<typeof UpdateChatParamsSchema>;
...@@ -194,13 +213,20 @@ export const chatContracts = { ...@@ -194,13 +213,20 @@ export const chatContracts = {
appId: z.number(), appId: z.number(),
title: z.string().nullable(), title: z.string().nullable(),
createdAt: z.date(), createdAt: z.date(),
chatMode: NullableChatModeSchema,
}), }),
), ),
}), }),
createChat: defineContract({ createChat: defineContract({
channel: "create-chat", 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, output: CreateChatResultSchema,
}), }),
......
...@@ -124,6 +124,7 @@ export type { ...@@ -124,6 +124,7 @@ export type {
FileAttachment, FileAttachment,
ChatAttachment, ChatAttachment,
ChatStreamParams, ChatStreamParams,
ChatResponseChunk,
ChatResponseEnd, ChatResponseEnd,
UpdateChatParams, UpdateChatParams,
TokenCountParams, 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 = { ...@@ -50,6 +50,8 @@ export const queryKeys = {
chats: { chats: {
all: ["chats"] as const, all: ["chats"] as const,
list: ({ appId }: { appId: number | null }) => ["chats", appId] 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 }) => search: ({ appId, query }: { appId: number | null; query: string }) =>
["chats", "search", appId, query] as const, ["chats", "search", appId, query] as const,
}, },
......
...@@ -15,6 +15,7 @@ export const ChatSummarySchema = z.object({ ...@@ -15,6 +15,7 @@ export const ChatSummarySchema = z.object({
appId: z.number(), appId: z.number(),
title: z.string().nullable(), title: z.string().nullable(),
createdAt: z.date(), createdAt: z.date(),
chatMode: z.enum(["build", "ask", "local-agent", "plan"]).nullable(),
}); });
/** /**
......
...@@ -44,6 +44,7 @@ import { ...@@ -44,6 +44,7 @@ import {
} from "@/components/ProBanner"; } from "@/components/ProBanner";
import { hasDyadProKey, getEffectiveDefaultChatMode } from "@/lib/schemas"; import { hasDyadProKey, getEffectiveDefaultChatMode } from "@/lib/schemas";
import { useFreeAgentQuota } from "@/hooks/useFreeAgentQuota"; import { useFreeAgentQuota } from "@/hooks/useFreeAgentQuota";
import { useInitialChatMode } from "@/hooks/useInitialChatMode";
// Track whether we've already checked release notes this session (module-scoped // Track whether we've already checked release notes this session (module-scoped
// so it persists across component unmount/remount cycles). // so it persists across component unmount/remount cycles).
...@@ -63,6 +64,7 @@ export default function HomePage() { ...@@ -63,6 +64,7 @@ export default function HomePage() {
const { refreshApps } = useLoadApps(); const { refreshApps } = useLoadApps();
const { settings, updateSettings, envVars } = useSettings(); const { settings, updateSettings, envVars } = useSettings();
const { isQuotaExceeded, isLoading: isQuotaLoading } = useFreeAgentQuota(); const { isQuotaExceeded, isLoading: isQuotaLoading } = useFreeAgentQuota();
const initialChatMode = useInitialChatMode();
const setIsPreviewOpen = useSetAtom(isPreviewOpenAtom); const setIsPreviewOpen = useSetAtom(isPreviewOpenAtom);
const { selectChat } = useSelectChat(); const { selectChat } = useSelectChat();
...@@ -184,15 +186,18 @@ export default function HomePage() { ...@@ -184,15 +186,18 @@ export default function HomePage() {
let chatId: number; let chatId: number;
let appId: number; let appId: number;
if (selectedApp) { if (selectedApp) {
// Existing app flow: create a new chat in the selected app // 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; appId = selectedApp.id;
} else { } else {
// New app flow (default behavior) // New app flow (default behavior)
const result = await ipc.app.createApp({ const result = await ipc.app.createApp({
name: generateCuteAppName(), name: generateCuteAppName(),
initialChatMode,
}); });
chatId = result.chatId; chatId = result.chatId;
appId = result.app.id; appId = result.app.id;
...@@ -221,6 +226,7 @@ export default function HomePage() { ...@@ -221,6 +226,7 @@ export default function HomePage() {
prompt: inputValue, prompt: inputValue,
chatId, chatId,
attachments, attachments,
requestedChatMode: initialChatMode,
}); });
await new Promise((resolve) => await new Promise((resolve) =>
setTimeout(resolve, settings?.isTestMode ? 0 : 2000), setTimeout(resolve, settings?.isTestMode ? 0 : 2000),
......
...@@ -18,7 +18,11 @@ import { db } from "@/db"; ...@@ -18,7 +18,11 @@ import { db } from "@/db";
import { chats, messages } from "@/db/schema"; import { chats, messages } from "@/db/schema";
import { eq } from "drizzle-orm"; 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 { readSettings } from "@/main/settings";
import { getDyadAppPath } from "@/paths/paths"; import { getDyadAppPath } from "@/paths/paths";
import { detectFrameworkType } from "@/ipc/utils/framework_utils"; import { detectFrameworkType } from "@/ipc/utils/framework_utils";
...@@ -267,6 +271,7 @@ export async function handleLocalAgentStream( ...@@ -267,6 +271,7 @@ export async function handleLocalAgentStream(
readOnly = false, readOnly = false,
planModeOnly = false, planModeOnly = false,
messageOverride, messageOverride,
settingsOverride,
}: { }: {
placeholderMessageId: number; placeholderMessageId: number;
systemPrompt: string; systemPrompt: string;
...@@ -286,9 +291,10 @@ export async function handleLocalAgentStream( ...@@ -286,9 +291,10 @@ export async function handleLocalAgentStream(
* Used for summarization where messages need to be transformed. * Used for summarization where messages need to be transformed.
*/ */
messageOverride?: ModelMessage[]; messageOverride?: ModelMessage[];
settingsOverride?: UserSettings;
}, },
): Promise<boolean> { ): Promise<boolean> {
const settings = readSettings(); const settings = settingsOverride ?? readSettings();
const maxToolCallSteps = const maxToolCallSteps =
settings.maxToolCallSteps ?? DEFAULT_MAX_TOOL_CALL_STEPS; settings.maxToolCallSteps ?? DEFAULT_MAX_TOOL_CALL_STEPS;
let fullResponse = ""; let fullResponse = "";
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论