Compare commits

..

25 Commits

Author SHA1 Message Date
Will Chen
7cf8317f55 Fix Playwright report comments on forked PRs (#1975)
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
## Summary
- update the Playwright summary script to support workflow_run events
and optional comment skipping
- stop the CI workflow from posting Playwright comments directly and
only generate the summary
- add a workflow_run-based commenter workflow that downloads artifacts
and posts results for PRs, including forks

## Testing
- not run (workflow changes only)


------
[Codex
Task](https://chatgpt.com/codex/tasks/task_e_694340b2da6083278e42db076ea89eba)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Moves Playwright commenting to a workflow_run job that downloads
artifacts and posts/updates the summary; CI now only uploads the report,
and the summary script improves PR/run detection and OS bucketing.
> 
> - **Workflows**:
> - **CI (`.github/workflows/ci.yml`)**: Remove in-job PR comment step;
keep merging reports and uploading `playwright-report` artifact.
> - **New (`.github/workflows/playwright-comment.yml`)**: `workflow_run`
on CI to download artifacts (`html-report--attempt-*`, `blob-report-*`)
and run `scripts/generate-playwright-summary.js` to comment on the PR
(supports forks).
> - **Script (`scripts/generate-playwright-summary.js`)**:
> - Add PR detection for `workflow_run` and `PR_NUMBER`; use
`PLAYWRIGHT_RUN_ID` for report link.
> - Improve OS detection/bucketing (auto-detect from attachments/stacks,
sensible defaults, lazy bucket creation).
> - Safer fallbacks when no artifacts; always write job summary; skip PR
comment when no PR is detected.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
8428f7ad6eb0671571cb4ae0e473434ffb1cf8d1. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->



<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Fixes Playwright report comments on forked PRs by moving comment posting
to a workflow_run job. CI now only uploads the reports; a separate
workflow posts or updates the PR comment using artifacts.

- **Bug Fixes**
- Added Playwright Report Comment workflow (workflow_run on CI) to
download artifacts and comment on PRs from forks.
  - Removed PR comment step from CI; CI only uploads Playwright reports.
- Updated summary script to support workflow_run, auto-detect the PR
number, use PLAYWRIGHT_RUN_ID for links, and improve OS
detection/bucketing.

<sup>Written for commit 8428f7ad6eb0671571cb4ae0e473434ffb1cf8d1.
Summary will update automatically on new commits.</sup>

<!-- End of auto-generated description by cubic. -->
2025-12-17 17:30:58 -08:00
Mohamed Aziz Mejri
2e31c508da Fixing scrollbar flickering in annotator mode (#1968)
<!-- This is an auto-generated description by cubic. -->
## Summary by cubic
Fixes scrollbar flickering in annotator mode by constraining draggable
inputs within the container and suppressing scroll during drag for
smoother movement.

- **Bug Fixes**
- Added containerRef to DraggableTextInput and elementRef to calculate
bounds.
- Constrained drag coordinates to container size and accounted for
scale.
- Prevented default and stopped propagation on mousemove to avoid scroll
jitter.
  - Passed containerRef from Annotator to DraggableTextInput.

<sup>Written for commit 959605ddaa5faf23252ee797bf206c6dff46a069.
Summary will update automatically on new commits.</sup>

<!-- End of auto-generated description by cubic. -->
2025-12-16 11:21:33 -08:00
Will Chen
3fd45ec253 Do not hardcode 32100 port (#1969)
Fixes #1949 

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> <sup>[Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) is
generating a summary for commit
46ac310c762fd4044c35bc59264122234ed19bbf. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Make app ports dynamic instead of hardcoded 32100 to prevent conflicts
and keep local runs, Docker, and env URLs in sync. Ports now derive from
appId using a base of 32100.

- **Bug Fixes**
  - Added getAppPort(appId) = 32100 + (appId % 10_000).
- Used the dynamic port in NEXT_PUBLIC_SERVER_URL, start commands,
Docker -p mapping, and cleanUpPort.
- Updated getCommand to accept appId and generate a per-app default
start command.

<sup>Written for commit 46ac310c762fd4044c35bc59264122234ed19bbf.
Summary will update automatically on new commits.</sup>

<!-- End of auto-generated description by cubic. -->
2025-12-16 11:18:09 -08:00
Will Chen
47992f48dd Leave GitHub comment with playwright results (#1965)
<!-- CURSOR_SUMMARY -->
> [!NOTE]
> Posts a per-OS Playwright test summary as a PR comment by adding a
JSON reporter and a CI step to generate and publish the results.
> 
> - **CI / Reporting**
> - Add permissions and a GitHub Script step in
`.github/workflows/ci.yml` to generate and post/update a Playwright test
summary comment after merging shard reports.
>   - Upload merged HTML report artifact and link to full run.
> - **Playwright config**
> - Update `merge.config.ts` to add JSON reporter output to
`playwright-report/results.json` alongside HTML.
> - **New Script**
> - Add `scripts/generate-playwright-summary.js` to parse Playwright
JSON, compute per-OS (macOS/Windows) pass/fail/flaky/skipped counts,
list top failures/flaky tests, and write both PR comment and job
summary.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
d5ca6987f65e9a7063533960382516af89e67391. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->





<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Posts Playwright test results as a PR comment with a per-OS summary,
flaky test counts, and top failures, improving CI visibility across
macOS and Windows. Adds a JSON reporter and a GitHub Script step that
links to the full report.

- **New Features**
  - Adds JSON reporter output to playwright-report/results.json.
- Adds a script to parse results, include flaky tests, post/update the
PR comment, and write the job summary.
- Updates CI workflow permissions and runs the summary script after
report merge.

<sup>Written for commit d5ca6987f65e9a7063533960382516af89e67391.
Summary will update automatically on new commits.</sup>

<!-- End of auto-generated description by cubic. -->
2025-12-15 23:24:08 -08:00
Will Chen
91cf1e97c3 Support shared modules for supabase edge functions (#1964)
Credit: thanks @SlayTheDragons whose PR
https://github.com/dyad-sh/dyad/pull/1665 paved the way for this
implementation.

<!-- CURSOR_SUMMARY -->
> [!NOTE]
> Adds _shared module support for Supabase edge functions with
import-map packaging and automatic redeploys; updates deployment to
include full function directories plus shared files, and adds path
utilities and tests.
> 
> - **Supabase Edge Functions**
> - **Shared Modules Support**: Detect `_shared` changes and redeploy
all functions; regular function changes deploy only that function.
> - **Deployment Overhaul**: `deploySupabaseFunctions` now uploads full
function directories plus `_shared` files via multipart form-data, sets
`entrypoint_path`, and writes `import_map.json` (`_shared/` →
`../_shared/`).
> - **Function Discovery & Packaging**: Add file collection helpers
(`listFilesWithStats`, `loadZipEntries`) and path utilities
(`toPosixPath`, `findFunctionDirectory`, `stripSupabaseFunctionsPrefix`)
with signature-based caching for `_shared`.
> - **APIs & Utils**: Introduce `isSharedServerModule`, refine
`isServerFunction` (excludes `_shared`), add
`extractFunctionNameFromPath`, and `buildSignature`.
> - **IPC Changes**
> - Update file edit/rename/delete flows to track shared module edits
and trigger full redeploys; otherwise deploy per-function using
extracted name and `appPath`.
> - **Prompts**
>   - Document `_shared` usage and import pattern in Supabase prompt.
> - **Tests**
> - Add tests for function/shared detection, name extraction, path
handling, and signature building.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
f35599ec0e708e2ef6b7e78ae7901b29953a6dff. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->









<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Adds support for shared modules for Supabase edge functions. Shared code
in supabase/functions/_shared is now bundled via an import map and
triggers redeploys across all functions when changed.

- **New Features**
- Detects shared modules in supabase/functions/_shared and redeploys all
functions when they change.
- Deploys full function directories plus shared files, and writes an
import_map.json that resolves "_shared/" imports.
- Auto-deploys only the affected function on file changes; switches to
redeploy-all when a shared module is touched.

- **Refactors**
- deploySupabaseFunction now uploads multiple files (function + shared)
using multipart form-data and sets entrypoint/import map.
- Added file collection, path utilities, and shared-file caching via
content signatures to reduce redundant reads.
- Updated deployAllSupabaseFunctions to skip non-function dirs (e.g.,
_shared) and use functionPath.
- Added tests for function/shared detection, path handling, and
signature building.

<sup>Written for commit 302d84625d9e61477db9ada052a027b29ff18cef.
Summary will update automatically on new commits.</sup>

<!-- End of auto-generated description by cubic. -->
2025-12-15 18:07:57 -08:00
Will Chen
a6d6a4cdaf Rename Agent mode to Build with MCP in UI (#1966)
<!-- CURSOR_SUMMARY -->
> [!NOTE]
> Rename the “Agent” chat mode to “Build with MCP” and update its label
and description in `src/components/ChatModeSelector.tsx`.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
a5ad57fa2492941186c0cdba9ea9d6340817ea6b. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->



<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Renamed the “Agent” chat mode to “Build with MCP” and added shared
module support for Supabase Edge Functions, including automatic
redeploys when shared code changes.

- **New Features**
- Updated UI: “Agent” → “Build (MCP)” in mode name and “Build with MCP
(experimental)” in selector.
- Detect changes in supabase/functions/_shared and redeploy all
functions accordingly.
- Deploy functions with their full directory plus shared files; add an
import_map to resolve “_shared/” imports.
  - Cache shared files by signature to avoid redundant reads.
  - Added tests for path helpers and signature building.

- **Refactors**
- deploySupabaseFunctions now accepts appPath and functionPath, and
uploads multiple files instead of a single content string.
- Updated app and response handlers to use the new deploy flow, skip
per-function redeploys when shared modules change, and avoid treating
_shared as a function.
- Added isSharedServerModule and refined isServerFunction to exclude
_shared.

<sup>Written for commit a5ad57fa2492941186c0cdba9ea9d6340817ea6b.
Summary will update automatically on new commits.</sup>

<!-- End of auto-generated description by cubic. -->
2025-12-15 18:00:50 -08:00
Will Chen
213def4a67 Use user info proxy (#1963)
<!-- CURSOR_SUMMARY -->
> [!NOTE]
> Switches budget fetch to `https://api.dyad.sh/v1/user/info` and
validates/consumes `usedCredits`, `totalCredits`, and `budgetResetDate`
directly via Zod.
> 
> - **IPC/Pro handlers (`src/ipc/handlers/pro_handlers.ts`)**:
> - **Endpoint**: Update user info URL to
`https://api.dyad.sh/v1/user/info`.
> - **Validation**: Add `zod` schema `UserInfoResponseSchema` to
validate API response.
> - **Data mapping**: Use `usedCredits`, `totalCredits`,
`budgetResetDate`, and `userId` from response directly; remove
conversion logic and old nested `user_info` parsing.
> - **Redaction**: Compute `redactedUserId` from `userId` and return
parsed `UserBudgetInfo`.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
da1f192c2cabb2154bd10b69555c27d62fbb6368. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

<!-- This is an auto-generated description by cubic. -->
## Summary by cubic
Switched user budget fetch to the new user info proxy and added schema
validation. Uses API-provided credits directly and removes the old
conversion logic.

- **Refactors**
  - Use https://api.dyad.sh/v1/user/info instead of llm-gateway.
- Validate response with a Zod schema (usedCredits, totalCredits,
budgetResetDate, userId).
  - Map fields directly to UserBudgetInfo and remove CONVERSION_RATIO.
  - Keep redacted user ID format (****1234).

- **Dependencies**
- Removed unused html-dom-parser, html-react-parser, and react-property
from the lockfile.

<sup>Written for commit da1f192c2cabb2154bd10b69555c27d62fbb6368.
Summary will update automatically on new commits.</sup>

<!-- End of auto-generated description by cubic. -->
2025-12-15 14:25:55 -08:00
Mohamed Aziz Mejri
9d33f3757d logging and presenting cpu/memory usage when app is force-closed (#1894)
closes #1803 











<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Detects when the app was force-closed and shows a dialog with the last
known CPU and memory usage. Adds background performance monitoring so we
can surface metrics on next launch.

- **New Features**
- Start a performance monitor at app launch; captures process and system
memory/CPU every 30s and on quit.
- Persist metrics in settings.lastKnownPerformance and track
settings.isRunning to detect improper shutdowns.
- On startup, if the previous run was force-closed, send a
"force-close-detected" IPC event after the window loads.
  - Add ForceCloseDialog to display timestamped process/system metrics.
- Whitelist the new IPC channel in preload and listen for it on the home
page.

<sup>Written for commit 0543cdc234da7f94024e8506749aaa9ca36ef916.
Summary will update automatically on new commits.</sup>

<!-- End of auto-generated description by cubic. -->
2025-12-14 21:07:56 +01:00
Mohamed Aziz Mejri
a4ab1a7f84 Annotator (#1861)
<!-- This is an auto-generated description by cubic. -->
## Summary by cubic
Adds an in-app screenshot annotator to the Preview panel for Pro users
so you can capture the current app view, draw or add text, and submit an
annotated image to chat.

- **New Features**
- Pen button in PreviewIframe to toggle annotator; captures a screenshot
via worker messaging and displays it in a Konva canvas.
- Tools: select, freehand draw, and draggable text; supports undo/redo,
delete, and resizing with Transformer. Canvas scales to the container.
Includes a color picker.
- Submit exports a PNG and attaches it to the chat via useAttachments;
prefills the chat input; annotator auto-closes after submit.
  - Pro-only: non-Pro users see an upsell screen.
- State atoms added: annotatorModeAtom, screenshotDataUrlAtom,
attachmentsAtom; PreviewIframe now handles dyad-screenshot-response
messages.

- **Dependencies**
  - Added konva, react-konva, perfect-freehand, and html-to-image.
- Proxy now injects html-to-image and the new dyad-screenshot-client.js
for screenshot capture.

<sup>Written for commit 580aca271c5993a0dc7426e36e34393e073bd67b.
Summary will update automatically on new commits.</sup>

<!-- End of auto-generated description by cubic. -->
2025-12-13 19:40:31 +01:00
Will Chen
86e4005795 Parameterize undo e2e test (#1943)
<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Parameterizes the undo e2e test and adds a second run using native Git
via a shared helper.
> 
> - **Tests (e2e)**:
> - Refactor `e2e-tests/undo.spec.ts` to use `runUndoTest(po,
nativeGit)` helper.
> - Pass `nativeGit` through `po.setUp({ autoApprove: true, nativeGit
})`.
>     - Import `PageObject` from `helpers/test_helper`.
> - Add second test "undo with native git" alongside existing "undo"
test.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
2108a7d73669794f0052192e8b5a1ffac3d54ec1. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Parameterized the undo e2e test to run with and without native Git,
improving coverage without duplicating code.

- **Refactors**
- Added runUndoTest(po, nativeGit) helper and passed nativeGit to setUp.
  - Split into two test cases: “undo” and “undo with native git”.
- Minor whitespace-only formatting updates in snapshot and fixture
files.

<sup>Written for commit 2108a7d73669794f0052192e8b5a1ffac3d54ec1.
Summary will update automatically on new commits.</sup>

<!-- End of auto-generated description by cubic. -->
2025-12-12 13:13:39 -08:00
Will Chen
70d4f5980e Load Monaco from CDN (#1939)
<!-- CURSOR_SUMMARY -->
> [!NOTE]
> Load Monaco via @monaco-editor/react loader, drop custom workers, and
initialize themes/TypeScript after loader init.
> 
> - **Editor/Monaco**:
> - Switch to `@monaco-editor/react` `loader.init()` with type-only
`editor` import.
> - Remove worker imports and `MonacoEnvironment.getWorker`
configuration.
> - Move theme registration (`dyad-light`, `dyad-dark`) and TypeScript
compiler/diagnostics setup into the loader init callback.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
c4b7c025725273068463feac3fbdb7b61125fc10. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->



<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Load Monaco from a CDN via @monaco-editor/react and initialize
themes/TypeScript settings after loader init. This reduces bundle size
and removes custom worker setup.

- **Refactors**
  - Removed web worker imports and MonacoEnvironment configuration.
- Switched from direct monaco import to type-only import; initialization
now uses loader.init().
- Moved theme registration (dyad-light/dark) and TS compiler/diagnostics
setup into the loader init callback.

<sup>Written for commit c4b7c025725273068463feac3fbdb7b61125fc10.
Summary will update automatically on new commits.</sup>

<!-- End of auto-generated description by cubic. -->
2025-12-11 23:09:04 -08:00
Will Chen
1ce399584e Use specific languages for shiki (#1938)
<!-- CURSOR_SUMMARY -->
> [!NOTE]
> Refactors code highlighting to use react-shiki/core with a singleton
highlighter and preloaded langs/themes, adds a fallback renderer, bumps
react-shiki, and adds tests for message summarization.
> 
> - **Frontend**
> - **CodeHighlight** (`src/components/chat/CodeHighlight.tsx`): Replace
`useShikiHighlighter` with `ShikiHighlighter` from `react-shiki/core`
using a singleton `createHighlighterCore` and JS regex engine; preload
common languages and GitHub light/dark themes; add `<pre><code>`
fallback while loading.
> - **Tests**
> - Add/expand Vitest suite for `formatMessagesForSummary`
(`src/__tests__/formatMessagesForSummary.test.ts`): covers truncation,
ordering, special chars, undefined content, and edge cases.
> - **Dependencies**
>   - Upgrade `react-shiki` to `^0.9.0` in `package.json`.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
b32d224cd21d3c76e77799f2995905e523406bf9. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->





<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Preloaded a specific set of Shiki languages and themes using react-shiki
core, and updated CodeHighlight to use a singleton highlighter. This
reduces bundle size and stabilizes code rendering with a simple fallback
while loading.

- **Refactors**
- Switched to react-shiki/core with ShikiHighlighter and a singleton
highlighter.
- Preloaded common languages
(js/ts/jsx/tsx/html/css/json/markdown/python/etc.) and GitHub light/dark
themes.
- Used the JavaScript regex engine and added a plain <pre><code>
fallback until the highlighter is ready.

- **Dependencies**
  - Upgraded react-shiki to ^0.9.0.

<sup>Written for commit b32d224cd21d3c76e77799f2995905e523406bf9.
Summary will update automatically on new commits.</sup>

<!-- End of auto-generated description by cubic. -->
2025-12-11 23:06:04 -08:00
Will Chen
8d88460fe1 disable sourcemap (#1936)
The sourcemap is almost 20mb (and we still don't get proper stacktraces
for errors in the main/node.js thread)

<!-- CURSOR_SUMMARY -->
> [!NOTE]
> <sup>[Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) is
generating a summary for commit
267749b95f6416b3f88c8caa9a72e21438fc1ee8. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
2025-12-11 16:54:45 -08:00
Will Chen
f2960a94b9 Bump to beta v0.30 (#1934)
<!-- CURSOR_SUMMARY -->
> [!NOTE]
> <sup>[Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) is
generating a summary for commit
ade02043a363fbe88d4405f97362e0f8995ef5ca. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
2025-12-11 14:20:36 -08:00
Will Chen
976e065fe5 Include last 4-chars of Dyad Pro user id for bug reports (#1933)
This allows us to identify which Dyad Pro user filed an issue on GitHub
by using a partial internal identifier

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Adds a redacted Dyad Pro user ID (last 4 chars) to bug report/session
templates, sourced from the Pro user info endpoint and exposed via user
budget info.
> 
> - **Frontend (HelpDialog)**:
> - Display `Pro User ID` in prefilled bug report and session report
bodies using `userBudget.redactedUserId`.
>   - Consume `useUserBudgetInfo` to access `userBudget`.
> - **IPC/Backend**:
> - `get-user-budget`: derive `redactedUserId` from `user_info.user_id`
(mask all but last 4 chars); include in test mock and response.
> - **Types**:
>   - Extend `UserBudgetInfoSchema` with `redactedUserId: string`.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
1883a1ef94fec25b370df3d46054fb56d659dee8. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->



<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Adds a redacted Dyad Pro user ID (last 4 chars) to bug report templates
to help correlate GitHub issues with Pro accounts while protecting
privacy.

- **New Features**
- Derives redactedUserId from user_info.user_id in the Pro IPC handler
and adds it to UserBudgetInfo.
  - Shows “Pro User ID” in HelpDialog’s debug info and session details.
  - Extends UserBudgetInfo schema with a redactedUserId field.

<sup>Written for commit 1883a1ef94fec25b370df3d46054fb56d659dee8.
Summary will update automatically on new commits.</sup>

<!-- End of auto-generated description by cubic. -->
2025-12-11 14:19:57 -08:00
Will Chen
5b789cb971 Add GPT 5.2 and remove older OpenAI models (#1932) 2025-12-11 13:29:43 -08:00
Adeniji Adekunle James
d3f3ac3ae1 Replace native Git with Dugite to support users without Git installed (#1760)
I moved all isomorphic-git usage into a single git_utils.ts file and
added Dugite as an alternative Git provider. The app now checks the
user’s settings and uses dugite when user enabled native git for all
isomorphic-git commands. This makes it easy to fully remove
isomorphic-git in the future by updating only git_utils.ts.

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Adds Dugite-based native Git (bundled binary) and refactors all Git
calls to a unified git_utils API, replacing direct isomorphic-git usage
across the app.
> 
> - **Git Platform Abstraction**:
> - Introduces `dugite` and bundles Git via Electron Forge
(`extraResource`) with `LOCAL_GIT_DIRECTORY` setup in `src/main.ts`.
> - Adds `src/ipc/git_types.ts` and a comprehensive
`src/ipc/utils/git_utils.ts` wrapper supporting both Dugite (native) and
`isomorphic-git` (fallback): `commit`, `add`/`addAll`, `remove`, `init`,
`clone`, `push`, `setRemoteUrl`, `currentBranch`, `listBranches`,
`renameBranch`, `log`, `isIgnored`, `getCurrentCommitHash`,
`getGitUncommittedFiles`, `getFileAtCommit`, `checkout`,
`stageToRevert`.
> - **Refactors (switch to git_utils)**:
> - Replaces direct `isomorphic-git` imports in handlers and processors:
`app_handlers`, `chat_handlers`, `createFromTemplate`,
`github_handlers`, `import_handlers`, `portal_handlers`,
`version_handlers`, `response_processor`, `neon_timestamp_utils`,
`utils/codebase`.
> - Updates tests to mock `git_utils`
(`src/__tests__/chat_stream_handlers.test.ts`).
> - **Behavioral/Feature Updates**:
> - `createFromTemplate` uses `fetch` for GitHub API and `gitClone` for
cloning with cache validation.
> - GitHub integration uses `gitSetRemoteUrl`/`gitPush`/`gitClone`,
handling public vs token URLs and directory creation when native Git is
disabled.
> - Versioning, imports, app file edits, migrations now stage/commit via
`git_utils`.
> - **UI/Copy**:
>   - Updates Settings description for “Enable Native Git”.
> - **Config/Version**:
>   - Bumps version to `0.29.0-beta.1`; adds `dugite` dependency.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
ba098f7f25d85fc6330a41dc718fbfd43fff2d6c. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Will Chen <willchen90@gmail.com>
2025-12-09 19:01:25 -08:00
Adeniji Adekunle James
a7bcec220a Fix: Custom Model Not Updating (#1817) (#1840)
Closes (#1817)



<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Fixes the bug where editing a custom model didn’t update the currently
selected model. The selection now stays in sync after renaming or
editing.

- **Bug Fixes**
- Use useSettings to detect if the edited model is the active one (match
provider and apiName).
- Update settings.selectedModel with the new apiName; on failure show an
error and keep the dialog open; otherwise show success, call onSuccess,
then close.

<sup>Written for commit 88045165a3989277e703a8f31712fcf1dfeaa32a.
Summary will update automatically on new commits.</sup>

<!-- End of auto-generated description by cubic. -->
2025-12-09 17:07:03 -08:00
Will Chen
20866d5d8c Update macos-intel to use macos-15 (#1912)
<!-- CURSOR_SUMMARY -->
> [!NOTE]
> Updates the release workflow matrix to use the `macos-15-intel` runner
for `macos-intel` builds.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
37416cf576675ea399dd84cf1e50a2511b768de8. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Switch the macos-intel release job to the macos-15-intel GitHub Actions
runner. This updates CI to the macOS 15 Intel image and keeps our matrix
consistent with current supported runners.

<sup>Written for commit 37416cf576675ea399dd84cf1e50a2511b768de8.
Summary will update automatically on new commits.</sup>

<!-- End of auto-generated description by cubic. -->
2025-12-09 13:22:55 -08:00
Mohamed Aziz Mejri
352d4330ed Visual editor (Pro only) (#1828)
<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Prototype visual editing mode for the preview app. Toggle the mode, pick
elements (single or multiple), and edit margin, padding, border,
background, static text, and text styles with live updates, then save
changes back to code.

- **New Features**
- Pen tool button to enable/disable visual editing in the preview and
toggle single/multi select; pro-only.
- Inline toolbar anchored to the selected element for Margin (X/Y),
Padding (X/Y), Border (width/radius/color), Background color, Edit Text
(when static), and Text Style (font size/weight/color/font family).
- Reads computed styles from the iframe and applies changes in real
time; auto-appends px; overlay updates on scroll/resize.
- Save/Discard dialog batches edits and writes Tailwind classes to
source files via IPC; uses AST/recast to update className and text,
replacing conflicting classes by prefix; supports multiple components.
- New visual editor worker to get/apply styles and enable inline text
editing via postMessage; selector client updated for coordinates
streaming and highlight/deselect.
- Proxy injects the visual editor client; new atoms track selected
component, coordinates, and pending changes; component analysis flags
dynamic styling and static text.
  - Uses runtimeId to correctly target and edit duplicate components.

- **Dependencies**
  - Added @babel/parser for AST-based text updates.
  - Added recast for safer code transformations.

<sup>Written for commit cdd50d33387a29103864f4743ae7570d64d61e93.
Summary will update automatically on new commits.</sup>

<!-- End of auto-generated description by cubic. -->
2025-12-09 13:09:19 -08:00
Mohamed Aziz Mejri
c174778d5f Adding a button for copying error messages (#1882)
close #1870 





<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Adds a “Copy” button to error banners and chat error output so users can
quickly copy error messages with clear feedback. Addresses Linear #1870.

- **New Features**
- Introduced CopyErrorMessage component that writes to clipboard and
shows “Copied” for 2s.
- Added the copy button to the Preview error banner and DyadOutput;
actions grouped at the bottom beside “Fix with AI”.
- Added Playwright e2e test and helpers to verify copy behavior and
clipboard content.

<sup>Written for commit 12e9bf1437ded36dc022e1d795025580d2ffd111.
Summary will update automatically on new commits.</sup>

<!-- End of auto-generated description by cubic. -->
2025-12-09 03:54:59 +01:00
Will Chen
4b17870049 Warn (not error) on identical search-replace blocks and include searc… (#1899)
…h-replace failure in error message

Addresses part of #1898 

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Treat identical SEARCH/REPLACE as a no-op warning and propagate
detailed applySearchReplace errors (incl. fuzzy match stats) to the UI;
add scoped logging and update tests/snapshots.
> 
> - **Processors**:
> - `src/pro/main/ipc/processors/search_replace_processor.ts`: Log a
warning (not error) when SEARCH and REPLACE blocks are identical; add
scoped logger; keep content unchanged.
> - `src/ipc/processors/response_processor.ts`: Include detailed failure
reason from `applySearchReplace` in dry-run issues.
> - **Tests & Snapshots**:
> - `search_replace_processor.spec.ts`: Update test to expect success
when blocks are identical.
> - `e2e-tests/...turbo-edits-v2...snapshot`: Reflect detailed error
message with fuzzy match similarity/threshold.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
a394d297d5561ada3bdd197dbb4e6aca6928ad99. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->



<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Warn instead of error when search and replace blocks are identical, and
include the specific failure reason in search-replace error messages for
clearer feedback.

- **Bug Fixes**
- Treat identical search/replace blocks as a no-op: log a warning and
return success.
- Bubble up detailed failure reasons to the UI (e.g., no match and fuzzy
similarity/threshold).
- Add scoped logging to the processor and update the e2e snapshot to
reflect new error messaging.

<sup>Written for commit a394d297d5561ada3bdd197dbb4e6aca6928ad99.
Summary will update automatically on new commits.</sup>

<!-- End of auto-generated description by cubic. -->
2025-12-08 11:21:07 -08:00
Will Chen
1b678041ab Fix supabase list getting into drag list area (#1749)
Fixes #1717 

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Disable `.app-region-drag` while `body[data-scroll-locked]` is present
to prevent drag interference with open Select.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
8d5e4774dfa924e073d79dc999a5f065425e985b. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->







<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Fixes window dragging interfering with the Supabase project dropdown.
Disables dragging on .app-region-drag while the Radix Select is open by
targeting body[data-scroll-locked], so users can scroll and select
normally.

<sup>Written for commit 8d5e4774dfa924e073d79dc999a5f065425e985b.
Summary will update automatically on new commits.</sup>

<!-- End of auto-generated description by cubic. -->
2025-12-08 11:13:52 -08:00
Will Chen
6d66e13ea2 Pin capacitor to v7.4.4 (#1906)
besides making our capacitor e2e test more deterministic, it also
prevents silent upgrades given the various npm ecosystem compromises in
the past.
2025-12-08 11:12:38 -08:00
Will Chen
560cd1791d Add e2e test for balanced smart context mode (#1901)
<!-- CURSOR_SUMMARY -->
> [!NOTE]
> Adds an e2e test for the balanced Smart Context mode with
corresponding ARIA and request payload snapshots.
> 
> - **Tests**:
> - Add `e2e-tests/smart_context_balanced.spec.ts` to exercise Pro Modes
dialog, set Smart Context to `balanced`, send `[dump]`, and snapshot
server dump and messages.
>   - Add snapshots:
> -
`e2e-tests/snapshots/smart_context_balanced.spec.ts_smart-context-balanced---simple-1.aria.yml`
> -
`e2e-tests/snapshots/smart_context_balanced.spec.ts_smart-context-balanced---simple-1.txt`
(captures request body/options).
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
66bf07057ded2a6afb328b7de01c821ab6a0fca8. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

<!-- This is an auto-generated description by cubic. -->
## Summary by cubic
Added an end-to-end test for the “balanced” Smart Context mode to verify
the Pro Modes dialog switches to balanced and the mode is applied. The
test sends a [dump] prompt, snapshots the server request and messages
(ARIA + text), and is skipped on Windows.

<sup>Written for commit 66bf07057ded2a6afb328b7de01c821ab6a0fca8.
Summary will update automatically on new commits.</sup>

<!-- End of auto-generated description by cubic. -->
2025-12-08 10:01:27 -08:00
136 changed files with 8676 additions and 2457 deletions

View File

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

View File

@@ -0,0 +1,59 @@
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" },
# See https://github.com/dyad-sh/dyad/issues/96
{ name: "linux", image: "ubuntu-22.04" },
{ name: "macos-intel", image: "macos-13" },
{ name: "macos-intel", image: "macos-15-intel" },
{ name: "macos", image: "macos-latest" },
]
runs-on: ${{ matrix.os.image }}

View File

@@ -1,198 +0,0 @@
# 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,3 +765,4 @@
"indexes": {}
}
}

View File

@@ -1,7 +0,0 @@
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

@@ -1,60 +0,0 @@
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

@@ -1,18 +0,0 @@
// 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

@@ -1,65 +0,0 @@
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

@@ -1,212 +0,0 @@
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,13 +0,0 @@
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

@@ -0,0 +1,75 @@
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,4 +1,5 @@
import { testSkipIfWindows, test } from "./helpers/test_helper";
import { expect } from "@playwright/test";
testSkipIfWindows("fix error with AI", async ({ po }) => {
await po.setUp({ autoApprove: true });
@@ -20,6 +21,26 @@ testSkipIfWindows("fix error with AI", async ({ po }) => {
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 }) => {
await po.setUp({ autoApprove: true });
await po.sendPrompt("tc=create-multiple-errors");

View File

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

View File

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

View File

@@ -553,6 +553,22 @@ export class PageObject {
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() {
return this.page.getByText("Preparing app preview...");
}
@@ -575,6 +591,13 @@ export class PageObject {
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() {
await this.page.getByRole("button", { name: /Fix All Errors/ }).click();
}

View File

@@ -0,0 +1,150 @@
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

@@ -0,0 +1,15 @@
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,5 +16,6 @@
"enableAutoFixProblems": false,
"enableAutoUpdate": false,
"releaseChannel": "stable",
"isRunning": true,
"isTestMode": true
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,12 @@
- 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,5 +24,6 @@
"enableAutoFixProblems": false,
"enableAutoUpdate": true,
"releaseChannel": "stable",
"isRunning": true,
"isTestMode": true
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,22 +8,6 @@
"role": "system",
"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"

View File

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

View File

@@ -8,30 +8,6 @@
"role": "system",
"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",
"content": "[dump] hi"

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,7 +18,7 @@
},
{
"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"
"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%)"
},
{
"role": "assistant",
@@ -26,7 +26,7 @@
},
{
"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"
"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%)"
}
],
"stream": true,

View File

@@ -0,0 +1,18 @@
=== 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

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

View File

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

View File

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

View File

@@ -0,0 +1,350 @@
// 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 };

7
shared/ports.ts Normal file
View File

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

View File

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

View File

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

View File

@@ -0,0 +1,118 @@
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

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

View File

@@ -1,6 +1,23 @@
import { ComponentSelection } from "@/ipc/ipc_types";
import { ComponentSelection, VisualEditingChange } from "@/ipc/ipc_types";
import { atom } from "jotai";
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 annotatorModeAtom = atom<boolean>(false);
export const screenshotDataUrlAtom = atom<string | null>(null);
export const pendingVisualChangesAtom = atom<Map<string, VisualEditingChange>>(
new Map(),
);

View File

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

View File

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

View File

@@ -0,0 +1,49 @@
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,6 +11,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { IpcClient } from "@/ipc/ipc_client";
import { useSettings } from "@/hooks/useSettings";
import { useMutation } from "@tanstack/react-query";
import { showError, showSuccess } from "@/lib/toast";
@@ -44,6 +45,7 @@ export function EditCustomModelDialog({
const [description, setDescription] = useState("");
const [maxOutputTokens, setMaxOutputTokens] = useState<string>("");
const [contextWindow, setContextWindow] = useState<string>("");
const { settings, updateSettings } = useSettings();
const ipcClient = IpcClient.getInstance();
@@ -89,7 +91,22 @@ export function EditCustomModelDialog({
// Then create the new model
await ipcClient.createCustomLanguageModel(newParams);
},
onSuccess: () => {
onSuccess: async () => {
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!");
onSuccess();
onClose();

View File

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

View File

@@ -16,6 +16,7 @@ import {
ChevronsDownUp,
ChartColumnIncreasing,
SendHorizontalIcon,
Lock,
} from "lucide-react";
import type React from "react";
import { useCallback, useEffect, useState } from "react";
@@ -65,11 +66,16 @@ import { ChatErrorBox } from "./ChatErrorBox";
import {
selectedComponentsPreviewAtom,
previewIframeRefAtom,
visualEditingSelectedComponentAtom,
currentComponentCoordinatesAtom,
pendingVisualChangesAtom,
} from "@/atoms/previewAtoms";
import { SelectedComponentsDisplay } from "./SelectedComponentDisplay";
import { useCheckProblems } from "@/hooks/useCheckProblems";
import { LexicalChatInput } from "./LexicalChatInput";
import { useChatModeToggle } from "@/hooks/useChatModeToggle";
import { VisualEditingChangesDialog } from "@/components/preview_panel/VisualEditingChangesDialog";
import { useUserBudgetInfo } from "@/hooks/useUserBudgetInfo";
const showTokenBarAtom = atom(false);
@@ -92,7 +98,15 @@ export function ChatInput({ chatId }: { chatId?: number }) {
selectedComponentsPreviewAtom,
);
const previewIframeRef = useAtomValue(previewIframeRefAtom);
const setVisualEditingSelectedComponent = useSetAtom(
visualEditingSelectedComponentAtom,
);
const setCurrentComponentCoordinates = useSetAtom(
currentComponentCoordinatesAtom,
);
const setPendingVisualChanges = useSetAtom(pendingVisualChangesAtom);
const { checkProblems } = useCheckProblems(appId);
const { refreshAppIframe } = useRunApp();
// Use the attachments hook
const {
attachments,
@@ -124,6 +138,8 @@ export function ChatInput({ chatId }: { chatId?: number }) {
proposal.type === "code-proposal" &&
messageId === lastMessage.id;
const { userBudget } = useUserBudgetInfo();
useEffect(() => {
if (error) {
setShowError(true);
@@ -160,7 +176,7 @@ export function ChatInput({ chatId }: { chatId?: number }) {
? selectedComponents
: [];
setSelectedComponents([]);
setVisualEditingSelectedComponent(null);
// Clear overlays in the preview iframe
if (previewIframeRef?.contentWindow) {
previewIframeRef.contentWindow.postMessage(
@@ -307,6 +323,58 @@ 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 />
{/* Use the AttachmentsList component */}

View File

@@ -1,16 +1,78 @@
import React, {
useState,
useEffect,
useRef,
memo,
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 React, { useState, useEffect, memo, type ReactNode } from "react";
import ShikiHighlighter, {
isInlineCode,
createHighlighterCore,
createJavaScriptRegexEngine,
} from "react-shiki/core";
import type { Element as HastElement } from "hast";
import { useTheme } from "../../contexts/ThemeContext";
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 {
className?: string | undefined;
@@ -32,29 +94,8 @@ export const CodeHighlight = memo(
};
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 ? (
<div
className="shiki not-prose relative [&_pre]:overflow-auto
@@ -77,7 +118,20 @@ export const CodeHighlight = memo(
)}
</div>
) : null}
{displayedCode}
{highlighter ? (
<ShikiHighlighter
highlighter={highlighter}
language={language}
theme={isDarkMode ? "github-dark-default" : "github-light-default"}
delay={150}
>
{code}
</ShikiHighlighter>
) : (
<pre>
<code>{code}</code>
</pre>
)}
</div>
) : (
<code className={className} {...props}>

View File

@@ -9,6 +9,7 @@ import {
import { useAtomValue } from "jotai";
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
import { useStreamChat } from "@/hooks/useStreamChat";
import { CopyErrorMessage } from "@/components/CopyErrorMessage";
interface DyadOutputProps {
type: "error" | "warning";
message?: string;
@@ -59,19 +60,6 @@ export const DyadOutput: React.FC<DyadOutputProps> = ({
<span>{label}</span>
</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 */}
<div className="flex items-center justify-between pl-24 pr-6">
<div className="flex items-center gap-2">
@@ -103,6 +91,22 @@ export const DyadOutput: React.FC<DyadOutputProps> = ({
{children}
</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>
);
};

View File

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

View File

@@ -1,40 +1,6 @@
import { editor } from "monaco-editor";
import type { editor } from "monaco-editor";
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 = {
base: "vs",
inherit: false,
@@ -106,8 +72,6 @@ export const customLight: editor.IStandaloneThemeData = {
},
};
editor.defineTheme("dyad-light", customLight);
export const customDark: editor.IStandaloneThemeData = {
base: "vs-dark",
inherit: false,
@@ -178,12 +142,15 @@ export const customDark: editor.IStandaloneThemeData = {
},
};
editor.defineTheme("dyad-dark", customDark);
loader.init().then((monaco) => {
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
});
monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({
});
monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({
// Too noisy because we don't have the full TS environment.
noSemanticValidation: true,
});
});

View File

@@ -0,0 +1,53 @@
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

@@ -0,0 +1,214 @@
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

@@ -0,0 +1,176 @@
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,8 +23,10 @@ import {
Monitor,
Tablet,
Smartphone,
Pen,
} from "lucide-react";
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
import { CopyErrorMessage } from "@/components/CopyErrorMessage";
import { IpcClient } from "@/ipc/ipc_client";
import { useParseRouter } from "@/hooks/useParseRouter";
@@ -37,7 +39,12 @@ import {
import { useStreamChat } from "@/hooks/useStreamChat";
import {
selectedComponentsPreviewAtom,
visualEditingSelectedComponentAtom,
currentComponentCoordinatesAtom,
previewIframeRefAtom,
annotatorModeAtom,
screenshotDataUrlAtom,
pendingVisualChangesAtom,
} from "@/atoms/previewAtoms";
import { ComponentSelection } from "@/ipc/ipc_types";
import {
@@ -56,6 +63,12 @@ import { useRunApp } from "@/hooks/useRunApp";
import { useShortcut } from "@/hooks/useShortcut";
import { cn } from "@/lib/utils";
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 {
error: { message: string; source: "preview-app" | "dyad-app" } | undefined;
@@ -136,13 +149,14 @@ const ErrorBanner = ({ error, onDismiss, onAIFix }: ErrorBannerProps) => {
</div>
</div>
{/* AI Fix button at the bottom */}
{/* Action buttons at the bottom */}
{!isDockerError && error.source === "preview-app" && (
<div className="mt-2 flex justify-end">
<div className="mt-3 px-6 flex justify-end gap-2">
<CopyErrorMessage errorMessage={error.message} />
<button
disabled={isStreaming}
onClick={onAIFix}
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"
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"
>
<Sparkles size={14} />
<span>Fix error with AI</span>
@@ -165,6 +179,8 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
const { streamMessage } = useStreamChat();
const { routes: availableRoutes } = useParseRouter(selectedAppId);
const { restartApp } = useRunApp();
const { userBudget } = useUserBudgetInfo();
const isProMode = !!userBudget;
// Navigation state
const [isComponentSelectorInitialized, setIsComponentSelectorInitialized] =
@@ -173,12 +189,28 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
const [canGoForward, setCanGoForward] = useState(false);
const [navigationHistory, setNavigationHistory] = useState<string[]>([]);
const [currentHistoryPosition, setCurrentHistoryPosition] = useState(0);
const [selectedComponentsPreview, setSelectedComponentsPreview] = useAtom(
const setSelectedComponentsPreview = useSetAtom(
selectedComponentsPreviewAtom,
);
const [visualEditingSelectedComponent, setVisualEditingSelectedComponent] =
useAtom(visualEditingSelectedComponentAtom);
const setCurrentComponentCoordinates = useSetAtom(
currentComponentCoordinatesAtom,
);
const setPreviewIframeRef = useSetAtom(previewIframeRefAtom);
const iframeRef = useRef<HTMLIFrameElement>(null);
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
type DeviceMode = "desktop" | "tablet" | "mobile";
@@ -194,23 +226,117 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
//detect if the user is using Mac
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
useEffect(() => {
setPreviewIframeRef(iframeRef.current);
}, [iframeRef.current, setPreviewIframeRef]);
// Deactivate component selector when selection is cleared
// Send pro mode status to iframe
useEffect(() => {
if (!selectedComponentsPreview || selectedComponentsPreview.length === 0) {
if (iframeRef.current?.contentWindow) {
if (iframeRef.current?.contentWindow && isComponentSelectorInitialized) {
iframeRef.current.contentWindow.postMessage(
{ type: "deactivate-dyad-component-selector" },
{ type: "dyad-pro-mode", enabled: isProMode },
"*",
);
}
setIsPicking(false);
}
}, [selectedComponentsPreview]);
}, [isProMode, isComponentSelectorInitialized]);
// Add message listener for iframe errors and navigation events
useEffect(() => {
@@ -222,41 +348,102 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
if (event.data?.type === "dyad-component-selector-initialized") {
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;
}
if (event.data?.type === "dyad-component-selected") {
console.log("Component picked:", 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;
const component = parseComponentSelection(event.data);
if (!component) return;
// Add to existing components, avoiding duplicates by id
// Store the coordinates
if (event.data.coordinates && isProMode) {
setCurrentComponentCoordinates(event.data.coordinates);
}
// Add to selected components if not already there
setSelectedComponentsPreview((prev) => {
// Check if this component is already selected
if (prev.some((c) => c.id === component.id)) {
const exists = prev.some((c) => {
// Check by runtimeId if available otherwise by 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, component];
});
if (isProMode) {
// Set as the highlighted component for visual editing
setVisualEditingSelectedComponent(component);
// Trigger AST analysis
analyzeComponent(component.id);
}
return;
}
if (event.data?.type === "dyad-component-deselected") {
const componentId = event.data.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) =>
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;
}
@@ -346,6 +533,7 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
setErrorMessage,
setIsComponentSelectorInitialized,
setSelectedComponentsPreview,
setVisualEditingSelectedComponent,
]);
useEffect(() => {
@@ -364,11 +552,26 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
}
}, [appUrl]);
// Get current styles when component is selected for visual editing
useEffect(() => {
if (visualEditingSelectedComponent) {
getCurrentElementStyles();
}
}, [visualEditingSelectedComponent]);
// Function to activate component selector in the iframe
const handleActivateComponentSelector = () => {
if (iframeRef.current?.contentWindow) {
const newIsPicking = !isPicking;
if (!newIsPicking) {
// Clean up any text editing states when deactivating
iframeRef.current.contentWindow.postMessage(
{ type: "cleanup-all-text-editing" },
"*",
);
}
setIsPicking(newIsPicking);
setVisualEditingSelectedComponent(null);
iframeRef.current.contentWindow.postMessage(
{
type: newIsPicking
@@ -380,6 +583,22 @@ 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
useShortcut(
"c",
@@ -431,6 +650,10 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
const handleReload = () => {
setReloadKey((prevKey) => prevKey + 1);
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
// For now, just changing the key should remount the iframe
console.debug("Reloading iframe preview for app", selectedAppId);
@@ -493,8 +716,9 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
return (
<div className="flex flex-col h-full">
{/* Browser-style header */}
<div className="flex items-center p-2 border-b space-x-2 ">
{/* Browser-style header - hide when annotator is active */}
{!annotatorMode && (
<div className="flex items-center p-2 border-b space-x-2">
{/* Navigation Buttons */}
<div className="flex space-x-1">
<TooltipProvider>
@@ -508,7 +732,9 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
: " text-purple-700 hover:bg-purple-200 dark:text-purple-300 dark:hover:bg-purple-900"
}`}
disabled={
loading || !selectedAppId || !isComponentSelectorInitialized
loading ||
!selectedAppId ||
!isComponentSelectorInitialized
}
data-testid="preview-pick-element-button"
>
@@ -525,6 +751,36 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
</TooltipContent>
</Tooltip>
</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
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}
@@ -580,7 +836,9 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
</DropdownMenuItem>
))
) : (
<DropdownMenuItem disabled>Loading routes...</DropdownMenuItem>
<DropdownMenuItem disabled>
Loading routes...
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
@@ -688,8 +946,9 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
</Popover>
</div>
</div>
)}
<div className="relative flex-grow ">
<div className="relative flex-grow overflow-hidden">
<ErrorBanner
error={errorMessage}
onDismiss={() => setErrorMessage(undefined)}
@@ -717,6 +976,29 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
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
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"
@@ -735,6 +1017,19 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
src={appUrl}
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>
@@ -743,16 +1038,20 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
};
function parseComponentSelection(data: any): ComponentSelection | null {
if (!data || data.type !== "dyad-component-selected") {
return null;
}
const component = data.component;
if (
!data ||
data.type !== "dyad-component-selected" ||
typeof data.id !== "string" ||
typeof data.name !== "string"
!component ||
typeof component.id !== "string" ||
typeof component.name !== "string"
) {
return null;
}
const { id, name } = data;
const { id, name, runtimeId } = component;
// The id is expected to be in the format "filepath:line:column"
const parts = id.split(":");
@@ -781,6 +1080,7 @@ function parseComponentSelection(data: any): ComponentSelection | null {
return {
id,
name,
runtimeId,
relativePath: normalizePath(relativePath),
lineNumber,
columnNumber,

View File

@@ -0,0 +1,56 @@
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

@@ -0,0 +1,25 @@
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

@@ -0,0 +1,179 @@
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

@@ -0,0 +1,531 @@
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

@@ -0,0 +1,35 @@
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

@@ -0,0 +1,42 @@
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

@@ -1,60 +0,0 @@
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

@@ -1,18 +0,0 @@
// 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

@@ -1,65 +0,0 @@
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

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

60
src/ipc/git_types.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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