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

Persist chat mode per chat (#3249)

## Summary - Adds chat mode persistence to chats so Ask, Build, and Plan are remembered per conversation. - Resolves effective chat mode for chat creation and streaming paths, including fallback metadata for the UI. - Updates chat mode hooks, selectors, tab flows, and coverage for persistence behavior. ## Test plan - npm run fmt && npm run lint:fix && npm run ts - npm test - npm run build - PLAYWRIGHT_HTML_OPEN=never npm run e2e -- e2e-tests/chat_mode.spec.ts Generated with Codex <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/dyad-sh/dyad/pull/3249" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open in Devin Review"> </picture> </a> <!-- devin-review-badge-end --> --------- Co-authored-by: 's avatarWill Chen <7344640+wwwillchen@users.noreply.github.com>
上级 a5a735e8
......@@ -5,7 +5,7 @@ description: Root-cause flaky or failing E2E tests from a specific CI run by dow
# Deflake E2E Tests from a CI Run
Use this skill when the user points you at a specific failing CI run (e.g. `https://github.com/dyad-sh/dyad/actions/runs/<id>`) and asks you to root-cause the E2E failures. Unlike `deflake-e2e`, this skill does NOT rebuild and re-run tests — it reads the already-recorded Playwright report from the run's artifacts, which is faster and gives you the *exact* failure state CI saw.
Use this skill when the user points you at a specific failing CI run (e.g. `https://github.com/dyad-sh/dyad/actions/runs/<id>`) and asks you to root-cause the E2E failures. Unlike `deflake-e2e`, this skill does NOT rebuild and re-run tests — it reads the already-recorded Playwright report from the run's artifacts, which is faster and gives you the _exact_ failure state CI saw.
## Arguments
......@@ -60,7 +60,7 @@ Group by error shape. If every failure shares the same locator / error ("element
if obj.get('type') == 'before' and obj.get('class') == 'Test':
print(round(obj['startTime']/1000, 2), obj.get('method'), obj.get('title','')[:200])
```
Look for the last few actions before the timeout — that tells you *which call hung and what its locator resolved to*.
Look for the last few actions before the timeout — that tells you _which call hung and what its locator resolved to_.
4. Correlate with app logs. Electron `console.log`/`console.error` lands in `stderr`/`stdout` trace events:
```python
for line in open('/tmp/trace-extract/test.trace'):
......@@ -81,7 +81,7 @@ Group by error shape. If every failure shares the same locator / error ("element
Common patterns and what they mean:
- **"element is not enabled" on a button after fill()** → React render race between URL/atom state updates and the editor's onChange. The fill runs, onChange writes under the *old* key, next render clears the editor for the new context. Fix: wrap fill+click in `expect.toPass()` and assert editor content + button enabled before clicking. See `ChatActions.sendPrompt()`.
- **"element is not enabled" on a button after fill()** → React render race between URL/atom state updates and the editor's onChange. The fill runs, onChange writes under the _old_ key, next render clears the editor for the new context. Fix: wrap fill+click in `expect.toPass()` and assert editor content + button enabled before clicking. See `ChatActions.sendPrompt()`.
- **"locator.click timeout"** with multiple matching elements → stale component still in DOM during a transition. Fix: scope the locator tighter (`getChatInputContainer().locator(...)`) or add a visibility assertion on the stable target first.
- **Assertion flakes right after navigation** → atom/URL mismatch during a single render cycle. Either wait for a post-navigation signal (e.g. a data-loaded state) or wrap the assertion in `toPass` with a bounded timeout.
- **Different error on retry vs. first attempt** → test is mutating shared state. Look for missing teardown or cross-test singletons.
......@@ -92,7 +92,7 @@ Prefer fixing the test over the app unless the race would actually bite a real u
1. Make the minimal change — usually in `e2e-tests/helpers/page-objects/` since many specs share the same helper.
2. `npm run fmt && npm run lint && npm run ts`.
3. Skip local `npm run build && npm run e2e` unless you're genuinely unsure — the CI loop is ~15min and this analysis path is for *obvious* root causes. If you're guessing, stop guessing and run it locally instead.
3. Skip local `npm run build && npm run e2e` unless you're genuinely unsure — the CI loop is ~15min and this analysis path is for _obvious_ root causes. If you're guessing, stop guessing and run it locally instead.
4. Use `/dyad:pr-push` or commit + `gh pr create` directly. The PR body MUST include:
- A link to the failing run.
- The root-cause narrative (what raced, in concrete terms — not "timing issue").
......@@ -101,7 +101,7 @@ Prefer fixing the test over the app unless the race would actually bite a real u
## Gotchas
- `gh run download` needs `-R <owner>/<repo>` if you're not in a cwd with matching origin.
- `results.json` paths inside `attachments[]` are *CI-side*; only use them to match hashes, never to read files.
- `results.json` paths inside `attachments[]` are _CI-side_; only use them to match hashes, never to read files.
- A fork PR's artifacts live on the fork's run, not the upstream's. Make sure `run_id` is on the right repo.
- Many traces unpack to the same `/tmp/trace-extract/` — clean between extractions or use unique subdirs.
- The `html-report` is the *merged* report across shards. Individual shard artifacts (`blob-report-*`, `flakiness-report-*`) are usually unnecessary for root-causing.
- The `html-report` is the _merged_ report across shards. Individual shard artifacts (`blob-report-*`, `flakiness-report-*`) are usually unnecessary for root-causing.
ALTER TABLE `chats` ADD `chat_mode` text;
\ No newline at end of file
差异被折叠。
......@@ -197,6 +197,13 @@
"when": 1774487675535,
"tag": "0027_unusual_scalphunter",
"breakpoints": true
},
{
"idx": 28,
"version": "6",
"when": 1776728360068,
"tag": "0028_icy_veda",
"breakpoints": true
}
]
}
\ No newline at end of file
import { test } from "./helpers/test_helper";
import { expect } from "@playwright/test";
test("chat mode selector - default build mode", async ({ po }) => {
await po.setUp({ autoApprove: true });
......@@ -23,6 +24,37 @@ test("chat mode selector - ask mode", async ({ po }) => {
await po.snapshotMessages({ replaceDumpPath: true });
});
test("chat mode selector - mode persists per chat", async ({ po }) => {
await po.setUp({ autoApprove: true });
await po.importApp("minimal");
const selector = po.page.getByTestId("chat-mode-selector");
await po.sendPrompt("[dump] first chat setup");
await po.chatActions.waitForChatCompletion();
await po.chatActions.selectChatMode("ask");
await expect(selector).toContainText("Ask");
await po.chatActions.clickNewChat();
await expect(selector).not.toContainText("Ask");
await po.chatActions.selectChatMode("plan");
await expect(selector).toContainText("Plan");
const inactiveTab = po.page
.locator("div[draggable]")
.filter({ hasNot: po.page.locator('button[aria-current="page"]') });
await inactiveTab.locator("button").first().click();
await expect(selector).toContainText("Ask");
const inactiveTab2 = po.page
.locator("div[draggable]")
.filter({ hasNot: po.page.locator('button[aria-current="page"]') });
await inactiveTab2.locator("button").first().click();
await expect(selector).toContainText("Plan");
});
test.skip("dyadwrite edit and save - basic flow", async ({ po }) => {
await po.setUp({ autoApprove: true });
await po.importApp("minimal");
......
......@@ -25,7 +25,9 @@ testSkipIfWindows(
await po.sendPrompt("tc=local-agent/simple-response");
// Verify the compaction status indicator is visible
await expect(po.page.getByText("Conversation compacted")).toBeVisible({
await expect(
po.page.getByText("Conversation compacted").first(),
).toBeVisible({
timeout: Timeout.MEDIUM,
});
......@@ -35,10 +37,10 @@ testSkipIfWindows(
// Verify key compaction elements are present (order-independent checks
// since compaction restructures messages non-deterministically)
await expect(
po.page.getByRole("button", { name: "Conversation compacted" }),
po.page.getByRole("button", { name: "Conversation compacted" }).first(),
).toBeVisible();
await expect(
po.page.getByRole("heading", { name: "Key Decisions Made" }),
po.page.getByRole("heading", { name: "Key Decisions Made" }).first(),
).toBeVisible();
await expect(
po.page.getByText(
......@@ -62,7 +64,9 @@ testSkipIfWindows(
await po.sendPrompt("tc=local-agent/compaction-mid-turn");
// Mid-turn compaction summary should be visible after a single prompt.
await expect(po.page.getByText("Conversation compacted")).toBeVisible({
await expect(
po.page.getByText("Conversation compacted").first(),
).toBeVisible({
timeout: Timeout.MEDIUM,
});
......@@ -77,10 +81,10 @@ testSkipIfWindows(
// Verify key compaction elements are present (order-independent checks
// since compaction restructures messages non-deterministically)
await expect(
po.page.getByRole("button", { name: "Conversation compacted" }),
po.page.getByRole("button", { name: "Conversation compacted" }).first(),
).toBeVisible();
await expect(
po.page.getByRole("heading", { name: "Key Decisions Made" }),
po.page.getByRole("heading", { name: "Key Decisions Made" }).first(),
).toBeVisible();
await expect(po.page.getByText("END OF COMPACTED TURN.")).toBeVisible();
},
......
......@@ -56,15 +56,21 @@ testSkipIfWindows(
po.page.getByRole("button", { name: "Switch back to Build mode" }),
).toBeVisible();
// 6. Try to send an 11th message - should be blocked with error
// 6. Try to send an 11th message - the app should fall back to Build mode
// instead of attempting another Basic Agent request.
await po.sendPrompt("tc=local-agent/simple-response message 11");
// Verify error message appears indicating quota exceeded
await expect(po.page.getByTestId("chat-error-box")).toBeVisible({
await expect(po.page.getByTestId("chat-error-box")).not.toBeVisible({
timeout: 1000,
});
await expect(
po.page
.getByText(
"Hello! I understand your request. This is a simple response from the Basic Agent mode.",
)
.last(),
).toBeVisible({
timeout: Timeout.MEDIUM,
});
await expect(po.page.getByTestId("chat-error-box")).toContainText(
"You have used all 10 free Agent messages for today",
);
// 8. Click "Switch back to Build mode" and verify mode changes
await po.page
......
......@@ -101,11 +101,12 @@ export class ChatActions {
await expect(async () => {
await chatInput.click();
await chatInput.fill(prompt);
await expect(chatInput).toContainText(prompt);
const visiblePrompt = prompt.replace(/@app:/g, "@");
expect(await chatInput.textContent()).toContain(visiblePrompt);
await expect(sendButton).toBeEnabled();
await sendButton.click();
}).toPass({ timeout: Timeout.MEDIUM });
await sendButton.click();
if (!skipWaitForCompletion) {
await this.waitForChatCompletion({ timeout });
}
......
......@@ -18,7 +18,7 @@
- img
- text: less than a minute ago
- img
- text: wrote 1 file(s)
- text: "Version 2: wrote 1 file(s)"
- button "Undo":
- img
- text: ""
......
......@@ -16,7 +16,7 @@
- img
- text: less than a minute ago
- img
- text: wrote 1 file(s)
- text: "Version 2: wrote 1 file(s)"
- paragraph: "[dump]"
- 'button "Expand image: logo.png"':
- img "logo.png"
......
......@@ -16,7 +16,7 @@
- img
- text: less than a minute ago
- img
- text: wrote 1 file(s)
- text: "Version 2: wrote 1 file(s)"
- paragraph: "[dump]"
- 'button "Expand image: logo.png"':
- img "logo.png"
......
......@@ -18,7 +18,7 @@
- img
- text: less than a minute ago
- img
- text: wrote 1 file(s)
- text: "Version 2: wrote 1 file(s)"
- button "Undo":
- img
- text: ""
......
......@@ -66,7 +66,7 @@
- img
- text: less than a minute ago
- img
- text: wrote 3 file(s)
- text: "Version 2: wrote 3 file(s)"
- paragraph: "Fix all of the following errors:"
- list:
- listitem: First error in Index
......@@ -89,7 +89,7 @@
- img
- text: less than a minute ago
- img
- text: wrote 1 file(s)
- text: "Version 3: wrote 1 file(s)"
- button "Undo":
- img
- text: ""
......
......@@ -17,7 +17,7 @@
- img
- text: less than a minute ago
- img
- text: wrote 1 file(s)
- text: "Version 2: wrote 1 file(s)"
- paragraph:
- text: "Fix error: Error Line 6 error Stack trace: Index ("
- link /http:\/\/localhost:\d+\/src\/pages\/Index\.tsx:6:6/:
......@@ -43,7 +43,7 @@
- img
- text: less than a minute ago
- img
- text: wrote 1 file(s)
- text: "Version 3: wrote 1 file(s)"
- button "Undo":
- img
- text: ""
......
......@@ -16,7 +16,7 @@
- img
- text: less than a minute ago
- img
- text: (1 files changed)
- text: "Version 2: (1 files changed)"
- button "Copy Request ID":
- img
- text: ""
......@@ -34,9 +34,13 @@
- button "Copy":
- img
- img
- text: Approved
- img
- text: claude-opus-4-5
- img
- text: less than a minute ago
- img
- text: "Version 3: (1 files changed)"
- button "Copy Request ID":
- img
- text: ""
......
......@@ -16,7 +16,7 @@
- img
- text: less than a minute ago
- img
- text: (1 files changed)
- text: "Version 2: (1 files changed)"
- button "Copy Request ID":
- img
- text: ""
......@@ -33,6 +33,8 @@
- button "Copy":
- img
- img
- text: Approved
- img
- text: claude-opus-4-5
- img
- text: less than a minute ago
......
......@@ -16,7 +16,7 @@
- img
- text: less than a minute ago
- img
- text: (1 files changed)
- text: "Version 2: (1 files changed)"
- button "Copy Request ID":
- img
- text: ""
......@@ -40,7 +40,7 @@
- img
- text: less than a minute ago
- img
- text: (1 files changed)
- text: "Version 3: (1 files changed)"
- button "Copy Request ID":
- img
- text: ""
......@@ -67,9 +67,13 @@
- button "Copy":
- img
- img
- text: Approved
- img
- text: claude-opus-4-5
- img
- text: less than a minute ago
- img
- text: "Version 4: (2 files changed)"
- button "Copy Request ID":
- img
- text: ""
......
......@@ -16,7 +16,7 @@
- img
- text: less than a minute ago
- img
- text: (1 files changed)
- text: "Version 2: (1 files changed)"
- button "Copy Request ID":
- img
- text: ""
......@@ -171,6 +171,156 @@
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- paragraph: "/Step \\d+: reading file\\./"
- img
- text: Read package.json
- img
- text: /Paused after \d+ tool calls/
- button "Continue":
......@@ -200,6 +350,8 @@
- button "Copy":
- img
- img
- text: Approved
- img
- text: claude-opus-4-5
- img
- text: less than a minute ago
......
......@@ -16,7 +16,7 @@
- img
- text: less than a minute ago
- img
- text: (1 files changed)
- text: "Version 2: (1 files changed)"
- button "Copy Request ID":
- img
- text: ""
......@@ -30,6 +30,8 @@
- button "Copy":
- img
- img
- text: Approved
- img
- text: claude-opus-4-5
- img
- text: less than a minute ago
......
......@@ -53,9 +53,15 @@
- code: "`src/config/aws.ts`"
- text: ","
- code: "`src/services/s3-uploader.ts`"
- button "file1.txt file1.txt Edit"
- button "file1.txt file1.txt Edit":
- img
- text: ""
- button "Edit":
- img
- text: ""
- img
- paragraph: More EOM
- button:
- button "Copy":
- img
- img
- text: Approved
......@@ -64,8 +70,10 @@
- img
- text: less than a minute ago
- img
- text: wrote 1 file(s)
- text: "Version 2: wrote 1 file(s)"
- button "Undo":
- img
- text: ""
- button "Retry":
- img
- text: ""
\ No newline at end of file
......@@ -24,9 +24,15 @@
- strong: Relevant Files
- text: ":"
- code: "`src/api/users.ts`"
- button "file1.txt file1.txt Edit"
- button "file1.txt file1.txt Edit":
- img
- text: ""
- button "Edit":
- img
- text: ""
- img
- paragraph: More EOM
- button:
- button "Copy":
- img
- img
- text: Approved
......@@ -35,8 +41,10 @@
- img
- text: less than a minute ago
- img
- text: wrote 1 file(s)
- text: "Version 2: wrote 1 file(s)"
- button "Undo":
- img
- text: ""
- button "Retry":
- img
- text: ""
\ No newline at end of file
......@@ -33,7 +33,7 @@
- img
- text: less than a minute ago
- img
- text: wrote 1 file(s)
- text: "Version 2: wrote 1 file(s)"
- button "Copy Request ID":
- img
- text: ""
......
差异被折叠。
......@@ -109,6 +109,10 @@ If this happens:
2. Re-run the same `npm run e2e -- e2e-tests/<spec>` command outside the sandbox before treating it as an app regression.
3. If the test passes outside the sandbox, treat the sandbox launch failure as environmental rather than a product bug.
## Native rebuild Python issues during E2E builds
If `npm run build` fails while rebuilding native modules with `ImportError` from Homebrew Python 3.14's `pyexpat` (for example `Symbol not found: _XML_SetAllocTrackerActivationThreshold`), rerun the build with the system Python: `PYTHON=/usr/bin/python3 npm run build`.
## Common flaky test patterns and fixes
- **After `po.importApp(...)`**: Some imports trigger an initial assistant turn (for example `minimal` generating `AI_RULES.md`) that can leave a visible `Retry` button in the chat. If the test is about a later prompt, first wait for that import-time turn to finish, then start a new chat before calling `sendPrompt()`, or helper methods that wait on `Retry` visibility may return too early.
......
......@@ -6,6 +6,8 @@ The pre-commit hook runs `tsgo` (via `npm run ts`), which is stricter than `tsc
`tsgo` is a Go binary, **not** an npm package — running `npx tsgo` fails with `npm error 404 Not Found - GET https://registry.npmjs.org/tsgo` because it is not in the npm registry. It is installed by the project's `npm install` step via a local package. If node_modules is missing or `npm install` fails (e.g., because the environment runs Node.js < 24, which the project requires), skip the `npm run ts` check and note that CI will verify types instead.
If `npm run ts` fails because installed dependency types are missing APIs the repo already uses (for example `@neondatabase/api-client` missing `getNeonAuth` or `BetterAuth`), run `npm install` before editing source. Stale `node_modules` can lag behind the lockfile even when `package.json` is unchanged.
## ES2020 target limitations
The project's `tsconfig.app.json` targets ES2020 with `lib: ["ES2020"]`. Methods introduced in ES2021+ (like `String.prototype.replaceAll`) are not available on the `string` type. If code uses `replaceAll`, it needs an `as any` cast to avoid `TS2550: Property 'replaceAll' does not exist on type 'string'`. Do not remove these casts without updating the tsconfig target.
......
import { describe, expect, it } from "vitest";
import { normalizeStoredChatMode, resolveChatMode } from "@/lib/chatMode";
import type { UserSettings } from "@/lib/schemas";
function makeSettings(overrides: Partial<UserSettings> = {}): UserSettings {
return {
selectedModel: { provider: "auto", name: "auto" },
providerSettings: {},
selectedTemplateId: "react",
enableAutoUpdate: true,
releaseChannel: "stable",
...overrides,
} as UserSettings;
}
describe("chat mode resolution", () => {
it("migrates deprecated agent mode to build", () => {
expect(normalizeStoredChatMode("agent")).toBe("build");
});
it("uses the effective default when a chat has no stored mode", () => {
const settings = makeSettings({ defaultChatMode: "ask" });
expect(
resolveChatMode({
storedChatMode: null,
settings,
envVars: {},
}),
).toEqual({ mode: "ask" });
});
it("uses a stored mode when it is available", () => {
const settings = makeSettings({ defaultChatMode: "build" });
expect(
resolveChatMode({
storedChatMode: "plan",
settings,
envVars: {},
}),
).toEqual({ mode: "plan" });
});
it("falls back when stored local-agent mode has no provider", () => {
const settings = makeSettings({ defaultChatMode: "build" });
expect(
resolveChatMode({
storedChatMode: "local-agent",
settings,
envVars: {},
freeAgentQuotaAvailable: true,
}),
).toEqual({ mode: "build", fallbackReason: "no-provider" });
});
it("falls back when stored local-agent mode is out of quota", () => {
const settings = makeSettings({
defaultChatMode: "build",
providerSettings: {
openai: { apiKey: { value: "test-key" } },
},
});
expect(
resolveChatMode({
storedChatMode: "local-agent",
settings,
envVars: {},
freeAgentQuotaAvailable: false,
}),
).toEqual({ mode: "build", fallbackReason: "quota-exhausted" });
});
it("does not treat unknown quota as exhausted", () => {
const settings = makeSettings({
defaultChatMode: "build",
providerSettings: {
openai: { apiKey: { value: "test-key" } },
},
});
expect(
resolveChatMode({
storedChatMode: "local-agent",
settings,
envVars: {},
freeAgentQuotaAvailable: undefined,
}),
).toEqual({ mode: "local-agent" });
});
it("allows basic agent mode when Pro is enabled without a key but free quota is available", () => {
const settings = makeSettings({
enableDyadPro: true,
defaultChatMode: "build",
providerSettings: {
openai: { apiKey: { value: "test-key" } },
},
});
expect(
resolveChatMode({
storedChatMode: "local-agent",
settings,
envVars: {},
freeAgentQuotaAvailable: true,
}),
).toEqual({ mode: "local-agent" });
});
it("reports quota exhausted before Pro required when a provider is configured", () => {
const settings = makeSettings({
enableDyadPro: true,
defaultChatMode: "build",
providerSettings: {
openai: { apiKey: { value: "test-key" } },
},
});
expect(
resolveChatMode({
storedChatMode: "local-agent",
settings,
envVars: {},
freeAgentQuotaAvailable: false,
}),
).toEqual({ mode: "build", fallbackReason: "quota-exhausted" });
});
it("allows stored local-agent mode for Pro users", () => {
const settings = makeSettings({
enableDyadPro: true,
providerSettings: {
auto: { apiKey: { value: "dyad-key" } },
},
});
expect(
resolveChatMode({
storedChatMode: "local-agent",
settings,
envVars: {},
freeAgentQuotaAvailable: false,
}),
).toEqual({ mode: "local-agent" });
});
});
......@@ -27,6 +27,7 @@ function chat(id: number, appId = 1): ChatSummary {
appId,
title: `Chat ${id}`,
createdAt: new Date(),
chatMode: null,
};
}
......
......@@ -5,6 +5,8 @@ import { ChatModeSelector } from "./ChatModeSelector";
import { McpToolsPicker } from "@/components/McpToolsPicker";
import { useSettings } from "@/hooks/useSettings";
import { useMcp } from "@/hooks/useMcp";
import { useChatMode } from "@/hooks/useChatMode";
import { useRouterState } from "@tanstack/react-router";
export function ChatInputControls({
showContextFilesPicker = false,
......@@ -12,6 +14,12 @@ export function ChatInputControls({
showContextFilesPicker?: boolean;
}) {
const { settings } = useSettings();
const routerState = useRouterState();
const chatId =
routerState.location.pathname === "/chat"
? (routerState.location.search.id as number | undefined)
: null;
const { selectedMode } = useChatMode(chatId);
const { servers } = useMcp();
const enabledMcpServersCount = servers.filter((s) => s.enabled).length;
......@@ -20,7 +28,7 @@ export function ChatInputControls({
// 2. Mode is "build" AND there are enabled MCP servers
const showMcpToolsPicker =
!!settings?.enableMcpServersForBuildMode &&
settings?.selectedChatMode === "build" &&
selectedMode === "build" &&
enabledMcpServersCount > 0;
return (
......
......@@ -14,9 +14,7 @@ import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { dropdownOpenAtom } from "@/atoms/uiAtoms";
import { ipc } from "@/ipc/types";
import { showError, showSuccess } from "@/lib/toast";
import { useSettings } from "@/hooks/useSettings";
import { getEffectiveDefaultChatMode } from "@/lib/schemas";
import { useFreeAgentQuota } from "@/hooks/useFreeAgentQuota";
import { useInitialChatMode } from "@/hooks/useInitialChatMode";
import {
SidebarGroup,
SidebarGroupContent,
......@@ -44,8 +42,7 @@ export function ChatList({ show }: { show?: boolean }) {
const [selectedChatId, setSelectedChatId] = useAtom(selectedChatIdAtom);
const [selectedAppId] = useAtom(selectedAppIdAtom);
const [, setIsDropdownOpen] = useAtom(dropdownOpenAtom);
const { settings, updateSettings, envVars } = useSettings();
const { isQuotaExceeded, isLoading: isQuotaLoading } = useFreeAgentQuota();
const initialChatMode = useInitialChatMode();
const { chats, loading, invalidateChats } = useChats(selectedAppId);
const routerState = useRouterState();
......@@ -109,19 +106,10 @@ export function ChatList({ show }: { show?: boolean }) {
if (selectedAppId) {
try {
// Create a new chat with an empty title for now
const chatId = await ipc.chat.createChat(selectedAppId);
// Set the default chat mode for the new chat
// Only consider quota available if it has finished loading and is not exceeded
if (settings) {
const freeAgentQuotaAvailable = !isQuotaLoading && !isQuotaExceeded;
const effectiveDefaultMode = getEffectiveDefaultChatMode(
settings,
envVars,
freeAgentQuotaAvailable,
);
updateSettings({ selectedChatMode: effectiveDefaultMode });
}
const chatId = await ipc.chat.createChat({
appId: selectedAppId,
initialChatMode,
});
// Refresh the chat list first so the new chat is in the cache
// before selectChat adds it to the tab bar
......
......@@ -11,10 +11,16 @@ import {
TooltipContent,
} from "@/components/ui/tooltip";
import { useSettings } from "@/hooks/useSettings";
import { useChatMode } from "@/hooks/useChatMode";
import { useFreeAgentQuota } from "@/hooks/useFreeAgentQuota";
import { useMcp } from "@/hooks/useMcp";
import type { ChatMode } from "@/lib/schemas";
import { isDyadProEnabled } from "@/lib/schemas";
import {
getChatModeFallbackToastId,
getChatModeDisplayName,
showChatModeFallbackToast,
} from "@/lib/chatModeToast";
import { cn } from "@/lib/utils";
import { detectIsMac } from "@/hooks/useChatModeToggle";
import { useRouterState } from "@tanstack/react-router";
......@@ -23,26 +29,58 @@ import { LocalAgentNewChatToast } from "./LocalAgentNewChatToast";
import { useAtomValue } from "jotai";
import { chatMessagesByIdAtom } from "@/atoms/chatAtoms";
import { Hammer, Bot, MessageCircle, Lightbulb } from "lucide-react";
import { useEffect, useRef } from "react";
export function ChatModeSelector() {
const { settings, updateSettings } = useSettings();
const { updateSettings } = useSettings();
const routerState = useRouterState();
const isChatRoute = routerState.location.pathname === "/chat";
const messagesById = useAtomValue(chatMessagesByIdAtom);
const chatId = routerState.location.search.id as number | undefined;
const currentChatMessages = chatId ? (messagesById.get(chatId) ?? []) : [];
const {
selectedMode,
effectiveMode,
storedChatMode,
fallbackReason,
setChatMode,
settings,
} = useChatMode(isChatRoute ? chatId : null);
const fallbackToastKeyRef = useRef<string | null>(null);
// Migration happens on read, so selectedChatMode will never be "agent"
const selectedMode = settings?.selectedChatMode || "build";
const isProEnabled = settings ? isDyadProEnabled(settings) : false;
const { messagesRemaining, messagesLimit, isQuotaExceeded } =
useFreeAgentQuota();
const { servers } = useMcp();
const enabledMcpServersCount = servers.filter((s) => s.enabled).length;
useEffect(() => {
if (!chatId || !fallbackReason || !storedChatMode) {
fallbackToastKeyRef.current = null;
return;
}
const toastKey = getChatModeFallbackToastId({
chatId,
reason: fallbackReason,
effectiveMode,
});
if (fallbackToastKeyRef.current === toastKey) {
return;
}
fallbackToastKeyRef.current = toastKey;
showChatModeFallbackToast({
reason: fallbackReason,
effectiveMode,
isPro: isProEnabled,
toastId: toastKey,
});
}, [chatId, effectiveMode, fallbackReason, isProEnabled, storedChatMode]);
const handleModeChange = (value: string) => {
const newMode = value as ChatMode;
updateSettings({ selectedChatMode: newMode });
void setChatMode(newMode).catch(() => {});
// We want to show a toast when user is switching to the new agent mode
// because they might weird results mixing Build and Agent mode in the same chat.
......@@ -73,19 +111,7 @@ export function ChatModeSelector() {
};
const getModeDisplayName = (mode: ChatMode) => {
switch (mode) {
case "build":
return "Build";
case "ask":
return "Ask";
case "local-agent":
// Show "Basic Agent" for non-Pro users, "Agent" for Pro users
return isProEnabled ? "Agent" : "Basic Agent";
case "plan":
return "Plan";
default:
return "Build";
}
return getChatModeDisplayName(mode, isProEnabled);
};
const getModeIcon = (mode: ChatMode) => {
......@@ -115,6 +141,7 @@ export function ChatModeSelector() {
render={
<MiniSelectTrigger
data-testid="chat-mode-selector"
aria-label={`Chat mode: ${getModeDisplayName(selectedMode)}`}
className={cn(
"cursor-pointer w-fit px-2 py-0 text-xs font-medium border-none shadow-none gap-1 rounded-lg transition-colors",
selectedMode === "build" || selectedMode === "local-agent"
......
......@@ -24,7 +24,8 @@ import {
import { ArrowDown } from "lucide-react";
import { useSettings } from "@/hooks/useSettings";
import { useFreeAgentQuota } from "@/hooks/useFreeAgentQuota";
import { isBasicAgentMode } from "@/lib/schemas";
import { useChatMode } from "@/hooks/useChatMode";
import { isDyadProEnabled } from "@/lib/schemas";
interface ChatPanelProps {
chatId?: number;
......@@ -44,10 +45,14 @@ export function ChatPanel({
const [error, setError] = useState<string | null>(null);
const streamCountById = useAtomValue(chatStreamCountByIdAtom);
const isStreamingById = useAtomValue(isStreamingByIdAtom);
const { settings, updateSettings } = useSettings();
const { settings } = useSettings();
const { selectedMode, setChatMode } = useChatMode(chatId);
const { isQuotaExceeded } = useFreeAgentQuota();
const showFreeAgentQuotaBanner =
settings && isBasicAgentMode(settings) && isQuotaExceeded;
settings &&
!isDyadProEnabled(settings) &&
selectedMode === "local-agent" &&
isQuotaExceeded;
const messagesEndRef = useRef<HTMLDivElement | null>(null);
const messagesContainerRef = useRef<HTMLDivElement | null>(null);
......@@ -223,7 +228,7 @@ export function ChatPanel({
{showFreeAgentQuotaBanner && (
<FreeAgentQuotaBanner
onSwitchToBuildMode={() =>
updateSettings({ selectedChatMode: "build" })
void setChatMode("build").catch(() => {})
}
/>
)}
......
......@@ -31,6 +31,7 @@ import { useRenameBranch } from "@/hooks/useRenameBranch";
import { isAnyCheckoutVersionInProgressAtom } from "@/store/appAtoms";
import { LoadingBar } from "../ui/LoadingBar";
import { UncommittedFilesBanner } from "./UncommittedFilesBanner";
import { useInitialChatMode } from "@/hooks/useInitialChatMode";
interface ChatHeaderProps {
isVersionPaneOpen: boolean;
......@@ -53,6 +54,7 @@ export function ChatHeader({
const { invalidateChats } = useChats(appId);
const { selectChat } = useSelectChat();
const { isStreaming } = useStreamChat();
const initialChatMode = useInitialChatMode();
const isAnyCheckoutVersionInProgress = useAtomValue(
isAnyCheckoutVersionInProgressAtom,
);
......@@ -88,7 +90,10 @@ export function ChatHeader({
const handleNewChat = async () => {
if (appId) {
try {
const chatId = await ipc.chat.createChat(appId);
const chatId = await ipc.chat.createChat({
appId,
initialChatMode,
});
await invalidateChats();
selectChat({ chatId, appId });
} catch (error) {
......
......@@ -105,6 +105,8 @@ import { showError as showErrorToast } from "@/lib/toast";
import { cn } from "@/lib/utils";
import { useVoiceToText } from "@/hooks/useVoiceToText";
import { isDyadProEnabled } from "@/lib/schemas";
import { useChatMode } from "@/hooks/useChatMode";
import { useInitialChatMode } from "@/hooks/useInitialChatMode";
const showTokenBarAtom = atom(false);
......@@ -113,6 +115,12 @@ export function ChatInput({ chatId }: { chatId?: number }) {
const posthog = usePostHog();
const [inputValue, setInputValue] = useAtom(chatInputValueAtom);
const { settings } = useSettings();
const {
selectedMode: chatMode,
effectiveMode,
isLoading: isChatModeLoading,
} = useChatMode(chatId);
const initialChatMode = useInitialChatMode();
const appId = useAtomValue(selectedAppIdAtom);
const { refreshVersions } = useVersions(appId);
const {
......@@ -231,7 +239,7 @@ export function ChatInput({ chatId }: { chatId?: number }) {
const lastMessage = (chatId ? (messagesById.get(chatId) ?? []) : []).at(-1);
const disableSendButton =
settings?.selectedChatMode !== "local-agent" &&
effectiveMode !== "local-agent" &&
lastMessage?.role === "assistant" &&
!lastMessage.approvalState &&
!!proposal &&
......@@ -269,10 +277,23 @@ export function ChatInput({ chatId }: { chatId?: number }) {
);
// Detect transition to plan mode from another mode in a chat with messages
const prevModeRef = useRef(settings?.selectedChatMode);
const prevModeRef = useRef(chatMode);
const prevModeChatIdRef = useRef(chatId);
const hasInitializedModeRef = useRef(false);
useEffect(() => {
if (isChatModeLoading) return;
if (
!hasInitializedModeRef.current ||
prevModeChatIdRef.current !== chatId
) {
hasInitializedModeRef.current = true;
prevModeChatIdRef.current = chatId;
prevModeRef.current = chatMode;
return;
}
const prevMode = prevModeRef.current;
const currentMode = settings?.selectedChatMode;
const currentMode = chatMode;
prevModeRef.current = currentMode;
if (prevMode && prevMode !== "plan" && currentMode === "plan") {
......@@ -281,7 +302,13 @@ export function ChatInput({ chatId }: { chatId?: number }) {
setNeedsFreshPlanChat(true);
}
}
}, [settings?.selectedChatMode, chatId, messagesById, setNeedsFreshPlanChat]);
}, [
chatMode,
chatId,
isChatModeLoading,
messagesById,
setNeedsFreshPlanChat,
]);
// Token counting for context limit banner
const { result: tokenCountResult } = useCountTokens(
......@@ -483,11 +510,14 @@ export function ChatInput({ chatId }: { chatId?: number }) {
// If switching to plan mode from another mode in a chat with messages,
// create a new chat for a clean context.
if (needsFreshPlanChat && settings?.selectedChatMode === "plan" && appId) {
if (needsFreshPlanChat && chatMode === "plan" && appId) {
setInputValue("");
setNeedsFreshPlanChat(false);
const newChatId = await ipc.chat.createChat(appId);
const newChatId = await ipc.chat.createChat({
appId,
initialChatMode: "plan",
});
setSelectedChatId(newChatId);
navigate({ to: "/chat", search: { id: newChatId } });
queryClient.invalidateQueries({ queryKey: queryKeys.chats.all });
......@@ -498,9 +528,10 @@ export function ChatInput({ chatId }: { chatId?: number }) {
chatId: newChatId,
attachments,
redo: false,
requestedChatMode: "plan",
});
clearAttachments();
posthog.capture("chat:submit", { chatMode: settings?.selectedChatMode });
posthog.capture("chat:submit", { chatMode });
return;
}
......@@ -569,9 +600,10 @@ export function ChatInput({ chatId }: { chatId?: number }) {
attachments,
redo: false,
selectedComponents: componentsToSend,
requestedChatMode: isChatModeLoading ? null : chatMode,
});
clearAttachments();
posthog.capture("chat:submit", { chatMode: settings?.selectedChatMode });
posthog.capture("chat:submit", { chatMode });
};
const handleCancel = () => {
......@@ -598,7 +630,10 @@ export function ChatInput({ chatId }: { chatId?: number }) {
const handleNewChat = async () => {
if (appId) {
try {
const newChatId = await ipc.chat.createChat(appId);
const newChatId = await ipc.chat.createChat({
appId,
initialChatMode,
});
setSelectedChatId(newChatId);
navigate({
to: "/chat",
......@@ -798,8 +833,8 @@ export function ChatInput({ chatId }: { chatId?: number }) {
{!pendingAgentConsent &&
proposal &&
proposalResult?.chatId === chatId &&
settings.selectedChatMode !== "ask" &&
settings.selectedChatMode !== "local-agent" && (
effectiveMode !== "ask" &&
effectiveMode !== "local-agent" && (
<ChatInputActions
proposal={proposal}
onApprove={handleApprove}
......
......@@ -22,7 +22,11 @@ export function useSummarizeInNewChat() {
return;
}
try {
const newChatId = await ipc.chat.createChat(appId);
const sourceChat = await ipc.chat.getChat(chatId);
const newChatId = await ipc.chat.createChat({
appId,
initialChatMode: sourceChat.chatMode ?? undefined,
});
// navigate to new chat
await navigate({ to: "/chat", search: { id: newChatId } });
await streamMessage({
......
......@@ -18,7 +18,7 @@ import { previewModeAtom } from "@/atoms/appAtoms";
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
import { useStreamChat } from "@/hooks/useStreamChat";
import { usePlan } from "@/hooks/usePlan";
import { useSettings } from "@/hooks/useSettings";
import { useChatMode } from "@/hooks/useChatMode";
import { SelectionCommentButton } from "./plan/SelectionCommentButton";
import { CommentsFloatingButton } from "./plan/CommentsFloatingButton";
import { CommentPopover } from "./plan/CommentPopover";
......@@ -34,7 +34,7 @@ export const PlanPanel: React.FC = () => {
const setPreviewMode = useSetAtom(previewModeAtom);
const { streamMessage, isStreaming } = useStreamChat();
const { savedPlan } = usePlan();
const { settings } = useSettings();
const { selectedMode } = useChatMode(chatId);
const annotations = useAtomValue(planAnnotationsAtom);
const planContentRef = useRef<HTMLDivElement>(null);
......@@ -154,7 +154,7 @@ export const PlanPanel: React.FC = () => {
const handleAccept = () => {
if (!chatId) return;
if (settings?.selectedChatMode !== "plan") return;
if (selectedMode !== "plan") return;
if (isSubmitting) return;
setIsSubmitting(true);
......
......@@ -2,6 +2,7 @@ import { sql } from "drizzle-orm";
import { integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
import { relations } from "drizzle-orm";
import type { ModelMessage } from "ai";
import type { StoredChatMode } from "@/lib/schemas";
export const AI_MESSAGES_SDK_VERSION = "ai@v6" as const;
......@@ -82,6 +83,7 @@ export const chats = sqliteTable("chats", {
compactedAt: integer("compacted_at", { mode: "timestamp" }),
compactionBackupPath: text("compaction_backup_path"),
pendingCompaction: integer("pending_compaction", { mode: "boolean" }),
chatMode: text("chat_mode").$type<StoredChatMode | null>(),
});
export const messages = sqliteTable("messages", {
......
import { useCallback, useMemo } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ipc, type Chat } from "@/ipc/types";
import type { ChatSummary } from "@/lib/schemas";
import {
getEffectiveDefaultChatMode,
type ChatMode,
type UserSettings,
} from "@/lib/schemas";
import {
getUnavailableChatModeReason,
type ChatModeFallbackReason,
} from "@/lib/chatMode";
import { queryKeys } from "@/lib/queryKeys";
import { useSettings } from "./useSettings";
import { useFreeAgentQuota } from "./useFreeAgentQuota";
type ChatModeMutationContext = {
previousChat?: Chat;
previousLists: [readonly unknown[], ChatSummary[] | undefined][];
};
const chatListQueryFilter = {
predicate: (query: { queryKey: readonly unknown[] }) =>
query.queryKey[0] === "chats" && query.queryKey.length === 2,
};
export function useChatMode(chatId: number | null | undefined) {
const queryClient = useQueryClient();
const { settings, envVars, updateSettings } = useSettings();
const { isQuotaExceeded, isLoading: isQuotaLoading } = useFreeAgentQuota();
const activeChatId = chatId ?? null;
const chatQuery = useQuery({
queryKey: queryKeys.chats.detail({ chatId: activeChatId }),
queryFn: () => ipc.chat.getChat(activeChatId!),
enabled: activeChatId !== null,
});
const freeAgentQuotaAvailable = isQuotaLoading ? undefined : !isQuotaExceeded;
const effectiveDefaultMode = settings
? getEffectiveDefaultChatMode(settings, envVars, freeAgentQuotaAvailable)
: "build";
const storedChatMode = chatQuery.data?.chatMode ?? null;
const selectedMode = activeChatId
? (storedChatMode ?? effectiveDefaultMode)
: (settings?.selectedChatMode ?? "build");
const fallbackReason = useMemo<ChatModeFallbackReason | undefined>(() => {
if (!settings || !activeChatId || !storedChatMode) {
return undefined;
}
return getUnavailableChatModeReason({
mode: storedChatMode,
settings,
envVars,
freeAgentQuotaAvailable,
});
}, [
activeChatId,
envVars,
freeAgentQuotaAvailable,
settings,
storedChatMode,
]);
const effectiveMode =
activeChatId && fallbackReason ? effectiveDefaultMode : selectedMode;
const updateChatModeMutation = useMutation<
void,
Error,
ChatMode | null,
ChatModeMutationContext
>({
mutationFn: async (chatMode) => {
if (activeChatId === null) {
return;
}
await ipc.chat.updateChat({
chatId: activeChatId,
chatMode,
});
},
onMutate: async (chatMode) => {
if (activeChatId === null) {
return { previousLists: [] };
}
await queryClient.cancelQueries({
queryKey: queryKeys.chats.detail({ chatId: activeChatId }),
});
await queryClient.cancelQueries(chatListQueryFilter);
const previousChat = queryClient.getQueryData<Chat>(
queryKeys.chats.detail({ chatId: activeChatId }),
);
const previousLists =
queryClient.getQueriesData<ChatSummary[]>(chatListQueryFilter);
queryClient.setQueryData<Chat>(
queryKeys.chats.detail({ chatId: activeChatId }),
(old) => (old ? { ...old, chatMode } : old),
);
queryClient.setQueriesData<ChatSummary[]>(chatListQueryFilter, (old) =>
old?.map((chat) =>
chat.id === activeChatId ? { ...chat, chatMode } : chat,
),
);
return { previousChat, previousLists };
},
onError: (_error, _chatMode, context) => {
if (activeChatId !== null && context?.previousChat) {
queryClient.setQueryData(
queryKeys.chats.detail({ chatId: activeChatId }),
context.previousChat,
);
}
for (const [queryKey, data] of context?.previousLists ?? []) {
queryClient.setQueryData(queryKey, data);
}
},
onSettled: () => {
if (activeChatId !== null) {
queryClient.invalidateQueries({
queryKey: queryKeys.chats.detail({ chatId: activeChatId }),
});
queryClient.invalidateQueries({ queryKey: queryKeys.chats.all });
}
},
meta: { showErrorToast: true },
});
const setChatMode = useCallback(
async (mode: ChatMode | null) => {
if (activeChatId !== null) {
await updateChatModeMutation.mutateAsync(mode);
return;
}
if (mode !== null) {
await updateSettings({ selectedChatMode: mode });
}
},
[activeChatId, updateChatModeMutation, updateSettings],
);
return {
chat: chatQuery.data ?? null,
isLoading: chatQuery.isLoading,
storedChatMode,
selectedMode,
effectiveMode,
effectiveDefaultMode,
fallbackReason,
setChatMode,
isUpdating: updateChatModeMutation.isPending,
settings: settings as UserSettings | null,
};
}
import { useCallback, useMemo } from "react";
import { useSettings } from "./useSettings";
import { useShortcut } from "./useShortcut";
import { usePostHog } from "posthog-js/react";
import { ChatModeSchema } from "../lib/schemas";
import { useChatMode } from "./useChatMode";
import { useRouterState } from "@tanstack/react-router";
export function useChatModeToggle() {
const { settings, updateSettings } = useSettings();
const routerState = useRouterState();
const routeChatId =
routerState.location.pathname === "/chat"
? (routerState.location.search.id as number | undefined)
: null;
const { selectedMode, setChatMode, settings } = useChatMode(routeChatId);
const posthog = usePostHog();
// Detect if user is on mac
......@@ -22,21 +28,21 @@ export function useChatModeToggle() {
// Function to toggle between chat modes
const toggleChatMode = useCallback(() => {
if (!settings || !settings.selectedChatMode) return;
if (!settings || !selectedMode) return;
const currentMode = settings.selectedChatMode;
const currentMode = selectedMode;
// Migration on read ensures currentMode is never "agent"
const modes = ChatModeSchema.options;
const currentIndex = modes.indexOf(currentMode);
const newMode = modes[(currentIndex + 1) % modes.length];
updateSettings({ selectedChatMode: newMode });
void setChatMode(newMode).catch(() => {});
posthog.capture("chat:mode_toggle", {
from: currentMode,
to: newMode,
trigger: "keyboard_shortcut",
});
}, [settings, updateSettings, posthog]);
}, [selectedMode, setChatMode, settings, posthog]);
// Add keyboard shortcut with memoized modifiers
useShortcut(
......
import { useMemo } from "react";
import { getEffectiveDefaultChatMode, type ChatMode } from "@/lib/schemas";
import { useFreeAgentQuota } from "./useFreeAgentQuota";
import { useSettings } from "./useSettings";
export function useInitialChatMode(): ChatMode | undefined {
const { settings, envVars } = useSettings();
const { isQuotaExceeded, isLoading: isQuotaLoading } = useFreeAgentQuota();
return useMemo(() => {
if (!settings) {
return undefined;
}
if (settings.selectedChatMode) {
return settings.selectedChatMode;
}
if (isQuotaLoading) {
return undefined;
}
return getEffectiveDefaultChatMode(settings, envVars, !isQuotaExceeded);
}, [envVars, isQuotaExceeded, isQuotaLoading, settings]);
}
......@@ -37,7 +37,7 @@ export function usePlanEvents() {
const setSelectedChatId = useSetAtom(selectedChatIdAtom);
const navigate = useNavigate();
const queryClient = useQueryClient();
const { settings, updateSettings } = useSettings();
const { settings } = useSettings();
// Use refs for values accessed in event handlers to avoid stale closures
const planStateRef = useRef(planState);
......@@ -113,11 +113,6 @@ export function usePlanEvents() {
const currentState = planStateRef.current;
const planData = currentState.plansByChatId.get(payload.chatId);
// Switch chat mode to local-agent for implementation (only if currently in plan mode)
if (settingsRef.current?.selectedChatMode === "plan") {
updateSettings({ selectedChatMode: "local-agent" });
}
// Switch preview back to preview mode
setPreviewMode("preview");
......@@ -146,7 +141,10 @@ export function usePlanEvents() {
}
try {
const newChatId = await ipc.chat.createChat(selectedAppIdRef.current);
const newChatId = await ipc.chat.createChat({
appId: selectedAppIdRef.current,
initialChatMode: "local-agent",
});
// Navigate to the new chat
setSelectedChatId(newChatId);
......@@ -205,7 +203,6 @@ export function usePlanEvents() {
}, [
setPlanState,
setPreviewMode,
updateSettings,
setPendingPlanImplementation,
setPendingQuestionnaire,
setSelectedChatId,
......
......@@ -7,6 +7,8 @@ import {
chatErrorByIdAtom,
} from "@/atoms/chatAtoms";
import { ipc } from "@/ipc/types";
import { useSettings } from "./useSettings";
import { handleEffectiveChatModeChunk } from "@/lib/chatModeStream";
/**
* Hook to handle starting plan implementation when a plan is accepted.
......@@ -20,6 +22,7 @@ export function usePlanImplementation() {
const setIsStreamingById = useSetAtom(isStreamingByIdAtom);
const setMessagesById = useSetAtom(chatMessagesByIdAtom);
const setErrorById = useSetAtom(chatErrorByIdAtom);
const { settings } = useSettings();
// Track if we've already triggered implementation for this pending plan
const hasTriggeredRef = useRef(false);
......@@ -102,9 +105,21 @@ export function usePlanImplementation() {
messages: updatedMessages,
streamingMessageId,
streamingContent,
effectiveChatMode,
chatModeFallbackReason,
}) => {
if (!isMountedRef.current) return;
if (
handleEffectiveChatModeChunk(
{ effectiveChatMode, chatModeFallbackReason },
settings,
chatId,
)
) {
return;
}
if (updatedMessages) {
// Full messages update (initial load, post-compaction, etc.)
setMessagesById((prev) => {
......@@ -177,5 +192,6 @@ export function usePlanImplementation() {
setIsStreamingById,
setMessagesById,
setErrorById,
settings,
]);
}
......@@ -9,7 +9,9 @@ import {
} from "@/atoms/chatAtoms";
import { useStreamChat } from "./useStreamChat";
import { usePostHog } from "posthog-js/react";
import { useSettings } from "./useSettings";
import { useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/queryKeys";
import type { Chat } from "@/ipc/types";
/**
* Root-level hook that processes queued messages for any chat,
......@@ -25,7 +27,7 @@ export function useQueueProcessor() {
const [queuePausedById] = useAtom(queuePausedByIdAtom);
const [isStreamingById] = useAtom(isStreamingByIdAtom);
const posthog = usePostHog();
const { settings } = useSettings();
const queryClient = useQueryClient();
useEffect(() => {
// Find any chatId that has both completed successfully and has queued messages
......@@ -68,9 +70,11 @@ export function useQueueProcessor() {
if (!messageToSend) return;
posthog.capture("chat:submit", {
chatMode: settings?.selectedChatMode,
});
const chatMode = queryClient.getQueryData<Chat>(
queryKeys.chats.detail({ chatId }),
)?.chatMode;
posthog.capture("chat:submit", { chatMode });
streamMessage({
prompt: messageToSend.prompt,
......@@ -78,6 +82,7 @@ export function useQueueProcessor() {
redo: false,
attachments: messageToSend.attachments,
selectedComponents: messageToSend.selectedComponents,
requestedChatMode: chatMode,
});
// Only process one chatId per effect run
......@@ -92,6 +97,6 @@ export function useQueueProcessor() {
setQueuedMessagesById,
setStreamCompletedSuccessfullyById,
posthog,
settings?.selectedChatMode,
queryClient,
]);
}
......@@ -12,6 +12,8 @@ import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { showError } from "@/lib/toast";
import { useChats } from "@/hooks/useChats";
import { useLoadApp } from "@/hooks/useLoadApp";
import { useSettings } from "@/hooks/useSettings";
import { handleEffectiveChatModeChunk } from "@/lib/chatModeStream";
interface UseResolveMergeConflictsWithAIProps {
appId: number;
......@@ -38,6 +40,7 @@ export function useResolveMergeConflictsWithAI({
const isResolvingRef = useRef(false);
const { invalidateChats } = useChats(appId);
const { refreshApp } = useLoadApp(appId);
const { settings } = useSettings();
const resolveWithAI = useCallback(async () => {
if (!appId) {
......@@ -58,7 +61,10 @@ export function useResolveMergeConflictsWithAI({
let chatId: number | null = null;
try {
// Create a new chat for conflict resolution
const newChatId = await ipc.chat.createChat(appId);
const newChatId = await ipc.chat.createChat({
appId,
initialChatMode: "build",
});
chatId = newChatId;
// Clear conflicts state after successful chat creation
......@@ -97,7 +103,23 @@ For each file, review the conflict markers (<<<<<<<, =======, >>>>>>>) and choos
prompt,
},
{
onChunk: ({ messages, streamingMessageId, streamingContent }) => {
onChunk: ({
messages,
streamingMessageId,
streamingContent,
effectiveChatMode,
chatModeFallbackReason,
}) => {
if (
handleEffectiveChatModeChunk(
{ effectiveChatMode, chatModeFallbackReason },
settings,
newChatId,
)
) {
return;
}
if (!hasIncrementedStreamCount) {
setStreamCountById((prev) => {
const next = new Map(prev);
......@@ -183,6 +205,7 @@ For each file, review the conflict markers (<<<<<<<, =======, >>>>>>>) and choos
navigate,
invalidateChats,
refreshApp,
settings,
]);
return { resolveWithAI, isResolving };
......
......@@ -18,7 +18,7 @@ import {
} from "@/atoms/chatAtoms";
import { ipc } from "@/ipc/types";
import { isPreviewOpenAtom } from "@/atoms/viewAtoms";
import type { ChatResponseEnd, App } from "@/ipc/types";
import type { ChatResponseEnd, App, Chat } from "@/ipc/types";
import type { ChatSummary } from "@/lib/schemas";
import { useChats } from "./useChats";
import { useLoadApp } from "./useLoadApp";
......@@ -35,6 +35,7 @@ import { useSettings } from "./useSettings";
import { useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/queryKeys";
import { applyCancellationNoticeToLastAssistantMessage } from "@/shared/chatCancellation";
import { handleEffectiveChatModeChunk } from "@/lib/chatModeStream";
export function getRandomNumberId() {
return Math.floor(Math.random() * 1_000_000_000_000_000);
......@@ -90,6 +91,7 @@ export function useStreamChat({
redo,
attachments,
selectedComponents,
requestedChatMode,
onSettled,
}: {
prompt: string;
......@@ -97,6 +99,7 @@ export function useStreamChat({
redo?: boolean;
attachments?: FileAttachment[];
selectedComponents?: ComponentSelection[];
requestedChatMode?: Chat["chatMode"] | null;
onSettled?: (result: { success: boolean }) => void;
}) => {
if (
......@@ -167,6 +170,13 @@ export function useStreamChat({
let hasIncrementedStreamCount = false;
try {
const cachedChat =
requestedChatMode === null
? undefined
: queryClient.getQueryData<Chat>(
queryKeys.chats.detail({ chatId }),
);
ipc.chatStream.start(
{
chatId,
......@@ -174,13 +184,34 @@ export function useStreamChat({
redo,
attachments: convertedAttachments,
selectedComponents: selectedComponents ?? [],
requestedChatMode:
requestedChatMode === null
? undefined
: (requestedChatMode ?? cachedChat?.chatMode ?? undefined),
},
{
onChunk: ({
messages: updatedMessages,
streamingMessageId,
streamingContent,
effectiveChatMode,
chatModeFallbackReason,
}) => {
if (
handleEffectiveChatModeChunk(
{ effectiveChatMode, chatModeFallbackReason },
settings,
chatId,
)
) {
if (chatModeFallbackReason) {
queryClient.invalidateQueries({
queryKey: queryKeys.chats.detail({ chatId }),
});
}
return;
}
if (!hasIncrementedStreamCount) {
setStreamCountById((prev) => {
const next = new Map(prev);
......@@ -323,6 +354,10 @@ export function useStreamChat({
// that may only be finalized at stream completion.
try {
const latestChat = await ipc.chat.getChat(chatId);
queryClient.setQueryData(
queryKeys.chats.detail({ chatId }),
latestChat,
);
setMessagesById((prev) => {
const next = new Map(prev);
next.set(chatId, latestChat.messages);
......
......@@ -64,6 +64,7 @@ import {
uploadCloudSandboxFiles,
} from "../utils/cloud_sandbox_provider";
import { createFromTemplate } from "./createFromTemplate";
import { getInitialChatModeForNewChat } from "./chat_mode_resolution";
import {
gitCommit,
gitAdd,
......@@ -1205,11 +1206,16 @@ export function registerAppHandlers() {
})
.returning();
const initialChatMode = await getInitialChatModeForNewChat(
params.initialChatMode,
);
// Create an initial chat for this app
const [chat] = await db
.insert(chats)
.values({
appId: app.id,
chatMode: initialChatMode,
})
.returning();
......
......@@ -9,11 +9,20 @@ import { getDyadAppPath } from "../../paths/paths";
import { getCurrentCommitHash } from "../utils/git_utils";
import { createTypedHandler } from "./base";
import { chatContracts } from "../types/chat";
import {
getInitialChatModeForNewChat,
normalizeStoredChatMode,
} from "./chat_mode_resolution";
const logger = log.scope("chat_handlers");
export function registerChatHandlers() {
createTypedHandler(chatContracts.createChat, async (_, appId) => {
createTypedHandler(chatContracts.createChat, async (_, input) => {
const { appId, initialChatMode } =
typeof input === "number"
? { appId: input, initialChatMode: undefined }
: input;
// Get the app's path first
const app = await db.query.apps.findFirst({
where: eq(apps.id, appId),
......@@ -37,12 +46,15 @@ export function registerChatHandlers() {
// Continue without the git revision
}
const chatMode = await getInitialChatModeForNewChat(initialChatMode);
// Create a new chat
const [chat] = await db
.insert(chats)
.values({
appId,
initialCommitHash,
chatMode,
})
.returning();
logger.info(
......@@ -73,6 +85,7 @@ export function registerChatHandlers() {
return {
...chat,
title: chat.title ?? "",
chatMode: normalizeStoredChatMode(chat.chatMode),
messages: chat.messages.map((m) => ({
...m,
role: m.role as "user" | "assistant",
......@@ -90,6 +103,7 @@ export function registerChatHandlers() {
title: true,
createdAt: true,
appId: true,
chatMode: true,
},
orderBy: [desc(chats.createdAt)],
})
......@@ -99,12 +113,16 @@ export function registerChatHandlers() {
title: true,
createdAt: true,
appId: true,
chatMode: true,
},
orderBy: [desc(chats.createdAt)],
});
const allChats = await query;
return allChats as ChatSummary[];
return allChats.map((chat) => ({
...chat,
chatMode: normalizeStoredChatMode(chat.chatMode),
})) satisfies ChatSummary[];
});
createTypedHandler(chatContracts.deleteChat, async (_, chatId) => {
......@@ -112,8 +130,18 @@ export function registerChatHandlers() {
});
createTypedHandler(chatContracts.updateChat, async (_, params) => {
const { chatId, title } = params;
await db.update(chats).set({ title }).where(eq(chats.id, chatId));
const { chatId, title, chatMode } = params;
const updates: Partial<typeof chats.$inferInsert> = {};
if (title !== undefined) {
updates.title = title;
}
if (chatMode !== undefined) {
updates.chatMode = chatMode;
}
if (Object.keys(updates).length === 0) {
return;
}
await db.update(chats).set(updates).where(eq(chats.id, chatId));
});
createTypedHandler(chatContracts.deleteMessages, async (_, chatId) => {
......
import {
getEffectiveDefaultChatMode,
isDyadProEnabled,
type ChatMode,
type UserSettings,
} from "@/lib/schemas";
import {
normalizeStoredChatMode,
resolveChatMode,
type ChatModeResolution,
} from "@/lib/chatMode";
import { readSettings } from "@/main/settings";
import { PROVIDER_TO_ENV_VAR } from "@/ipc/shared/language_model_constants";
import { getEnvVar } from "@/ipc/utils/read_env";
import { getFreeAgentQuotaStatus } from "./free_agent_quota_handlers";
export { normalizeStoredChatMode };
export async function resolveChatModeForTurn({
storedChatMode,
requestedChatMode,
settings = readSettings(),
}: {
storedChatMode: string | null | undefined;
requestedChatMode?: ChatMode;
settings?: UserSettings;
}): Promise<ChatModeResolution & { settings: UserSettings }> {
const modeForTurn = requestedChatMode ?? storedChatMode;
const normalizedChatMode = normalizeStoredChatMode(modeForTurn);
const envVars = getChatModeEnvVars();
const freeAgentQuotaAvailable = await getFreeAgentQuotaAvailableIfNeeded(
settings,
normalizedChatMode,
);
return {
...resolveChatMode({
storedChatMode: modeForTurn,
settings,
envVars,
freeAgentQuotaAvailable,
}),
settings,
};
}
export async function getInitialChatModeForNewChat(
initialChatMode?: ChatMode,
): Promise<ChatMode> {
if (initialChatMode) {
return initialChatMode;
}
const settings = readSettings();
if (settings.selectedChatMode) {
return settings.selectedChatMode;
}
const envVars = getChatModeEnvVars();
const freeAgentQuotaAvailable = await getFreeAgentQuotaAvailableIfNeeded(
settings,
null,
);
return getEffectiveDefaultChatMode(
settings,
envVars,
freeAgentQuotaAvailable,
);
}
function getChatModeEnvVars(): Record<string, string | undefined> {
const openAiEnvVar = PROVIDER_TO_ENV_VAR.openai;
const anthropicEnvVar = PROVIDER_TO_ENV_VAR.anthropic;
return {
[openAiEnvVar]: getEnvVar(openAiEnvVar),
[anthropicEnvVar]: getEnvVar(anthropicEnvVar),
};
}
async function getFreeAgentQuotaAvailableIfNeeded(
settings: UserSettings,
chatMode: ChatMode | null,
): Promise<boolean | undefined> {
if (isDyadProEnabled(settings)) {
return undefined;
}
const defaultMayUseLocalAgent =
!settings.defaultChatMode || settings.defaultChatMode === "local-agent";
const needsQuota =
chatMode === "local-agent" ||
(chatMode === null && defaultMayUseLocalAgent);
if (!needsQuota) {
return undefined;
}
const quotaStatus = await getFreeAgentQuotaStatus();
return !quotaStatus.isQuotaExceeded;
}
......@@ -30,7 +30,6 @@ import {
import { buildNeonPromptForApp } from "../../neon_admin/neon_prompt_context";
import { getDyadAppPath } from "../../paths/paths";
import { buildDyadMediaUrl } from "../../lib/dyadMediaUrl";
import { readSettings } from "../../main/settings";
import type { ChatResponseEnd, ChatStreamParams } from "@/ipc/types";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import {
......@@ -104,6 +103,7 @@ import {
isSupabaseConnected,
isTurboEditsV2Enabled,
} from "@/lib/schemas";
import { resolveChatModeForTurn } from "./chat_mode_resolution";
import {
getFreeAgentQuotaStatus,
markMessageAsUsingFreeAgentQuota,
......@@ -116,6 +116,7 @@ import {
VersionedFiles,
} from "../utils/versioned_codebase_context";
import { getAiMessagesJsonIfWithinLimit } from "../utils/ai_messages_utils";
import { readSettings } from "@/main/settings";
type AsyncIterableStream<T> = AsyncIterable<T> & ReadableStream<T>;
......@@ -536,7 +537,23 @@ ${componentSnippet}
})
.returning({ id: messages.id });
const userMessageId = insertedUserMessage.id;
const settings = readSettings();
const {
settings: storedSettings,
mode: selectedChatMode,
fallbackReason: chatModeFallbackReason,
} = await resolveChatModeForTurn({
storedChatMode: chat.chatMode,
requestedChatMode: req.requestedChatMode,
});
const settings = {
...storedSettings,
selectedChatMode,
};
safeSend(event.sender, "chat:response:chunk", {
chatId: req.chatId,
effectiveChatMode: selectedChatMode,
chatModeFallbackReason,
});
// Only Dyad Pro requests have request ids.
if (settings.enableDyadPro) {
// Generate requestId early so it can be saved with the message
......@@ -653,8 +670,7 @@ ${componentSnippet}
updatedChat.app.id, // Exclude current app
);
const willUseLocalAgentStream =
(settings.selectedChatMode === "local-agent" ||
settings.selectedChatMode === "ask") &&
(selectedChatMode === "local-agent" || selectedChatMode === "ask") &&
!mentionedAppsCodebases.length;
const isDeepContextEnabled =
......@@ -772,7 +788,7 @@ ${componentSnippet}
// Migration on read converts "agent" to "build", so no need to check for it here
let systemPrompt = constructSystemPrompt({
aiRules,
chatMode: settings.selectedChatMode,
chatMode: selectedChatMode,
enableTurboEditsV2: isTurboEditsV2Enabled(settings),
themePrompt,
basicAgentMode: isBasicAgentMode(settings),
......@@ -822,7 +838,7 @@ ${componentSnippet}
getSupabaseAvailableSystemPrompt(supabaseClientCode) +
"\n\n" +
// For local agent, we will explicitly fetch the database context when needed.
(settings.selectedChatMode === "local-agent"
(selectedChatMode === "local-agent"
? ""
: await getSupabaseContext({
supabaseProjectId: updatedChat.app.supabaseProjectId,
......@@ -838,12 +854,12 @@ ${componentSnippet}
neonProjectId: updatedChat.app.neonProjectId!,
neonActiveBranchId: updatedChat.app.neonActiveBranchId,
neonDevelopmentBranchId: updatedChat.app.neonDevelopmentBranchId,
selectedChatMode: settings.selectedChatMode ?? "",
selectedChatMode,
})) +
"\n\n";
} else if (
// In local agent mode, we will suggest integrations as part of the add-integration tool
settings.selectedChatMode !== "local-agent" &&
selectedChatMode !== "local-agent" &&
// If in security review mode, we don't need to mention integrations are available.
!isSecurityReviewIntent
) {
......@@ -873,7 +889,7 @@ ${componentSnippet}
// print out the dyad-write tags.
// Usually, AI models will want to use the image as reference to generate code (e.g. UI mockups) anyways, so
// it's not that critical to include the image analysis instructions.
const isAskMode = settings.selectedChatMode === "ask";
const isAskMode = selectedChatMode === "ask";
if (hasUploadedAttachments) {
if (willUseLocalAgentStream && !isAskMode) {
systemPrompt += `
......@@ -946,7 +962,7 @@ This conversation includes one or more image attachments. When the user uploads
// Thinking tags are generally not critical for the context
// and eats up extra tokens.
content:
settings.selectedChatMode === "ask"
selectedChatMode === "ask"
? removeDyadTags(removeNonEssentialTags(msg.content))
: removeNonEssentialTags(msg.content),
providerOptions: {
......@@ -1164,10 +1180,7 @@ This conversation includes one or more image attachments. When the user uploads
// Handle ask mode: use local-agent in read-only mode
// This gives users access to code reading tools while in ask mode
// Ask mode does not consume free agent quota
if (
settings.selectedChatMode === "ask" &&
!mentionedAppsCodebases.length
) {
if (selectedChatMode === "ask" && !mentionedAppsCodebases.length) {
// Reconstruct system prompt for local-agent read-only mode
const readOnlySystemPrompt = constructSystemPrompt({
aiRules,
......@@ -1196,6 +1209,7 @@ This conversation includes one or more image attachments. When the user uploads
dyadRequestId: dyadRequestId ?? "[no-request-id]",
readOnly: true,
messageOverride: isSummarizeIntent ? chatMessages : undefined,
settingsOverride: settings,
},
);
if (!streamSuccess) {
......@@ -1208,10 +1222,7 @@ This conversation includes one or more image attachments. When the user uploads
// Handle plan mode: use local-agent with plan tools only
// Plan mode is for requirements gathering and creating implementation plans
if (
settings.selectedChatMode === "plan" &&
!mentionedAppsCodebases.length
) {
if (selectedChatMode === "plan" && !mentionedAppsCodebases.length) {
// Reconstruct system prompt for plan mode
const planModeSystemPrompt = constructSystemPrompt({
aiRules,
......@@ -1226,6 +1237,7 @@ This conversation includes one or more image attachments. When the user uploads
dyadRequestId: dyadRequestId ?? "[no-request-id]",
planModeOnly: true,
messageOverride: isSummarizeIntent ? chatMessages : undefined,
settingsOverride: settings,
});
return;
}
......@@ -1234,7 +1246,7 @@ This conversation includes one or more image attachments. When the user uploads
// Mentioned apps can't be handled by the local agent (defer to balanced smart context
// in build mode)
if (
settings.selectedChatMode === "local-agent" &&
selectedChatMode === "local-agent" &&
!mentionedAppsCodebases.length
) {
// Check quota for Basic Agent mode (non-Pro users)
......@@ -1271,6 +1283,7 @@ This conversation includes one or more image attachments. When the user uploads
systemPrompt,
dyadRequestId: dyadRequestId ?? "[no-request-id]",
messageOverride: isSummarizeIntent ? chatMessages : undefined,
settingsOverride: settings,
},
);
} finally {
......@@ -1288,7 +1301,7 @@ This conversation includes one or more image attachments. When the user uploads
// 2. Mode is "build" AND there are enabled MCP servers
if (
settings.enableMcpServersForBuildMode &&
settings.selectedChatMode === "build"
selectedChatMode === "build"
) {
const tools = await getMcpTools(event);
const hasEnabledMcpServers = Object.keys(tools).length > 0;
......@@ -1355,10 +1368,7 @@ This conversation includes one or more image attachments. When the user uploads
});
fullResponse = result.fullResponse;
if (
settings.selectedChatMode !== "ask" &&
isTurboEditsV2Enabled(settings)
) {
if (selectedChatMode !== "ask" && isTurboEditsV2Enabled(settings)) {
let issues = await dryRunSearchReplace({
fullResponse,
appPath: getDyadAppPath(updatedChat.app.path),
......@@ -1457,7 +1467,7 @@ ${formattedSearchReplaceIssues}`,
if (
!abortController.signal.aborted &&
settings.selectedChatMode !== "ask" &&
selectedChatMode !== "ask" &&
hasUnclosedDyadWrite(fullResponse)
) {
let continuationAttempts = 0;
......@@ -1511,7 +1521,7 @@ ${formattedSearchReplaceIssues}`,
// installed yet.
addDependencies.length === 0 &&
settings.enableAutoFixProblems &&
settings.selectedChatMode !== "ask"
selectedChatMode !== "ask"
) {
try {
// IF auto-fix is enabled
......@@ -1695,11 +1705,8 @@ ${problemReport.problems
.update(messages)
.set({ content: fullResponse })
.where(eq(messages.id, placeholderAssistantMessage.id));
const settings = readSettings();
if (
settings.autoApproveChanges &&
settings.selectedChatMode !== "ask"
) {
const latestSettings = readSettings();
if (latestSettings.autoApproveChanges && selectedChatMode !== "ask") {
const status = await processFullResponseActions(
fullResponse,
req.chatId,
......
......@@ -13,6 +13,7 @@ import { ImportAppParams, ImportAppResult } from "@/ipc/types";
import { copyDirectoryRecursive } from "../utils/file_utils";
import { gitCommit, gitAdd, gitInit } from "../utils/git_utils";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import { getInitialChatModeForNewChat } from "./chat_mode_resolution";
const logger = log.scope("import-handlers");
const handle = createLoggedHandler(logger);
......@@ -152,11 +153,14 @@ export function registerImportHandlers() {
})
.returning();
const initialChatMode = await getInitialChatModeForNewChat();
// Create an initial chat for this app
const [chat] = await db
.insert(chats)
.values({
appId: app.id,
chatMode: initialChatMode,
})
.returning();
return { appId: app.id, chatId: chat.id };
......
......@@ -34,6 +34,7 @@ import { createLoggedHandler } from "./safe_handle";
import { ApproveProposalResult } from "@/ipc/types";
import { validateChatContext } from "../utils/context_paths_utils";
import { readSettings } from "@/main/settings";
import { resolveChatModeForTurn } from "./chat_mode_resolution";
const logger = log.scope("proposal_handlers");
const handle = createLoggedHandler(logger);
......@@ -338,7 +339,15 @@ const approveProposalHandler = async (
{ chatId, messageId }: { chatId: number; messageId: number },
): Promise<ApproveProposalResult> => {
const settings = readSettings();
if (settings.selectedChatMode === "ask") {
const chat = await db.query.chats.findFirst({
where: eq(chats.id, chatId),
columns: { chatMode: true },
});
const { mode: selectedChatMode } = await resolveChatModeForTurn({
storedChatMode: chat?.chatMode ?? null,
settings,
});
if (selectedChatMode === "ask") {
throw new Error(
"Ask mode is not supported for proposal approval. Please switch to build mode.",
);
......
......@@ -28,6 +28,7 @@ import { extractMentionedAppsCodebases } from "../utils/mention_apps";
import { parseAppMentions } from "@/shared/parse_mention_apps";
import { isTurboEditsV2Enabled } from "@/lib/schemas";
import { DyadError, DyadErrorKind } from "@/errors/dyad_error";
import { resolveChatModeForTurn } from "./chat_mode_resolution";
const logger = log.scope("token_count_handlers");
......@@ -63,7 +64,15 @@ export function registerTokenCountHandlers() {
// Count input tokens
const inputTokens = estimateTokens(req.input);
const settings = readSettings();
const storedSettings = readSettings();
const { mode: selectedChatMode } = await resolveChatModeForTurn({
storedChatMode: chat.chatMode,
settings: storedSettings,
});
const settings = {
...storedSettings,
selectedChatMode,
};
// Parse app mentions from the input
const mentionedAppNames = parseAppMentions(req.input);
......@@ -74,9 +83,7 @@ export function registerTokenCountHandlers() {
let systemPrompt = constructSystemPrompt({
aiRules: await readAiRules(getDyadAppPath(chat.app.path)),
chatMode:
settings.selectedChatMode === "local-agent"
? "build"
: settings.selectedChatMode,
selectedChatMode === "local-agent" ? "build" : selectedChatMode,
enableTurboEditsV2: isTurboEditsV2Enabled(settings),
themePrompt,
});
......@@ -101,7 +108,7 @@ export function registerTokenCountHandlers() {
neonProjectId: chat.app.neonProjectId!,
neonActiveBranchId: chat.app.neonActiveBranchId,
neonDevelopmentBranchId: chat.app.neonDevelopmentBranchId,
selectedChatMode: settings.selectedChatMode ?? "",
selectedChatMode,
}));
} else {
// Neon projects don't need Supabase (already handled above).
......
import { z } from "zod";
import { defineContract, createClient } from "../contracts/core";
import { APP_FRAMEWORK_TYPES } from "../../lib/framework_constants";
import { ChatModeSchema } from "../../lib/schemas";
// =============================================================================
// App Schemas
......@@ -54,6 +55,7 @@ export type App = z.infer<typeof AppSchema>;
*/
export const CreateAppParamsSchema = z.object({
name: z.string().min(1),
initialChatMode: ChatModeSchema.optional(),
});
/**
......
......@@ -5,6 +5,12 @@ import {
createClient,
createStreamClient,
} from "../contracts/core";
import {
ChatModeSchema,
StoredChatModeSchema,
migrateStoredChatMode,
type ChatMode,
} from "../../lib/schemas";
// =============================================================================
// Chat Schemas
......@@ -29,6 +35,10 @@ export const MessageSchema = z.object({
export type Message = z.infer<typeof MessageSchema>;
export const NullableChatModeSchema = StoredChatModeSchema.nullable().transform(
(mode): ChatMode | null => migrateStoredChatMode(mode ?? undefined) ?? null,
);
/**
* Schema for a Chat object.
*/
......@@ -38,6 +48,7 @@ export const ChatSchema = z.object({
messages: z.array(MessageSchema),
initialCommitHash: z.string().nullable().optional(),
dbTimestamp: z.string().nullable().optional(),
chatMode: NullableChatModeSchema,
});
export type Chat = z.infer<typeof ChatSchema>;
......@@ -86,6 +97,7 @@ export const ChatStreamParamsSchema = z.object({
redo: z.boolean().optional(),
attachments: z.array(ChatAttachmentSchema).optional(),
selectedComponents: z.array(ComponentSelectionSchema).optional(),
requestedChatMode: ChatModeSchema.optional(),
});
export type ChatStreamParams = z.infer<typeof ChatStreamParamsSchema>;
......@@ -104,8 +116,14 @@ export const ChatResponseChunkSchema = z.object({
messages: z.array(MessageSchema).optional(),
streamingMessageId: z.number().optional(),
streamingContent: z.string().optional(),
effectiveChatMode: ChatModeSchema.optional(),
chatModeFallbackReason: z
.enum(["pro-required", "quota-exhausted", "no-provider"])
.optional(),
});
export type ChatResponseChunk = z.infer<typeof ChatResponseChunkSchema>;
/**
* Schema for chat response end event.
*/
......@@ -143,7 +161,8 @@ export const CreateChatResultSchema = z.number();
*/
export const UpdateChatParamsSchema = z.object({
chatId: z.number(),
title: z.string(),
title: z.string().optional(),
chatMode: ChatModeSchema.nullable().optional(),
});
export type UpdateChatParams = z.infer<typeof UpdateChatParamsSchema>;
......@@ -194,13 +213,20 @@ export const chatContracts = {
appId: z.number(),
title: z.string().nullable(),
createdAt: z.date(),
chatMode: NullableChatModeSchema,
}),
),
}),
createChat: defineContract({
channel: "create-chat",
input: z.number(), // appId
input: z.union([
z.number(), // appId (legacy shape)
z.object({
appId: z.number(),
initialChatMode: ChatModeSchema.optional(),
}),
]),
output: CreateChatResultSchema,
}),
......
......@@ -124,6 +124,7 @@ export type {
FileAttachment,
ChatAttachment,
ChatStreamParams,
ChatResponseChunk,
ChatResponseEnd,
UpdateChatParams,
TokenCountParams,
......
import { isOpenAIOrAnthropicSetup } from "./providerUtils";
import {
getEffectiveDefaultChatMode,
hasDyadProKey,
isDyadProEnabled,
migrateStoredChatMode,
StoredChatModeSchema,
type ChatMode,
type UserSettings,
} from "./schemas";
export type ChatModeFallbackReason =
| "pro-required"
| "quota-exhausted"
| "no-provider";
export interface ChatModeResolution {
mode: ChatMode;
fallbackReason?: ChatModeFallbackReason;
}
export function normalizeStoredChatMode(
mode: string | null | undefined,
): ChatMode | null {
if (!mode) {
return null;
}
const parsed = StoredChatModeSchema.safeParse(mode);
if (!parsed.success) {
return null;
}
return migrateStoredChatMode(parsed.data) ?? null;
}
export function getUnavailableChatModeReason({
mode,
settings,
envVars,
freeAgentQuotaAvailable,
}: {
mode: ChatMode | null | undefined;
settings: UserSettings;
envVars: Record<string, string | undefined>;
freeAgentQuotaAvailable?: boolean;
}): ChatModeFallbackReason | undefined {
if (mode !== "local-agent") {
return undefined;
}
if (isDyadProEnabled(settings)) {
return undefined;
}
if (isOpenAIOrAnthropicSetup(settings, envVars)) {
if (freeAgentQuotaAvailable === false) {
return "quota-exhausted";
}
return undefined;
}
if (settings.enableDyadPro === true && !hasDyadProKey(settings)) {
return "pro-required";
}
return "no-provider";
}
export function resolveChatMode({
storedChatMode,
settings,
envVars,
freeAgentQuotaAvailable,
}: {
storedChatMode: string | null | undefined;
settings: UserSettings;
envVars: Record<string, string | undefined>;
freeAgentQuotaAvailable?: boolean;
}): ChatModeResolution {
const chatMode = normalizeStoredChatMode(storedChatMode);
const effectiveDefault = getEffectiveDefaultChatMode(
settings,
envVars,
freeAgentQuotaAvailable,
);
if (!chatMode) {
return { mode: effectiveDefault };
}
const fallbackReason = getUnavailableChatModeReason({
mode: chatMode,
settings,
envVars,
freeAgentQuotaAvailable,
});
if (fallbackReason && effectiveDefault !== chatMode) {
return { mode: effectiveDefault, fallbackReason };
}
return { mode: chatMode };
}
import type { ChatMode, UserSettings } from "./schemas";
import { isDyadProEnabled } from "./schemas";
import type { ChatModeFallbackReason } from "./chatMode";
import {
getChatModeFallbackToastId,
showChatModeFallbackToast,
} from "./chatModeToast";
export function handleEffectiveChatModeChunk(
chunk: {
effectiveChatMode?: ChatMode;
chatModeFallbackReason?: ChatModeFallbackReason;
},
settings: UserSettings | null | undefined,
chatId?: number,
): boolean {
if (!chunk.effectiveChatMode) {
return false;
}
if (chunk.chatModeFallbackReason) {
showChatModeFallbackToast({
reason: chunk.chatModeFallbackReason,
effectiveMode: chunk.effectiveChatMode,
isPro: settings ? isDyadProEnabled(settings) : false,
toastId: getChatModeFallbackToastId({
chatId,
reason: chunk.chatModeFallbackReason,
effectiveMode: chunk.effectiveChatMode,
}),
});
}
return true;
}
import { toast } from "sonner";
import type { ChatMode } from "./schemas";
import type { ChatModeFallbackReason } from "./chatMode";
export function getChatModeDisplayName(mode: ChatMode, isPro: boolean): string {
switch (mode) {
case "build":
return "Build";
case "ask":
return "Ask";
case "local-agent":
return isPro ? "Agent" : "Basic Agent";
case "plan":
return "Plan";
}
}
export function getChatModeFallbackToastId({
chatId,
reason,
effectiveMode,
}: {
chatId?: number;
reason: ChatModeFallbackReason;
effectiveMode: ChatMode;
}) {
return chatId
? `chat-mode-fallback:${chatId}:${reason}:${effectiveMode}`
: `chat-mode-fallback:${reason}:${effectiveMode}`;
}
export function showChatModeFallbackToast({
reason,
effectiveMode,
isPro,
toastId,
}: {
reason: ChatModeFallbackReason;
effectiveMode: ChatMode;
isPro: boolean;
toastId?: string;
}) {
const modeName = getChatModeDisplayName(effectiveMode, isPro);
const message =
reason === "pro-required"
? `Agent v2 unavailable (Pro required). Using ${modeName} mode.`
: reason === "quota-exhausted"
? `Quota exhausted. Using ${modeName} mode.`
: `No provider configured. Using ${modeName} mode.`;
toast.warning(message, {
id: toastId,
duration: 8000,
action: {
label: "Switch mode",
onClick: () => {
const trigger = document.querySelector<HTMLElement>(
'[data-testid="chat-mode-selector"]',
);
if (trigger) {
trigger.focus();
trigger.click();
return;
}
if (toastId) {
toast.dismiss(toastId);
}
toast.info("Open a chat to switch modes.", { duration: 5000 });
},
},
});
}
......@@ -50,6 +50,8 @@ export const queryKeys = {
chats: {
all: ["chats"] as const,
list: ({ appId }: { appId: number | null }) => ["chats", appId] as const,
detail: ({ chatId }: { chatId: number | null }) =>
["chats", "detail", chatId] as const,
search: ({ appId, query }: { appId: number | null; query: string }) =>
["chats", "search", appId, query] as const,
},
......
......@@ -15,6 +15,7 @@ export const ChatSummarySchema = z.object({
appId: z.number(),
title: z.string().nullable(),
createdAt: z.date(),
chatMode: z.enum(["build", "ask", "local-agent", "plan"]).nullable(),
});
/**
......
......@@ -44,6 +44,7 @@ import {
} from "@/components/ProBanner";
import { hasDyadProKey, getEffectiveDefaultChatMode } from "@/lib/schemas";
import { useFreeAgentQuota } from "@/hooks/useFreeAgentQuota";
import { useInitialChatMode } from "@/hooks/useInitialChatMode";
// Track whether we've already checked release notes this session (module-scoped
// so it persists across component unmount/remount cycles).
......@@ -63,6 +64,7 @@ export default function HomePage() {
const { refreshApps } = useLoadApps();
const { settings, updateSettings, envVars } = useSettings();
const { isQuotaExceeded, isLoading: isQuotaLoading } = useFreeAgentQuota();
const initialChatMode = useInitialChatMode();
const setIsPreviewOpen = useSetAtom(isPreviewOpenAtom);
const { selectChat } = useSelectChat();
......@@ -184,15 +186,18 @@ export default function HomePage() {
let chatId: number;
let appId: number;
if (selectedApp) {
// Existing app flow: create a new chat in the selected app
chatId = await ipc.chat.createChat(selectedApp.id);
chatId = await ipc.chat.createChat({
appId: selectedApp.id,
initialChatMode,
});
appId = selectedApp.id;
} else {
// New app flow (default behavior)
const result = await ipc.app.createApp({
name: generateCuteAppName(),
initialChatMode,
});
chatId = result.chatId;
appId = result.app.id;
......@@ -221,6 +226,7 @@ export default function HomePage() {
prompt: inputValue,
chatId,
attachments,
requestedChatMode: initialChatMode,
});
await new Promise((resolve) =>
setTimeout(resolve, settings?.isTestMode ? 0 : 2000),
......
......@@ -18,7 +18,11 @@ import { db } from "@/db";
import { chats, messages } from "@/db/schema";
import { eq } from "drizzle-orm";
import { isDyadProEnabled, isBasicAgentMode } from "@/lib/schemas";
import {
isDyadProEnabled,
isBasicAgentMode,
type UserSettings,
} from "@/lib/schemas";
import { readSettings } from "@/main/settings";
import { getDyadAppPath } from "@/paths/paths";
import { detectFrameworkType } from "@/ipc/utils/framework_utils";
......@@ -267,6 +271,7 @@ export async function handleLocalAgentStream(
readOnly = false,
planModeOnly = false,
messageOverride,
settingsOverride,
}: {
placeholderMessageId: number;
systemPrompt: string;
......@@ -286,9 +291,10 @@ export async function handleLocalAgentStream(
* Used for summarization where messages need to be transformed.
*/
messageOverride?: ModelMessage[];
settingsOverride?: UserSettings;
},
): Promise<boolean> {
const settings = readSettings();
const settings = settingsOverride ?? readSettings();
const maxToolCallSteps =
settings.maxToolCallSteps ?? DEFAULT_MAX_TOOL_CALL_STEPS;
let fullResponse = "";
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论