Compare commits

..

2 Commits

Author SHA1 Message Date
Kunthawat Greethong
705608ae46 Add Dyad Update Management Guide and custom hooks for smart context
Some checks failed
CI / test (map[image:macos-latest name:macos], 1, 4) (push) Has been cancelled
CI / test (map[image:macos-latest name:macos], 2, 4) (push) Has been cancelled
CI / test (map[image:macos-latest name:macos], 3, 4) (push) Has been cancelled
CI / test (map[image:macos-latest name:macos], 4, 4) (push) Has been cancelled
CI / test (map[image:windows-latest name:windows], 1, 4) (push) Has been cancelled
CI / test (map[image:windows-latest name:windows], 2, 4) (push) Has been cancelled
CI / test (map[image:windows-latest name:windows], 3, 4) (push) Has been cancelled
CI / test (map[image:windows-latest name:windows], 4, 4) (push) Has been cancelled
CI / merge-reports (push) Has been cancelled
2025-12-05 20:01:06 +07:00
Kunthawat Greethong
73fc42bf4f Add custom code structure and update scripts
Some checks failed
CI / test (map[image:macos-latest name:macos], 1, 4) (push) Has been cancelled
CI / test (map[image:macos-latest name:macos], 2, 4) (push) Has been cancelled
CI / test (map[image:macos-latest name:macos], 3, 4) (push) Has been cancelled
CI / test (map[image:macos-latest name:macos], 4, 4) (push) Has been cancelled
CI / test (map[image:windows-latest name:windows], 1, 4) (push) Has been cancelled
CI / test (map[image:windows-latest name:windows], 2, 4) (push) Has been cancelled
CI / test (map[image:windows-latest name:windows], 3, 4) (push) Has been cancelled
CI / test (map[image:windows-latest name:windows], 4, 4) (push) Has been cancelled
CI / merge-reports (push) Has been cancelled
- Organized custom modifications in src/custom/
- Added update-dyad-v2.sh for selective updates
- Preserved custom smart context functionality
- Created backup system for safe updates
2025-12-05 19:57:45 +07:00
136 changed files with 2454 additions and 8673 deletions

View File

@@ -107,10 +107,6 @@ jobs:
# Merge reports after playwright-tests, even if some shards have failed # Merge reports after playwright-tests, even if some shards have failed
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
needs: [test] needs: [test]
permissions:
contents: read
pull-requests: write
actions: read
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:

View File

@@ -1,59 +0,0 @@
name: Playwright Report Comment
on:
workflow_run:
workflows: ["CI"]
types:
- completed
permissions:
contents: read
pull-requests: write
issues: write
actions: read
jobs:
comment:
if: ${{ github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.pull_requests[0].number }}
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ github.event.workflow_run.base_ref }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Download Playwright HTML report
uses: actions/download-artifact@v4
with:
name: html-report--attempt-${{ github.event.workflow_run.run_attempt }}
path: playwright-report
github-token: ${{ github.token }}
repository: ${{ github.event.workflow_run.repository.full_name }}
run-id: ${{ github.event.workflow_run.id }}
if-no-artifact-found: warn
- name: Download blob reports
uses: actions/download-artifact@v4
with:
path: all-blob-reports
pattern: blob-report-*
merge-multiple: true
github-token: ${{ github.token }}
repository: ${{ github.event.workflow_run.repository.full_name }}
run-id: ${{ github.event.workflow_run.id }}
if-no-artifact-found: warn
- name: Generate Playwright summary comment
uses: actions/github-script@v7
env:
PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }}
PLAYWRIGHT_RUN_ID: ${{ github.event.workflow_run.id }}
with:
script: |
const { run } = require('./scripts/generate-playwright-summary.js');
await run({ github, context, core });

View File

@@ -21,7 +21,7 @@ jobs:
{ name: "windows", image: "windows-latest" }, { name: "windows", image: "windows-latest" },
# See https://github.com/dyad-sh/dyad/issues/96 # See https://github.com/dyad-sh/dyad/issues/96
{ name: "linux", image: "ubuntu-22.04" }, { name: "linux", image: "ubuntu-22.04" },
{ name: "macos-intel", image: "macos-15-intel" }, { name: "macos-intel", image: "macos-13" },
{ name: "macos", image: "macos-latest" }, { name: "macos", image: "macos-latest" },
] ]
runs-on: ${{ matrix.os.image }} runs-on: ${{ matrix.os.image }}

198
UPDATE_GUIDE.md Normal file
View File

@@ -0,0 +1,198 @@
# Dyad Update Management Guide
This guide explains how to update your forked Dyad application while preserving your custom modifications.
## 🎯 Overview
Your setup uses a **selective update strategy** that:
- Keeps your custom code separate from the main codebase
- Automatically preserves custom modifications during updates
- Provides backup and rollback capabilities
- Minimizes merge conflicts
## 📁 Custom Code Structure
Your custom modifications are organized in `src/custom/`:
```
src/custom/
├── index.ts # Main entry point for custom features
├── hooks/
│ └── useSmartContext.ts # Custom smart context hook
├── ipc/
│ └── smart_context_handlers.ts # Custom IPC handlers
└── utils/
└── smart_context_store.ts # Custom utilities
```
## 🚀 Update Process
### Method 1: Automated Update (Recommended)
Use the provided update script:
```bash
./update-dyad-v2.sh
```
**What the script does:**
1. Creates a timestamped backup
2. Backs up your custom code
3. Fetches latest changes from upstream
4. Resets to the latest upstream version
5. Restores your custom code
6. Pushes updates to your fork
### Method 2: Manual Update
If you prefer manual control:
```bash
# 1. Create backup
cp -r src/custom/ dyad-backup-$(date +%Y%m%d-%H%M%S)/
# 2. Fetch latest changes
git fetch upstream
# 3. Reset to latest upstream
git reset --hard upstream/main
# 4. Restore custom code
cp -r dyad-backup-*/src/custom/ src/
# 5. Commit and push
git add src/custom/
git commit -m "Restore custom code after update"
git push origin main
```
## 🔄 Update Workflow
### Before Updating
1. **Test current state** - Ensure your app works properly
2. **Commit any changes** - Don't have uncommitted work
3. **Check custom code** - Note any modifications that might need updates
### After Updating
1. **Run npm install** - Update dependencies if needed
2. **Test the application** - Ensure everything works
3. **Check custom integrations** - Verify custom features still work
4. **Update custom code** if needed - Adapt to any API changes
## 🛠️ Adding New Custom Features
When adding new custom features:
1. **Place in src/custom/** - Keep custom code organized
2. **Update imports** - Use relative imports within custom directory
3. **Document changes** - Note what each custom feature does
4. **Test integration** - Ensure custom features work with main app
Example:
```typescript
// src/custom/components/MyCustomComponent.tsx
import { useSmartContext } from '../hooks/useSmartContext';
export const MyCustomComponent = () => {
// Your custom logic
};
```
## 🚨 Troubleshooting
### Merge Conflicts
If you encounter merge conflicts during manual updates:
```bash
# Abort the merge
git merge --abort
# Use the automated script instead
./update-dyad-v2.sh
```
### Custom Code Not Working
After an update, if custom features don't work:
1. **Check API changes** - The upstream may have changed interfaces
2. **Update imports** - File paths might have changed
3. **Review breaking changes** - Check upstream release notes
4. **Test incrementally** - Isolate the problematic code
### Backup Restoration
If you need to restore from backup:
```bash
# Find your backup directory
ls dyad-backup-*
# Restore specific files
cp -r dyad-backup-YYYYMMDD-HHMMSS/src/custom/ src/
# Or restore entire project (last resort)
rm -rf * .gitignore
cp -r dyad-backup-YYYYMMDD-HHMMSS/* .
cp dyad-backup-YYYYMMDD-HHMMSS/.gitignore .
```
## 📋 Best Practices
### Regular Maintenance
- **Update frequently** - Smaller updates are easier to manage
- **Test after each update** - Catch issues early
- **Keep custom code minimal** - Only customize what's necessary
- **Document customizations** - Future you will thank you
### Code Organization
- **Separate concerns** - Keep UI, logic, and utilities separate
- **Use TypeScript** - Catch integration issues early
- **Follow existing patterns** - Match the upstream code style
- **Avoid modifying core files** - Use extension patterns when possible
### Backup Strategy
- **Multiple backups** - Keep several backup versions
- **Offsite backup** - Consider cloud storage for critical backups
- **Test backups** - Ensure you can restore from backup
- **Label clearly** - Use descriptive backup names
## 🔧 Advanced Configuration
### Custom Update Script
You can modify `update-dyad-v2.sh` to:
- Skip certain files from backup
- Add custom post-update steps
- Include additional validation
- Send notifications on completion
### Selective File Restoration
To restore only specific custom files:
```bash
# Restore specific directory
cp -r dyad-backup-*/src/custom/hooks/ src/custom/
# Restore specific file
cp dyad-backup-*/src/custom/index.ts src/custom/
```
## 📞 Getting Help
If you encounter issues:
1. **Check this guide first** - Most common issues are covered
2. **Review the script output** - Error messages are informative
3. **Test with a clean state** - Start fresh if needed
4. **Document the issue** - Note what you were trying to do
## 🎉 Success Indicators
You'll know the update was successful when:
- ✅ Script completes without errors
- ✅ Custom code is present in `src/custom/`
- ✅ Application starts and runs normally
- ✅ Custom features work as expected
- ✅ No merge conflicts in git status
---
**Remember**: The goal is to make updates painless and predictable. When in doubt, use the automated script and keep good backups!

View File

@@ -765,4 +765,3 @@
"indexes": {} "indexes": {}
} }
} }

View File

@@ -0,0 +1,7 @@
52a977b backup: auto-commit before update - Fri Dec 5 15:16:35 +07 2025
8a1cecb backup: auto-commit before update - Fri Dec 5 15:12:17 +07 2025
e6de49c fix: update .gitignore to exclude 'out/' directory
6d74721 Add user settings configuration for GPT-5 Codex model with Azure provider
d22227b feat: implement fuzzy search and replace functionality with Levenshtein distance
11986a0 Add project files
3b43cb5 Add blank

View File

@@ -0,0 +1,60 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { IpcClient } from "@/ipc/ipc_client";
import type {
SmartContextMeta,
SmartContextSnippet,
SmartContextRetrieveResult,
} from "@/ipc/ipc_types";
export function useSmartContextMeta(chatId: number) {
return useQuery<SmartContextMeta, Error>({
queryKey: ["smart-context", chatId, "meta"],
queryFn: async () => {
const ipc = IpcClient.getInstance();
return ipc.getSmartContextMeta(chatId);
},
enabled: !!chatId,
});
}
export function useRetrieveSmartContext(
chatId: number,
query: string,
budgetTokens: number,
) {
return useQuery<SmartContextRetrieveResult, Error>({
queryKey: ["smart-context", chatId, "retrieve", query, budgetTokens],
queryFn: async () => {
const ipc = IpcClient.getInstance();
return ipc.retrieveSmartContext({ chatId, query, budgetTokens });
},
enabled: !!chatId && !!query && budgetTokens > 0,
meta: { showErrorToast: true },
});
}
export function useUpsertSmartContextSnippets(chatId: number) {
const qc = useQueryClient();
return useMutation<number, Error, Array<Pick<SmartContextSnippet, "text" | "source">>>({
mutationFn: async (snippets) => {
const ipc = IpcClient.getInstance();
return ipc.upsertSmartContextSnippets(chatId, snippets);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["smart-context", chatId] });
},
});
}
export function useUpdateRollingSummary(chatId: number) {
const qc = useQueryClient();
return useMutation<SmartContextMeta, Error, { summary: string }>({
mutationFn: async ({ summary }) => {
const ipc = IpcClient.getInstance();
return ipc.updateSmartContextRollingSummary(chatId, summary);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["smart-context", chatId, "meta"] });
},
});
}

View File

@@ -0,0 +1,18 @@
// Custom modules for moreminimore-vibe
// This file exports all custom functionality to make imports easier
// Custom hooks
export { useSmartContextMeta, useRetrieveSmartContext, useUpsertSmartContextSnippets, useUpdateRollingSummary } from './hooks/useSmartContext';
// Custom IPC handlers (these will need to be imported and registered in the main process)
export { registerSmartContextHandlers } from './ipc/smart_context_handlers';
// Custom utilities
export * from './utils/smart_context_store';
// Re-export types that might be needed
export type {
SmartContextMeta,
SmartContextSnippet,
SmartContextRetrieveResult,
} from '../ipc/ipc_types';

View File

@@ -0,0 +1,65 @@
import log from "electron-log";
import { createLoggedHandler } from "./safe_handle";
import {
appendSnippets,
readMeta,
retrieveContext,
updateRollingSummary,
rebuildIndex,
type SmartContextSnippet,
type SmartContextMeta,
} from "../utils/smart_context_store";
const logger = log.scope("smart_context_handlers");
const handle = createLoggedHandler(logger);
export interface UpsertSnippetsParams {
chatId: number;
snippets: Array<{
text: string;
source:
| { type: "message"; messageIndex?: number }
| { type: "code"; filePath: string }
| { type: "attachment"; name: string; mime?: string }
| { type: "other"; label?: string };
}>;
}
export interface RetrieveContextParams {
chatId: number;
query: string;
budgetTokens: number;
}
export function registerSmartContextHandlers() {
handle("sc:get-meta", async (_event, chatId: number): Promise<SmartContextMeta> => {
return readMeta(chatId);
});
handle(
"sc:upsert-snippets",
async (_event, params: UpsertSnippetsParams): Promise<number> => {
const count = await appendSnippets(params.chatId, params.snippets);
return count;
},
);
handle(
"sc:update-rolling-summary",
async (_event, params: { chatId: number; summary: string }): Promise<SmartContextMeta> => {
return updateRollingSummary(params.chatId, params.summary);
},
);
handle(
"sc:retrieve-context",
async (_event, params: RetrieveContextParams) => {
return retrieveContext(params.chatId, params.query, params.budgetTokens);
},
);
handle("sc:rebuild-index", async (_event, chatId: number) => {
await rebuildIndex(chatId);
return { ok: true } as const;
});
}

View File

@@ -0,0 +1,212 @@
import path from "node:path";
import { promises as fs } from "node:fs";
import { randomUUID } from "node:crypto";
import { getUserDataPath } from "../../paths/paths";
import { estimateTokens } from "./token_utils";
export type SmartContextSource =
| { type: "message"; messageIndex?: number }
| { type: "code"; filePath: string }
| { type: "attachment"; name: string; mime?: string }
| { type: "other"; label?: string };
export interface SmartContextSnippet {
id: string;
text: string;
score?: number;
source: SmartContextSource;
ts: number; // epoch ms
tokens?: number;
}
export interface SmartContextMetaConfig {
maxSnippets?: number;
}
export interface SmartContextMeta {
entityId: string; // e.g., chatId as string
updatedAt: number;
rollingSummary?: string;
summaryTokens?: number;
config?: SmartContextMetaConfig;
}
function getThreadDir(chatId: number): string {
const base = path.join(getUserDataPath(), "smart-context", "threads");
return path.join(base, String(chatId));
}
function getMetaPath(chatId: number): string {
return path.join(getThreadDir(chatId), "meta.json");
}
function getSnippetsPath(chatId: number): string {
return path.join(getThreadDir(chatId), "snippets.jsonl");
}
async function ensureDir(dir: string): Promise<void> {
await fs.mkdir(dir, { recursive: true });
}
export async function readMeta(chatId: number): Promise<SmartContextMeta> {
const dir = getThreadDir(chatId);
await ensureDir(dir);
const metaPath = getMetaPath(chatId);
try {
const raw = await fs.readFile(metaPath, "utf8");
const meta = JSON.parse(raw) as SmartContextMeta;
return meta;
} catch {
const fresh: SmartContextMeta = {
entityId: String(chatId),
updatedAt: Date.now(),
rollingSummary: "",
summaryTokens: 0,
config: { maxSnippets: 400 },
};
await fs.writeFile(metaPath, JSON.stringify(fresh, null, 2), "utf8");
return fresh;
}
}
export async function writeMeta(
chatId: number,
meta: SmartContextMeta,
): Promise<void> {
const dir = getThreadDir(chatId);
await ensureDir(dir);
const metaPath = getMetaPath(chatId);
const updated: SmartContextMeta = {
...meta,
entityId: String(chatId),
updatedAt: Date.now(),
};
await fs.writeFile(metaPath, JSON.stringify(updated, null, 2), "utf8");
}
export async function updateRollingSummary(
chatId: number,
summary: string,
): Promise<SmartContextMeta> {
const meta = await readMeta(chatId);
const summaryTokens = estimateTokens(summary || "");
const next: SmartContextMeta = {
...meta,
rollingSummary: summary,
summaryTokens,
};
await writeMeta(chatId, next);
return next;
}
export async function appendSnippets(
chatId: number,
snippets: Omit<SmartContextSnippet, "id" | "ts" | "tokens">[],
): Promise<number> {
const dir = getThreadDir(chatId);
await ensureDir(dir);
const snippetsPath = getSnippetsPath(chatId);
const withDefaults: SmartContextSnippet[] = snippets.map((s) => ({
id: randomUUID(),
ts: Date.now(),
tokens: estimateTokens(s.text),
...s,
}));
const lines = withDefaults.map((obj) => JSON.stringify(obj)).join("\n");
await fs.appendFile(snippetsPath, (lines ? lines + "\n" : ""), "utf8");
// prune if exceeded max
const meta = await readMeta(chatId);
const maxSnippets = meta.config?.maxSnippets ?? 400;
try {
const file = await fs.readFile(snippetsPath, "utf8");
const allLines = file.split("\n").filter(Boolean);
if (allLines.length > maxSnippets) {
const toKeep = allLines.slice(allLines.length - maxSnippets);
await fs.writeFile(snippetsPath, toKeep.join("\n") + "\n", "utf8");
return toKeep.length;
}
return allLines.length;
} catch {
return withDefaults.length;
}
}
export async function readAllSnippets(chatId: number): Promise<SmartContextSnippet[]> {
try {
const raw = await fs.readFile(getSnippetsPath(chatId), "utf8");
return raw
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line) as SmartContextSnippet);
} catch {
return [];
}
}
function normalize(value: number, min: number, max: number): number {
if (max === min) return 0;
return (value - min) / (max - min);
}
function keywordScore(text: string, query: string): number {
const toTokens = (s: string) =>
s
.toLowerCase()
.replace(/[^a-z0-9_\- ]+/g, " ")
.split(/\s+/)
.filter(Boolean);
const qTokens = new Set(toTokens(query));
const tTokens = toTokens(text);
if (qTokens.size === 0 || tTokens.length === 0) return 0;
let hits = 0;
for (const tok of tTokens) if (qTokens.has(tok)) hits++;
return hits / tTokens.length; // simple overlap ratio
}
export interface RetrieveContextResult {
rollingSummary?: string;
usedTokens: number;
snippets: SmartContextSnippet[];
}
export async function retrieveContext(
chatId: number,
query: string,
budgetTokens: number,
): Promise<RetrieveContextResult> {
const meta = await readMeta(chatId);
const snippets = await readAllSnippets(chatId);
const now = Date.now();
let minTs = now;
let maxTs = 0;
for (const s of snippets) {
if (s.ts < minTs) minTs = s.ts;
if (s.ts > maxTs) maxTs = s.ts;
}
const scored = snippets.map((s) => {
const recency = normalize(s.ts, minTs, maxTs);
const kw = keywordScore(s.text, query);
const base = 0.6 * kw + 0.4 * recency;
const score = base;
return { ...s, score } as SmartContextSnippet;
});
scored.sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
const picked: SmartContextSnippet[] = [];
let usedTokens = 0;
for (const s of scored) {
const t = s.tokens ?? estimateTokens(s.text);
if (usedTokens + t > budgetTokens) break;
picked.push(s);
usedTokens += t;
}
const rollingSummary = meta.rollingSummary || "";
return { rollingSummary, usedTokens, snippets: picked };
}
export async function rebuildIndex(_chatId: number): Promise<void> {
// Placeholder for future embedding/vector index rebuild.
return;
}

View File

@@ -0,0 +1,13 @@
diff --git a/update-dyad.sh b/update-dyad.sh
index 2763b94..4d7da4c 100755
--- a/update-dyad.sh
+++ b/update-dyad.sh
@@ -78,7 +78,7 @@ git log --oneline HEAD..upstream/main --reverse
# Attempt to merge
print_status "Merging upstream changes into your branch..."
-if git merge upstream/main -m "merge: update from upstream - $(date)"; then
+if git merge upstream/main -m "merge: update from upstream - $(date)" --allow-unrelated-histories; then
print_success "✅ Update completed successfully!"
# Check for any conflicts that need manual resolution

View File

@@ -1,75 +0,0 @@
import { testSkipIfWindows } from "./helpers/test_helper";
import { expect } from "@playwright/test";
import fs from "fs";
testSkipIfWindows(
"annotator - capture and submit screenshot",
async ({ po }) => {
await po.setUpDyadPro({ autoApprove: true });
// Create a basic app
await po.sendPrompt("basic");
// Click the annotator button to activate annotator mode
await po.clickPreviewAnnotatorButton();
// Wait for annotator mode to be active
await po.waitForAnnotatorMode();
// Submit the screenshot to chat
await po.clickAnnotatorSubmit();
await expect(po.getChatInput()).toContainText(
"Please update the UI based on these screenshots",
);
// Verify the screenshot was attached to chat context
await po.sendPrompt("[dump]");
// Wait for the LLM response containing the dump path to appear in the UI
// before attempting to extract it from the messages list
await po.page.waitForSelector("text=/\\[\\[dyad-dump-path=.*\\]\\]/");
// Get the dump file path from the messages list
const messagesListText = await po.page
.getByTestId("messages-list")
.textContent();
const dumpPathMatch = messagesListText?.match(
/\[\[dyad-dump-path=([^\]]+)\]\]/,
);
if (!dumpPathMatch) {
throw new Error("No dump path found in messages list");
}
const dumpFilePath = dumpPathMatch[1];
const dumpContent = fs.readFileSync(dumpFilePath, "utf-8");
const parsedDump = JSON.parse(dumpContent);
// Get the last message from the dump
const messages = parsedDump.body.messages;
const lastMessage = messages[messages.length - 1];
expect(lastMessage).toBeTruthy();
expect(lastMessage.content).toBeTruthy();
// The content is an array with text and image parts
expect(Array.isArray(lastMessage.content)).toBe(true);
// Find the text part and verify it mentions the PNG attachment
const textPart = lastMessage.content.find(
(part: any) => part.type === "text",
);
expect(textPart).toBeTruthy();
expect(textPart.text).toMatch(/annotated-screenshot-.*\.png/);
expect(textPart.text).toMatch(/image\/png/);
// Find the image part and verify it has the correct structure
const imagePart = lastMessage.content.find(
(part: any) => part.type === "image_url",
);
expect(imagePart).toBeTruthy();
expect(imagePart.image_url).toBeTruthy();
expect(imagePart.image_url.url).toMatch(/^data:image\/png;base64,/);
},
);

View File

@@ -1,5 +1,4 @@
import { testSkipIfWindows, test } from "./helpers/test_helper"; import { testSkipIfWindows, test } from "./helpers/test_helper";
import { expect } from "@playwright/test";
testSkipIfWindows("fix error with AI", async ({ po }) => { testSkipIfWindows("fix error with AI", async ({ po }) => {
await po.setUp({ autoApprove: true }); await po.setUp({ autoApprove: true });
@@ -21,26 +20,6 @@ testSkipIfWindows("fix error with AI", async ({ po }) => {
await po.snapshotPreview(); await po.snapshotPreview();
}); });
testSkipIfWindows("copy error message from banner", async ({ po }) => {
await po.setUp({ autoApprove: true });
await po.sendPrompt("tc=create-error");
await po.page.getByText("Error Line 6 error", { exact: true }).waitFor({
state: "visible",
});
await po.clickCopyErrorMessage();
const clipboardText = await po.getClipboardText();
expect(clipboardText).toContain("Error Line 6 error");
expect(clipboardText.length).toBeGreaterThan(0);
await expect(po.page.getByRole("button", { name: "Copied" })).toBeVisible();
await expect(po.page.getByRole("button", { name: "Copied" })).toBeHidden({
timeout: 3000,
});
});
test("fix all errors button", async ({ po }) => { test("fix all errors button", async ({ po }) => {
await po.setUp({ autoApprove: true }); await po.setUp({ autoApprove: true });
await po.sendPrompt("tc=create-multiple-errors"); await po.sendPrompt("tc=create-multiple-errors");

View File

@@ -2,4 +2,3 @@ Here is a simple response to test the context limit banner functionality.
This message simulates being close to the model's context window limit. This message simulates being close to the model's context window limit.

View File

@@ -1 +0,0 @@
This is a simple basic response

View File

@@ -553,22 +553,6 @@ export class PageObject {
await this.page.getByTestId("preview-open-browser-button").click(); await this.page.getByTestId("preview-open-browser-button").click();
} }
async clickPreviewAnnotatorButton() {
await this.page
.getByTestId("preview-annotator-button")
.click({ timeout: Timeout.EXTRA_LONG });
}
async waitForAnnotatorMode() {
// Wait for the annotator toolbar to be visible
await expect(this.page.getByRole("button", { name: "Select" })).toBeVisible(
{ timeout: Timeout.MEDIUM },
);
}
async clickAnnotatorSubmit() {
await this.page.getByRole("button", { name: "Add to Chat" }).click();
}
locateLoadingAppPreview() { locateLoadingAppPreview() {
return this.page.getByText("Preparing app preview..."); return this.page.getByText("Preparing app preview...");
} }
@@ -591,13 +575,6 @@ export class PageObject {
await this.page.getByRole("button", { name: "Fix error with AI" }).click(); await this.page.getByRole("button", { name: "Fix error with AI" }).click();
} }
async clickCopyErrorMessage() {
await this.page.getByRole("button", { name: /Copy/ }).click();
}
async getClipboardText(): Promise<string> {
return await this.page.evaluate(() => navigator.clipboard.readText());
}
async clickFixAllErrors() { async clickFixAllErrors() {
await this.page.getByRole("button", { name: /Fix All Errors/ }).click(); await this.page.getByRole("button", { name: /Fix All Errors/ }).click();
} }

View File

@@ -1,150 +0,0 @@
import { expect } from "@playwright/test";
import { Timeout, testWithConfig } from "./helpers/test_helper";
import * as fs from "node:fs";
import * as path from "node:path";
testWithConfig({
preLaunchHook: async ({ userDataDir }) => {
// Set up a force-close scenario by creating settings with isRunning: true
// and lastKnownPerformance data
const settingsPath = path.join(userDataDir, "user-settings.json");
const settings = {
hasRunBefore: true,
isRunning: true, // Simulate force-close
enableAutoUpdate: false,
releaseChannel: "stable",
lastKnownPerformance: {
timestamp: Date.now() - 5000, // 5 seconds ago
memoryUsageMB: 256,
cpuUsagePercent: 45.5,
systemMemoryUsageMB: 8192,
systemMemoryTotalMB: 16384,
systemMemoryPercent: 50.0,
systemCpuPercent: 35.2,
},
};
fs.mkdirSync(userDataDir, { recursive: true });
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
},
})(
"force-close detection shows dialog with performance data",
async ({ po }) => {
// Wait for the home page to be visible first
await expect(po.getHomeChatInputContainer()).toBeVisible({
timeout: Timeout.LONG,
});
// Check if the force-close dialog is visible by looking for the heading
await expect(
po.page.getByRole("heading", { name: "Force Close Detected" }),
).toBeVisible({ timeout: Timeout.MEDIUM });
// Verify the warning message
await expect(
po.page.getByText(
"The app was not closed properly the last time it was running",
),
).toBeVisible();
// Verify performance data is displayed
await expect(po.page.getByText("Last Known State:")).toBeVisible();
// Check Process Metrics section
await expect(po.page.getByText("Process Metrics")).toBeVisible();
await expect(po.page.getByText("256 MB")).toBeVisible();
await expect(po.page.getByText("45.5%")).toBeVisible();
// Check System Metrics section
await expect(po.page.getByText("System Metrics")).toBeVisible();
await expect(po.page.getByText("8192 / 16384 MB")).toBeVisible();
await expect(po.page.getByText("35.2%")).toBeVisible();
// Close the dialog
await po.page.getByRole("button", { name: "OK" }).click();
// Verify dialog is closed by checking the heading is no longer visible
await expect(
po.page.getByRole("heading", { name: "Force Close Detected" }),
).not.toBeVisible();
},
);
testWithConfig({
preLaunchHook: async ({ userDataDir }) => {
// Set up scenario without force-close (proper shutdown)
const settingsPath = path.join(userDataDir, "user-settings.json");
const settings = {
hasRunBefore: true,
isRunning: false, // Proper shutdown - no force-close
enableAutoUpdate: false,
releaseChannel: "stable",
lastKnownPerformance: {
timestamp: Date.now() - 5000,
memoryUsageMB: 256,
cpuUsagePercent: 45.5,
systemMemoryUsageMB: 8192,
systemMemoryTotalMB: 16384,
systemMemoryPercent: 50.0,
systemCpuPercent: 35.2,
},
};
fs.mkdirSync(userDataDir, { recursive: true });
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
},
})("no force-close dialog when app was properly shut down", async ({ po }) => {
// Verify the home page loaded normally
await expect(po.getHomeChatInputContainer()).toBeVisible({
timeout: Timeout.LONG,
});
// Verify that the force-close dialog is NOT shown
await expect(
po.page.getByRole("heading", { name: "Force Close Detected" }),
).not.toBeVisible();
});
testWithConfig({})(
"performance information is being captured during normal operation",
async ({ po, electronApp }) => {
// Wait for the app to load
await expect(po.getHomeChatInputContainer()).toBeVisible({
timeout: Timeout.LONG,
});
// Get the user data directory
const userDataDir = (electronApp as any).$dyadUserDataDir;
const settingsPath = path.join(userDataDir, "user-settings.json");
// Wait a bit to allow performance monitoring to capture at least one data point
// Performance monitoring runs every 30 seconds, but we'll wait 35 seconds to be safe
await po.page.waitForTimeout(35000);
// Read the settings file
const settingsContent = fs.readFileSync(settingsPath, "utf-8");
const settings = JSON.parse(settingsContent);
// Verify that lastKnownPerformance exists and has all required fields
expect(settings.lastKnownPerformance).toBeDefined();
expect(settings.lastKnownPerformance.timestamp).toBeGreaterThan(0);
expect(settings.lastKnownPerformance.memoryUsageMB).toBeGreaterThan(0);
expect(
settings.lastKnownPerformance.cpuUsagePercent,
).toBeGreaterThanOrEqual(0);
expect(settings.lastKnownPerformance.systemMemoryUsageMB).toBeGreaterThan(
0,
);
expect(settings.lastKnownPerformance.systemMemoryTotalMB).toBeGreaterThan(
0,
);
expect(
settings.lastKnownPerformance.systemCpuPercent,
).toBeGreaterThanOrEqual(0);
// Verify the timestamp is recent (within the last minute)
const now = Date.now();
const timeDiff = now - settings.lastKnownPerformance.timestamp;
expect(timeDiff).toBeLessThan(60000); // Less than 1 minute old
},
);

View File

@@ -1,15 +0,0 @@
import { testSkipIfWindows } from "./helpers/test_helper";
testSkipIfWindows("smart context balanced - simple", async ({ po }) => {
await po.setUpDyadPro({ autoApprove: true });
const proModesDialog = await po.openProModesDialog({
location: "home-chat-input-container",
});
await proModesDialog.setSmartContextMode("balanced");
await proModesDialog.close();
await po.sendPrompt("[dump]");
await po.snapshotServerDump("request");
await po.snapshotMessages({ replaceDumpPath: true });
});

View File

@@ -16,6 +16,5 @@
"enableAutoFixProblems": false, "enableAutoFixProblems": false,
"enableAutoUpdate": false, "enableAutoUpdate": false,
"releaseChannel": "stable", "releaseChannel": "stable",
"isRunning": true,
"isTestMode": true "isTestMode": true
} }

View File

@@ -16,6 +16,5 @@
"enableAutoFixProblems": false, "enableAutoFixProblems": false,
"enableAutoUpdate": true, "enableAutoUpdate": true,
"releaseChannel": "stable", "releaseChannel": "stable",
"isRunning": true,
"isTestMode": true "isTestMode": true
} }

View File

@@ -18,6 +18,5 @@
"enableAutoFixProblems": false, "enableAutoFixProblems": false,
"enableAutoUpdate": true, "enableAutoUpdate": true,
"releaseChannel": "stable", "releaseChannel": "stable",
"isRunning": true,
"isTestMode": true "isTestMode": true
} }

View File

@@ -7,12 +7,11 @@
- img - img
- text: "src/pages/Index.tsx Summary: intentionally add first error" - text: "src/pages/Index.tsx Summary: intentionally add first error"
- img - img
- text: Error First error in Index... - text: Error
- img
- button "Copy":
- img
- button "Fix with AI": - button "Fix with AI":
- img - img
- text: First error in Index...
- img
- img - img
- text: ErrorComponent.tsx - text: ErrorComponent.tsx
- button "Edit": - button "Edit":
@@ -20,12 +19,11 @@
- img - img
- text: "src/components/ErrorComponent.tsx Summary: intentionally add second error" - text: "src/components/ErrorComponent.tsx Summary: intentionally add second error"
- img - img
- text: Error Second error in ErrorComponent... - text: Error
- img
- button "Copy":
- img
- button "Fix with AI": - button "Fix with AI":
- img - img
- text: Second error in ErrorComponent...
- img
- img - img
- text: helper.ts - text: helper.ts
- button "Edit": - button "Edit":
@@ -33,12 +31,11 @@
- img - img
- text: "src/utils/helper.ts Summary: intentionally add third error" - text: "src/utils/helper.ts Summary: intentionally add third error"
- img - img
- text: Error Third error in helper... - text: Error
- img
- button "Copy":
- img
- button "Fix with AI": - button "Fix with AI":
- img - img
- text: Third error in helper...
- img
- button "Fix All Errors (3)": - button "Fix All Errors (3)":
- img - img
- button: - button:

View File

@@ -16,6 +16,5 @@
"enableAutoFixProblems": false, "enableAutoFixProblems": false,
"enableAutoUpdate": true, "enableAutoUpdate": true,
"releaseChannel": "beta", "releaseChannel": "beta",
"isRunning": true,
"isTestMode": true "isTestMode": true
} }

View File

@@ -16,6 +16,5 @@
"enableAutoFixProblems": false, "enableAutoFixProblems": false,
"enableAutoUpdate": true, "enableAutoUpdate": true,
"releaseChannel": "stable", "releaseChannel": "stable",
"isRunning": true,
"isTestMode": true "isTestMode": true
} }

View File

@@ -1,12 +0,0 @@
- paragraph: "[dump]"
- paragraph: "[[dyad-dump-path=*]]"
- button:
- img
- img
- text: Approved
- img
- text: less than a minute ago
- button "Request ID":
- img
- button "Retry":
- img

File diff suppressed because one or more lines are too long

View File

@@ -24,6 +24,5 @@
"enableAutoFixProblems": false, "enableAutoFixProblems": false,
"enableAutoUpdate": true, "enableAutoUpdate": true,
"releaseChannel": "stable", "releaseChannel": "stable",
"isRunning": true,
"isTestMode": true "isTestMode": true
} }

View File

@@ -25,6 +25,5 @@
"enableAutoFixProblems": false, "enableAutoFixProblems": false,
"enableAutoUpdate": true, "enableAutoUpdate": true,
"releaseChannel": "stable", "releaseChannel": "stable",
"isRunning": true,
"isTestMode": true "isTestMode": true
} }

View File

@@ -24,6 +24,5 @@
"enableAutoFixProblems": false, "enableAutoFixProblems": false,
"enableAutoUpdate": true, "enableAutoUpdate": true,
"releaseChannel": "stable", "releaseChannel": "stable",
"isRunning": true,
"isTestMode": true "isTestMode": true
} }

View File

@@ -25,6 +25,5 @@
"enableAutoFixProblems": false, "enableAutoFixProblems": false,
"enableAutoUpdate": true, "enableAutoUpdate": true,
"releaseChannel": "stable", "releaseChannel": "stable",
"isRunning": true,
"isTestMode": true "isTestMode": true
} }

View File

@@ -15,6 +15,5 @@
"enableAutoFixProblems": false, "enableAutoFixProblems": false,
"enableAutoUpdate": true, "enableAutoUpdate": true,
"releaseChannel": "stable", "releaseChannel": "stable",
"isRunning": true,
"isTestMode": true "isTestMode": true
} }

View File

@@ -16,6 +16,5 @@
"enableAutoFixProblems": false, "enableAutoFixProblems": false,
"enableAutoUpdate": true, "enableAutoUpdate": true,
"releaseChannel": "stable", "releaseChannel": "stable",
"isRunning": true,
"isTestMode": true "isTestMode": true
} }

View File

@@ -15,6 +15,5 @@
"enableAutoFixProblems": false, "enableAutoFixProblems": false,
"enableAutoUpdate": true, "enableAutoUpdate": true,
"releaseChannel": "stable", "releaseChannel": "stable",
"isRunning": true,
"isTestMode": true "isTestMode": true
} }

View File

@@ -16,6 +16,5 @@
"enableAutoFixProblems": false, "enableAutoFixProblems": false,
"enableAutoUpdate": true, "enableAutoUpdate": true,
"releaseChannel": "stable", "releaseChannel": "stable",
"isRunning": true,
"isTestMode": true "isTestMode": true
} }

View File

@@ -15,6 +15,5 @@
"enableAutoFixProblems": false, "enableAutoFixProblems": false,
"enableAutoUpdate": true, "enableAutoUpdate": true,
"releaseChannel": "stable", "releaseChannel": "stable",
"isRunning": true,
"isTestMode": true "isTestMode": true
} }

View File

@@ -16,6 +16,5 @@
"enableAutoFixProblems": false, "enableAutoFixProblems": false,
"enableAutoUpdate": true, "enableAutoUpdate": true,
"releaseChannel": "stable", "releaseChannel": "stable",
"isRunning": true,
"isTestMode": true "isTestMode": true
} }

View File

@@ -16,6 +16,5 @@
"enableAutoFixProblems": false, "enableAutoFixProblems": false,
"enableAutoUpdate": true, "enableAutoUpdate": true,
"releaseChannel": "stable", "releaseChannel": "stable",
"isRunning": true,
"isTestMode": true "isTestMode": true
} }

View File

@@ -17,6 +17,5 @@
"enableAutoFixProblems": false, "enableAutoFixProblems": false,
"enableAutoUpdate": true, "enableAutoUpdate": true,
"releaseChannel": "stable", "releaseChannel": "stable",
"isRunning": true,
"isTestMode": true "isTestMode": true
} }

View File

@@ -17,6 +17,5 @@
"enableAutoFixProblems": false, "enableAutoFixProblems": false,
"enableAutoUpdate": true, "enableAutoUpdate": true,
"releaseChannel": "stable", "releaseChannel": "stable",
"isRunning": true,
"isTestMode": true "isTestMode": true
} }

View File

@@ -25,6 +25,5 @@
"enableAutoFixProblems": false, "enableAutoFixProblems": false,
"enableAutoUpdate": true, "enableAutoUpdate": true,
"releaseChannel": "stable", "releaseChannel": "stable",
"isRunning": true,
"isTestMode": true "isTestMode": true
} }

View File

@@ -8,6 +8,14 @@
"role": "system", "role": "system",
"content": "[[SYSTEM_MESSAGE]]" "content": "[[SYSTEM_MESSAGE]]"
}, },
{
"role": "user",
"content": "tc=1"
},
{
"role": "assistant",
"content": "Error: Test case file not found: 1.md"
},
{ {
"role": "user", "role": "user",
"content": "[dump] hi" "content": "[dump] hi"

View File

@@ -25,6 +25,5 @@
"enableAutoFixProblems": false, "enableAutoFixProblems": false,
"enableAutoUpdate": true, "enableAutoUpdate": true,
"releaseChannel": "stable", "releaseChannel": "stable",
"isRunning": true,
"isTestMode": true "isTestMode": true
} }

View File

@@ -8,6 +8,22 @@
"role": "system", "role": "system",
"content": "[[SYSTEM_MESSAGE]]" "content": "[[SYSTEM_MESSAGE]]"
}, },
{
"role": "user",
"content": "tc=1"
},
{
"role": "assistant",
"content": "Error: Test case file not found: 1.md"
},
{
"role": "user",
"content": "[dump] hi"
},
{
"role": "assistant",
"content": "[[dyad-dump-path=*]]"
},
{ {
"role": "user", "role": "user",
"content": "[dump] hi" "content": "[dump] hi"

View File

@@ -25,6 +25,5 @@
"enableAutoFixProblems": false, "enableAutoFixProblems": false,
"enableAutoUpdate": true, "enableAutoUpdate": true,
"releaseChannel": "stable", "releaseChannel": "stable",
"isRunning": true,
"isTestMode": true "isTestMode": true
} }

View File

@@ -8,6 +8,30 @@
"role": "system", "role": "system",
"content": "[[SYSTEM_MESSAGE]]" "content": "[[SYSTEM_MESSAGE]]"
}, },
{
"role": "user",
"content": "tc=1"
},
{
"role": "assistant",
"content": "Error: Test case file not found: 1.md"
},
{
"role": "user",
"content": "[dump] hi"
},
{
"role": "assistant",
"content": "[[dyad-dump-path=*]]"
},
{
"role": "user",
"content": "[dump] hi"
},
{
"role": "assistant",
"content": "[[dyad-dump-path=*]]"
},
{ {
"role": "user", "role": "user",
"content": "[dump] hi" "content": "[dump] hi"

View File

@@ -24,6 +24,5 @@
"enableAutoFixProblems": false, "enableAutoFixProblems": false,
"enableAutoUpdate": true, "enableAutoUpdate": true,
"releaseChannel": "stable", "releaseChannel": "stable",
"isRunning": true,
"isTestMode": true "isTestMode": true
} }

View File

@@ -25,6 +25,5 @@
"enableAutoFixProblems": false, "enableAutoFixProblems": false,
"enableAutoUpdate": true, "enableAutoUpdate": true,
"releaseChannel": "stable", "releaseChannel": "stable",
"isRunning": true,
"isTestMode": true "isTestMode": true
} }

View File

@@ -25,6 +25,5 @@
"enableAutoFixProblems": false, "enableAutoFixProblems": false,
"enableAutoUpdate": true, "enableAutoUpdate": true,
"releaseChannel": "stable", "releaseChannel": "stable",
"isRunning": true,
"isTestMode": true "isTestMode": true
} }

View File

@@ -25,6 +25,5 @@
"enableAutoFixProblems": false, "enableAutoFixProblems": false,
"enableAutoUpdate": true, "enableAutoUpdate": true,
"releaseChannel": "stable", "releaseChannel": "stable",
"isRunning": true,
"isTestMode": true "isTestMode": true
} }

View File

@@ -18,7 +18,7 @@
}, },
{ {
"role": "user", "role": "user",
"content": "There was an issue with the following `dyad-search-replace` tags. Make sure you use `dyad-read` to read the latest version of the file and then trying to do search & replace again.\n \nFile path: src/pages/Index.tsx\nError: Unable to apply search-replace to file because: Search block did not match any content in the target file. Best fuzzy match had similarity of 0.0% (threshold: 90.0%)" "content": "There was an issue with the following `dyad-search-replace` tags. Make sure you use `dyad-read` to read the latest version of the file and then trying to do search & replace again.\n \nFile path: src/pages/Index.tsx\nError: Unable to apply search-replace to file"
}, },
{ {
"role": "assistant", "role": "assistant",
@@ -26,7 +26,7 @@
}, },
{ {
"role": "user", "role": "user",
"content": "There was an issue with the following `dyad-search-replace` tags. Please fix the errors by generating the code changes using `dyad-write` tags instead.\n \nFile path: src/pages/Index.tsx\nError: Unable to apply search-replace to file because: Search block did not match any content in the target file. Best fuzzy match had similarity of 0.0% (threshold: 90.0%)" "content": "There was an issue with the following `dyad-search-replace` tags. Please fix the errors by generating the code changes using `dyad-write` tags instead.\n \nFile path: src/pages/Index.tsx\nError: Unable to apply search-replace to file"
} }
], ],
"stream": true, "stream": true,

View File

@@ -1,18 +0,0 @@
=== src/pages/Index.tsx ===
// Update this page (the content is just a fallback if you fail to update the page)
import { MadeWithDyad } from "@/components/made-with-dyad";
const Index = () => {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="text-center">
<h1 className="text-4xl font-bold mx-[20px] my-[10px]">Welcome to Your Blank App</h1>
<p className="text-xl text-gray-600">Start building your amazing project here!
</p>
</div>
<MadeWithDyad />
</div>
);
};
export default Index;

View File

@@ -1,18 +0,0 @@
=== src/pages/Index.tsx ===
// Update this page (the content is just a fallback if you fail to update the page)
import { MadeWithDyad } from "@/components/made-with-dyad";
const Index = () => {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="text-center">
<h1 className="text-4xl font-bold mb-4">Hello from E2E Test</h1>
<p className="text-xl text-gray-600">Start building your amazing project here!
</p>
</div>
<MadeWithDyad />
</div>
);
};
export default Index;

View File

@@ -1,8 +1,8 @@
import { PageObject, testSkipIfWindows, Timeout } from "./helpers/test_helper"; import { testSkipIfWindows, Timeout } from "./helpers/test_helper";
import { expect } from "@playwright/test"; import { expect } from "@playwright/test";
const runUndoTest = async (po: PageObject, nativeGit: boolean) => { testSkipIfWindows("undo", async ({ po }) => {
await po.setUp({ autoApprove: true, nativeGit }); await po.setUp({ autoApprove: true });
await po.sendPrompt("tc=write-index"); await po.sendPrompt("tc=write-index");
await po.sendPrompt("tc=write-index-2"); await po.sendPrompt("tc=write-index-2");
@@ -31,12 +31,4 @@ const runUndoTest = async (po: PageObject, nativeGit: boolean) => {
// Also, could be slow. // Also, could be slow.
timeout: Timeout.LONG, timeout: Timeout.LONG,
}); });
};
testSkipIfWindows("undo", async ({ po }) => {
await runUndoTest(po, false);
});
testSkipIfWindows("undo with native git", async ({ po }) => {
await runUndoTest(po, true);
}); });

View File

@@ -1,219 +0,0 @@
import { expect } from "@playwright/test";
import { testSkipIfWindows, Timeout } from "./helpers/test_helper";
const fs = require("fs");
const path = require("path");
testSkipIfWindows("edit style of one selected component", async ({ po }) => {
await po.setUpDyadPro();
await po.sendPrompt("tc=basic");
await po.clickTogglePreviewPanel();
await po.clickPreviewPickElement();
// Select a component
await po
.getPreviewIframeElement()
.contentFrame()
.getByRole("heading", { name: "Welcome to Your Blank App" })
.click();
// Wait for the toolbar to appear (check for the Margin button which is always visible)
const marginButton = po.page.getByRole("button", { name: "Margin" });
await expect(marginButton).toBeVisible({
timeout: Timeout.MEDIUM,
});
// Ensure the toolbar has proper coordinates before clicking
await expect(async () => {
const box = await marginButton.boundingBox();
expect(box).not.toBeNull();
expect(box!.y).toBeGreaterThan(0);
}).toPass({ timeout: Timeout.MEDIUM });
// Click on margin button to open the margin popover
await marginButton.click();
// Wait for the popover to fully open by checking for the popover content container
const marginDialog = po.page
.locator('[role="dialog"]')
.filter({ hasText: "Margin" });
await expect(marginDialog).toBeVisible({
timeout: Timeout.LONG,
});
// Edit margin - set horizontal margin
const marginXInput = po.page.getByLabel("Horizontal");
await marginXInput.fill("20");
// Edit margin - set vertical margin
const marginYInput = po.page.getByLabel("Vertical");
await marginYInput.fill("10");
// Close the popover by clicking outside or pressing escape
await po.page.keyboard.press("Escape");
// Check if the changes are applied to UI by verifying the visual changes dialog appears
await expect(po.page.getByText(/\d+ component[s]? modified/)).toBeVisible({
timeout: Timeout.MEDIUM,
});
// Save the changes
await po.page.getByRole("button", { name: "Save Changes" }).click();
// Wait for the success toast
await po.waitForToastWithText("Visual changes saved to source files");
// Verify that the changes are applied to codebase
await po.snapshotAppFiles({
name: "visual-editing-single-component-margin",
files: ["src/pages/Index.tsx"],
});
});
testSkipIfWindows("edit text of the selected component", async ({ po }) => {
await po.setUpDyadPro();
await po.sendPrompt("tc=basic");
await po.clickTogglePreviewPanel();
await po.clickPreviewPickElement();
// Click on component that contains static text
await po
.getPreviewIframeElement()
.contentFrame()
.getByRole("heading", { name: "Welcome to Your Blank App" })
.click();
// Wait for the toolbar to appear (check for the Margin button which is always visible)
await expect(po.page.getByRole("button", { name: "Margin" })).toBeVisible({
timeout: Timeout.MEDIUM,
});
// Get the iframe and access the content
const iframe = po.getPreviewIframeElement();
const frame = iframe.contentFrame();
// Find the heading element in the iframe
const heading = frame.getByRole("heading", {
name: "Welcome to Your Blank App",
});
await heading.dblclick();
// Wait for contentEditable to be enabled
await expect(async () => {
const isEditable = await heading.evaluate(
(el) => (el as HTMLElement).isContentEditable,
);
expect(isEditable).toBe(true);
}).toPass({ timeout: Timeout.MEDIUM });
// Clear the existing text and type new text
await heading.press("Meta+A");
await heading.type("Hello from E2E Test");
// Click outside to finish editing
await frame.locator("body").click({ position: { x: 10, y: 10 } });
// Verify the changes are applied in the UI
await expect(frame.getByText("Hello from E2E Test")).toBeVisible({
timeout: Timeout.MEDIUM,
});
// Verify the visual changes dialog appears
await expect(po.page.getByText(/\d+ component[s]? modified/)).toBeVisible({
timeout: Timeout.MEDIUM,
});
// Save the changes
await po.page.getByRole("button", { name: "Save Changes" }).click();
// Wait for the success toast
await po.waitForToastWithText("Visual changes saved to source files");
// Verify that the changes are applied to the codebase
await po.snapshotAppFiles({
name: "visual-editing-text-content",
files: ["src/pages/Index.tsx"],
});
});
testSkipIfWindows("discard changes", async ({ po }) => {
await po.setUpDyadPro();
await po.sendPrompt("tc=basic");
await po.clickTogglePreviewPanel();
await po.clickPreviewPickElement();
// Select a component
await po
.getPreviewIframeElement()
.contentFrame()
.getByRole("heading", { name: "Welcome to Your Blank App" })
.click();
// Wait for the toolbar to appear (check for the Margin button which is always visible)
const marginButton = po.page.getByRole("button", { name: "Margin" });
await expect(marginButton).toBeVisible({
timeout: Timeout.MEDIUM,
});
// Ensure the toolbar has proper coordinates before clicking
await expect(async () => {
const box = await marginButton.boundingBox();
expect(box).not.toBeNull();
expect(box!.y).toBeGreaterThan(0);
}).toPass({ timeout: Timeout.MEDIUM });
// Click on margin button to open the margin popover
await marginButton.click();
// Wait for the popover to fully open by checking for the popover content container
const marginDialog = po.page
.locator('[role="dialog"]')
.filter({ hasText: "Margin" });
await expect(marginDialog).toBeVisible({
timeout: Timeout.LONG,
});
// Edit margin
const marginXInput = po.page.getByLabel("Horizontal");
await marginXInput.fill("30");
const marginYInput = po.page.getByLabel("Vertical");
await marginYInput.fill("30");
// Close the popover
await po.page.keyboard.press("Escape");
// Wait for the popover to close
await expect(marginDialog).not.toBeVisible({
timeout: Timeout.MEDIUM,
});
// Check if the changes are applied to UI
await expect(po.page.getByText(/\d+ component[s]? modified/)).toBeVisible({
timeout: Timeout.MEDIUM,
});
// Take a snapshot of the app files before discarding
const appPathBefore = await po.getCurrentAppPath();
const appFileBefore = fs.readFileSync(
path.join(appPathBefore, "src", "pages", "Index.tsx"),
"utf-8",
);
// Discard the changes
await po.page.getByRole("button", { name: "Discard" }).click();
// Verify the visual changes dialog is gone
await expect(po.page.getByText(/\d+ component[s]? modified/)).not.toBeVisible(
{ timeout: Timeout.MEDIUM },
);
// Verify that the changes are NOT applied to codebase
const appFileAfter = fs.readFileSync(
path.join(appPathBefore, "src", "pages", "Index.tsx"),
"utf-8",
);
// The file content should be the same as before
expect(appFileAfter).toBe(appFileBefore);
});

View File

@@ -32,9 +32,6 @@ const ignore = (file: string) => {
if (file.startsWith("/node_modules/stacktrace-js/dist")) { if (file.startsWith("/node_modules/stacktrace-js/dist")) {
return false; return false;
} }
if (file.startsWith("/node_modules/html-to-image")) {
return false;
}
if (file.startsWith("/node_modules/better-sqlite3")) { if (file.startsWith("/node_modules/better-sqlite3")) {
return false; return false;
} }
@@ -77,7 +74,6 @@ const config: ForgeConfig = {
}, },
asar: true, asar: true,
ignore, ignore,
extraResource: ["node_modules/dugite/git"],
// ignore: [/node_modules\/(?!(better-sqlite3|bindings|file-uri-to-path)\/)/], // ignore: [/node_modules\/(?!(better-sqlite3|bindings|file-uri-to-path)\/)/],
}, },
rebuildConfig: { rebuildConfig: {

View File

@@ -1,7 +1,4 @@
export default { export default {
testDir: "e2e-tests", testDir: "e2e-tests",
reporter: [ reporter: [["html", { open: "never" }]],
["html", { open: "never" }],
["json", { outputFile: "playwright-report/results.json" }],
],
}; };

820
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{ {
"name": "dyad", "name": "dyad",
"productName": "dyad", "productName": "dyad",
"version": "0.30.0-beta.1", "version": "0.29.0-beta.1",
"description": "Free, local, open-source AI app builder", "description": "Free, local, open-source AI app builder",
"main": ".vite/build/main.js", "main": ".vite/build/main.js",
"repository": { "repository": {
@@ -94,7 +94,6 @@
"@ai-sdk/openai-compatible": "^1.0.8", "@ai-sdk/openai-compatible": "^1.0.8",
"@ai-sdk/provider-utils": "^3.0.3", "@ai-sdk/provider-utils": "^3.0.3",
"@ai-sdk/xai": "^2.0.16", "@ai-sdk/xai": "^2.0.16",
"@babel/parser": "^7.28.5",
"@biomejs/biome": "^1.9.4", "@biomejs/biome": "^1.9.4",
"@dyad-sh/supabase-management-js": "v1.0.1", "@dyad-sh/supabase-management-js": "v1.0.1",
"@lexical/react": "^0.33.1", "@lexical/react": "^0.33.1",
@@ -135,7 +134,6 @@
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"drizzle-orm": "^0.41.0", "drizzle-orm": "^0.41.0",
"dugite": "^3.0.0",
"electron-log": "^5.3.3", "electron-log": "^5.3.3",
"electron-playwright-helpers": "^1.7.1", "electron-playwright-helpers": "^1.7.1",
"electron-squirrel-startup": "^1.0.1", "electron-squirrel-startup": "^1.0.1",
@@ -145,25 +143,20 @@
"framer-motion": "^12.6.3", "framer-motion": "^12.6.3",
"geist": "^1.3.1", "geist": "^1.3.1",
"glob": "^11.0.2", "glob": "^11.0.2",
"html-to-image": "^1.11.13",
"isomorphic-git": "^1.30.1", "isomorphic-git": "^1.30.1",
"jotai": "^2.12.2", "jotai": "^2.12.2",
"kill-port": "^2.0.1", "kill-port": "^2.0.1",
"konva": "^10.0.12",
"lexical": "^0.33.1", "lexical": "^0.33.1",
"lexical-beautiful-mentions": "^0.1.47", "lexical-beautiful-mentions": "^0.1.47",
"lucide-react": "^0.487.0", "lucide-react": "^0.487.0",
"monaco-editor": "^0.52.2", "monaco-editor": "^0.52.2",
"openai": "^4.91.1", "openai": "^4.91.1",
"perfect-freehand": "^1.2.2",
"posthog-js": "^1.236.3", "posthog-js": "^1.236.3",
"react": "^19.0.0", "react": "^19.2.1",
"react-dom": "^19.0.0", "react-dom": "^19.2.1",
"react-konva": "^19.2.1",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-resizable-panels": "^2.1.7", "react-resizable-panels": "^2.1.7",
"react-shiki": "^0.9.0", "react-shiki": "^0.5.2",
"recast": "^0.23.11",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"shell-env": "^4.0.1", "shell-env": "^4.0.1",
"shiki": "^3.2.1", "shiki": "^3.2.1",

View File

@@ -1,350 +0,0 @@
// This script parses Playwright JSON results and generates a PR comment summary
// Used by the CI workflow's merge-reports job
const fs = require("fs");
// Strip ANSI escape codes from terminal output
function stripAnsi(str) {
if (!str) return str;
// eslint-disable-next-line no-control-regex
return str.replace(/\x1b\[[0-9;]*m/g, "").replace(/\u001b\[[0-9;]*m/g, "");
}
function ensureOsBucket(resultsByOs, os) {
if (!os) return;
if (!resultsByOs[os]) {
resultsByOs[os] = {
passed: 0,
failed: 0,
skipped: 0,
flaky: 0,
failures: [],
flakyTests: [],
};
}
}
function detectOperatingSystemsFromReport(report) {
const detected = new Set();
function traverseSuites(suites = []) {
for (const suite of suites) {
for (const spec of suite.specs || []) {
for (const test of spec.tests || []) {
for (const result of test.results || []) {
for (const attachment of result.attachments || []) {
const p = attachment.path || "";
if (p.includes("darwin") || p.includes("macos")) {
detected.add("macOS");
} else if (p.includes("win32") || p.includes("windows")) {
detected.add("Windows");
}
}
const stack = result.error?.stack || "";
if (stack.includes("/Users/")) {
detected.add("macOS");
} else if (stack.includes("C:\\") || stack.includes("D:\\")) {
detected.add("Windows");
}
}
}
}
if (suite.suites?.length) {
traverseSuites(suite.suites);
}
}
}
traverseSuites(report?.suites);
return detected;
}
function determineIssueNumber({ context }) {
const envNumber = process.env.PR_NUMBER;
if (envNumber) return Number(envNumber);
if (context.eventName === "workflow_run") {
const prFromPayload =
context.payload?.workflow_run?.pull_requests?.[0]?.number;
if (prFromPayload) return prFromPayload;
} else {
throw new Error("This script should only be run in a workflow_run")
}
return null;
}
async function run({ github, context, core }) {
// Read the JSON report
const reportPath = "playwright-report/results.json";
if (!fs.existsSync(reportPath)) {
console.log("No results.json found, skipping comment");
return;
}
const report = JSON.parse(fs.readFileSync(reportPath, "utf8"));
// Identify which OS each blob report came from
const blobDir = "all-blob-reports";
const blobFiles = fs.existsSync(blobDir) ? fs.readdirSync(blobDir) : [];
const hasMacOS = blobFiles.some((f) => f.includes("darwin"));
const hasWindows = blobFiles.some((f) => f.includes("win32"));
// Initialize per-OS results
const resultsByOs = {};
if (hasMacOS) ensureOsBucket(resultsByOs, "macOS");
if (hasWindows) ensureOsBucket(resultsByOs, "Windows");
if (Object.keys(resultsByOs).length === 0) {
const detected = detectOperatingSystemsFromReport(report);
if (detected.size === 0) {
ensureOsBucket(resultsByOs, "macOS");
ensureOsBucket(resultsByOs, "Windows");
} else {
for (const os of detected) ensureOsBucket(resultsByOs, os);
}
}
// Traverse suites and collect test results
function traverseSuites(suites, parentTitle = "") {
for (const suite of suites || []) {
const suiteTitle = parentTitle
? `${parentTitle} > ${suite.title}`
: suite.title;
for (const spec of suite.specs || []) {
for (const test of spec.tests || []) {
const results = test.results || [];
if (results.length === 0) continue;
// Use the final result (last retry attempt) to determine the test outcome
const finalResult = results[results.length - 1];
// Determine OS from attachments in any result (they contain platform paths)
let os = null;
for (const result of results) {
for (const att of result.attachments || []) {
const p = att.path || "";
if (p.includes("darwin") || p.includes("macos")) {
os = "macOS";
break;
}
if (p.includes("win32") || p.includes("windows")) {
os = "Windows";
break;
}
}
if (os) break;
// Fallback: check error stack for OS paths
if (result.error?.stack) {
if (result.error.stack.includes("/Users/")) {
os = "macOS";
break;
} else if (
result.error.stack.includes("C:\\") ||
result.error.stack.includes("D:\\")
) {
os = "Windows";
break;
}
}
}
// If we still don't know, assign to both (will be roughly split)
const osTargets = os
? [os]
: Object.keys(resultsByOs).length > 0
? Object.keys(resultsByOs)
: ["macOS", "Windows"];
// Check if this is a flaky test (passed eventually but had prior failures)
const hadPriorFailure = results
.slice(0, -1)
.some(
(r) =>
r.status === "failed" ||
r.status === "timedOut" ||
r.status === "interrupted",
);
const isFlaky = finalResult.status === "passed" && hadPriorFailure;
for (const targetOs of osTargets) {
ensureOsBucket(resultsByOs, targetOs);
const status = finalResult.status;
if (isFlaky) {
resultsByOs[targetOs].flaky++;
resultsByOs[targetOs].passed++;
resultsByOs[targetOs].flakyTests.push({
title: `${suiteTitle} > ${spec.title}`,
retries: results.length - 1,
});
} else if (status === "passed") {
resultsByOs[targetOs].passed++;
} else if (
status === "failed" ||
status === "timedOut" ||
status === "interrupted"
) {
resultsByOs[targetOs].failed++;
const errorMsg =
finalResult.error?.message?.split("\n")[0] || "Test failed";
resultsByOs[targetOs].failures.push({
title: `${suiteTitle} > ${spec.title}`,
error: stripAnsi(errorMsg),
});
} else if (status === "skipped") {
resultsByOs[targetOs].skipped++;
}
}
}
}
// Recurse into nested suites
if (suite.suites) {
traverseSuites(suite.suites, suiteTitle);
}
}
}
traverseSuites(report.suites);
// Calculate totals
let totalPassed = 0,
totalFailed = 0,
totalSkipped = 0,
totalFlaky = 0;
for (const os of Object.keys(resultsByOs)) {
totalPassed += resultsByOs[os].passed;
totalFailed += resultsByOs[os].failed;
totalSkipped += resultsByOs[os].skipped;
totalFlaky += resultsByOs[os].flaky;
}
// Build the comment
let comment = "## 🎭 Playwright Test Results\n\n";
const allPassed = totalFailed === 0;
if (allPassed) {
comment += "### ✅ All tests passed!\n\n";
comment += "| OS | Passed | Flaky | Skipped |\n";
comment += "|:---|:---:|:---:|:---:|\n";
for (const [os, data] of Object.entries(resultsByOs)) {
const emoji = os === "macOS" ? "🍎" : "🪟";
comment += `| ${emoji} ${os} | ${data.passed} | ${data.flaky} | ${data.skipped} |\n`;
}
comment += `\n**Total: ${totalPassed} tests passed**`;
if (totalFlaky > 0) comment += ` (${totalFlaky} flaky)`;
if (totalSkipped > 0) comment += ` (${totalSkipped} skipped)`;
// List flaky tests even when all passed
if (totalFlaky > 0) {
comment += "\n\n### ⚠️ Flaky Tests\n\n";
for (const [os, data] of Object.entries(resultsByOs)) {
if (data.flakyTests.length === 0) continue;
const emoji = os === "macOS" ? "🍎" : "🪟";
comment += `#### ${emoji} ${os}\n\n`;
for (const f of data.flakyTests.slice(0, 10)) {
comment += `- \`${f.title}\` (passed after ${f.retries} ${f.retries === 1 ? "retry" : "retries"})\n`;
}
if (data.flakyTests.length > 10) {
comment += `- ... and ${data.flakyTests.length - 10} more\n`;
}
comment += "\n";
}
}
} else {
comment += "### ❌ Some tests failed\n\n";
comment += "| OS | Passed | Failed | Flaky | Skipped |\n";
comment += "|:---|:---:|:---:|:---:|:---:|\n";
for (const [os, data] of Object.entries(resultsByOs)) {
const emoji = os === "macOS" ? "🍎" : "🪟";
comment += `| ${emoji} ${os} | ${data.passed} | ${data.failed} | ${data.flaky} | ${data.skipped} |\n`;
}
comment += `\n**Summary: ${totalPassed} passed, ${totalFailed} failed**`;
if (totalFlaky > 0) comment += `, ${totalFlaky} flaky`;
if (totalSkipped > 0) comment += `, ${totalSkipped} skipped`;
comment += "\n\n### Failed Tests\n\n";
for (const [os, data] of Object.entries(resultsByOs)) {
if (data.failures.length === 0) continue;
const emoji = os === "macOS" ? "🍎" : "🪟";
comment += `#### ${emoji} ${os}\n\n`;
for (const f of data.failures.slice(0, 10)) {
const errorPreview =
f.error.length > 150 ? f.error.substring(0, 150) + "..." : f.error;
comment += `- \`${f.title}\`\n - ${errorPreview}\n`;
}
if (data.failures.length > 10) {
comment += `- ... and ${data.failures.length - 10} more\n`;
}
comment += "\n";
}
// List flaky tests
if (totalFlaky > 0) {
comment += "### ⚠️ Flaky Tests\n\n";
for (const [os, data] of Object.entries(resultsByOs)) {
if (data.flakyTests.length === 0) continue;
const emoji = os === "macOS" ? "🍎" : "🪟";
comment += `#### ${emoji} ${os}\n\n`;
for (const f of data.flakyTests.slice(0, 10)) {
comment += `- \`${f.title}\` (passed after ${f.retries} ${f.retries === 1 ? "retry" : "retries"})\n`;
}
if (data.flakyTests.length > 10) {
comment += `- ... and ${data.flakyTests.length - 10} more\n`;
}
comment += "\n";
}
}
}
const repoUrl = `https://github.com/${process.env.GITHUB_REPOSITORY}`;
const runId = process.env.PLAYWRIGHT_RUN_ID || process.env.GITHUB_RUN_ID;
comment += `\n---\n📊 [View full report](${repoUrl}/actions/runs/${runId})`;
// Post or update comment on PR
const prNumber = determineIssueNumber({ context });
if (prNumber) {
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
});
const botComment = comments.find(
(c) =>
c.user?.type === "Bot" &&
c.body?.includes("🎭 Playwright Test Results"),
);
if (botComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: comment,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: comment,
});
}
} else if (!prNumber) {
console.log("No pull request detected; skipping PR comment");
}
// Always output to job summary
await core.summary.addRaw(comment).write();
}
module.exports = { run };

View File

@@ -1,7 +0,0 @@
/**
* Calculate the port for a given app based on its ID.
* Uses a base port of 32100 and offsets by appId % 10_000.
*/
export function getAppPort(appId: number): number {
return 32100 + (appId % 10_000);
}

View File

@@ -13,9 +13,9 @@ import {
hasUnclosedDyadWrite, hasUnclosedDyadWrite,
} from "../ipc/handlers/chat_stream_handlers"; } from "../ipc/handlers/chat_stream_handlers";
import fs from "node:fs"; import fs from "node:fs";
import git from "isomorphic-git";
import { db } from "../db"; import { db } from "../db";
import { cleanFullResponse } from "../ipc/utils/cleanFullResponse"; import { cleanFullResponse } from "@/ipc/utils/cleanFullResponse";
import { gitAdd, gitRemove, gitCommit } from "../ipc/utils/git_utils";
// Mock fs with default export // Mock fs with default export
vi.mock("node:fs", async () => { vi.mock("node:fs", async () => {
@@ -43,19 +43,14 @@ vi.mock("node:fs", async () => {
}; };
}); });
// Mock Git utils // Mock isomorphic-git
vi.mock("../ipc/utils/git_utils", () => ({ vi.mock("isomorphic-git", () => ({
gitAdd: vi.fn(), default: {
gitCommit: vi.fn(), add: vi.fn().mockResolvedValue(undefined),
gitRemove: vi.fn(), remove: vi.fn().mockResolvedValue(undefined),
gitRenameBranch: vi.fn(), commit: vi.fn().mockResolvedValue(undefined),
gitCurrentBranch: vi.fn(), statusMatrix: vi.fn().mockResolvedValue([]),
gitLog: vi.fn(), },
gitInit: vi.fn(),
gitPush: vi.fn(),
gitSetRemoteUrl: vi.fn(),
gitStatus: vi.fn().mockResolvedValue([]),
getGitUncommittedFiles: vi.fn().mockResolvedValue([]),
})); }));
// Mock paths module to control getDyadAppPath // Mock paths module to control getDyadAppPath
@@ -708,12 +703,12 @@ describe("processFullResponse", () => {
"/mock/user/data/path/mock-app-path/src/file1.js", "/mock/user/data/path/mock-app-path/src/file1.js",
"console.log('Hello');", "console.log('Hello');",
); );
expect(gitAdd).toHaveBeenCalledWith( expect(git.add).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
filepath: "src/file1.js", filepath: "src/file1.js",
}), }),
); );
expect(gitCommit).toHaveBeenCalled(); expect(git.commit).toHaveBeenCalled();
expect(result).toEqual({ updatedFiles: true }); expect(result).toEqual({ updatedFiles: true });
}); });
@@ -788,24 +783,24 @@ describe("processFullResponse", () => {
); );
// Verify git operations were called for each file // Verify git operations were called for each file
expect(gitAdd).toHaveBeenCalledWith( expect(git.add).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
filepath: "src/file1.js", filepath: "src/file1.js",
}), }),
); );
expect(gitAdd).toHaveBeenCalledWith( expect(git.add).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
filepath: "src/utils/file2.js", filepath: "src/utils/file2.js",
}), }),
); );
expect(gitAdd).toHaveBeenCalledWith( expect(git.add).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
filepath: "src/components/Button.tsx", filepath: "src/components/Button.tsx",
}), }),
); );
// Verify commit was called once after all files were added // Verify commit was called once after all files were added
expect(gitCommit).toHaveBeenCalledTimes(1); expect(git.commit).toHaveBeenCalledTimes(1);
expect(result).toEqual({ updatedFiles: true }); expect(result).toEqual({ updatedFiles: true });
}); });
@@ -830,17 +825,17 @@ describe("processFullResponse", () => {
"/mock/user/data/path/mock-app-path/src/components/OldComponent.jsx", "/mock/user/data/path/mock-app-path/src/components/OldComponent.jsx",
"/mock/user/data/path/mock-app-path/src/components/NewComponent.jsx", "/mock/user/data/path/mock-app-path/src/components/NewComponent.jsx",
); );
expect(gitAdd).toHaveBeenCalledWith( expect(git.add).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
filepath: "src/components/NewComponent.jsx", filepath: "src/components/NewComponent.jsx",
}), }),
); );
expect(gitRemove).toHaveBeenCalledWith( expect(git.remove).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
filepath: "src/components/OldComponent.jsx", filepath: "src/components/OldComponent.jsx",
}), }),
); );
expect(gitCommit).toHaveBeenCalled(); expect(git.commit).toHaveBeenCalled();
expect(result).toEqual({ updatedFiles: true }); expect(result).toEqual({ updatedFiles: true });
}); });
@@ -857,7 +852,7 @@ describe("processFullResponse", () => {
expect(fs.mkdirSync).toHaveBeenCalled(); expect(fs.mkdirSync).toHaveBeenCalled();
expect(fs.renameSync).not.toHaveBeenCalled(); expect(fs.renameSync).not.toHaveBeenCalled();
expect(gitCommit).not.toHaveBeenCalled(); expect(git.commit).not.toHaveBeenCalled();
expect(result).toEqual({ expect(result).toEqual({
updatedFiles: false, updatedFiles: false,
extraFiles: undefined, extraFiles: undefined,
@@ -880,12 +875,12 @@ describe("processFullResponse", () => {
expect(fs.unlinkSync).toHaveBeenCalledWith( expect(fs.unlinkSync).toHaveBeenCalledWith(
"/mock/user/data/path/mock-app-path/src/components/Unused.jsx", "/mock/user/data/path/mock-app-path/src/components/Unused.jsx",
); );
expect(gitRemove).toHaveBeenCalledWith( expect(git.remove).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
filepath: "src/components/Unused.jsx", filepath: "src/components/Unused.jsx",
}), }),
); );
expect(gitCommit).toHaveBeenCalled(); expect(git.commit).toHaveBeenCalled();
expect(result).toEqual({ updatedFiles: true }); expect(result).toEqual({ updatedFiles: true });
}); });
@@ -901,8 +896,8 @@ describe("processFullResponse", () => {
}); });
expect(fs.unlinkSync).not.toHaveBeenCalled(); expect(fs.unlinkSync).not.toHaveBeenCalled();
expect(gitRemove).not.toHaveBeenCalled(); expect(git.remove).not.toHaveBeenCalled();
expect(gitCommit).not.toHaveBeenCalled(); expect(git.commit).not.toHaveBeenCalled();
expect(result).toEqual({ expect(result).toEqual({
updatedFiles: false, updatedFiles: false,
extraFiles: undefined, extraFiles: undefined,
@@ -947,11 +942,11 @@ describe("processFullResponse", () => {
); );
// Check git operations // Check git operations
expect(gitAdd).toHaveBeenCalledTimes(2); // For the write and rename expect(git.add).toHaveBeenCalledTimes(2); // For the write and rename
expect(gitRemove).toHaveBeenCalledTimes(2); // For the rename and delete expect(git.remove).toHaveBeenCalledTimes(2); // For the rename and delete
// Check the commit message includes all operations // Check the commit message includes all operations
expect(gitCommit).toHaveBeenCalledWith( expect(git.commit).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
message: expect.stringContaining( message: expect.stringContaining(
"wrote 1 file(s), renamed 1 file(s), deleted 1 file(s)", "wrote 1 file(s), renamed 1 file(s), deleted 1 file(s)",

View File

@@ -1,5 +1,4 @@
import { formatMessagesForSummary } from "../ipc/handlers/chat_stream_handlers"; import { formatMessagesForSummary } from "../ipc/handlers/chat_stream_handlers";
import { describe, it, expect } from "vitest";
describe("formatMessagesForSummary", () => { describe("formatMessagesForSummary", () => {
it("should return all messages when there are 8 or fewer messages", () => { it("should return all messages when there are 8 or fewer messages", () => {

View File

@@ -59,8 +59,6 @@ describe("readSettings", () => {
"enableProSmartFilesContextMode": true, "enableProSmartFilesContextMode": true,
"experiments": {}, "experiments": {},
"hasRunBefore": false, "hasRunBefore": false,
"isRunning": false,
"lastKnownPerformance": undefined,
"providerSettings": {}, "providerSettings": {},
"releaseChannel": "stable", "releaseChannel": "stable",
"selectedChatMode": "build", "selectedChatMode": "build",
@@ -307,8 +305,6 @@ describe("readSettings", () => {
"enableProSmartFilesContextMode": true, "enableProSmartFilesContextMode": true,
"experiments": {}, "experiments": {},
"hasRunBefore": false, "hasRunBefore": false,
"isRunning": false,
"lastKnownPerformance": undefined,
"providerSettings": {}, "providerSettings": {},
"releaseChannel": "stable", "releaseChannel": "stable",
"selectedChatMode": "build", "selectedChatMode": "build",

View File

@@ -1,118 +0,0 @@
import { describe, it, expect } from "vitest";
import { stylesToTailwind } from "../utils/style-utils";
describe("convertSpacingToTailwind", () => {
describe("margin conversion", () => {
it("should convert equal margins on all sides", () => {
const result = stylesToTailwind({
margin: { left: "16px", right: "16px", top: "16px", bottom: "16px" },
});
expect(result).toEqual(["m-[16px]"]);
});
it("should convert equal horizontal margins", () => {
const result = stylesToTailwind({
margin: { left: "16px", right: "16px" },
});
expect(result).toEqual(["mx-[16px]"]);
});
it("should convert equal vertical margins", () => {
const result = stylesToTailwind({
margin: { top: "16px", bottom: "16px" },
});
expect(result).toEqual(["my-[16px]"]);
});
});
describe("padding conversion", () => {
it("should convert equal padding on all sides", () => {
const result = stylesToTailwind({
padding: { left: "20px", right: "20px", top: "20px", bottom: "20px" },
});
expect(result).toEqual(["p-[20px]"]);
});
it("should convert equal horizontal padding", () => {
const result = stylesToTailwind({
padding: { left: "12px", right: "12px" },
});
expect(result).toEqual(["px-[12px]"]);
});
it("should convert equal vertical padding", () => {
const result = stylesToTailwind({
padding: { top: "8px", bottom: "8px" },
});
expect(result).toEqual(["py-[8px]"]);
});
});
describe("combined margin and padding", () => {
it("should handle both margin and padding", () => {
const result = stylesToTailwind({
margin: { left: "16px", right: "16px" },
padding: { top: "8px", bottom: "8px" },
});
expect(result).toContain("mx-[16px]");
expect(result).toContain("py-[8px]");
expect(result).toHaveLength(2);
});
});
describe("edge cases: equal horizontal and vertical spacing", () => {
it("should consolidate px = py to p when values match", () => {
const result = stylesToTailwind({
padding: { left: "16px", right: "16px", top: "16px", bottom: "16px" },
});
// When all four sides are equal, should use p-[]
expect(result).toEqual(["p-[16px]"]);
});
it("should consolidate mx = my to m when values match (but not all four sides)", () => {
const result = stylesToTailwind({
margin: { left: "20px", right: "20px", top: "20px", bottom: "20px" },
});
// When all four sides are equal, should use m-[]
expect(result).toEqual(["m-[20px]"]);
});
it("should not consolidate when px != py", () => {
const result = stylesToTailwind({
padding: { left: "16px", right: "16px", top: "8px", bottom: "8px" },
});
expect(result).toContain("px-[16px]");
expect(result).toContain("py-[8px]");
expect(result).toHaveLength(2);
});
it("should not consolidate when mx != my", () => {
const result = stylesToTailwind({
margin: { left: "20px", right: "20px", top: "10px", bottom: "10px" },
});
expect(result).toContain("mx-[20px]");
expect(result).toContain("my-[10px]");
expect(result).toHaveLength(2);
});
it("should handle case where left != right", () => {
const result = stylesToTailwind({
padding: { left: "16px", right: "12px", top: "8px", bottom: "8px" },
});
expect(result).toContain("pl-[16px]");
expect(result).toContain("pr-[12px]");
expect(result).toContain("py-[8px]");
expect(result).toHaveLength(3);
});
it("should handle case where top != bottom", () => {
const result = stylesToTailwind({
margin: { left: "20px", right: "20px", top: "10px", bottom: "15px" },
});
expect(result).toContain("mx-[20px]");
expect(result).toContain("mt-[10px]");
expect(result).toContain("mb-[15px]");
expect(result).toHaveLength(3);
});
});
});

View File

@@ -1,352 +0,0 @@
import { describe, it, expect } from "vitest";
import {
isServerFunction,
isSharedServerModule,
extractFunctionNameFromPath,
} from "@/supabase_admin/supabase_utils";
import {
toPosixPath,
stripSupabaseFunctionsPrefix,
buildSignature,
type FileStatEntry,
} from "@/supabase_admin/supabase_management_client";
describe("isServerFunction", () => {
describe("returns true for valid function paths", () => {
it("should return true for function index.ts", () => {
expect(isServerFunction("supabase/functions/hello/index.ts")).toBe(true);
});
it("should return true for nested function files", () => {
expect(isServerFunction("supabase/functions/hello/lib/utils.ts")).toBe(
true,
);
});
it("should return true for function with complex name", () => {
expect(isServerFunction("supabase/functions/send-email/index.ts")).toBe(
true,
);
});
});
describe("returns false for non-function paths", () => {
it("should return false for shared modules", () => {
expect(isServerFunction("supabase/functions/_shared/utils.ts")).toBe(
false,
);
});
it("should return false for regular source files", () => {
expect(isServerFunction("src/components/Button.tsx")).toBe(false);
});
it("should return false for root supabase files", () => {
expect(isServerFunction("supabase/config.toml")).toBe(false);
});
it("should return false for non-supabase paths", () => {
expect(isServerFunction("package.json")).toBe(false);
});
});
});
describe("isSharedServerModule", () => {
describe("returns true for _shared paths", () => {
it("should return true for files in _shared", () => {
expect(isSharedServerModule("supabase/functions/_shared/utils.ts")).toBe(
true,
);
});
it("should return true for nested _shared files", () => {
expect(
isSharedServerModule("supabase/functions/_shared/lib/helpers.ts"),
).toBe(true);
});
it("should return true for _shared directory itself", () => {
expect(isSharedServerModule("supabase/functions/_shared/")).toBe(true);
});
});
describe("returns false for non-_shared paths", () => {
it("should return false for regular functions", () => {
expect(isSharedServerModule("supabase/functions/hello/index.ts")).toBe(
false,
);
});
it("should return false for similar but different paths", () => {
expect(isSharedServerModule("supabase/functions/shared/utils.ts")).toBe(
false,
);
});
it("should return false for _shared in wrong location", () => {
expect(isSharedServerModule("src/_shared/utils.ts")).toBe(false);
});
});
});
describe("extractFunctionNameFromPath", () => {
describe("extracts function name correctly from nested paths", () => {
it("should extract function name from index.ts path", () => {
expect(
extractFunctionNameFromPath("supabase/functions/hello/index.ts"),
).toBe("hello");
});
it("should extract function name from deeply nested path", () => {
expect(
extractFunctionNameFromPath("supabase/functions/hello/lib/utils.ts"),
).toBe("hello");
});
it("should extract function name from very deeply nested path", () => {
expect(
extractFunctionNameFromPath(
"supabase/functions/hello/src/helpers/format.ts",
),
).toBe("hello");
});
it("should extract function name with dashes", () => {
expect(
extractFunctionNameFromPath("supabase/functions/send-email/index.ts"),
).toBe("send-email");
});
it("should extract function name with underscores", () => {
expect(
extractFunctionNameFromPath("supabase/functions/my_function/index.ts"),
).toBe("my_function");
});
});
describe("throws for invalid paths", () => {
it("should throw for _shared paths", () => {
expect(() =>
extractFunctionNameFromPath("supabase/functions/_shared/utils.ts"),
).toThrow(/Function names starting with "_" are reserved/);
});
it("should throw for other _ prefixed directories", () => {
expect(() =>
extractFunctionNameFromPath("supabase/functions/_internal/utils.ts"),
).toThrow(/Function names starting with "_" are reserved/);
});
it("should throw for non-supabase paths", () => {
expect(() =>
extractFunctionNameFromPath("src/components/Button.tsx"),
).toThrow(/Invalid Supabase function path/);
});
it("should throw for supabase root files", () => {
expect(() => extractFunctionNameFromPath("supabase/config.toml")).toThrow(
/Invalid Supabase function path/,
);
});
it("should throw for partial matches", () => {
expect(() => extractFunctionNameFromPath("supabase/functions")).toThrow(
/Invalid Supabase function path/,
);
});
});
describe("handles edge cases", () => {
it("should handle backslashes (Windows paths)", () => {
expect(
extractFunctionNameFromPath(
"supabase\\functions\\hello\\lib\\utils.ts",
),
).toBe("hello");
});
it("should handle mixed slashes", () => {
expect(
extractFunctionNameFromPath("supabase/functions\\hello/lib\\utils.ts"),
).toBe("hello");
});
});
});
describe("toPosixPath", () => {
it("should keep forward slashes unchanged", () => {
expect(toPosixPath("supabase/functions/hello/index.ts")).toBe(
"supabase/functions/hello/index.ts",
);
});
it("should handle empty string", () => {
expect(toPosixPath("")).toBe("");
});
it("should handle single filename", () => {
expect(toPosixPath("index.ts")).toBe("index.ts");
});
// Note: On Unix, path.sep is "/", so backslashes won't be converted
// This test is for documentation - actual behavior depends on platform
it("should handle path with no separators", () => {
expect(toPosixPath("filename")).toBe("filename");
});
});
describe("stripSupabaseFunctionsPrefix", () => {
describe("strips prefix correctly", () => {
it("should strip full prefix from index.ts", () => {
expect(
stripSupabaseFunctionsPrefix(
"supabase/functions/hello/index.ts",
"hello",
),
).toBe("index.ts");
});
it("should strip prefix from nested file", () => {
expect(
stripSupabaseFunctionsPrefix(
"supabase/functions/hello/lib/utils.ts",
"hello",
),
).toBe("lib/utils.ts");
});
it("should handle leading slash", () => {
expect(
stripSupabaseFunctionsPrefix(
"/supabase/functions/hello/index.ts",
"hello",
),
).toBe("index.ts");
});
});
describe("handles edge cases", () => {
it("should return filename when no prefix match", () => {
const result = stripSupabaseFunctionsPrefix("just-a-file.ts", "hello");
expect(result).toBe("just-a-file.ts");
});
it("should handle paths without function name", () => {
const result = stripSupabaseFunctionsPrefix(
"supabase/functions/other/index.ts",
"hello",
);
// Should strip base prefix and return the rest
expect(result).toBe("other/index.ts");
});
it("should handle empty relative path after prefix", () => {
// When the path is exactly the function directory
const result = stripSupabaseFunctionsPrefix(
"supabase/functions/hello",
"hello",
);
expect(result).toBe("hello");
});
});
});
describe("buildSignature", () => {
it("should build signature from single entry", () => {
const entries: FileStatEntry[] = [
{
absolutePath: "/app/file.ts",
relativePath: "file.ts",
mtimeMs: 1000,
size: 100,
},
];
const result = buildSignature(entries);
expect(result).toBe("file.ts:3e8:64");
});
it("should build signature from multiple entries sorted by relativePath", () => {
const entries: FileStatEntry[] = [
{
absolutePath: "/app/b.ts",
relativePath: "b.ts",
mtimeMs: 2000,
size: 200,
},
{
absolutePath: "/app/a.ts",
relativePath: "a.ts",
mtimeMs: 1000,
size: 100,
},
];
const result = buildSignature(entries);
// Should be sorted by relativePath
expect(result).toBe("a.ts:3e8:64|b.ts:7d0:c8");
});
it("should return empty string for empty array", () => {
const result = buildSignature([]);
expect(result).toBe("");
});
it("should produce different signatures for different mtimes", () => {
const entries1: FileStatEntry[] = [
{
absolutePath: "/app/file.ts",
relativePath: "file.ts",
mtimeMs: 1000,
size: 100,
},
];
const entries2: FileStatEntry[] = [
{
absolutePath: "/app/file.ts",
relativePath: "file.ts",
mtimeMs: 2000,
size: 100,
},
];
expect(buildSignature(entries1)).not.toBe(buildSignature(entries2));
});
it("should produce different signatures for different sizes", () => {
const entries1: FileStatEntry[] = [
{
absolutePath: "/app/file.ts",
relativePath: "file.ts",
mtimeMs: 1000,
size: 100,
},
];
const entries2: FileStatEntry[] = [
{
absolutePath: "/app/file.ts",
relativePath: "file.ts",
mtimeMs: 1000,
size: 200,
},
];
expect(buildSignature(entries1)).not.toBe(buildSignature(entries2));
});
it("should include path in signature for cache invalidation", () => {
const entries1: FileStatEntry[] = [
{
absolutePath: "/app/a.ts",
relativePath: "a.ts",
mtimeMs: 1000,
size: 100,
},
];
const entries2: FileStatEntry[] = [
{
absolutePath: "/app/b.ts",
relativePath: "b.ts",
mtimeMs: 1000,
size: 100,
},
];
expect(buildSignature(entries1)).not.toBe(buildSignature(entries2));
});
});

View File

@@ -1,4 +1,4 @@
import type { FileAttachment, Message } from "@/ipc/ipc_types"; import type { Message } from "@/ipc/ipc_types";
import { atom } from "jotai"; import { atom } from "jotai";
import type { ChatSummary } from "@/lib/schemas"; import type { ChatSummary } from "@/lib/schemas";
@@ -20,5 +20,3 @@ export const chatsLoadingAtom = atom<boolean>(false);
// Used for scrolling to the bottom of the chat messages (per chat) // Used for scrolling to the bottom of the chat messages (per chat)
export const chatStreamCountByIdAtom = atom<Map<number, number>>(new Map()); export const chatStreamCountByIdAtom = atom<Map<number, number>>(new Map());
export const recentStreamChatIdsAtom = atom<Set<number>>(new Set<number>()); export const recentStreamChatIdsAtom = atom<Set<number>>(new Set<number>());
export const attachmentsAtom = atom<FileAttachment[]>([]);

View File

@@ -1,23 +1,6 @@
import { ComponentSelection, VisualEditingChange } from "@/ipc/ipc_types"; import { ComponentSelection } from "@/ipc/ipc_types";
import { atom } from "jotai"; import { atom } from "jotai";
export const selectedComponentsPreviewAtom = atom<ComponentSelection[]>([]); export const selectedComponentsPreviewAtom = atom<ComponentSelection[]>([]);
export const visualEditingSelectedComponentAtom =
atom<ComponentSelection | null>(null);
export const currentComponentCoordinatesAtom = atom<{
top: number;
left: number;
width: number;
height: number;
} | null>(null);
export const previewIframeRefAtom = atom<HTMLIFrameElement | null>(null); export const previewIframeRefAtom = atom<HTMLIFrameElement | null>(null);
export const annotatorModeAtom = atom<boolean>(false);
export const screenshotDataUrlAtom = atom<string | null>(null);
export const pendingVisualChangesAtom = atom<Map<string, VisualEditingChange>>(
new Map(),
);

View File

@@ -1,5 +1,4 @@
import { IpcClient } from "@/ipc/ipc_client"; import { IpcClient } from "@/ipc/ipc_client";
import { getAppPort } from "../../shared/ports";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
@@ -30,7 +29,7 @@ export async function neonTemplateHook({
}, },
{ {
key: "NEXT_PUBLIC_SERVER_URL", key: "NEXT_PUBLIC_SERVER_URL",
value: `http://localhost:${getAppPort(appId)}`, value: "http://localhost:32100",
}, },
{ {
key: "GMAIL_USER", key: "GMAIL_USER",

View File

@@ -31,7 +31,7 @@ export function ChatModeSelector() {
case "ask": case "ask":
return "Ask"; return "Ask";
case "agent": case "agent":
return "Build (MCP)"; return "Agent";
default: default:
return "Build"; return "Build";
} }
@@ -83,9 +83,9 @@ export function ChatModeSelector() {
</SelectItem> </SelectItem>
<SelectItem value="agent"> <SelectItem value="agent">
<div className="flex flex-col items-start"> <div className="flex flex-col items-start">
<span className="font-medium">Build with MCP (experimental)</span> <span className="font-medium">Agent (experimental)</span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
Like Build, but can use tools (MCP) to generate code Agent can use tools (MCP) and generate code
</span> </span>
</div> </div>
</SelectItem> </SelectItem>

View File

@@ -1,49 +0,0 @@
import { Copy, Check } from "lucide-react";
import { useState } from "react";
interface CopyErrorMessageProps {
errorMessage: string;
className?: string;
}
export const CopyErrorMessage = ({
errorMessage,
className = "",
}: CopyErrorMessageProps) => {
const [isCopied, setIsCopied] = useState(false);
const handleCopy = async (e: React.MouseEvent) => {
e.stopPropagation();
try {
await navigator.clipboard.writeText(errorMessage);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
} catch (err) {
console.error("Failed to copy error message:", err);
}
};
return (
<button
onClick={handleCopy}
className={`flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors ${
isCopied
? "bg-green-100 dark:bg-green-900 text-green-700 dark:text-green-300"
: "bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600"
} ${className}`}
title={isCopied ? "Copied!" : "Copy error message"}
>
{isCopied ? (
<>
<Check size={14} />
<span>Copied</span>
</>
) : (
<>
<Copy size={14} />
<span>Copy</span>
</>
)}
</button>
);
};

View File

@@ -11,7 +11,6 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { IpcClient } from "@/ipc/ipc_client"; import { IpcClient } from "@/ipc/ipc_client";
import { useSettings } from "@/hooks/useSettings";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { showError, showSuccess } from "@/lib/toast"; import { showError, showSuccess } from "@/lib/toast";
@@ -45,7 +44,6 @@ export function EditCustomModelDialog({
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [maxOutputTokens, setMaxOutputTokens] = useState<string>(""); const [maxOutputTokens, setMaxOutputTokens] = useState<string>("");
const [contextWindow, setContextWindow] = useState<string>(""); const [contextWindow, setContextWindow] = useState<string>("");
const { settings, updateSettings } = useSettings();
const ipcClient = IpcClient.getInstance(); const ipcClient = IpcClient.getInstance();
@@ -91,22 +89,7 @@ export function EditCustomModelDialog({
// Then create the new model // Then create the new model
await ipcClient.createCustomLanguageModel(newParams); await ipcClient.createCustomLanguageModel(newParams);
}, },
onSuccess: async () => { onSuccess: () => {
if (
settings?.selectedModel?.name === model?.apiName &&
settings?.selectedModel?.provider === providerId
) {
const newModel = {
...settings.selectedModel,
name: apiName,
};
try {
await updateSettings({ selectedModel: newModel });
} catch {
showError("Failed to update settings");
return; // stop closing dialog
}
}
showSuccess("Custom model updated successfully!"); showSuccess("Custom model updated successfully!");
onSuccess(); onSuccess();
onClose(); onClose();

View File

@@ -1,128 +0,0 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { AlertTriangle } from "lucide-react";
interface ForceCloseDialogProps {
isOpen: boolean;
onClose: () => void;
performanceData?: {
timestamp: number;
memoryUsageMB: number;
cpuUsagePercent?: number;
systemMemoryUsageMB?: number;
systemMemoryTotalMB?: number;
systemCpuPercent?: number;
};
}
export function ForceCloseDialog({
isOpen,
onClose,
performanceData,
}: ForceCloseDialogProps) {
const formatTimestamp = (timestamp: number) => {
return new Date(timestamp).toLocaleString();
};
return (
<AlertDialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<AlertDialogContent className="max-w-2xl">
<AlertDialogHeader>
<div className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-yellow-500" />
<AlertDialogTitle>Force Close Detected</AlertDialogTitle>
</div>
<AlertDialogDescription asChild>
<div className="space-y-4 pt-2">
<div className="text-base">
The app was not closed properly the last time it was running.
This could indicate a crash or unexpected termination.
</div>
{performanceData && (
<div className="rounded-lg border bg-muted/50 p-4 space-y-3">
<div className="font-semibold text-sm text-foreground">
Last Known State:{" "}
<span className="font-normal text-muted-foreground">
{formatTimestamp(performanceData.timestamp)}
</span>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
{/* Process Metrics */}
<div className="space-y-2">
<div className="font-medium text-foreground">
Process Metrics
</div>
<div className="space-y-1">
<div className="flex justify-between">
<span className="text-muted-foreground">Memory:</span>
<span className="font-mono">
{performanceData.memoryUsageMB} MB
</span>
</div>
{performanceData.cpuUsagePercent !== undefined && (
<div className="flex justify-between">
<span className="text-muted-foreground">CPU:</span>
<span className="font-mono">
{performanceData.cpuUsagePercent}%
</span>
</div>
)}
</div>
</div>
{/* System Metrics */}
{(performanceData.systemMemoryUsageMB !== undefined ||
performanceData.systemCpuPercent !== undefined) && (
<div className="space-y-2">
<div className="font-medium text-foreground">
System Metrics
</div>
<div className="space-y-1">
{performanceData.systemMemoryUsageMB !== undefined &&
performanceData.systemMemoryTotalMB !==
undefined && (
<div className="flex justify-between">
<span className="text-muted-foreground">
Memory:
</span>
<span className="font-mono">
{performanceData.systemMemoryUsageMB} /{" "}
{performanceData.systemMemoryTotalMB} MB
</span>
</div>
)}
{performanceData.systemCpuPercent !== undefined && (
<div className="flex justify-between">
<span className="text-muted-foreground">
CPU:
</span>
<span className="font-mono">
{performanceData.systemCpuPercent}%
</span>
</div>
)}
</div>
</div>
)}
</div>
</div>
)}
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction onClick={onClose}>OK</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -26,7 +26,6 @@ import { showError } from "@/lib/toast";
import { HelpBotDialog } from "./HelpBotDialog"; import { HelpBotDialog } from "./HelpBotDialog";
import { useSettings } from "@/hooks/useSettings"; import { useSettings } from "@/hooks/useSettings";
import { BugScreenshotDialog } from "./BugScreenshotDialog"; import { BugScreenshotDialog } from "./BugScreenshotDialog";
import { useUserBudgetInfo } from "@/hooks/useUserBudgetInfo";
interface HelpDialogProps { interface HelpDialogProps {
isOpen: boolean; isOpen: boolean;
@@ -44,7 +43,7 @@ export function HelpDialog({ isOpen, onClose }: HelpDialogProps) {
const [isBugScreenshotOpen, setIsBugScreenshotOpen] = useState(false); const [isBugScreenshotOpen, setIsBugScreenshotOpen] = useState(false);
const selectedChatId = useAtomValue(selectedChatIdAtom); const selectedChatId = useAtomValue(selectedChatIdAtom);
const { settings } = useSettings(); const { settings } = useSettings();
const { userBudget } = useUserBudgetInfo();
const isDyadProUser = settings?.providerSettings?.["auto"]?.apiKey?.value; const isDyadProUser = settings?.providerSettings?.["auto"]?.apiKey?.value;
// Function to reset all dialog state // Function to reset all dialog state
@@ -104,7 +103,6 @@ Issues that do not meet these requirements will be closed and may need to be res
- Node Version: ${debugInfo.nodeVersion || "n/a"} - Node Version: ${debugInfo.nodeVersion || "n/a"}
- PNPM Version: ${debugInfo.pnpmVersion || "n/a"} - PNPM Version: ${debugInfo.pnpmVersion || "n/a"}
- Node Path: ${debugInfo.nodePath || "n/a"} - Node Path: ${debugInfo.nodePath || "n/a"}
- Pro User ID: ${userBudget?.redactedUserId || "n/a"}
- Telemetry ID: ${debugInfo.telemetryId || "n/a"} - Telemetry ID: ${debugInfo.telemetryId || "n/a"}
- Model: ${debugInfo.selectedLanguageModel || "n/a"} - Model: ${debugInfo.selectedLanguageModel || "n/a"}
@@ -228,7 +226,6 @@ Issues that do not meet these requirements will be closed and may need to be res
--> -->
Session ID: ${sessionId} Session ID: ${sessionId}
Pro User ID: ${userBudget?.redactedUserId || "n/a"}
## Issue Description (required) ## Issue Description (required)
<!-- Please describe the issue you're experiencing --> <!-- Please describe the issue you're experiencing -->

View File

@@ -16,7 +16,6 @@ import {
ChevronsDownUp, ChevronsDownUp,
ChartColumnIncreasing, ChartColumnIncreasing,
SendHorizontalIcon, SendHorizontalIcon,
Lock,
} from "lucide-react"; } from "lucide-react";
import type React from "react"; import type React from "react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
@@ -66,16 +65,11 @@ import { ChatErrorBox } from "./ChatErrorBox";
import { import {
selectedComponentsPreviewAtom, selectedComponentsPreviewAtom,
previewIframeRefAtom, previewIframeRefAtom,
visualEditingSelectedComponentAtom,
currentComponentCoordinatesAtom,
pendingVisualChangesAtom,
} from "@/atoms/previewAtoms"; } from "@/atoms/previewAtoms";
import { SelectedComponentsDisplay } from "./SelectedComponentDisplay"; import { SelectedComponentsDisplay } from "./SelectedComponentDisplay";
import { useCheckProblems } from "@/hooks/useCheckProblems"; import { useCheckProblems } from "@/hooks/useCheckProblems";
import { LexicalChatInput } from "./LexicalChatInput"; import { LexicalChatInput } from "./LexicalChatInput";
import { useChatModeToggle } from "@/hooks/useChatModeToggle"; import { useChatModeToggle } from "@/hooks/useChatModeToggle";
import { VisualEditingChangesDialog } from "@/components/preview_panel/VisualEditingChangesDialog";
import { useUserBudgetInfo } from "@/hooks/useUserBudgetInfo";
const showTokenBarAtom = atom(false); const showTokenBarAtom = atom(false);
@@ -98,15 +92,7 @@ export function ChatInput({ chatId }: { chatId?: number }) {
selectedComponentsPreviewAtom, selectedComponentsPreviewAtom,
); );
const previewIframeRef = useAtomValue(previewIframeRefAtom); const previewIframeRef = useAtomValue(previewIframeRefAtom);
const setVisualEditingSelectedComponent = useSetAtom(
visualEditingSelectedComponentAtom,
);
const setCurrentComponentCoordinates = useSetAtom(
currentComponentCoordinatesAtom,
);
const setPendingVisualChanges = useSetAtom(pendingVisualChangesAtom);
const { checkProblems } = useCheckProblems(appId); const { checkProblems } = useCheckProblems(appId);
const { refreshAppIframe } = useRunApp();
// Use the attachments hook // Use the attachments hook
const { const {
attachments, attachments,
@@ -138,8 +124,6 @@ export function ChatInput({ chatId }: { chatId?: number }) {
proposal.type === "code-proposal" && proposal.type === "code-proposal" &&
messageId === lastMessage.id; messageId === lastMessage.id;
const { userBudget } = useUserBudgetInfo();
useEffect(() => { useEffect(() => {
if (error) { if (error) {
setShowError(true); setShowError(true);
@@ -176,7 +160,7 @@ export function ChatInput({ chatId }: { chatId?: number }) {
? selectedComponents ? selectedComponents
: []; : [];
setSelectedComponents([]); setSelectedComponents([]);
setVisualEditingSelectedComponent(null);
// Clear overlays in the preview iframe // Clear overlays in the preview iframe
if (previewIframeRef?.contentWindow) { if (previewIframeRef?.contentWindow) {
previewIframeRef.contentWindow.postMessage( previewIframeRef.contentWindow.postMessage(
@@ -323,58 +307,6 @@ export function ChatInput({ chatId }: { chatId?: number }) {
/> />
)} )}
{userBudget ? (
<VisualEditingChangesDialog
iframeRef={
previewIframeRef
? { current: previewIframeRef }
: { current: null }
}
onReset={() => {
// Exit component selection mode and visual editing
setSelectedComponents([]);
setVisualEditingSelectedComponent(null);
setCurrentComponentCoordinates(null);
setPendingVisualChanges(new Map());
refreshAppIframe();
// Deactivate component selector in iframe
if (previewIframeRef?.contentWindow) {
previewIframeRef.contentWindow.postMessage(
{ type: "deactivate-dyad-component-selector" },
"*",
);
}
}}
/>
) : (
selectedComponents.length > 0 && (
<div className="border-b border-border p-3 bg-muted/30">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => {
IpcClient.getInstance().openExternalUrl(
"https://dyad.sh/pro",
);
}}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-primary transition-colors cursor-pointer"
>
<Lock size={16} />
<span className="font-medium">Visual editor (Pro)</span>
</button>
</TooltipTrigger>
<TooltipContent>
Visual editing lets you make UI changes without AI and is
a Pro-only feature
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)
)}
<SelectedComponentsDisplay /> <SelectedComponentsDisplay />
{/* Use the AttachmentsList component */} {/* Use the AttachmentsList component */}

View File

@@ -1,78 +1,16 @@
import React, { useState, useEffect, memo, type ReactNode } from "react"; import React, {
import ShikiHighlighter, { useState,
isInlineCode, useEffect,
createHighlighterCore, useRef,
createJavaScriptRegexEngine, memo,
} from "react-shiki/core"; type ReactNode,
} from "react";
import { isInlineCode, useShikiHighlighter } from "react-shiki";
import github from "@shikijs/themes/github-light-default";
import githubDark from "@shikijs/themes/github-dark-default";
import type { Element as HastElement } from "hast"; import type { Element as HastElement } from "hast";
import { useTheme } from "../../contexts/ThemeContext"; import { useTheme } from "../../contexts/ThemeContext";
import { Copy, Check } from "lucide-react"; import { Copy, Check } from "lucide-react";
import github from "@shikijs/themes/github-light-default";
import githubDark from "@shikijs/themes/github-dark-default";
// common languages
import astro from "@shikijs/langs/astro";
import css from "@shikijs/langs/css";
import graphql from "@shikijs/langs/graphql";
import html from "@shikijs/langs/html";
import java from "@shikijs/langs/java";
import javascript from "@shikijs/langs/javascript";
import json from "@shikijs/langs/json";
import jsx from "@shikijs/langs/jsx";
import less from "@shikijs/langs/less";
import markdown from "@shikijs/langs/markdown";
import python from "@shikijs/langs/python";
import sass from "@shikijs/langs/sass";
import scss from "@shikijs/langs/scss";
import shell from "@shikijs/langs/shell";
import sql from "@shikijs/langs/sql";
import tsx from "@shikijs/langs/tsx";
import typescript from "@shikijs/langs/typescript";
import vue from "@shikijs/langs/vue";
type HighlighterCore = Awaited<ReturnType<typeof createHighlighterCore>>;
// Create a singleton highlighter instance
let highlighterPromise: Promise<HighlighterCore> | null = null;
function getHighlighter(): Promise<HighlighterCore> {
if (!highlighterPromise) {
highlighterPromise = createHighlighterCore({
themes: [github, githubDark],
langs: [
astro,
css,
graphql,
html,
java,
javascript,
json,
jsx,
less,
markdown,
python,
sass,
scss,
shell,
sql,
tsx,
typescript,
vue,
],
engine: createJavaScriptRegexEngine(),
});
}
return highlighterPromise as Promise<HighlighterCore>;
}
function useHighlighter() {
const [highlighter, setHighlighter] = useState<HighlighterCore>();
useEffect(() => {
getHighlighter().then(setHighlighter);
}, []);
return highlighter;
}
interface CodeHighlightProps { interface CodeHighlightProps {
className?: string | undefined; className?: string | undefined;
@@ -94,8 +32,29 @@ export const CodeHighlight = memo(
}; };
const { isDarkMode } = useTheme(); const { isDarkMode } = useTheme();
const highlighter = useHighlighter();
// Cache for the highlighted code
const highlightedCodeCache = useRef<ReactNode | null>(null);
// Only update the highlighted code if the inputs change
const highlightedCode = useShikiHighlighter(
code,
language,
isDarkMode ? githubDark : github,
{
delay: 150,
},
);
// Update the cache whenever we get a new highlighted code
useEffect(() => {
if (highlightedCode) {
highlightedCodeCache.current = highlightedCode;
}
}, [highlightedCode]);
// Use the cached version during transitions to prevent flickering
const displayedCode = highlightedCode || highlightedCodeCache.current;
return !isInline ? ( return !isInline ? (
<div <div
className="shiki not-prose relative [&_pre]:overflow-auto className="shiki not-prose relative [&_pre]:overflow-auto
@@ -118,20 +77,7 @@ export const CodeHighlight = memo(
)} )}
</div> </div>
) : null} ) : null}
{highlighter ? ( {displayedCode}
<ShikiHighlighter
highlighter={highlighter}
language={language}
theme={isDarkMode ? "github-dark-default" : "github-light-default"}
delay={150}
>
{code}
</ShikiHighlighter>
) : (
<pre>
<code>{code}</code>
</pre>
)}
</div> </div>
) : ( ) : (
<code className={className} {...props}> <code className={className} {...props}>

View File

@@ -9,7 +9,6 @@ import {
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { selectedChatIdAtom } from "@/atoms/chatAtoms"; import { selectedChatIdAtom } from "@/atoms/chatAtoms";
import { useStreamChat } from "@/hooks/useStreamChat"; import { useStreamChat } from "@/hooks/useStreamChat";
import { CopyErrorMessage } from "@/components/CopyErrorMessage";
interface DyadOutputProps { interface DyadOutputProps {
type: "error" | "warning"; type: "error" | "warning";
message?: string; message?: string;
@@ -60,6 +59,19 @@ export const DyadOutput: React.FC<DyadOutputProps> = ({
<span>{label}</span> <span>{label}</span>
</div> </div>
{/* Fix with AI button - always visible for errors */}
{isError && message && (
<div className="absolute top-9 left-2">
<button
onClick={handleAIFix}
className="cursor-pointer flex items-center justify-center bg-red-600 hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-800 text-white rounded text-xs p-1 w-24 h-6"
>
<Sparkles size={16} className="mr-1" />
<span>Fix with AI</span>
</button>
</div>
)}
{/* Main content, padded to avoid label */} {/* Main content, padded to avoid label */}
<div className="flex items-center justify-between pl-24 pr-6"> <div className="flex items-center justify-between pl-24 pr-6">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -91,22 +103,6 @@ export const DyadOutput: React.FC<DyadOutputProps> = ({
{children} {children}
</div> </div>
)} )}
{/* Action buttons at the bottom - always visible for errors */}
{isError && message && (
<div className="mt-3 px-6 flex justify-end gap-2">
<CopyErrorMessage
errorMessage={children ? `${message}\n${children}` : message}
/>
<button
onClick={handleAIFix}
className="cursor-pointer flex items-center justify-center bg-red-600 hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-800 text-white rounded text-xs px-2 py-1 h-6"
>
<Sparkles size={14} className="mr-1" />
<span>Fix with AI</span>
</button>
</div>
)}
</div> </div>
); );
}; };

View File

@@ -1,9 +1,8 @@
import { import {
selectedComponentsPreviewAtom, selectedComponentsPreviewAtom,
previewIframeRefAtom, previewIframeRefAtom,
visualEditingSelectedComponentAtom,
} from "@/atoms/previewAtoms"; } from "@/atoms/previewAtoms";
import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { Code2, X } from "lucide-react"; import { Code2, X } from "lucide-react";
export function SelectedComponentsDisplay() { export function SelectedComponentsDisplay() {
@@ -11,15 +10,11 @@ export function SelectedComponentsDisplay() {
selectedComponentsPreviewAtom, selectedComponentsPreviewAtom,
); );
const previewIframeRef = useAtomValue(previewIframeRefAtom); const previewIframeRef = useAtomValue(previewIframeRefAtom);
const setVisualEditingSelectedComponent = useSetAtom(
visualEditingSelectedComponentAtom,
);
const handleRemoveComponent = (index: number) => { const handleRemoveComponent = (index: number) => {
const componentToRemove = selectedComponents[index]; const componentToRemove = selectedComponents[index];
const newComponents = selectedComponents.filter((_, i) => i !== index); const newComponents = selectedComponents.filter((_, i) => i !== index);
setSelectedComponents(newComponents); setSelectedComponents(newComponents);
setVisualEditingSelectedComponent(null);
// Remove the specific overlay from the iframe // Remove the specific overlay from the iframe
if (previewIframeRef?.contentWindow) { if (previewIframeRef?.contentWindow) {
@@ -35,7 +30,7 @@ export function SelectedComponentsDisplay() {
const handleClearAll = () => { const handleClearAll = () => {
setSelectedComponents([]); setSelectedComponents([]);
setVisualEditingSelectedComponent(null);
if (previewIframeRef?.contentWindow) { if (previewIframeRef?.contentWindow) {
previewIframeRef.contentWindow.postMessage( previewIframeRef.contentWindow.postMessage(
{ type: "clear-dyad-component-overlays" }, { type: "clear-dyad-component-overlays" },

View File

@@ -1,6 +1,40 @@
import type { editor } from "monaco-editor"; import { editor } from "monaco-editor";
import { loader } from "@monaco-editor/react"; import { loader } from "@monaco-editor/react";
import * as monaco from "monaco-editor";
// @ts-ignore
import editorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker";
// @ts-ignore
import jsonWorker from "monaco-editor/esm/vs/language/json/json.worker?worker";
// @ts-ignore
import cssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker";
// @ts-ignore
import htmlWorker from "monaco-editor/esm/vs/language/html/html.worker?worker";
// @ts-ignore
import tsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker";
self.MonacoEnvironment = {
getWorker(_, label) {
if (label === "json") {
return new jsonWorker();
}
if (label === "css" || label === "scss" || label === "less") {
return new cssWorker();
}
if (label === "html" || label === "handlebars" || label === "razor") {
return new htmlWorker();
}
if (label === "typescript" || label === "javascript") {
return new tsWorker();
}
return new editorWorker();
},
};
loader.config({ monaco });
// loader.init().then(/* ... */);
export const customLight: editor.IStandaloneThemeData = { export const customLight: editor.IStandaloneThemeData = {
base: "vs", base: "vs",
inherit: false, inherit: false,
@@ -72,6 +106,8 @@ export const customLight: editor.IStandaloneThemeData = {
}, },
}; };
editor.defineTheme("dyad-light", customLight);
export const customDark: editor.IStandaloneThemeData = { export const customDark: editor.IStandaloneThemeData = {
base: "vs-dark", base: "vs-dark",
inherit: false, inherit: false,
@@ -142,15 +178,12 @@ export const customDark: editor.IStandaloneThemeData = {
}, },
}; };
loader.init().then((monaco) => { editor.defineTheme("dyad-dark", customDark);
monaco.editor.defineTheme("dyad-light", customLight);
monaco.editor.defineTheme("dyad-dark", customDark);
monaco.languages.typescript.typescriptDefaults.setCompilerOptions({ monaco.languages.typescript.typescriptDefaults.setCompilerOptions({
jsx: monaco.languages.typescript.JsxEmit.React, // Enable JSX jsx: monaco.languages.typescript.JsxEmit.React, // Enable JSX
}); });
monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({ monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({
// Too noisy because we don't have the full TS environment. // Too noisy because we don't have the full TS environment.
noSemanticValidation: true, noSemanticValidation: true,
});
}); });

View File

@@ -1,53 +0,0 @@
import { Lock, ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { IpcClient } from "@/ipc/ipc_client";
interface AnnotatorOnlyForProProps {
onGoBack: () => void;
}
export const AnnotatorOnlyForPro = ({ onGoBack }: AnnotatorOnlyForProProps) => {
const handleGetPro = () => {
IpcClient.getInstance().openExternalUrl("https://dyad.sh/pro");
};
return (
<div className="w-full h-full bg-background relative">
{/* Go Back Button */}
<button
onClick={onGoBack}
className="absolute top-4 left-4 p-2 hover:bg-accent rounded-md transition-all z-10 group"
aria-label="Go back"
>
<ArrowLeft
size={20}
className="text-foreground/70 group-hover:text-foreground transition-colors"
/>
</button>
{/* Centered Content */}
<div className="flex flex-col items-center justify-center h-full px-8">
{/* Lock Icon */}
<Lock size={72} className="text-primary/60 dark:text-primary/70 mb-8" />
{/* Message */}
<h2 className="text-3xl font-semibold text-foreground mb-4 text-center">
Annotator is a Pro Feature
</h2>
<p className="text-muted-foreground mb-10 text-center max-w-md text-base leading-relaxed">
Unlock the ability to annotate screenshots and enhance your workflow
with Dyad Pro.
</p>
{/* Get Pro Button */}
<Button
onClick={handleGetPro}
size="lg"
className="px-8 shadow-md hover:shadow-lg transition-all"
>
Get Dyad Pro
</Button>
</div>
</div>
);
};

View File

@@ -1,214 +0,0 @@
import {
MousePointer2,
Pencil,
Type,
Trash2,
Undo,
Redo,
Check,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { ToolbarColorPicker } from "./ToolbarColorPicker";
interface AnnotatorToolbarProps {
tool: "select" | "draw" | "text";
color: string;
selectedId: string | null;
historyStep: number;
historyLength: number;
onToolChange: (tool: "select" | "draw" | "text") => void;
onColorChange: (color: string) => void;
onDelete: () => void;
onUndo: () => void;
onRedo: () => void;
onSubmit: () => void;
onDeactivate: () => void;
hasSubmitHandler: boolean;
}
export const AnnotatorToolbar = ({
tool,
color,
selectedId,
historyStep,
historyLength,
onToolChange,
onColorChange,
onDelete,
onUndo,
onRedo,
onSubmit,
onDeactivate,
hasSubmitHandler,
}: AnnotatorToolbarProps) => {
return (
<div className="flex items-center justify-center p-2 border-b space-x-2">
<TooltipProvider>
{/* Tool Selection Buttons */}
<div className="flex space-x-1">
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onToolChange("select")}
aria-label="Select"
className={cn(
"p-1 rounded transition-colors duration-200",
tool === "select"
? "bg-purple-500 text-white hover:bg-purple-600 dark:bg-purple-600 dark:hover:bg-purple-700"
: " text-purple-700 hover:bg-purple-200 dark:text-purple-300 dark:hover:bg-purple-900",
)}
>
<MousePointer2 size={16} />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Select</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onToolChange("draw")}
aria-label="Draw"
className={cn(
"p-1 rounded transition-colors duration-200",
tool === "draw"
? "bg-purple-500 text-white hover:bg-purple-600 dark:bg-purple-600 dark:hover:bg-purple-700"
: " text-purple-700 hover:bg-purple-200 dark:text-purple-300 dark:hover:bg-purple-900",
)}
>
<Pencil size={16} />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Draw</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onToolChange("text")}
aria-label="Text"
className={cn(
"p-1 rounded transition-colors duration-200",
tool === "text"
? "bg-purple-500 text-white hover:bg-purple-600 dark:bg-purple-600 dark:hover:bg-purple-700"
: "text-purple-700 hover:bg-purple-200 dark:text-purple-300 dark:hover:bg-purple-900",
)}
>
<Type size={16} />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Text</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<div className="p-1 rounded transition-colors duration-200 hover:bg-purple-200 dark:hover:bg-purple-900">
<ToolbarColorPicker color={color} onChange={onColorChange} />
</div>
</TooltipTrigger>
<TooltipContent>
<p>Color</p>
</TooltipContent>
</Tooltip>
<div className="w-px bg-gray-200 dark:bg-gray-700 h-4" />
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onDelete}
aria-label="Delete"
className="p-1 rounded transition-colors duration-200 text-purple-700 hover:bg-purple-200 dark:text-purple-300 dark:hover:bg-purple-900 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={!selectedId}
>
<Trash2 size={16} />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Delete Selected</p>
</TooltipContent>
</Tooltip>
<div className="w-px bg-gray-200 dark:bg-gray-700 h-4" />
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onUndo}
aria-label="Undo"
className="p-1 rounded transition-colors duration-200 text-purple-700 hover:bg-purple-200 dark:text-purple-300 dark:hover:bg-purple-900 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={historyStep === 0}
>
<Undo size={16} />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Undo</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onRedo}
aria-label="Redo"
className="p-1 rounded transition-colors duration-200 text-purple-700 hover:bg-purple-200 dark:text-purple-300 dark:hover:bg-purple-900 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={historyStep === historyLength - 1}
>
<Redo size={16} />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Redo</p>
</TooltipContent>
</Tooltip>
<div className="w-px bg-gray-200 dark:bg-gray-700 h-4" />
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onSubmit}
aria-label="Add to Chat"
className="p-1 rounded transition-colors duration-200 text-purple-700 hover:bg-purple-200 dark:text-purple-300 dark:hover:bg-purple-900 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={!hasSubmitHandler}
>
<Check size={16} />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Add to Chat</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={onDeactivate}
aria-label="Close Annotator"
className="p-1 rounded transition-colors duration-200 text-purple-700 hover:bg-purple-200 dark:text-purple-300 dark:hover:bg-purple-900"
>
<X size={16} />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Close Annotator</p>
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</div>
);
};

View File

@@ -1,176 +0,0 @@
import React, { useState, useRef, useEffect } from "react";
import { X } from "lucide-react";
interface DraggableTextInputProps {
input: {
id: string;
x: number;
y: number;
adjustedX: number;
adjustedY: number;
value: string;
};
index: number;
totalInputs: number;
scale: number;
onMove: (
id: string,
x: number,
y: number,
adjustedX: number,
adjustedY: number,
) => void;
onChange: (id: string, value: string) => void;
onKeyDown: (id: string, e: React.KeyboardEvent, index: number) => void;
onRemove: (id: string) => void;
spanRef: React.MutableRefObject<HTMLSpanElement[]>;
inputRef: React.MutableRefObject<HTMLInputElement[]>;
color: string;
containerRef?: React.RefObject<HTMLDivElement | null>;
}
export const DraggableTextInput = ({
input,
index,
totalInputs,
scale,
onMove,
onChange,
onKeyDown,
onRemove,
spanRef,
inputRef,
color,
containerRef,
}: DraggableTextInputProps) => {
const [isDragging, setIsDragging] = useState(false);
const dragOffset = useRef({ x: 0, y: 0 });
const elementRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (isDragging && containerRef?.current && elementRef.current) {
const containerRect = containerRef.current.getBoundingClientRect();
const elementRect = elementRef.current.getBoundingClientRect();
let newX = e.clientX - dragOffset.current.x;
let newY = e.clientY - dragOffset.current.y;
// Constrain within container bounds
newX = Math.max(
0,
Math.min(newX, containerRect.width - elementRect.width),
);
newY = Math.max(
0,
Math.min(newY, containerRect.height - elementRect.height),
);
// Calculate adjusted coordinates for the canvas
const adjustedX = newX / scale;
const adjustedY = newY / scale;
onMove(input.id, newX, newY, adjustedX, adjustedY);
}
};
const handleMouseUp = () => {
setIsDragging(false);
};
if (isDragging) {
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}
}, [isDragging, input.id, onMove, scale, containerRef]);
return (
<div
ref={elementRef}
className="absolute z-[999]"
style={{
left: `${input.x}px`,
top: `${input.y}px`,
}}
>
<div className="relative">
{/* Drag Handle */}
<div
className="absolute left-2 top-1/2 -translate-y-1/2 cursor-move p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors z-10"
onMouseDown={(e) => {
setIsDragging(true);
dragOffset.current = {
x: e.clientX - input.x,
y: e.clientY - input.y,
};
e.preventDefault();
e.stopPropagation();
}}
title="Drag to move"
>
{/* Grip dots icon - smaller and more subtle */}
<svg
width="8"
height="12"
viewBox="0 0 8 12"
fill="currentColor"
className="text-gray-400 dark:text-gray-500"
>
<circle cx="2" cy="2" r="1" />
<circle cx="6" cy="2" r="1" />
<circle cx="2" cy="6" r="1" />
<circle cx="6" cy="6" r="1" />
<circle cx="2" cy="10" r="1" />
<circle cx="6" cy="10" r="1" />
</svg>
</div>
<span
ref={(e) => {
if (e) spanRef.current[index] = e;
}}
className="
absolute
invisible
whitespace-pre
text-base
font-normal
"
></span>
<input
autoFocus={index === totalInputs - 1}
type="text"
value={input.value}
onChange={(e) => onChange(input.id, e.target.value)}
onKeyDown={(e) => onKeyDown(input.id, e, index)}
className="pl-8 pr-8 py-2 bg-[var(--background)] border-2 rounded-md shadow-lg text-gray-900 dark:text-gray-100 focus:outline-none min-w-[200px] cursor-text"
style={{ borderColor: color }}
placeholder="Type text..."
ref={(e) => {
if (e) inputRef.current[index] = e;
}}
/>
{/* Close Button - Rightmost */}
<button
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-red-100 dark:hover:bg-red-900/30 rounded transition-colors z-10 group"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onRemove(input.id);
}}
title="Remove text input"
type="button"
>
<X className="w-3 h-3 text-gray-400 dark:text-gray-500 group-hover:text-red-600 dark:group-hover:text-red-400" />
</button>
</div>
</div>
);
};

View File

@@ -23,10 +23,8 @@ import {
Monitor, Monitor,
Tablet, Tablet,
Smartphone, Smartphone,
Pen,
} from "lucide-react"; } from "lucide-react";
import { selectedChatIdAtom } from "@/atoms/chatAtoms"; import { selectedChatIdAtom } from "@/atoms/chatAtoms";
import { CopyErrorMessage } from "@/components/CopyErrorMessage";
import { IpcClient } from "@/ipc/ipc_client"; import { IpcClient } from "@/ipc/ipc_client";
import { useParseRouter } from "@/hooks/useParseRouter"; import { useParseRouter } from "@/hooks/useParseRouter";
@@ -39,12 +37,7 @@ import {
import { useStreamChat } from "@/hooks/useStreamChat"; import { useStreamChat } from "@/hooks/useStreamChat";
import { import {
selectedComponentsPreviewAtom, selectedComponentsPreviewAtom,
visualEditingSelectedComponentAtom,
currentComponentCoordinatesAtom,
previewIframeRefAtom, previewIframeRefAtom,
annotatorModeAtom,
screenshotDataUrlAtom,
pendingVisualChangesAtom,
} from "@/atoms/previewAtoms"; } from "@/atoms/previewAtoms";
import { ComponentSelection } from "@/ipc/ipc_types"; import { ComponentSelection } from "@/ipc/ipc_types";
import { import {
@@ -63,12 +56,6 @@ import { useRunApp } from "@/hooks/useRunApp";
import { useShortcut } from "@/hooks/useShortcut"; import { useShortcut } from "@/hooks/useShortcut";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { normalizePath } from "../../../shared/normalizePath"; import { normalizePath } from "../../../shared/normalizePath";
import { showError } from "@/lib/toast";
import { AnnotatorOnlyForPro } from "./AnnotatorOnlyForPro";
import { useAttachments } from "@/hooks/useAttachments";
import { useUserBudgetInfo } from "@/hooks/useUserBudgetInfo";
import { Annotator } from "@/pro/ui/components/Annotator/Annotator";
import { VisualEditingToolbar } from "./VisualEditingToolbar";
interface ErrorBannerProps { interface ErrorBannerProps {
error: { message: string; source: "preview-app" | "dyad-app" } | undefined; error: { message: string; source: "preview-app" | "dyad-app" } | undefined;
@@ -149,14 +136,13 @@ const ErrorBanner = ({ error, onDismiss, onAIFix }: ErrorBannerProps) => {
</div> </div>
</div> </div>
{/* Action buttons at the bottom */} {/* AI Fix button at the bottom */}
{!isDockerError && error.source === "preview-app" && ( {!isDockerError && error.source === "preview-app" && (
<div className="mt-3 px-6 flex justify-end gap-2"> <div className="mt-2 flex justify-end">
<CopyErrorMessage errorMessage={error.message} />
<button <button
disabled={isStreaming} disabled={isStreaming}
onClick={onAIFix} onClick={onAIFix}
className="cursor-pointer flex items-center space-x-1 px-2 py-1 bg-red-500 dark:bg-red-600 text-white rounded text-sm hover:bg-red-600 dark:hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed" className="cursor-pointer flex items-center space-x-1 px-2 py-0.5 bg-red-500 dark:bg-red-600 text-white rounded text-sm hover:bg-red-600 dark:hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
> >
<Sparkles size={14} /> <Sparkles size={14} />
<span>Fix error with AI</span> <span>Fix error with AI</span>
@@ -179,8 +165,6 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
const { streamMessage } = useStreamChat(); const { streamMessage } = useStreamChat();
const { routes: availableRoutes } = useParseRouter(selectedAppId); const { routes: availableRoutes } = useParseRouter(selectedAppId);
const { restartApp } = useRunApp(); const { restartApp } = useRunApp();
const { userBudget } = useUserBudgetInfo();
const isProMode = !!userBudget;
// Navigation state // Navigation state
const [isComponentSelectorInitialized, setIsComponentSelectorInitialized] = const [isComponentSelectorInitialized, setIsComponentSelectorInitialized] =
@@ -189,28 +173,12 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
const [canGoForward, setCanGoForward] = useState(false); const [canGoForward, setCanGoForward] = useState(false);
const [navigationHistory, setNavigationHistory] = useState<string[]>([]); const [navigationHistory, setNavigationHistory] = useState<string[]>([]);
const [currentHistoryPosition, setCurrentHistoryPosition] = useState(0); const [currentHistoryPosition, setCurrentHistoryPosition] = useState(0);
const setSelectedComponentsPreview = useSetAtom( const [selectedComponentsPreview, setSelectedComponentsPreview] = useAtom(
selectedComponentsPreviewAtom, selectedComponentsPreviewAtom,
); );
const [visualEditingSelectedComponent, setVisualEditingSelectedComponent] =
useAtom(visualEditingSelectedComponentAtom);
const setCurrentComponentCoordinates = useSetAtom(
currentComponentCoordinatesAtom,
);
const setPreviewIframeRef = useSetAtom(previewIframeRefAtom); const setPreviewIframeRef = useSetAtom(previewIframeRefAtom);
const iframeRef = useRef<HTMLIFrameElement>(null); const iframeRef = useRef<HTMLIFrameElement>(null);
const [isPicking, setIsPicking] = useState(false); const [isPicking, setIsPicking] = useState(false);
const [annotatorMode, setAnnotatorMode] = useAtom(annotatorModeAtom);
const [screenshotDataUrl, setScreenshotDataUrl] = useAtom(
screenshotDataUrlAtom,
);
const { addAttachments } = useAttachments();
const setPendingChanges = useSetAtom(pendingVisualChangesAtom);
// AST Analysis State
const [isDynamicComponent, setIsDynamicComponent] = useState(false);
const [hasStaticText, setHasStaticText] = useState(false);
// Device mode state // Device mode state
type DeviceMode = "desktop" | "tablet" | "mobile"; type DeviceMode = "desktop" | "tablet" | "mobile";
@@ -226,117 +194,23 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
//detect if the user is using Mac //detect if the user is using Mac
const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0; const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
const analyzeComponent = async (componentId: string) => {
if (!componentId || !selectedAppId) return;
try {
const result = await IpcClient.getInstance().analyzeComponent({
appId: selectedAppId,
componentId,
});
setIsDynamicComponent(result.isDynamic);
setHasStaticText(result.hasStaticText);
// Automatically enable text editing if component has static text
if (result.hasStaticText && iframeRef.current?.contentWindow) {
iframeRef.current.contentWindow.postMessage(
{
type: "enable-dyad-text-editing",
data: {
componentId: componentId,
runtimeId: visualEditingSelectedComponent?.runtimeId,
},
},
"*",
);
}
} catch (err) {
console.error("Failed to analyze component", err);
setIsDynamicComponent(false);
setHasStaticText(false);
}
};
const handleTextUpdated = async (data: any) => {
const { componentId, text } = data;
if (!componentId || !selectedAppId) return;
// Parse componentId to extract file path and line number
const [filePath, lineStr] = componentId.split(":");
const lineNumber = parseInt(lineStr, 10);
if (!filePath || isNaN(lineNumber)) {
console.error("Invalid componentId format:", componentId);
return;
}
// Store text change in pending changes
setPendingChanges((prev) => {
const updated = new Map(prev);
const existing = updated.get(componentId);
updated.set(componentId, {
componentId: componentId,
componentName:
existing?.componentName || visualEditingSelectedComponent?.name || "",
relativePath: filePath,
lineNumber: lineNumber,
styles: existing?.styles || {},
textContent: text,
});
return updated;
});
};
// Function to get current styles from selected element
const getCurrentElementStyles = () => {
if (!iframeRef.current?.contentWindow || !visualEditingSelectedComponent)
return;
try {
// Send message to iframe to get current styles
iframeRef.current.contentWindow.postMessage(
{
type: "get-dyad-component-styles",
data: {
elementId: visualEditingSelectedComponent.id,
runtimeId: visualEditingSelectedComponent.runtimeId,
},
},
"*",
);
} catch (error) {
console.error("Failed to get element styles:", error);
}
};
useEffect(() => {
setAnnotatorMode(false);
}, []);
// Reset visual editing state when app changes or component unmounts
useEffect(() => {
return () => {
// Cleanup on unmount or when app changes
setVisualEditingSelectedComponent(null);
setPendingChanges(new Map());
setCurrentComponentCoordinates(null);
};
}, [selectedAppId]);
// Update iframe ref atom // Update iframe ref atom
useEffect(() => { useEffect(() => {
setPreviewIframeRef(iframeRef.current); setPreviewIframeRef(iframeRef.current);
}, [iframeRef.current, setPreviewIframeRef]); }, [iframeRef.current, setPreviewIframeRef]);
// Send pro mode status to iframe // Deactivate component selector when selection is cleared
useEffect(() => { useEffect(() => {
if (iframeRef.current?.contentWindow && isComponentSelectorInitialized) { if (!selectedComponentsPreview || selectedComponentsPreview.length === 0) {
if (iframeRef.current?.contentWindow) {
iframeRef.current.contentWindow.postMessage( iframeRef.current.contentWindow.postMessage(
{ type: "dyad-pro-mode", enabled: isProMode }, { type: "deactivate-dyad-component-selector" },
"*", "*",
); );
} }
}, [isProMode, isComponentSelectorInitialized]); setIsPicking(false);
}
}, [selectedComponentsPreview]);
// Add message listener for iframe errors and navigation events // Add message listener for iframe errors and navigation events
useEffect(() => { useEffect(() => {
@@ -348,102 +222,41 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
if (event.data?.type === "dyad-component-selector-initialized") { if (event.data?.type === "dyad-component-selector-initialized") {
setIsComponentSelectorInitialized(true); setIsComponentSelectorInitialized(true);
iframeRef.current?.contentWindow?.postMessage(
{ type: "dyad-pro-mode", enabled: isProMode },
"*",
);
return;
}
if (event.data?.type === "dyad-text-updated") {
handleTextUpdated(event.data);
return;
}
if (event.data?.type === "dyad-text-finalized") {
handleTextUpdated(event.data);
return; return;
} }
if (event.data?.type === "dyad-component-selected") { if (event.data?.type === "dyad-component-selected") {
console.log("Component picked:", event.data); console.log("Component picked:", event.data);
const component = parseComponentSelection(event.data); // Parse the single selected component
const component = event.data.component
? parseComponentSelection({
type: "dyad-component-selected",
id: event.data.component.id,
name: event.data.component.name,
})
: null;
if (!component) return; if (!component) return;
// Store the coordinates // Add to existing components, avoiding duplicates by id
if (event.data.coordinates && isProMode) {
setCurrentComponentCoordinates(event.data.coordinates);
}
// Add to selected components if not already there
setSelectedComponentsPreview((prev) => { setSelectedComponentsPreview((prev) => {
const exists = prev.some((c) => { // Check if this component is already selected
// Check by runtimeId if available otherwise by id if (prev.some((c) => c.id === component.id)) {
// Stored components may have lost their runtimeId after re-renders or reloading the page
if (component.runtimeId && c.runtimeId) {
return c.runtimeId === component.runtimeId;
}
return c.id === component.id;
});
if (exists) {
return prev; return prev;
} }
return [...prev, component]; return [...prev, component];
}); });
if (isProMode) {
// Set as the highlighted component for visual editing
setVisualEditingSelectedComponent(component);
// Trigger AST analysis
analyzeComponent(component.id);
}
return; return;
} }
if (event.data?.type === "dyad-component-deselected") { if (event.data?.type === "dyad-component-deselected") {
const componentId = event.data.componentId; const componentId = event.data.componentId;
if (componentId) { if (componentId) {
// Disable text editing for the deselected component
if (iframeRef.current?.contentWindow) {
iframeRef.current.contentWindow.postMessage(
{
type: "disable-dyad-text-editing",
data: { componentId },
},
"*",
);
}
setSelectedComponentsPreview((prev) => setSelectedComponentsPreview((prev) =>
prev.filter((c) => c.id !== componentId), prev.filter((c) => c.id !== componentId),
); );
setVisualEditingSelectedComponent((prev) => {
const shouldClear = prev?.id === componentId;
if (shouldClear) {
setCurrentComponentCoordinates(null);
}
return shouldClear ? null : prev;
});
}
return;
}
if (event.data?.type === "dyad-component-coordinates-updated") {
if (event.data.coordinates) {
setCurrentComponentCoordinates(event.data.coordinates);
}
return;
}
if (event.data?.type === "dyad-screenshot-response") {
if (event.data.success && event.data.dataUrl) {
setScreenshotDataUrl(event.data.dataUrl);
setAnnotatorMode(true);
} else {
showError(event.data.error);
} }
return; return;
} }
@@ -533,7 +346,6 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
setErrorMessage, setErrorMessage,
setIsComponentSelectorInitialized, setIsComponentSelectorInitialized,
setSelectedComponentsPreview, setSelectedComponentsPreview,
setVisualEditingSelectedComponent,
]); ]);
useEffect(() => { useEffect(() => {
@@ -552,26 +364,11 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
} }
}, [appUrl]); }, [appUrl]);
// Get current styles when component is selected for visual editing
useEffect(() => {
if (visualEditingSelectedComponent) {
getCurrentElementStyles();
}
}, [visualEditingSelectedComponent]);
// Function to activate component selector in the iframe // Function to activate component selector in the iframe
const handleActivateComponentSelector = () => { const handleActivateComponentSelector = () => {
if (iframeRef.current?.contentWindow) { if (iframeRef.current?.contentWindow) {
const newIsPicking = !isPicking; const newIsPicking = !isPicking;
if (!newIsPicking) {
// Clean up any text editing states when deactivating
iframeRef.current.contentWindow.postMessage(
{ type: "cleanup-all-text-editing" },
"*",
);
}
setIsPicking(newIsPicking); setIsPicking(newIsPicking);
setVisualEditingSelectedComponent(null);
iframeRef.current.contentWindow.postMessage( iframeRef.current.contentWindow.postMessage(
{ {
type: newIsPicking type: newIsPicking
@@ -583,22 +380,6 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
} }
}; };
// Function to handle annotator button click
const handleAnnotatorClick = () => {
if (annotatorMode) {
setAnnotatorMode(false);
return;
}
if (iframeRef.current?.contentWindow) {
iframeRef.current.contentWindow.postMessage(
{
type: "dyad-take-screenshot",
},
"*",
);
}
};
// Activate component selector using a shortcut // Activate component selector using a shortcut
useShortcut( useShortcut(
"c", "c",
@@ -650,10 +431,6 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
const handleReload = () => { const handleReload = () => {
setReloadKey((prevKey) => prevKey + 1); setReloadKey((prevKey) => prevKey + 1);
setErrorMessage(undefined); setErrorMessage(undefined);
// Reset visual editing state
setVisualEditingSelectedComponent(null);
setPendingChanges(new Map());
setCurrentComponentCoordinates(null);
// Optionally, add logic here if you need to explicitly stop/start the app again // Optionally, add logic here if you need to explicitly stop/start the app again
// For now, just changing the key should remount the iframe // For now, just changing the key should remount the iframe
console.debug("Reloading iframe preview for app", selectedAppId); console.debug("Reloading iframe preview for app", selectedAppId);
@@ -716,9 +493,8 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Browser-style header - hide when annotator is active */} {/* Browser-style header */}
{!annotatorMode && ( <div className="flex items-center p-2 border-b space-x-2 ">
<div className="flex items-center p-2 border-b space-x-2">
{/* Navigation Buttons */} {/* Navigation Buttons */}
<div className="flex space-x-1"> <div className="flex space-x-1">
<TooltipProvider> <TooltipProvider>
@@ -732,9 +508,7 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
: " text-purple-700 hover:bg-purple-200 dark:text-purple-300 dark:hover:bg-purple-900" : " text-purple-700 hover:bg-purple-200 dark:text-purple-300 dark:hover:bg-purple-900"
}`} }`}
disabled={ disabled={
loading || loading || !selectedAppId || !isComponentSelectorInitialized
!selectedAppId ||
!isComponentSelectorInitialized
} }
data-testid="preview-pick-element-button" data-testid="preview-pick-element-button"
> >
@@ -751,36 +525,6 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={handleAnnotatorClick}
className={`p-1 rounded transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed ${
annotatorMode
? "bg-purple-500 text-white hover:bg-purple-600 dark:bg-purple-600 dark:hover:bg-purple-700"
: " text-purple-700 hover:bg-purple-200 dark:text-purple-300 dark:hover:bg-purple-900"
}`}
disabled={
loading ||
!selectedAppId ||
isPicking ||
!isComponentSelectorInitialized
}
data-testid="preview-annotator-button"
>
<Pen size={16} />
</button>
</TooltipTrigger>
<TooltipContent>
<p>
{annotatorMode
? "Annotator mode active"
: "Activate annotator"}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<button <button
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed dark:text-gray-300" className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed dark:text-gray-300"
disabled={!canGoBack || loading || !selectedAppId} disabled={!canGoBack || loading || !selectedAppId}
@@ -836,9 +580,7 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
</DropdownMenuItem> </DropdownMenuItem>
)) ))
) : ( ) : (
<DropdownMenuItem disabled> <DropdownMenuItem disabled>Loading routes...</DropdownMenuItem>
Loading routes...
</DropdownMenuItem>
)} )}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@@ -946,9 +688,8 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
</Popover> </Popover>
</div> </div>
</div> </div>
)}
<div className="relative flex-grow overflow-hidden"> <div className="relative flex-grow ">
<ErrorBanner <ErrorBanner
error={errorMessage} error={errorMessage}
onDismiss={() => setErrorMessage(undefined)} onDismiss={() => setErrorMessage(undefined)}
@@ -976,29 +717,6 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
deviceMode !== "desktop" && "flex justify-center", deviceMode !== "desktop" && "flex justify-center",
)} )}
> >
{annotatorMode && screenshotDataUrl ? (
<div
className="w-full h-full bg-white dark:bg-gray-950"
style={
deviceMode == "desktop"
? {}
: { width: `${deviceWidthConfig[deviceMode]}px` }
}
>
{userBudget ? (
<Annotator
screenshotUrl={screenshotDataUrl}
onSubmit={addAttachments}
handleAnnotatorClick={handleAnnotatorClick}
/>
) : (
<AnnotatorOnlyForPro
onGoBack={() => setAnnotatorMode(false)}
/>
)}
</div>
) : (
<>
<iframe <iframe
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-modals allow-orientation-lock allow-pointer-lock allow-presentation allow-downloads" sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-modals allow-orientation-lock allow-pointer-lock allow-presentation allow-downloads"
data-testid="preview-iframe-element" data-testid="preview-iframe-element"
@@ -1017,19 +735,6 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
src={appUrl} src={appUrl}
allow="clipboard-read; clipboard-write; fullscreen; microphone; camera; display-capture; geolocation; autoplay; picture-in-picture" allow="clipboard-read; clipboard-write; fullscreen; microphone; camera; display-capture; geolocation; autoplay; picture-in-picture"
/> />
{/* Visual Editing Toolbar */}
{isProMode &&
visualEditingSelectedComponent &&
selectedAppId && (
<VisualEditingToolbar
selectedComponent={visualEditingSelectedComponent}
iframeRef={iframeRef}
isDynamic={isDynamicComponent}
hasStaticText={hasStaticText}
/>
)}
</>
)}
</div> </div>
)} )}
</div> </div>
@@ -1038,20 +743,16 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
}; };
function parseComponentSelection(data: any): ComponentSelection | null { function parseComponentSelection(data: any): ComponentSelection | null {
if (!data || data.type !== "dyad-component-selected") {
return null;
}
const component = data.component;
if ( if (
!component || !data ||
typeof component.id !== "string" || data.type !== "dyad-component-selected" ||
typeof component.name !== "string" typeof data.id !== "string" ||
typeof data.name !== "string"
) { ) {
return null; return null;
} }
const { id, name, runtimeId } = component; const { id, name } = data;
// The id is expected to be in the format "filepath:line:column" // The id is expected to be in the format "filepath:line:column"
const parts = id.split(":"); const parts = id.split(":");
@@ -1080,7 +781,6 @@ function parseComponentSelection(data: any): ComponentSelection | null {
return { return {
id, id,
name, name,
runtimeId,
relativePath: normalizePath(relativePath), relativePath: normalizePath(relativePath),
lineNumber, lineNumber,
columnNumber, columnNumber,

View File

@@ -1,56 +0,0 @@
import { ReactNode } from "react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface StylePopoverProps {
icon: ReactNode;
title: string;
tooltip: string;
children: ReactNode;
side?: "top" | "right" | "bottom" | "left";
}
export function StylePopover({
icon,
title,
tooltip,
children,
side = "bottom",
}: StylePopoverProps) {
return (
<Popover>
<PopoverTrigger asChild>
<button
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 text-[#7f22fe] dark:text-gray-200"
aria-label={tooltip}
>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{icon}</TooltipTrigger>
<TooltipContent side={side}>
<p>{tooltip}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</button>
</PopoverTrigger>
<PopoverContent side={side} className="w-64">
<div className="space-y-3">
<h4 className="font-medium text-sm" style={{ color: "#7f22fe" }}>
{title}
</h4>
{children}
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -1,25 +0,0 @@
interface ToolbarColorPickerProps {
color: string;
onChange: (color: string) => void;
}
export const ToolbarColorPicker = ({
color,
onChange,
}: ToolbarColorPickerProps) => {
return (
<label
className="h-[16px] w-[16px] rounded-sm cursor-pointer transition-all overflow-hidden block self-center"
style={{ backgroundColor: color }}
title="Choose color"
>
<input
type="color"
value={color}
onChange={(e) => onChange(e.target.value)}
className="opacity-0 w-full h-full"
aria-label="Choose color"
/>
</label>
);
};

View File

@@ -1,179 +0,0 @@
import { useAtom, useAtomValue } from "jotai";
import { pendingVisualChangesAtom } from "@/atoms/previewAtoms";
import { Button } from "@/components/ui/button";
import { IpcClient } from "@/ipc/ipc_client";
import { Check, X } from "lucide-react";
import { useState, useEffect, useRef } from "react";
import { showError, showSuccess } from "@/lib/toast";
import { selectedAppIdAtom } from "@/atoms/appAtoms";
interface VisualEditingChangesDialogProps {
onReset?: () => void;
iframeRef?: React.RefObject<HTMLIFrameElement | null>;
}
export function VisualEditingChangesDialog({
onReset,
iframeRef,
}: VisualEditingChangesDialogProps) {
const [pendingChanges, setPendingChanges] = useAtom(pendingVisualChangesAtom);
const selectedAppId = useAtomValue(selectedAppIdAtom);
const [isSaving, setIsSaving] = useState(false);
const textContentCache = useRef<Map<string, string>>(new Map());
const [allResponsesReceived, setAllResponsesReceived] = useState(false);
const expectedResponsesRef = useRef<Set<string>>(new Set());
const isWaitingForResponses = useRef(false);
// Listen for text content responses
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.data?.type === "dyad-text-content-response") {
const { componentId, text } = event.data;
if (text !== null) {
textContentCache.current.set(componentId, text);
}
// Mark this response as received
expectedResponsesRef.current.delete(componentId);
// Check if all responses received (only if we're actually waiting)
if (
isWaitingForResponses.current &&
expectedResponsesRef.current.size === 0
) {
setAllResponsesReceived(true);
}
}
};
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, []);
// Execute when all responses are received
useEffect(() => {
if (allResponsesReceived && isSaving) {
const applyChanges = async () => {
try {
const changesToSave = Array.from(pendingChanges.values());
// Update changes with cached text content
const updatedChanges = changesToSave.map((change) => {
const cachedText = textContentCache.current.get(change.componentId);
if (cachedText !== undefined) {
return { ...change, textContent: cachedText };
}
return change;
});
await IpcClient.getInstance().applyVisualEditingChanges({
appId: selectedAppId!,
changes: updatedChanges,
});
setPendingChanges(new Map());
textContentCache.current.clear();
showSuccess("Visual changes saved to source files");
onReset?.();
} catch (error) {
console.error("Failed to save visual editing changes:", error);
showError(`Failed to save changes: ${error}`);
} finally {
setIsSaving(false);
setAllResponsesReceived(false);
isWaitingForResponses.current = false;
}
};
applyChanges();
}
}, [
allResponsesReceived,
isSaving,
pendingChanges,
selectedAppId,
onReset,
setPendingChanges,
]);
if (pendingChanges.size === 0) return null;
const handleSave = async () => {
setIsSaving(true);
try {
const changesToSave = Array.from(pendingChanges.values());
if (iframeRef?.current?.contentWindow) {
// Reset state for new request
setAllResponsesReceived(false);
expectedResponsesRef.current.clear();
isWaitingForResponses.current = true;
// Track which components we're expecting responses from
for (const change of changesToSave) {
expectedResponsesRef.current.add(change.componentId);
}
// Request text content for each component
for (const change of changesToSave) {
iframeRef.current.contentWindow.postMessage(
{
type: "get-dyad-text-content",
data: { componentId: change.componentId },
},
"*",
);
}
// If no responses are expected, trigger immediately
if (expectedResponsesRef.current.size === 0) {
setAllResponsesReceived(true);
}
} else {
await IpcClient.getInstance().applyVisualEditingChanges({
appId: selectedAppId!,
changes: changesToSave,
});
setPendingChanges(new Map());
textContentCache.current.clear();
showSuccess("Visual changes saved to source files");
onReset?.();
}
} catch (error) {
console.error("Failed to save visual editing changes:", error);
showError(`Failed to save changes: ${error}`);
setIsSaving(false);
isWaitingForResponses.current = false;
}
};
const handleDiscard = () => {
setPendingChanges(new Map());
onReset?.();
};
return (
<div className="bg-[var(--background)] border-b border-[var(--border)] px-2 lg:px-4 py-1.5 flex flex-col lg:flex-row items-start lg:items-center lg:justify-between gap-1.5 lg:gap-4 flex-wrap">
<p className="text-xs lg:text-sm w-full lg:w-auto">
<span className="font-medium">{pendingChanges.size}</span> component
{pendingChanges.size > 1 ? "s" : ""} modified
</p>
<div className="flex gap-1 lg:gap-2 w-full lg:w-auto flex-wrap">
<Button size="sm" onClick={handleSave} disabled={isSaving}>
<Check size={14} className="mr-1" />
<span>{isSaving ? "Saving..." : "Save Changes"}</span>
</Button>
<Button
size="sm"
variant="outline"
onClick={handleDiscard}
disabled={isSaving}
>
<X size={14} className="mr-1" />
<span>Discard</span>
</Button>
</div>
</div>
);
}

View File

@@ -1,531 +0,0 @@
import { useState, useEffect } from "react";
import { X, Move, Square, Palette, Type } from "lucide-react";
import { Label } from "@/components/ui/label";
import { ComponentSelection } from "@/ipc/ipc_types";
import { useSetAtom, useAtomValue } from "jotai";
import {
pendingVisualChangesAtom,
selectedComponentsPreviewAtom,
currentComponentCoordinatesAtom,
visualEditingSelectedComponentAtom,
} from "@/atoms/previewAtoms";
import { StylePopover } from "./StylePopover";
import { ColorPicker } from "@/components/ui/ColorPicker";
import { NumberInput } from "@/components/ui/NumberInput";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { rgbToHex, processNumericValue } from "@/utils/style-utils";
const FONT_WEIGHT_OPTIONS = [
{ value: "", label: "Default" },
{ value: "100", label: "Thin (100)" },
{ value: "200", label: "Extra Light (200)" },
{ value: "300", label: "Light (300)" },
{ value: "400", label: "Normal (400)" },
{ value: "500", label: "Medium (500)" },
{ value: "600", label: "Semi Bold (600)" },
{ value: "700", label: "Bold (700)" },
{ value: "800", label: "Extra Bold (800)" },
{ value: "900", label: "Black (900)" },
] as const;
const FONT_FAMILY_OPTIONS = [
{ value: "", label: "Default" },
// Sans-serif (clean, modern)
{ value: "Arial, sans-serif", label: "Arial" },
{ value: "Inter, sans-serif", label: "Inter" },
{ value: "Roboto, sans-serif", label: "Roboto" },
// Serif (traditional, elegant)
{ value: "Georgia, serif", label: "Georgia" },
{ value: "'Times New Roman', Times, serif", label: "Times New Roman" },
{ value: "Merriweather, serif", label: "Merriweather" },
// Monospace (code, technical)
{ value: "'Courier New', Courier, monospace", label: "Courier New" },
{ value: "'Fira Code', monospace", label: "Fira Code" },
{ value: "Consolas, monospace", label: "Consolas" },
// Display/Decorative (bold, distinctive)
{ value: "Impact, fantasy", label: "Impact" },
{ value: "'Bebas Neue', cursive", label: "Bebas Neue" },
// Cursive/Handwriting (casual, friendly)
{ value: "'Comic Sans MS', cursive", label: "Comic Sans MS" },
{ value: "'Brush Script MT', cursive", label: "Brush Script" },
] as const;
interface VisualEditingToolbarProps {
selectedComponent: ComponentSelection | null;
iframeRef: React.RefObject<HTMLIFrameElement | null>;
isDynamic: boolean;
hasStaticText: boolean;
}
export function VisualEditingToolbar({
selectedComponent,
iframeRef,
isDynamic,
hasStaticText,
}: VisualEditingToolbarProps) {
const coordinates = useAtomValue(currentComponentCoordinatesAtom);
const [currentMargin, setCurrentMargin] = useState({ x: "", y: "" });
const [currentPadding, setCurrentPadding] = useState({ x: "", y: "" });
const [currentBorder, setCurrentBorder] = useState({
width: "",
radius: "",
color: "#000000",
});
const [currentBackgroundColor, setCurrentBackgroundColor] =
useState("#ffffff");
const [currentTextStyles, setCurrentTextStyles] = useState({
fontSize: "",
fontWeight: "",
fontFamily: "",
color: "#000000",
});
const setPendingChanges = useSetAtom(pendingVisualChangesAtom);
const setSelectedComponentsPreview = useSetAtom(
selectedComponentsPreviewAtom,
);
const setVisualEditingSelectedComponent = useSetAtom(
visualEditingSelectedComponentAtom,
);
const handleDeselectComponent = () => {
if (!selectedComponent) return;
setSelectedComponentsPreview((prev) =>
prev.filter((c) => c.id !== selectedComponent.id),
);
setVisualEditingSelectedComponent(null);
if (iframeRef.current?.contentWindow) {
iframeRef.current.contentWindow.postMessage(
{
type: "remove-dyad-component-overlay",
componentId: selectedComponent.id,
},
"*",
);
}
};
const sendStyleModification = (styles: {
margin?: { left?: string; right?: string; top?: string; bottom?: string };
padding?: { left?: string; right?: string; top?: string; bottom?: string };
border?: { width?: string; radius?: string; color?: string };
backgroundColor?: string;
text?: { fontSize?: string; fontWeight?: string; color?: string };
}) => {
if (!iframeRef.current?.contentWindow || !selectedComponent) return;
iframeRef.current.contentWindow.postMessage(
{
type: "modify-dyad-component-styles",
data: {
elementId: selectedComponent.id,
runtimeId: selectedComponent.runtimeId,
styles,
},
},
"*",
);
iframeRef.current.contentWindow.postMessage(
{
type: "update-dyad-overlay-positions",
},
"*",
);
setPendingChanges((prev) => {
const updated = new Map(prev);
const existing = updated.get(selectedComponent.id);
const newStyles: any = { ...existing?.styles };
if (styles.margin) {
newStyles.margin = { ...existing?.styles?.margin, ...styles.margin };
}
if (styles.padding) {
newStyles.padding = { ...existing?.styles?.padding, ...styles.padding };
}
if (styles.border) {
newStyles.border = { ...existing?.styles?.border, ...styles.border };
}
if (styles.backgroundColor) {
newStyles.backgroundColor = styles.backgroundColor;
}
if (styles.text) {
newStyles.text = { ...existing?.styles?.text, ...styles.text };
}
updated.set(selectedComponent.id, {
componentId: selectedComponent.id,
componentName: selectedComponent.name,
relativePath: selectedComponent.relativePath,
lineNumber: selectedComponent.lineNumber,
styles: newStyles,
textContent: existing?.textContent || "",
});
return updated;
});
};
const getCurrentElementStyles = () => {
if (!iframeRef.current?.contentWindow || !selectedComponent) return;
try {
iframeRef.current.contentWindow.postMessage(
{
type: "get-dyad-component-styles",
data: {
elementId: selectedComponent.id,
runtimeId: selectedComponent.runtimeId,
},
},
"*",
);
} catch (error) {
console.error("Failed to get element styles:", error);
}
};
useEffect(() => {
if (selectedComponent) {
getCurrentElementStyles();
}
}, [selectedComponent]);
useEffect(() => {
if (coordinates && iframeRef.current?.contentWindow) {
iframeRef.current.contentWindow.postMessage(
{
type: "update-component-coordinates",
coordinates,
},
"*",
);
}
}, [coordinates, iframeRef]);
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.data?.type === "dyad-component-styles") {
const { margin, padding, border, backgroundColor, text } =
event.data.data;
const marginX = margin?.left === margin?.right ? margin.left : "";
const marginY = margin?.top === margin?.bottom ? margin.top : "";
const paddingX = padding?.left === padding?.right ? padding.left : "";
const paddingY = padding?.top === padding?.bottom ? padding.top : "";
setCurrentMargin({ x: marginX, y: marginY });
setCurrentPadding({ x: paddingX, y: paddingY });
setCurrentBorder({
width: border?.width || "",
radius: border?.radius || "",
color: rgbToHex(border?.color),
});
setCurrentBackgroundColor(rgbToHex(backgroundColor) || "#ffffff");
setCurrentTextStyles({
fontSize: text?.fontSize || "",
fontWeight: text?.fontWeight || "",
fontFamily: text?.fontFamily || "",
color: rgbToHex(text?.color) || "#000000",
});
}
};
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, []);
const handleSpacingChange = (
type: "margin" | "padding",
axis: "x" | "y",
value: string,
) => {
const setter = type === "margin" ? setCurrentMargin : setCurrentPadding;
setter((prev) => ({ ...prev, [axis]: value }));
if (value) {
const processedValue = processNumericValue(value);
const data =
axis === "x"
? { left: processedValue, right: processedValue }
: { top: processedValue, bottom: processedValue };
sendStyleModification({ [type]: data });
}
};
const handleBorderChange = (
property: "width" | "radius" | "color",
value: string,
) => {
const newBorder = { ...currentBorder, [property]: value };
setCurrentBorder(newBorder);
if (value) {
let processedValue = value;
if (property !== "color" && /^\d+$/.test(value)) {
processedValue = `${value}px`;
}
if (property === "width" || property === "color") {
sendStyleModification({
border: {
width:
property === "width"
? processedValue
: currentBorder.width || "0px",
color: property === "color" ? processedValue : currentBorder.color,
},
});
} else {
sendStyleModification({ border: { [property]: processedValue } });
}
}
};
const handleTextStyleChange = (
property: "fontSize" | "fontWeight" | "fontFamily" | "color",
value: string,
) => {
setCurrentTextStyles((prev) => ({ ...prev, [property]: value }));
if (value) {
let processedValue = value;
if (property === "fontSize" && /^\d+$/.test(value)) {
processedValue = `${value}px`;
}
sendStyleModification({ text: { [property]: processedValue } });
}
};
if (!selectedComponent || !coordinates) return null;
const toolbarTop = coordinates.top + coordinates.height + 4;
const toolbarLeft = coordinates.left;
return (
<div
className="absolute bg-[var(--background)] border border-[var(--border)] rounded-md shadow-lg z-50 flex flex-row items-center p-2 gap-1"
style={{
top: `${toolbarTop}px`,
left: `${toolbarLeft}px`,
}}
>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={handleDeselectComponent}
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 text-[#7f22fe] dark:text-gray-200"
aria-label="Deselect Component"
>
<X size={16} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>Deselect Component</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{isDynamic ? (
<div className="flex items-center px-2 py-1 text-yellow-800 dark:text-yellow-200 rounded text-xs font-medium">
<span>This component is styled dynamically</span>
</div>
) : (
<>
<StylePopover
icon={<Move size={16} />}
title="Margin"
tooltip="Margin"
>
<div className="grid grid-cols-1 gap-2">
<NumberInput
id="margin-x"
label="Horizontal"
value={currentMargin.x}
onChange={(v) => handleSpacingChange("margin", "x", v)}
placeholder="10"
/>
<NumberInput
id="margin-y"
label="Vertical"
value={currentMargin.y}
onChange={(v) => handleSpacingChange("margin", "y", v)}
placeholder="10"
/>
</div>
</StylePopover>
<StylePopover
icon={
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<rect x="7" y="7" width="10" height="10" rx="1" />
</svg>
}
title="Padding"
tooltip="Padding"
>
<div className="grid grid-cols-1 gap-2">
<NumberInput
id="padding-x"
label="Horizontal"
value={currentPadding.x}
onChange={(v) => handleSpacingChange("padding", "x", v)}
placeholder="10"
/>
<NumberInput
id="padding-y"
label="Vertical"
value={currentPadding.y}
onChange={(v) => handleSpacingChange("padding", "y", v)}
placeholder="10"
/>
</div>
</StylePopover>
<StylePopover
icon={<Square size={16} />}
title="Border"
tooltip="Border"
>
<div className="space-y-2">
<NumberInput
id="border-width"
label="Width"
value={currentBorder.width}
onChange={(v) => handleBorderChange("width", v)}
placeholder="1"
/>
<NumberInput
id="border-radius"
label="Radius"
value={currentBorder.radius}
onChange={(v) => handleBorderChange("radius", v)}
placeholder="4"
/>
<div>
<Label htmlFor="border-color" className="text-xs">
Color
</Label>
<ColorPicker
id="border-color"
value={currentBorder.color}
onChange={(v) => handleBorderChange("color", v)}
className="mt-1"
/>
</div>
</div>
</StylePopover>
<StylePopover
icon={<Palette size={16} />}
title="Background Color"
tooltip="Background"
>
<div>
<Label htmlFor="bg-color" className="text-xs">
Color
</Label>
<ColorPicker
id="bg-color"
value={currentBackgroundColor}
onChange={(v) => {
setCurrentBackgroundColor(v);
if (v) sendStyleModification({ backgroundColor: v });
}}
className="mt-1"
/>
</div>
</StylePopover>
{hasStaticText && (
<StylePopover
icon={<Type size={16} />}
title="Text Style"
tooltip="Text Style"
>
<div className="space-y-2">
<NumberInput
id="font-size"
label="Font Size"
value={currentTextStyles.fontSize}
onChange={(v) => handleTextStyleChange("fontSize", v)}
placeholder="16"
/>
<div>
<Label htmlFor="font-weight" className="text-xs">
Font Weight
</Label>
<select
id="font-weight"
className="mt-1 h-8 text-xs w-full rounded-md border border-input bg-background px-3 py-2"
value={currentTextStyles.fontWeight}
onChange={(e) =>
handleTextStyleChange("fontWeight", e.target.value)
}
>
{FONT_WEIGHT_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="font-family" className="text-xs">
Font Family
</Label>
<select
id="font-family"
className="mt-1 h-8 text-xs w-full rounded-md border border-input bg-background px-3 py-2"
value={currentTextStyles.fontFamily}
onChange={(e) =>
handleTextStyleChange("fontFamily", e.target.value)
}
>
{FONT_FAMILY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div>
<Label htmlFor="text-color" className="text-xs">
Text Color
</Label>
<ColorPicker
id="text-color"
value={currentTextStyles.color}
onChange={(v) => handleTextStyleChange("color", v)}
className="mt-1"
/>
</div>
</div>
</StylePopover>
)}
</>
)}
</div>
);
}

View File

@@ -1,35 +0,0 @@
import { Input } from "@/components/ui/input";
interface ColorPickerProps {
id: string;
label?: string;
value: string;
onChange: (value: string) => void;
className?: string;
}
export function ColorPicker({
id,
value,
onChange,
className = "",
}: ColorPickerProps) {
return (
<div className={`flex gap-2 ${className}`}>
<Input
id={id}
type="color"
className="h-8 w-12 p-1 cursor-pointer"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
<Input
type="text"
placeholder="#000000"
className="h-8 text-xs flex-1"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</div>
);
}

View File

@@ -1,42 +0,0 @@
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
interface NumberInputProps {
id: string;
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
step?: string;
min?: string;
className?: string;
}
export function NumberInput({
id,
label,
value,
onChange,
placeholder = "0",
step = "1",
min = "0",
className = "",
}: NumberInputProps) {
return (
<div className={className}>
<Label htmlFor={id} className="text-xs">
{label}
</Label>
<Input
id={id}
type="number"
placeholder={placeholder}
className="mt-1 h-8 text-xs"
value={value.replace(/[^\d.-]/g, "") || ""}
onChange={(e) => onChange(e.target.value)}
step={step}
min={min}
/>
</div>
);
}

View File

@@ -0,0 +1,60 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { IpcClient } from "@/ipc/ipc_client";
import type {
SmartContextMeta,
SmartContextSnippet,
SmartContextRetrieveResult,
} from "@/ipc/ipc_types";
export function useSmartContextMeta(chatId: number) {
return useQuery<SmartContextMeta, Error>({
queryKey: ["smart-context", chatId, "meta"],
queryFn: async () => {
const ipc = IpcClient.getInstance();
return ipc.getSmartContextMeta(chatId);
},
enabled: !!chatId,
});
}
export function useRetrieveSmartContext(
chatId: number,
query: string,
budgetTokens: number,
) {
return useQuery<SmartContextRetrieveResult, Error>({
queryKey: ["smart-context", chatId, "retrieve", query, budgetTokens],
queryFn: async () => {
const ipc = IpcClient.getInstance();
return ipc.retrieveSmartContext({ chatId, query, budgetTokens });
},
enabled: !!chatId && !!query && budgetTokens > 0,
meta: { showErrorToast: true },
});
}
export function useUpsertSmartContextSnippets(chatId: number) {
const qc = useQueryClient();
return useMutation<number, Error, Array<Pick<SmartContextSnippet, "text" | "source">>>({
mutationFn: async (snippets) => {
const ipc = IpcClient.getInstance();
return ipc.upsertSmartContextSnippets(chatId, snippets);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["smart-context", chatId] });
},
});
}
export function useUpdateRollingSummary(chatId: number) {
const qc = useQueryClient();
return useMutation<SmartContextMeta, Error, { summary: string }>({
mutationFn: async ({ summary }) => {
const ipc = IpcClient.getInstance();
return ipc.updateSmartContextRollingSummary(chatId, summary);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["smart-context", chatId, "meta"] });
},
});
}

18
src/custom/index.ts Normal file
View File

@@ -0,0 +1,18 @@
// Custom modules for moreminimore-vibe
// This file exports all custom functionality to make imports easier
// Custom hooks
export { useSmartContextMeta, useRetrieveSmartContext, useUpsertSmartContextSnippets, useUpdateRollingSummary } from './hooks/useSmartContext';
// Custom IPC handlers (these will need to be imported and registered in the main process)
export { registerSmartContextHandlers } from './ipc/smart_context_handlers';
// Custom utilities
export * from './utils/smart_context_store';
// Re-export types that might be needed
export type {
SmartContextMeta,
SmartContextSnippet,
SmartContextRetrieveResult,
} from '../ipc/ipc_types';

View File

@@ -0,0 +1,65 @@
import log from "electron-log";
import { createLoggedHandler } from "./safe_handle";
import {
appendSnippets,
readMeta,
retrieveContext,
updateRollingSummary,
rebuildIndex,
type SmartContextSnippet,
type SmartContextMeta,
} from "../utils/smart_context_store";
const logger = log.scope("smart_context_handlers");
const handle = createLoggedHandler(logger);
export interface UpsertSnippetsParams {
chatId: number;
snippets: Array<{
text: string;
source:
| { type: "message"; messageIndex?: number }
| { type: "code"; filePath: string }
| { type: "attachment"; name: string; mime?: string }
| { type: "other"; label?: string };
}>;
}
export interface RetrieveContextParams {
chatId: number;
query: string;
budgetTokens: number;
}
export function registerSmartContextHandlers() {
handle("sc:get-meta", async (_event, chatId: number): Promise<SmartContextMeta> => {
return readMeta(chatId);
});
handle(
"sc:upsert-snippets",
async (_event, params: UpsertSnippetsParams): Promise<number> => {
const count = await appendSnippets(params.chatId, params.snippets);
return count;
},
);
handle(
"sc:update-rolling-summary",
async (_event, params: { chatId: number; summary: string }): Promise<SmartContextMeta> => {
return updateRollingSummary(params.chatId, params.summary);
},
);
handle(
"sc:retrieve-context",
async (_event, params: RetrieveContextParams) => {
return retrieveContext(params.chatId, params.query, params.budgetTokens);
},
);
handle("sc:rebuild-index", async (_event, chatId: number) => {
await rebuildIndex(chatId);
return { ok: true } as const;
});
}

View File

@@ -0,0 +1,212 @@
import path from "node:path";
import { promises as fs } from "node:fs";
import { randomUUID } from "node:crypto";
import { getUserDataPath } from "../../paths/paths";
import { estimateTokens } from "./token_utils";
export type SmartContextSource =
| { type: "message"; messageIndex?: number }
| { type: "code"; filePath: string }
| { type: "attachment"; name: string; mime?: string }
| { type: "other"; label?: string };
export interface SmartContextSnippet {
id: string;
text: string;
score?: number;
source: SmartContextSource;
ts: number; // epoch ms
tokens?: number;
}
export interface SmartContextMetaConfig {
maxSnippets?: number;
}
export interface SmartContextMeta {
entityId: string; // e.g., chatId as string
updatedAt: number;
rollingSummary?: string;
summaryTokens?: number;
config?: SmartContextMetaConfig;
}
function getThreadDir(chatId: number): string {
const base = path.join(getUserDataPath(), "smart-context", "threads");
return path.join(base, String(chatId));
}
function getMetaPath(chatId: number): string {
return path.join(getThreadDir(chatId), "meta.json");
}
function getSnippetsPath(chatId: number): string {
return path.join(getThreadDir(chatId), "snippets.jsonl");
}
async function ensureDir(dir: string): Promise<void> {
await fs.mkdir(dir, { recursive: true });
}
export async function readMeta(chatId: number): Promise<SmartContextMeta> {
const dir = getThreadDir(chatId);
await ensureDir(dir);
const metaPath = getMetaPath(chatId);
try {
const raw = await fs.readFile(metaPath, "utf8");
const meta = JSON.parse(raw) as SmartContextMeta;
return meta;
} catch {
const fresh: SmartContextMeta = {
entityId: String(chatId),
updatedAt: Date.now(),
rollingSummary: "",
summaryTokens: 0,
config: { maxSnippets: 400 },
};
await fs.writeFile(metaPath, JSON.stringify(fresh, null, 2), "utf8");
return fresh;
}
}
export async function writeMeta(
chatId: number,
meta: SmartContextMeta,
): Promise<void> {
const dir = getThreadDir(chatId);
await ensureDir(dir);
const metaPath = getMetaPath(chatId);
const updated: SmartContextMeta = {
...meta,
entityId: String(chatId),
updatedAt: Date.now(),
};
await fs.writeFile(metaPath, JSON.stringify(updated, null, 2), "utf8");
}
export async function updateRollingSummary(
chatId: number,
summary: string,
): Promise<SmartContextMeta> {
const meta = await readMeta(chatId);
const summaryTokens = estimateTokens(summary || "");
const next: SmartContextMeta = {
...meta,
rollingSummary: summary,
summaryTokens,
};
await writeMeta(chatId, next);
return next;
}
export async function appendSnippets(
chatId: number,
snippets: Omit<SmartContextSnippet, "id" | "ts" | "tokens">[],
): Promise<number> {
const dir = getThreadDir(chatId);
await ensureDir(dir);
const snippetsPath = getSnippetsPath(chatId);
const withDefaults: SmartContextSnippet[] = snippets.map((s) => ({
id: randomUUID(),
ts: Date.now(),
tokens: estimateTokens(s.text),
...s,
}));
const lines = withDefaults.map((obj) => JSON.stringify(obj)).join("\n");
await fs.appendFile(snippetsPath, (lines ? lines + "\n" : ""), "utf8");
// prune if exceeded max
const meta = await readMeta(chatId);
const maxSnippets = meta.config?.maxSnippets ?? 400;
try {
const file = await fs.readFile(snippetsPath, "utf8");
const allLines = file.split("\n").filter(Boolean);
if (allLines.length > maxSnippets) {
const toKeep = allLines.slice(allLines.length - maxSnippets);
await fs.writeFile(snippetsPath, toKeep.join("\n") + "\n", "utf8");
return toKeep.length;
}
return allLines.length;
} catch {
return withDefaults.length;
}
}
export async function readAllSnippets(chatId: number): Promise<SmartContextSnippet[]> {
try {
const raw = await fs.readFile(getSnippetsPath(chatId), "utf8");
return raw
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line) as SmartContextSnippet);
} catch {
return [];
}
}
function normalize(value: number, min: number, max: number): number {
if (max === min) return 0;
return (value - min) / (max - min);
}
function keywordScore(text: string, query: string): number {
const toTokens = (s: string) =>
s
.toLowerCase()
.replace(/[^a-z0-9_\- ]+/g, " ")
.split(/\s+/)
.filter(Boolean);
const qTokens = new Set(toTokens(query));
const tTokens = toTokens(text);
if (qTokens.size === 0 || tTokens.length === 0) return 0;
let hits = 0;
for (const tok of tTokens) if (qTokens.has(tok)) hits++;
return hits / tTokens.length; // simple overlap ratio
}
export interface RetrieveContextResult {
rollingSummary?: string;
usedTokens: number;
snippets: SmartContextSnippet[];
}
export async function retrieveContext(
chatId: number,
query: string,
budgetTokens: number,
): Promise<RetrieveContextResult> {
const meta = await readMeta(chatId);
const snippets = await readAllSnippets(chatId);
const now = Date.now();
let minTs = now;
let maxTs = 0;
for (const s of snippets) {
if (s.ts < minTs) minTs = s.ts;
if (s.ts > maxTs) maxTs = s.ts;
}
const scored = snippets.map((s) => {
const recency = normalize(s.ts, minTs, maxTs);
const kw = keywordScore(s.text, query);
const base = 0.6 * kw + 0.4 * recency;
const score = base;
return { ...s, score } as SmartContextSnippet;
});
scored.sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
const picked: SmartContextSnippet[] = [];
let usedTokens = 0;
for (const s of scored) {
const t = s.tokens ?? estimateTokens(s.text);
if (usedTokens + t > budgetTokens) break;
picked.push(s);
usedTokens += t;
}
const rollingSummary = meta.rollingSummary || "";
return { rollingSummary, usedTokens, snippets: picked };
}
export async function rebuildIndex(_chatId: number): Promise<void> {
// Placeholder for future embedding/vector index rebuild.
return;
}

View File

@@ -1,10 +1,8 @@
import React, { useRef, useState } from "react"; import React, { useState, useRef } from "react";
import type { FileAttachment } from "@/ipc/ipc_types"; import type { FileAttachment } from "@/ipc/ipc_types";
import { useAtom } from "jotai";
import { attachmentsAtom } from "@/atoms/chatAtoms";
export function useAttachments() { export function useAttachments() {
const [attachments, setAttachments] = useAtom(attachmentsAtom); const [attachments, setAttachments] = useState<FileAttachment[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [isDraggingOver, setIsDraggingOver] = useState(false); const [isDraggingOver, setIsDraggingOver] = useState(false);
@@ -135,6 +133,5 @@ export function useAttachments() {
handleDrop, handleDrop,
clearAttachments, clearAttachments,
handlePaste, handlePaste,
addAttachments,
}; };
} }

View File

@@ -1,60 +0,0 @@
// Type definitions for Git operations
export type GitCommit = {
oid: string;
commit: {
message: string;
author: {
timestamp: number;
};
};
};
export interface GitBaseParams {
path: string;
}
export interface GitCommitParams extends GitBaseParams {
message: string;
amend?: boolean;
}
export interface GitFileParams extends GitBaseParams {
filepath: string;
}
export interface GitCheckoutParams extends GitBaseParams {
ref: string;
}
export interface GitBranchRenameParams extends GitBaseParams {
oldBranch: string;
newBranch: string;
}
export interface GitCloneParams {
path: string; // destination
url: string;
depth?: number | null;
singleBranch?: boolean;
accessToken?: string;
}
export interface GitLogParams extends GitBaseParams {
depth?: number;
}
export interface GitResult {
success: boolean;
error?: string;
}
export interface GitPushParams extends GitBaseParams {
branch: string;
accessToken: string;
force?: boolean;
}
export interface GitFileAtCommitParams extends GitBaseParams {
filePath: string;
commitHash: string;
}
export interface GitSetRemoteUrlParams extends GitBaseParams {
remoteUrl: string;
}
export interface GitInitParams extends GitBaseParams {
ref?: string; // branch name, default = "main"
}
export interface GitStageToRevertParams extends GitBaseParams {
targetOid: string;
}

View File

@@ -14,6 +14,7 @@ import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { getDyadAppPath, getUserDataPath } from "../../paths/paths"; import { getDyadAppPath, getUserDataPath } from "../../paths/paths";
import { ChildProcess, spawn } from "node:child_process"; import { ChildProcess, spawn } from "node:child_process";
import git from "isomorphic-git";
import { promises as fsPromises } from "node:fs"; import { promises as fsPromises } from "node:fs";
// Import our utility modules // Import our utility modules
@@ -35,7 +36,7 @@ import killPort from "kill-port";
import util from "util"; import util from "util";
import log from "electron-log"; import log from "electron-log";
import { import {
deploySupabaseFunction, deploySupabaseFunctions,
getSupabaseProjectName, getSupabaseProjectName,
} from "../../supabase_admin/supabase_management_client"; } from "../../supabase_admin/supabase_management_client";
import { createLoggedHandler } from "./safe_handle"; import { createLoggedHandler } from "./safe_handle";
@@ -43,31 +44,16 @@ import { getLanguageModelProviders } from "../shared/language_model_helpers";
import { startProxy } from "../utils/start_proxy_server"; import { startProxy } from "../utils/start_proxy_server";
import { Worker } from "worker_threads"; import { Worker } from "worker_threads";
import { createFromTemplate } from "./createFromTemplate"; import { createFromTemplate } from "./createFromTemplate";
import { import { gitCommit } from "../utils/git_utils";
gitCommit,
gitAdd,
gitInit,
gitListBranches,
gitRenameBranch,
} from "../utils/git_utils";
import { safeSend } from "../utils/safe_sender"; import { safeSend } from "../utils/safe_sender";
import { normalizePath } from "../../../shared/normalizePath"; import { normalizePath } from "../../../shared/normalizePath";
import { import { isServerFunction } from "@/supabase_admin/supabase_utils";
isServerFunction,
isSharedServerModule,
deployAllSupabaseFunctions,
extractFunctionNameFromPath,
} from "@/supabase_admin/supabase_utils";
import { getVercelTeamSlug } from "../utils/vercel_utils"; import { getVercelTeamSlug } from "../utils/vercel_utils";
import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils"; import { storeDbTimestampAtCurrentVersion } from "../utils/neon_timestamp_utils";
import { AppSearchResult } from "@/lib/schemas"; import { AppSearchResult } from "@/lib/schemas";
import { getAppPort } from "../../../shared/ports"; const DEFAULT_COMMAND =
"(pnpm install && pnpm run dev --port 32100) || (npm install --legacy-peer-deps && npm run dev -- --port 32100)";
function getDefaultCommand(appId: number): string {
const port = getAppPort(appId);
return `(pnpm install && pnpm run dev --port ${port}) || (npm install --legacy-peer-deps && npm run dev -- --port ${port})`;
}
async function copyDir( async function copyDir(
source: string, source: string,
destination: string, destination: string,
@@ -154,7 +140,7 @@ async function executeAppLocalNode({
installCommand?: string | null; installCommand?: string | null;
startCommand?: string | null; startCommand?: string | null;
}): Promise<void> { }): Promise<void> {
const command = getCommand({ appId, installCommand, startCommand }); const command = getCommand({ installCommand, startCommand });
const spawnedProcess = spawn(command, [], { const spawnedProcess = spawn(command, [], {
cwd: appPath, cwd: appPath,
shell: true, shell: true,
@@ -422,7 +408,6 @@ RUN npm install -g pnpm
}); });
// Run the Docker container // Run the Docker container
const port = getAppPort(appId);
const process = spawn( const process = spawn(
"docker", "docker",
[ [
@@ -431,7 +416,7 @@ RUN npm install -g pnpm
"--name", "--name",
containerName, containerName,
"-p", "-p",
`${port}:${port}`, "32100:32100",
"-v", "-v",
`${appPath}:/app`, `${appPath}:/app`,
"-v", "-v",
@@ -443,7 +428,7 @@ RUN npm install -g pnpm
`dyad-app-${appId}`, `dyad-app-${appId}`,
"sh", "sh",
"-c", "-c",
getCommand({ appId, installCommand, startCommand }), getCommand({ installCommand, startCommand }),
], ],
{ {
stdio: "pipe", stdio: "pipe",
@@ -600,11 +585,18 @@ export function registerAppHandlers() {
}); });
// Initialize git repo and create first commit // Initialize git repo and create first commit
await git.init({
await gitInit({ path: fullAppPath, ref: "main" }); fs: fs,
dir: fullAppPath,
defaultBranch: "main",
});
// Stage all files // Stage all files
await gitAdd({ path: fullAppPath, filepath: "." }); await git.add({
fs: fs,
dir: fullAppPath,
filepath: ".",
});
// Create initial commit // Create initial commit
const commitHash = await gitCommit({ const commitHash = await gitCommit({
@@ -665,10 +657,18 @@ export function registerAppHandlers() {
if (!withHistory) { if (!withHistory) {
// Initialize git repo and create first commit // Initialize git repo and create first commit
await gitInit({ path: newAppPath, ref: "main" }); await git.init({
fs: fs,
dir: newAppPath,
defaultBranch: "main",
});
// Stage all files // Stage all files
await gitAdd({ path: newAppPath, filepath: "." }); await git.add({
fs: fs,
dir: newAppPath,
filepath: ".",
});
// Create initial commit // Create initial commit
await gitCommit({ await gitCommit({
@@ -822,8 +822,8 @@ export function registerAppHandlers() {
const appPath = getDyadAppPath(app.path); const appPath = getDyadAppPath(app.path);
try { try {
// There may have been a previous run that left a process on this port. // There may have been a previous run that left a process on port 32100.
await cleanUpPort(getAppPort(appId)); await cleanUpPort(32100);
await executeApp({ await executeApp({
appPath, appPath,
appId, appId,
@@ -923,8 +923,8 @@ export function registerAppHandlers() {
logger.log(`App ${appId} not running. Proceeding to start.`); logger.log(`App ${appId} not running. Proceeding to start.`);
} }
// There may have been a previous run that left a process on this port. // There may have been a previous run that left a process on port 32100.
await cleanUpPort(getAppPort(appId)); await cleanUpPort(32100);
// Now start the app again // Now start the app again
const app = await db.query.apps.findFirst({ const app = await db.query.apps.findFirst({
@@ -1007,8 +1007,6 @@ export function registerAppHandlers() {
content, content,
}: { appId: number; filePath: string; content: string }, }: { appId: number; filePath: string; content: string },
): Promise<EditAppFileReturnType> => { ): Promise<EditAppFileReturnType> => {
// It should already be normalized, but just in case.
filePath = normalizePath(filePath);
const app = await db.query.apps.findFirst({ const app = await db.query.apps.findFirst({
where: eq(apps.id, appId), where: eq(apps.id, appId),
}); });
@@ -1051,7 +1049,11 @@ export function registerAppHandlers() {
// Check if git repository exists and commit the change // Check if git repository exists and commit the change
if (fs.existsSync(path.join(appPath, ".git"))) { if (fs.existsSync(path.join(appPath, ".git"))) {
await gitAdd({ path: appPath, filepath: filePath }); await git.add({
fs,
dir: appPath,
filepath: filePath,
});
await gitCommit({ await gitCommit({
path: appPath, path: appPath,
@@ -1063,51 +1065,20 @@ export function registerAppHandlers() {
throw new Error(`Failed to write file: ${error.message}`); throw new Error(`Failed to write file: ${error.message}`);
} }
if (app.supabaseProjectId) { if (isServerFunction(filePath) && app.supabaseProjectId) {
// Check if shared module was modified - redeploy all functions
if (isSharedServerModule(filePath)) {
try { try {
logger.info( await deploySupabaseFunctions({
`Shared module ${filePath} modified, redeploying all Supabase functions`,
);
const deployErrors = await deployAllSupabaseFunctions({
appPath,
supabaseProjectId: app.supabaseProjectId, supabaseProjectId: app.supabaseProjectId,
}); functionName: path.basename(path.dirname(filePath)),
if (deployErrors.length > 0) { content: content,
return {
warning: `File saved, but some Supabase functions failed to deploy: ${deployErrors.join(", ")}`,
};
}
} catch (error) {
logger.error(
`Error redeploying Supabase functions after shared module change:`,
error,
);
return {
warning: `File saved, but failed to redeploy Supabase functions: ${error}`,
};
}
} else if (isServerFunction(filePath)) {
// Regular function file - deploy just this function
try {
const functionName = extractFunctionNameFromPath(filePath);
await deploySupabaseFunction({
supabaseProjectId: app.supabaseProjectId,
functionName,
appPath,
}); });
} catch (error) { } catch (error) {
logger.error( logger.error(`Error deploying Supabase function ${filePath}:`, error);
`Error deploying Supabase function ${filePath}:`,
error,
);
return { return {
warning: `File saved, but failed to deploy Supabase function: ${filePath}: ${error}`, warning: `File saved, but failed to deploy Supabase function: ${filePath}: ${error}`,
}; };
} }
} }
}
return {}; return {};
}, },
); );
@@ -1427,7 +1398,7 @@ export function registerAppHandlers() {
return withLock(appId, async () => { return withLock(appId, async () => {
try { try {
// Check if the old branch exists // Check if the old branch exists
const branches = await gitListBranches({ path: appPath }); const branches = await git.listBranches({ fs, dir: appPath });
if (!branches.includes(oldBranchName)) { if (!branches.includes(oldBranchName)) {
throw new Error(`Branch '${oldBranchName}' not found.`); throw new Error(`Branch '${oldBranchName}' not found.`);
} }
@@ -1443,10 +1414,11 @@ export function registerAppHandlers() {
); );
} }
await gitRenameBranch({ await git.renameBranch({
path: appPath, fs: fs,
oldBranch: oldBranchName, dir: appPath,
newBranch: newBranchName, oldref: oldBranchName,
ref: newBranchName,
}); });
logger.info( logger.info(
`Branch renamed from '${oldBranchName}' to '${newBranchName}' for app ${appId}`, `Branch renamed from '${oldBranchName}' to '${newBranchName}' for app ${appId}`,
@@ -1578,18 +1550,16 @@ export function registerAppHandlers() {
} }
function getCommand({ function getCommand({
appId,
installCommand, installCommand,
startCommand, startCommand,
}: { }: {
appId: number;
installCommand?: string | null; installCommand?: string | null;
startCommand?: string | null; startCommand?: string | null;
}) { }) {
const hasCustomCommands = !!installCommand?.trim() && !!startCommand?.trim(); const hasCustomCommands = !!installCommand?.trim() && !!startCommand?.trim();
return hasCustomCommands return hasCustomCommands
? `${installCommand!.trim()} && ${startCommand!.trim()}` ? `${installCommand!.trim()} && ${startCommand!.trim()}`
: getDefaultCommand(appId); : DEFAULT_COMMAND;
} }
async function cleanUpPort(port: number) { async function cleanUpPort(port: number) {

View File

@@ -205,7 +205,7 @@ async function applyCapacitor({
// Install Capacitor dependencies // Install Capacitor dependencies
await simpleSpawn({ await simpleSpawn({
command: command:
"pnpm add @capacitor/core@7.4.4 @capacitor/cli@7.4.4 @capacitor/ios@7.4.4 @capacitor/android@7.4.4 || npm install @capacitor/core@7.4.4 @capacitor/cli@7.4.4 @capacitor/ios@7.4.4 @capacitor/android@7.4.4 --legacy-peer-deps", "pnpm add @capacitor/core @capacitor/cli @capacitor/ios @capacitor/android || npm install @capacitor/core @capacitor/cli @capacitor/ios @capacitor/android --legacy-peer-deps",
cwd: appPath, cwd: appPath,
successMessage: "Capacitor dependencies installed successfully", successMessage: "Capacitor dependencies installed successfully",
errorPrefix: "Failed to install Capacitor dependencies", errorPrefix: "Failed to install Capacitor dependencies",

View File

@@ -3,12 +3,13 @@ import { db } from "../../db";
import { apps, chats, messages } from "../../db/schema"; import { apps, chats, messages } from "../../db/schema";
import { desc, eq, and, like } from "drizzle-orm"; import { desc, eq, and, like } from "drizzle-orm";
import type { ChatSearchResult, ChatSummary } from "../../lib/schemas"; import type { ChatSearchResult, ChatSummary } from "../../lib/schemas";
import * as git from "isomorphic-git";
import * as fs from "fs";
import { createLoggedHandler } from "./safe_handle"; import { createLoggedHandler } from "./safe_handle";
import log from "electron-log"; import log from "electron-log";
import { getDyadAppPath } from "../../paths/paths"; import { getDyadAppPath } from "../../paths/paths";
import { UpdateChatParams } from "../ipc_types"; import { UpdateChatParams } from "../ipc_types";
import { getCurrentCommitHash } from "../utils/git_utils";
const logger = log.scope("chat_handlers"); const logger = log.scope("chat_handlers");
const handle = createLoggedHandler(logger); const handle = createLoggedHandler(logger);
@@ -30,8 +31,9 @@ export function registerChatHandlers() {
let initialCommitHash = null; let initialCommitHash = null;
try { try {
// Get the current git revision of main branch // Get the current git revision of main branch
initialCommitHash = await getCurrentCommitHash({ initialCommitHash = await git.resolveRef({
path: getDyadAppPath(app.path), fs,
dir: getDyadAppPath(app.path),
ref: "main", ref: "main",
}); });
} catch (error) { } catch (error) {

View File

@@ -1,8 +1,9 @@
import path from "path"; import path from "path";
import fs from "fs-extra"; import fs from "fs-extra";
import git from "isomorphic-git";
import http from "isomorphic-git/http/node";
import { app } from "electron"; import { app } from "electron";
import { copyDirectoryRecursive } from "../utils/file_utils"; import { copyDirectoryRecursive } from "../utils/file_utils";
import { gitClone, getCurrentCommitHash } from "../utils/git_utils";
import { readSettings } from "@/main/settings"; import { readSettings } from "@/main/settings";
import { getTemplateOrThrow } from "../utils/template_utils"; import { getTemplateOrThrow } from "../utils/template_utils";
import log from "electron-log"; import log from "electron-log";
@@ -34,6 +35,9 @@ export async function createFromTemplate({
} }
async function cloneRepo(repoUrl: string): Promise<string> { async function cloneRepo(repoUrl: string): Promise<string> {
let orgName: string;
let repoName: string;
const url = new URL(repoUrl); const url = new URL(repoUrl);
if (url.protocol !== "https:") { if (url.protocol !== "https:") {
throw new Error("Repository URL must use HTTPS."); throw new Error("Repository URL must use HTTPS.");
@@ -51,8 +55,8 @@ async function cloneRepo(repoUrl: string): Promise<string> {
); );
} }
const orgName = pathParts[0]; orgName = pathParts[0];
const repoName = path.basename(pathParts[1], ".git"); // Remove .git suffix if present repoName = path.basename(pathParts[1], ".git"); // Remove .git suffix if present
if (!orgName || !repoName) { if (!orgName || !repoName) {
// This case should ideally be caught by pathParts.length !== 2 // This case should ideally be caught by pathParts.length !== 2
@@ -79,31 +83,41 @@ async function cloneRepo(repoUrl: string): Promise<string> {
const apiUrl = `https://api.github.com/repos/${orgName}/${repoName}/commits/HEAD`; const apiUrl = `https://api.github.com/repos/${orgName}/${repoName}/commits/HEAD`;
logger.info(`Fetching remote SHA from ${apiUrl}`); logger.info(`Fetching remote SHA from ${apiUrl}`);
// Use native fetch instead of isomorphic-git http.request let remoteSha: string | undefined;
const response = await fetch(apiUrl, {
const response = await http.request({
url: apiUrl,
method: "GET", method: "GET",
headers: { headers: {
"User-Agent": "Dyad", // GitHub API requires this "User-Agent": "Dyad", // GitHub API requires a User-Agent
Accept: "application/vnd.github.v3+json", Accept: "application/vnd.github.v3+json",
}, },
}); });
// Handle non-200 responses
if (!response.ok) { if (response.statusCode === 200 && response.body) {
throw new Error( // Convert AsyncIterableIterator<Uint8Array> to string
`GitHub API request failed with status ${response.status}: ${response.statusText}`, const chunks: Uint8Array[] = [];
); for await (const chunk of response.body) {
chunks.push(chunk);
} }
// Parse JSON directly (fetch handles streaming internally) const responseBodyStr = Buffer.concat(chunks).toString("utf8");
const commitData = await response.json(); const commitData = JSON.parse(responseBodyStr);
const remoteSha = commitData.sha; remoteSha = commitData.sha;
if (!remoteSha) { if (!remoteSha) {
throw new Error("SHA not found in GitHub API response."); throw new Error("SHA not found in GitHub API response.");
} }
logger.info(`Successfully fetched remote SHA: ${remoteSha}`); logger.info(`Successfully fetched remote SHA: ${remoteSha}`);
} else {
throw new Error(
`GitHub API request failed with status ${response.statusCode}: ${response.statusMessage}`,
);
}
// Compare with local SHA const localSha = await git.resolveRef({
const localSha = await getCurrentCommitHash({ path: cachePath }); fs,
dir: cachePath,
ref: "HEAD",
});
if (remoteSha === localSha) { if (remoteSha === localSha) {
logger.info( logger.info(
@@ -115,7 +129,7 @@ async function cloneRepo(repoUrl: string): Promise<string> {
`Local cache for ${repoName} (SHA: ${localSha}) is outdated (Remote SHA: ${remoteSha}). Removing and re-cloning.`, `Local cache for ${repoName} (SHA: ${localSha}) is outdated (Remote SHA: ${remoteSha}). Removing and re-cloning.`,
); );
fs.rmSync(cachePath, { recursive: true, force: true }); fs.rmSync(cachePath, { recursive: true, force: true });
// Continue to clone // Proceed to clone
} }
} catch (err) { } catch (err) {
logger.warn( logger.warn(
@@ -130,7 +144,14 @@ async function cloneRepo(repoUrl: string): Promise<string> {
logger.info(`Cloning ${repoUrl} to ${cachePath}`); logger.info(`Cloning ${repoUrl} to ${cachePath}`);
try { try {
await gitClone({ path: cachePath, url: repoUrl, depth: 1 }); await git.clone({
fs,
http,
dir: cachePath,
url: repoUrl,
singleBranch: true,
depth: 1,
});
logger.info(`Successfully cloned ${repoUrl} to ${cachePath}`); logger.info(`Successfully cloned ${repoUrl} to ${cachePath}`);
} catch (err) { } catch (err) {
logger.error(`Failed to clone ${repoUrl} to ${cachePath}: `, err); logger.error(`Failed to clone ${repoUrl} to ${cachePath}: `, err);

View File

@@ -1,7 +1,8 @@
import { ipcMain, BrowserWindow, IpcMainInvokeEvent } from "electron"; import { ipcMain, BrowserWindow, IpcMainInvokeEvent } from "electron";
import fetch from "node-fetch"; // Use node-fetch for making HTTP requests in main process import fetch from "node-fetch"; // Use node-fetch for making HTTP requests in main process
import { writeSettings, readSettings } from "../../main/settings"; import { writeSettings, readSettings } from "../../main/settings";
import { gitSetRemoteUrl, gitPush, gitClone } from "../utils/git_utils"; import git, { clone } from "isomorphic-git";
import http from "isomorphic-git/http/node";
import * as schema from "../../db/schema"; import * as schema from "../../db/schema";
import fs from "node:fs"; import fs from "node:fs";
import { getDyadAppPath } from "../../paths/paths"; import { getDyadAppPath } from "../../paths/paths";
@@ -574,17 +575,25 @@ async function handlePushToGithub(
? `${GITHUB_GIT_BASE}/${app.githubOrg}/${app.githubRepo}.git` ? `${GITHUB_GIT_BASE}/${app.githubOrg}/${app.githubRepo}.git`
: `https://${accessToken}:x-oauth-basic@github.com/${app.githubOrg}/${app.githubRepo}.git`; : `https://${accessToken}:x-oauth-basic@github.com/${app.githubOrg}/${app.githubRepo}.git`;
// Set or update remote URL using git config // Set or update remote URL using git config
await gitSetRemoteUrl({ await git.setConfig({
path: appPath, fs,
remoteUrl, dir: appPath,
path: "remote.origin.url",
value: remoteUrl,
}); });
// Push to GitHub // Push to GitHub
await gitPush({ await git.push({
path: appPath, fs,
branch, http,
accessToken, dir: appPath,
force, remote: "origin",
ref: "main",
remoteRef: branch,
onAuth: () => ({
username: accessToken,
password: "x-oauth-basic",
}),
force: !!force,
}); });
return { success: true }; return { success: true };
} catch (err: any) { } catch (err: any) {
@@ -664,12 +673,9 @@ async function handleCloneRepoFromUrl(
} }
const appPath = getDyadAppPath(finalAppName); const appPath = getDyadAppPath(finalAppName);
// Ensure the app directory exists if native git is disabled
if (!settings.enableNativeGit) {
if (!fs.existsSync(appPath)) { if (!fs.existsSync(appPath)) {
fs.mkdirSync(appPath, { recursive: true }); fs.mkdirSync(appPath, { recursive: true });
} }
}
// Use authenticated URL if token exists, otherwise use public HTTPS URL // Use authenticated URL if token exists, otherwise use public HTTPS URL
const cloneUrl = accessToken const cloneUrl = accessToken
? IS_TEST_BUILD ? IS_TEST_BUILD
@@ -677,10 +683,17 @@ async function handleCloneRepoFromUrl(
: `https://${accessToken}:x-oauth-basic@github.com/${owner}/${repoName}.git` : `https://${accessToken}:x-oauth-basic@github.com/${owner}/${repoName}.git`
: `https://github.com/${owner}/${repoName}.git`; // Changed: use public HTTPS URL instead of original url : `https://github.com/${owner}/${repoName}.git`; // Changed: use public HTTPS URL instead of original url
try { try {
await gitClone({ await clone({
path: appPath, fs,
http,
dir: appPath,
url: cloneUrl, url: cloneUrl,
accessToken, onAuth: accessToken
? () => ({
username: accessToken,
password: "x-oauth-basic",
})
: undefined,
singleBranch: false, singleBranch: false,
}); });
} catch (cloneErr) { } catch (cloneErr) {

View File

@@ -8,10 +8,11 @@ import { apps } from "@/db/schema";
import { db } from "@/db"; import { db } from "@/db";
import { chats } from "@/db/schema"; import { chats } from "@/db/schema";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import git from "isomorphic-git";
import { ImportAppParams, ImportAppResult } from "../ipc_types"; 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 } from "../utils/git_utils";
const logger = log.scope("import-handlers"); const logger = log.scope("import-handlers");
const handle = createLoggedHandler(logger); const handle = createLoggedHandler(logger);
@@ -105,11 +106,18 @@ export function registerImportHandlers() {
.catch(() => false); .catch(() => false);
if (!isGitRepo) { if (!isGitRepo) {
// Initialize git repo and create first commit // Initialize git repo and create first commit
await gitInit({ path: destPath, ref: "main" }); await git.init({
fs: fs,
dir: destPath,
defaultBranch: "main",
});
// Stage all files // Stage all files
await git.add({
await gitAdd({ path: destPath, filepath: "." }); fs: fs,
dir: destPath,
filepath: ".",
});
// Create initial commit // Create initial commit
await gitCommit({ await gitCommit({

Some files were not shown because too many files have changed in this diff Show More