Skip to content

feat(workspace): trees + diffs sidepanel via daemon port - #46

Merged
dvaJi merged 2 commits into
masterfrom
feat/trees-diffs-workspace
Aug 12, 2026
Merged

dvaJi merged 2 commits into
masterfrom
feat/trees-diffs-workspace

Conversation

@dvaJi

@dvaJi dvaJi commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Summary

Replaces the workspace sidepanel''s hand-rolled file tree, read-only Monaco code viewer, and custom unified-diff parser with @pierre/trees and @pierre/diffs, adds inline file editing and a top-level Diffs tab, and ports the workspace presenter to the daemon so the feature works in web/headless mode too. Monaco + CodeMirror are removed entirely.

BEFORE / AFTER

BEFORE                                  AFTER
┌──────────────────────────────┐        ┌──────────────────────────────┐
│ Workspace | Browser          │        │ Workspace | Diffs | Browser  │
│ ───────────────────────────  │        │ ───────────────────────────  │
│ ▼ Files   (custom tree)      │        │ ▼ Files   (@pierre/trees)    │
│ ▼ Git     (changed files)    │        │ ▼ Artifacts                  │
│ ▼ Artifacts                  │        │                              │
│ ───────────────────────────  │        │ ───────────────────────────  │
│ Monaco read-only | Workspace │        │ @pierre/diffs <File> | Edit  │
│ hand-rolled diff parser      │        │ @pierre/diffs <PatchDiff>    │
└──────────────────────────────┘        └──────────────────────────────┘

Changes

  • Daemon port — new DaemonWorkspacePresenter (allow-list, FS reads/writes, git status/diff, chokidar watchers → workspace.invalidated, HTTP preview endpoint) + all 16 workspace routes wired into createDaemonDispatcher. Works for desktop and web/headless.
  • TreeTreesFileTree (@pierre/trees) with inline rename, drag-and-drop move, create/delete, git-status row signals, search; replaces the deleted WorkspaceFileNode renderer.
  • Code view + editing@pierre/diffs <File> (read-only) and <File edit> via EditProvider (inline edit, Save / Cmd-Ctrl+S). Monaco removed.
  • Diffs@pierre/diffs <PatchDiff> (splits multi-file + staged/unstaged patches); new top-level Diffs tab.
  • Chat file links — in-workspace links open the file''s diff in the Diffs tab; out-of-workspace links fall back to the viewer.
  • Text/binary detection — NUL-byte content sniff (default to text) so .cs/.csproj/.sln and unknown source files render instead of showing as binary.
  • Untracked files included in the full-workspace diff (new file mode injected so they show the added icon).
  • Removed: monaco-editor, stream-monaco, @dvaji/vite-plugin-monaco-editor + Vite workers; WorkspaceCodePane, WorkspaceDiffView, WorkspaceFileNode; CodeMirror mentions in README/landing.

Notes / follow-ups

  • Desktop main WorkspacePresenter is now dead code for these routes (cleanup pending).
  • Empty directories render as files until they contain something (path-first tree limitation).
  • Diffs tab never loads the slow full-workspace diff by default (auto-selects the first file).

Test plan

  • bun run format, bun run lint (architecture + agent-cleanup + route-catalog guards), bun run typecheck (daemon + desktop + UI), bun run build
  • workspacePresenter tests (25 pass, incl. 12 new for read/write/create/delete/rename + allow-list/traversal)
  • daemon test suite green (1 unrelated MCP env failure)
  • Manual: file tree browse/rename/dnd/create/delete, Diffs tab (modified/new/deleted), inline edit + save, chat link → diff

Summary by CodeRabbit

  • New Features
    • Added workspace file editing, creation, deletion, renaming, moving, and text-file reading.
    • Introduced a searchable workspace file tree with drag-and-drop support and context actions.
    • Added a Diffs tab for Git status, changed-file navigation, and staged or unstaged diffs.
    • Added inline code editing with save shortcuts and error feedback.
    • Added daemon support for workspace browsing, previews, file operations, and Git inspection.
  • Improvements
    • Markdown file links now open relevant workspace files in the Diffs view.
    • Updated code-rendering descriptions to highlight syntax highlighting.

Replace the workspace file tree, read-only code viewer, and hand-rolled diff parser with @pierre/trees and @pierre/diffs. Add inline file editing, a top-level Diffs tab, and port the workspace presenter to the daemon so the feature works in web/headless mode too. Monaco + CodeMirror are removed; chat file links open the diff for in-workspace files.
Copilot AI lite review requested due to automatic review settings August 12, 2026 03:54
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@dvaJi, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 39 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: cdae0c56-f5de-429a-b45d-e53d22f7ec40

📥 Commits

Reviewing files that changed from the base of the PR and between e6a1806 and d190a99.

📒 Files selected for processing (5)
  • apps/daemon/src/workspace/daemonWorkspacePresenter.ts
  • apps/desktop/src/main/presenter/workspacePresenter/index.ts
  • apps/desktop/test/main/presenter/workspacePresenter.test.ts
  • docs/features/trees-diffs-workspace/spec.md
  • packages/ui/src/components/sidepanel/TreesFileTree.tsx
📝 Walkthrough

Walkthrough

The workspace gains filesystem editing routes, daemon support, Trees navigation, Pierre-based code and diff views, inline editing, Git diff rendering, and watcher-driven updates. Monaco workspace viewers and related infrastructure are removed.

Changes

Trees and Diffs workspace

Layer / File(s) Summary
Workspace contracts and filesystem operations
packages/shared-contracts/..., packages/shared/..., apps/desktop/src/main/..., packages/ui/api/..., apps/desktop/test/...
Adds typed workspace filesystem routes and presenter methods for reading, writing, creating, deleting, and moving paths. Adds authorization, size, binary, and traversal checks.
Daemon workspace presenter and routing
apps/daemon/src/workspace/..., apps/daemon/src/dispatch/..., apps/daemon/src/index.ts, apps/daemon/package.json
Adds daemon workspace authorization, previews, filesystem operations, Git status and diffs, search, watchers, invalidation, and route dispatch.
Trees navigation and Diffs tab
packages/ui/src/components/sidepanel/..., packages/ui/src/components/markdown/..., packages/ui/src/stores/..., packages/ui/package.json
Adds Trees-based navigation, workspace mutations, markdown-link routing, shared Diffs selection, and a Git-aware Diffs panel.
Pierre viewers and inline editing
packages/ui/src/components/sidepanel/WorkspaceViewer.tsx, packages/ui/src/components/sidepanel/viewer/..., packages/ui/src/components/trace/TraceDialog.tsx, packages/ui/vite.config.ts
Replaces legacy Monaco code and diff viewers with Pierre panes. Adds inline editing and save handling. Removes Monaco dependencies and Vite configuration.
Compatibility and supporting records
packages/shared-contracts/src/domainSchemas.ts, packages/shared-contracts/src/desktop-only.ts, docs/features/trees-diffs-workspace/..., README.md, apps/landing/src/components/Spotlight.tsx
Coerces workspace timestamps to dates, documents desktop-only routes, records the feature plan and tasks, and updates code-rendering descriptions.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

  • dvaJi/argos#35: Shares daemon workspace routing and initialization wiring.
  • dvaJi/argos#36: Shares createDaemonDispatcher and UI Vite configuration changes.
  • dvaJi/argos#45: Shares daemon dispatcher capability injection and startup wiring.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant WorkspaceUI
  participant WorkspaceClient
  participant DaemonDispatcher
  participant WorkspacePresenter
  participant FileSystem
  User->>WorkspaceUI: Open workspace or Diffs tab
  WorkspaceUI->>WorkspaceClient: Load tree, status, or diff
  WorkspaceClient->>DaemonDispatcher: Send workspace route
  DaemonDispatcher->>WorkspacePresenter: Validate and execute request
  WorkspacePresenter->>FileSystem: Read, write, watch, or inspect workspace
  FileSystem-->>WorkspacePresenter: Workspace data or invalidation
  WorkspacePresenter-->>DaemonDispatcher: Route response
  DaemonDispatcher-->>WorkspaceClient: Validated response
  WorkspaceClient-->>WorkspaceUI: Update tree, editor, or diff view
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 32.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes: replacing the workspace sidepanel with Trees and Diffs and adding daemon support.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/trees-diffs-workspace

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown

React Doctor found 22 new issues in 10 files · 7 errors & 15 warnings · score 53 / 100 (Critical) · 30 fixed · vs master

Errors

15 warnings

src/components/markdown/useMarkdownLinkNavigation.ts

  • ⚠️ L50 Manual memoization in compiler-managed code react-compiler-no-manual-memoization

src/components/sidepanel/ChatSidePanel.tsx

  • ⚠️ L19 Large component is hard to read and change no-giant-component

src/components/sidepanel/DiffsPanel.tsx

  • ⚠️ L118 State adjusted after a prop changes no-adjust-state-on-prop-change
  • ⚠️ L118 React Compiler can't optimize this set-state-in-effect
  • ⚠️ L125 React Compiler can't optimize this set-state-in-effect

src/components/sidepanel/TreesFileTree.tsx

  • ⚠️ L194 Missing effect dependencies exhaustive-deps

src/components/sidepanel/WorkspaceViewer.tsx

  • ⚠️ L110 State adjusted after a prop changes no-adjust-state-on-prop-change
  • ⚠️ L110 React Compiler can't optimize this set-state-in-effect
  • ⚠️ L111 State adjusted after a prop changes no-adjust-state-on-prop-change

src/components/sidepanel/viewer/DiffsCodePane.tsx

  • ⚠️ L28 Manual memoization in compiler-managed code react-compiler-no-manual-memoization
  • ⚠️ L36 Manual memoization in compiler-managed code react-compiler-no-manual-memoization

src/components/sidepanel/viewer/DiffsPatchPane.tsx

  • ⚠️ L28 Manual memoization in compiler-managed code react-compiler-no-manual-memoization
  • ⚠️ L30 Manual memoization in compiler-managed code react-compiler-no-manual-memoization
  • ⚠️ L49 Array index used as a key no-array-index-as-key

src/components/sidepanel/viewer/diffsOptions.ts

  • ⚠️ L11 Manual memoization in compiler-managed code react-compiler-no-manual-memoization

Reviewed by React Doctor for commit d190a99. See inline comments for fixes.

@greptile-apps

greptile-apps Bot commented Aug 12, 2026

Copy link
Copy Markdown

Confidence Score: 1/5

The PR is not safe to merge until external-link authorization is separated from mutation privileges and workspace search is contained across symlinks.

The daemon currently permits an externally resolved markdown file to be overwritten or deleted and allows recursive search to escape the workspace or enter symlink cycles.

Files Needing Attention: apps/daemon/src/workspace/daemonWorkspacePresenter.ts

Security Review

Two filesystem-boundary issues require correction: external markdown-link resolution grants destructive access to the resolved host file, and recursive search follows symlinks outside the workspace or into cycles.

Important Files Changed

Filename Overview
apps/daemon/src/workspace/daemonWorkspacePresenter.ts Implements the daemon workspace port, but external-link authorization leaks into mutation routes and recursive search does not contain symlink traversal.
apps/daemon/src/dispatch/daemonDispatcher.ts Wires the new workspace contracts to presenter methods; the handlers expose the presenter’s filesystem-boundary defects to authenticated route callers.
apps/daemon/src/index.ts Instantiates the workspace presenter and adds the raw preview endpoint with allow-list enforcement.
packages/shared-contracts/src/routes/workspace.routes.ts Defines the expanded workspace route schemas, including path mutation, link resolution, Git, and search operations.
packages/ui/src/components/sidepanel/TreesFileTree.tsx Replaces the custom file-node renderer with the Pierre tree and adds create, rename, move, delete, search, and Git-status interactions.
packages/ui/src/components/sidepanel/DiffsPanel.tsx Adds the top-level staged and unstaged diff browsing experience.
packages/ui/src/components/sidepanel/viewer/DiffsEditorPane.tsx Adds inline text editing and save handling through the workspace client.

Fix All in Codex

Prompt To Fix All With AI
### Issue 1
apps/daemon/src/workspace/daemonWorkspacePresenter.ts:520
**External links grant mutation access**

When an authenticated client resolves an existing absolute or `file://` path outside the workspace, `authorizeExactFile` adds it to the same allow-list used by `writeFile`, `deletePath`, and `renameOrMovePath`, allowing that host file to be overwritten, deleted, or moved. Restrict exact-file authorization to preview/read operations rather than treating it as general workspace authorization.

**How this was verified:** The resolved external path is inserted into `allowedExactPaths`, and each mutation method authorizes its target through `isPathAllowed`, which accepts that set.

### Issue 2
apps/daemon/src/workspace/daemonWorkspacePresenter.ts:783-792
**Search escapes through directory symlinks**

When a registered workspace contains a directory symlink, `stat` follows its target and the recursive search continues without rechecking containment or tracking visited directories. A link outside the workspace exposes external filenames and paths, while a link to an ancestor can repeatedly traverse the same directories and consume daemon CPU and I/O.

**How this was verified:** `searchFiles` checks only the initial root, while `collectSearchMatches` follows each child with `stat` and recursively visits directories without containment or cycle checks.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "feat(workspace): trees+diffs sidepanel, ..." | Re-trigger Greptile

if (!stat.isFile()) return null;
// Authorize the resolved file for subsequent preview/open reads, even when it
// lives outside a registered workspace (e.g. a chat link to another project).
const normalizedPath = this.authorizeExactFile(resolvedPath);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security External links grant mutation access

When an authenticated client resolves an existing absolute or file:// path outside the workspace, authorizeExactFile adds it to the same allow-list used by writeFile, deletePath, and renameOrMovePath, allowing that host file to be overwritten, deleted, or moved. Restrict exact-file authorization to preview/read operations rather than treating it as general workspace authorization.

How this was verified: The resolved external path is inserted into allowedExactPaths, and each mutation method authorizes its target through isPathAllowed, which accepts that set.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/daemon/src/workspace/daemonWorkspacePresenter.ts
Line: 520

Comment:
**External links grant mutation access**

When an authenticated client resolves an existing absolute or `file://` path outside the workspace, `authorizeExactFile` adds it to the same allow-list used by `writeFile`, `deletePath`, and `renameOrMovePath`, allowing that host file to be overwritten, deleted, or moved. Restrict exact-file authorization to preview/read operations rather than treating it as general workspace authorization.

**How this was verified:** The resolved external path is inserted into `allowedExactPaths`, and each mutation method authorizes its target through `isPathAllowed`, which accepts that set.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Codex

Comment on lines +783 to +792
isDirectory = (await fsp.stat(childPath)).isDirectory();
} catch {
continue;
}
const relativePath = relativePrefix ? `${relativePrefix}/${name}` : name;
if (name.toLowerCase().includes(needle)) {
results.push({ name, path: childPath, isDirectory });
}
if (isDirectory) {
await this.collectSearchMatches(childPath, needle, relativePath, results, limit);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Search escapes through directory symlinks

When a registered workspace contains a directory symlink, stat follows its target and the recursive search continues without rechecking containment or tracking visited directories. A link outside the workspace exposes external filenames and paths, while a link to an ancestor can repeatedly traverse the same directories and consume daemon CPU and I/O.

How this was verified: searchFiles checks only the initial root, while collectSearchMatches follows each child with stat and recursively visits directories without containment or cycle checks.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/daemon/src/workspace/daemonWorkspacePresenter.ts
Line: 783-792

Comment:
**Search escapes through directory symlinks**

When a registered workspace contains a directory symlink, `stat` follows its target and the recursive search continues without rechecking containment or tracking visited directories. A link outside the workspace exposes external filenames and paths, while a link to an ancestor can repeatedly traverse the same directories and consume daemon CPU and I/O.

**How this was verified:** `searchFiles` checks only the initial root, while `collectSearchMatches` follows each child with `stat` and recursively visits directories without containment or cycle checks.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Codex

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR modernizes the workspace sidepanel by replacing the custom file tree, Monaco-based read-only viewer, and bespoke unified-diff renderer with @pierre/trees and @pierre/diffs, while also porting workspace routes/presenter functionality to the daemon so the same feature set can run in web/headless mode.

Changes:

  • Replace workspace file tree + diff/code viewers with Pierre components, adding inline file editing and a top-level Diffs tab.
  • Add new workspace route contracts + UI client wrappers for text reads and filesystem mutations (write/create/delete/move).
  • Implement daemon-side workspace presenter (FS + git + watchers + preview endpoint) and wire workspace routes into the daemon dispatcher; remove Monaco tooling/deps.

Reviewed changes

Copilot reviewed 34 out of 36 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
README.md Updates rich-rendering description (removes CodeMirror mention).
packages/ui/vite.config.ts Removes Monaco Vite plugin wiring and related optimizeDeps entries.
packages/ui/src/stores/ui/sidepanel.ts Adds Diffs tab state/actions (openDiffs, selection gating).
packages/ui/src/components/workspace/WorkspaceFileNode.tsx Deletes legacy hand-rolled file tree node renderer.
packages/ui/src/components/trace/TraceDialog.tsx Replaces Monaco JSON viewer with DiffsCodePane.
packages/ui/src/components/sidepanel/WorkspaceViewer.tsx Adds view/edit mode (inline editing) and swaps diff/code panes to Pierre-based panes.
packages/ui/src/components/sidepanel/WorkspacePanel.tsx Replaces legacy tree with TreesFileTree and removes Git section UI in favor of Diffs surfaces.
packages/ui/src/components/sidepanel/viewer/WorkspaceDiffView.tsx Deletes custom unified diff renderer.
packages/ui/src/components/sidepanel/viewer/WorkspaceCodePane.tsx Deletes Monaco-based read-only code viewer.
packages/ui/src/components/sidepanel/viewer/DiffsPatchPane.tsx New <PatchDiff> wrapper that splits multi-file patches for rendering.
packages/ui/src/components/sidepanel/viewer/diffsOptions.ts Centralizes @pierre/diffs theme options based on app theme.
packages/ui/src/components/sidepanel/viewer/DiffsEditorPane.tsx New inline editable file surface using @pierre/diffs editor + save workflow.
packages/ui/src/components/sidepanel/viewer/DiffsCodePane.tsx New read-only code viewer using @pierre/diffs <File>.
packages/ui/src/components/sidepanel/TreesFileTree.tsx New adapter for @pierre/trees tree with rename/dnd/create/delete + git status wiring.
packages/ui/src/components/sidepanel/DiffsPanel.tsx New top-level Diffs tab that lists changes + renders patches.
packages/ui/src/components/sidepanel/ChatSidePanel.tsx Adds Diffs tab button and routes to <DiffsPanel>.
packages/ui/src/components/markdown/useMarkdownLinkNavigation.ts Routes in-workspace file links to Diffs tab selection; normalizes local file hrefs.
packages/ui/package.json Adds @pierre/diffs/@pierre/trees; removes Monaco-related deps.
packages/ui/api/WorkspaceClient.ts Adds typed wrappers for new workspace edit routes.
packages/shared/src/types/presenters/workspace.d.ts Adds "diffs" tab + new workspace presenter edit APIs.
packages/shared-contracts/src/routes/workspace.routes.ts Adds route contracts for readFileText/write/create/delete/renameOrMove.
packages/shared-contracts/src/routes.ts Registers new workspace routes in ARGOS_ROUTE_CATALOG.
packages/shared-contracts/src/domainSchemas.ts Coerces file metadata dates to handle daemon JSON transport.
packages/shared-contracts/src/desktop-only.ts Documents which workspace routes remain desktop-only (open/reveal).
docs/features/trees-diffs-workspace/tasks.md Tracks implementation work items for Trees+Diffs workspace.
docs/features/trees-diffs-workspace/spec.md Defines feature spec/ACs/constraints (now needs alignment with implementation).
docs/features/trees-diffs-workspace/plan.md Documents implementation plan and architecture boundaries.
bun.lock Updates lockfile for new deps (Pierre libs, chokidar, shiki updates, removals).
apps/landing/src/components/Spotlight.tsx Updates marketing copy to remove CodeMirror wording.
apps/desktop/test/main/presenter/workspacePresenter.test.ts Adds tests for read/write/create/delete/rename workspace operations.
apps/desktop/src/main/routes/index.ts Adds route cases for new workspace edit routes (desktop dispatcher).
apps/desktop/src/main/presenter/workspacePresenter/index.ts Implements readFileText/write/create/delete/rename with allow-list + binary sniffing.
apps/daemon/src/workspace/daemonWorkspacePresenter.ts Adds daemon-side workspace presenter (FS/git/watchers/preview URL).
apps/daemon/src/index.ts Adds /api/v1/workspace/preview endpoint and daemon presenter instantiation/baseUrl wiring.
apps/daemon/src/dispatch/daemonDispatcher.ts Wires all workspace routes into the daemon dispatcher when presenter provided.
apps/daemon/package.json Adds chokidar dependency (and dependency ordering adjustments).
Suppressed comments (1)

docs/features/trees-diffs-workspace/spec.md:60

  • The Non-Goals section still says porting write/edit routes to the daemon is out of scope, but the PR adds DaemonWorkspacePresenter with write/edit routes. This bullet is now incorrect.
## Non-Goals

- Replace the markdown/html/image/pdf preview pane with `@pierre/diffs`.
- Port write/edit routes to the daemon (web/headless mode stays read-only for workspace files in this iteration).
- Multi-file staging/unstaging/commit actions in the Diffs tab (future work).

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +980 to +984
async writeFile(filePath: string, content: string): Promise<void> {
if (!this.isPathAllowed(filePath)) {
console.warn(`[Workspace] Blocked write attempt for unauthorized path: ${filePath}`);
return;
}
Comment on lines +572 to +577
async writeFile(filePath: string, content: string): Promise<void> {
if (!this.isPathAllowed(filePath)) return;
const normalizedPath = path.resolve(filePath);
await fsp.mkdir(path.dirname(normalizedPath), { recursive: true });
await fsp.writeFile(normalizedPath, content, "utf8");
}
Comment on lines +149 to +156
const handleSelectionChange = useCallback(
(selected: readonly string[]) => {
const first = selected[0];
if (!first) return;
sidepanelStore.selectFile(sessionId, toAbsolutePath(workspacePath, first), { open: false });
},
[sessionId, sidepanelStore, workspacePath],
);

- Follow the typed route/client boundary: new capabilities go through `shared-contracts/routes`, `routes/index.ts` dispatcher, `WorkspacePresenter`, and `WorkspaceClient`. No new `window.api`/legacy paths.
- All filesystem writes must be inside a registered workspace/workdir (security boundary already enforced by `isPathAllowed`).
- Desktop is the primary target (the presenter lives in main). The daemon/headless path only needs to remain non-breaking for the existing read routes; write routes are desktop-only for now.
- **AC-5** Context menu + tree affordances support "New File" and "New Folder" creation and "Delete".
- **AC-6** Git-status row signals (added/modified/deleted/untracked/...) render in the tree via Trees' built-in `gitStatus`.
- **AC-7** Selecting a changed file in the Git section renders its diff with `@pierre/diffs <PatchDiff>` (staged + unstaged).
- **AC-8** A new top-level **Diffs** tab exists beside Workspace/Browser; it lists all changed files (from `getGitStatus`) and renders them via `@pierre/diffs <CodeView>` (virtualized multi-file) using the workspace's unified diff.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 14

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🟡 Minor comments (10)
docs/features/trees-diffs-workspace/tasks.md-57-60 (1)

57-60: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Keep renderer verification status accurate.

Presenter tests do not cover TreesFileTree, DiffsPanel, or DiffsEditorPane. Line 60 also treats manual verification as coverage, but the PR status says manual testing is still pending. Complete AC-1 through AC-10 manual checks before claiming coverage, or add renderer or end-to-end tests for rename, drag-and-drop, deletion, patch selection, save, and invalidation refresh.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/features/trees-diffs-workspace/tasks.md` around lines 57 - 60, Update
the Tests section around item 9.2 to accurately reflect renderer verification:
either complete and document manual checks for AC-1 through AC-10, including the
listed tree and diff interactions, or add renderer/end-to-end coverage for them.
Do not claim coverage from presenter tests alone or mark manual verification
complete while it remains pending.
docs/features/trees-diffs-workspace/spec.md-49-60 (1)

49-60: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Keep the daemon and desktop route split consistent.

The current route classification supports daemon-backed workspace operations and desktop-only shell operations, but the feature records describe a different contract.

  • docs/features/trees-diffs-workspace/spec.md#L49-L60: state that workspace filesystem, Git, edit, search, watcher, and preview routes run through the daemon; keep only workspace.revealFileInFolder and workspace.openFile desktop-only.
  • docs/features/trees-diffs-workspace/plan.md#L120-L123: replace the statement that write routes return a desktop-only error.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/features/trees-diffs-workspace/spec.md` around lines 49 - 60, The route
contract documentation is inconsistent with the intended daemon/desktop split.
In docs/features/trees-diffs-workspace/spec.md lines 49-60, state that workspace
filesystem, Git, edit, search, watcher, and preview routes use the daemon, with
only workspace.revealFileInFolder and workspace.openFile remaining desktop-only.
In docs/features/trees-diffs-workspace/plan.md lines 120-123, replace the
statement that write routes return a desktop-only error to match this
daemon-backed contract.
packages/ui/src/components/sidepanel/TreesFileTree.tsx-149-156 (1)

149-156: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Skip directory selections.

Trees reports directory paths with a trailing slash. toAbsolutePath strips that slash, so a directory click calls selectFile with a directory path. The viewer then requests a file preview for a directory.

🛡️ Proposed guard
     (selected: readonly string[]) => {
       const first = selected[0];
       if (!first) return;
+      if (first.endsWith("/")) return;
       sidepanelStore.selectFile(sessionId, toAbsolutePath(workspacePath, first), { open: false });
     },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/components/sidepanel/TreesFileTree.tsx` around lines 149 -
156, Update handleSelectionChange to ignore directory selections before calling
selectFile: detect the trailing slash on the selected path and return early for
directories. Preserve file selection behavior and only pass normalized file
paths to sidepanelStore.selectFile.
packages/ui/src/components/sidepanel/DiffsPanel.tsx-226-226 (1)

226-226: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Both ternary branches return the same value.

themeStore.isDark ? "unified" : "unified" always resolves to "unified". The theme read has no effect. State the intended value, or select the correct alternative style.

🐛 Proposed fix
-              <DiffsPatchPane patch={patch} diffStyle={themeStore.isDark ? "unified" : "unified"} />
+              <DiffsPatchPane patch={patch} diffStyle="unified" />

Remove the now unused themeStore binding at Line 40 if no other code uses it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/components/sidepanel/DiffsPanel.tsx` at line 226, Update the
DiffsPatchPane invocation to remove the redundant themeStore.isDark ternary and
pass the intended diffStyle value directly, or select the correct alternate
style if dark mode should differ; remove the themeStore binding if it is unused
after this change.
packages/ui/src/components/markdown/useMarkdownLinkNavigation.ts-132-143 (1)

132-143: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Canonicalize paths before selecting a diff

When a changed file is a symlink, resolveMarkdownLinkedFile returns its real path, while getGitStatus stores the symlink path. Strict comparison then misses the active file, and getGitDiff can target the wrong path. Use one canonical path representation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/components/markdown/useMarkdownLinkNavigation.ts` around
lines 132 - 143, Canonicalize the resolved path before diff selection in the
markdown link navigation flow, especially around setDiffsSelection and
openDiffs. Ensure the path representation matches the symlink paths used by
getGitStatus so active-file comparison and getGitDiff target the correct file,
while preserving the existing workspace and external-file branching.
packages/ui/src/components/sidepanel/viewer/DiffsPatchPane.tsx-16-24 (1)

16-24: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Hunk-less file diffs disappear silently.

splitIntoFilePatches keeps only segments that contain @@. That filter also removes pure renames, mode-only changes, and empty-file additions, not only binary files. If every segment is filtered out, the pane renders "No changes" even though the patch describes real changes.

List the dropped file names instead of hiding them.

🛠️ Proposed change
-const splitIntoFilePatches = (patch: string): string[] => {
+const parseFileName = (segment: string): string => {
+  const match = /^diff --git a\/(.+?) b\//m.exec(segment);
+  return match?.[1] ?? "file";
+};
+
+const splitIntoFilePatches = (patch: string): { renderable: string[]; skipped: string[] } => {
   const segments = patch
     .split(/(?=^diff --git )/m)
     .map((segment) => segment.trim())
     .filter(Boolean);
-  // Skip segments with no hunks (e.g. "Binary files … differ") — PatchDiff
-  // cannot render them.
-  return segments.filter((segment) => segment.includes("@@"));
+  // PatchDiff cannot render segments with no hunks (binary files, pure
+  // renames, mode changes), so report them separately instead of hiding them.
+  return {
+    renderable: segments.filter((segment) => segment.includes("@@")),
+    skipped: segments.filter((segment) => !segment.includes("@@")).map(parseFileName),
+  };
 };

Render the skipped names above the patch list, and show "No changes" only when both lists are empty.

Also applies to: 35-41

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/components/sidepanel/viewer/DiffsPatchPane.tsx` around lines
16 - 24, Update splitIntoFilePatches to preserve the names of hunk-less
file-diff segments, including renames, mode-only changes, and empty-file
additions, while continuing to return renderable hunks separately. In the
DiffsPatchPane render flow, display the skipped file names above the patch list
and show “No changes” only when both renderable patches and skipped names are
empty.
packages/ui/src/components/sidepanel/WorkspaceViewer.tsx-104-104 (1)

104-104: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Update the stale comment.

The comment states "via Monaco". This PR removes Monaco. The editor is DiffsEditorPane, backed by @pierre/diffs.

📝 Proposed change
-  // Edit mode (inline file editing via Monaco). Reset when the file changes.
+  // Edit mode (inline file editing via DiffsEditorPane). Reset when the file changes.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/components/sidepanel/WorkspaceViewer.tsx` at line 104, Update
the comment above the edit-mode reset logic in WorkspaceViewer to remove the
outdated Monaco reference and identify DiffsEditorPane, backed by `@pierre/diffs`,
as the editor used for inline file editing.
packages/ui/src/components/sidepanel/WorkspaceViewer.tsx-114-127 (1)

114-127: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Report the failure when the file cannot be read for editing.

If readFileText returns content: null, the function returns without entering edit mode and without any message. The user sees the Edit button do nothing. Both presenters return content: null for a blocked path, a non-file, a file above READ_TEXT_MAX_BYTES, a binary file, or a read error (apps/daemon/src/workspace/daemonWorkspacePresenter.ts lines 552-570). The canEdit check only tests filePreview.kind === "text", so the size limit and the read-error path remain reachable.

Show a toast, as DiffsEditorPane does for save failures.

🛠️ Proposed change
     try {
       const result = await workspaceClient.readFileText(openFilePath);
-      if (result.content === null) return;
+      if (result.content === null) {
+        toast.error(result.exists ? "This file cannot be edited" : "File not found");
+        return;
+      }
       setEditContent(result.content);
       setEditMode(true);
     } catch (error) {
       console.error("[WorkspaceViewer] failed to load file for editing", error);
+      toast.error("Failed to open the file for editing");
     } finally {

Add the import:

import { toast } from "sonner";
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/components/sidepanel/WorkspaceViewer.tsx` around lines 114 -
127, Update enterEditMode in WorkspaceViewer to import and use sonner’s toast
when readFileText returns content: null, informing the user that the file could
not be loaded for editing before returning. Keep the existing error logging for
thrown failures, and ensure the loading state is still cleared through finally.
packages/ui/src/components/sidepanel/WorkspaceViewer.tsx-214-221 (1)

214-221: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Refresh the preview before leaving edit mode.

writeFile triggers a debounced watcher refresh, but onSaved={exitEditMode} switches to read-only mode immediately. filePreview can therefore show stale content until the refresh completes. Refresh the selected preview before calling exitEditMode.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/components/sidepanel/WorkspaceViewer.tsx` around lines 214 -
221, Update the edit-mode save flow in WorkspaceViewer so the selected file
preview is refreshed after the DiffsEditorPane save completes and before
exitEditMode switches back to read-only mode. Ensure the refreshed preview is
available before invoking exitEditMode, rather than passing exitEditMode
directly as onSaved.
packages/ui/src/components/sidepanel/viewer/DiffsCodePane.tsx-5-10 (1)

5-10: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Pass source.language to <File>. @pierre/diffs uses name for language detection and lang as the override. Artifact IDs do not guarantee a recognized extension, so artifact code can render as plain text. Add lang: source.language ?? undefined and include source.language in the useMemo dependencies.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/components/sidepanel/viewer/DiffsCodePane.tsx` around lines 5
- 10, Update the File rendering logic in DiffsCodePane to pass the source
language as the lang override using source.language ?? undefined, and add
source.language to the related useMemo dependency array so changes recompute the
rendered output.
🧹 Nitpick comments (12)
packages/ui/src/components/sidepanel/TreesFileTree.tsx (1)

194-215: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Refresh the tree after a mutation instead of relying only on the watcher.

handleCreate and handleDelete depend on a watcher invalidation event to refresh the paths. The watcher registration at Lines 260-264 can fail and only logs the error. In that case, created and deleted entries never appear or disappear. Call reload after a successful mutation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/components/sidepanel/TreesFileTree.tsx` around lines 194 -
215, Update handleCreate and handleDelete to call reload immediately after their
respective workspaceClient mutations succeed, ensuring the tree refreshes
without relying on watcher events. Preserve the existing error handling and do
not reload when the mutation fails; include reload in each callback’s
dependencies.
packages/ui/src/components/sidepanel/WorkspacePanel.tsx (2)

80-87: 🚀 Performance & Scalability | 🔵 Trivial | 🏗️ Heavy lift

useWorkspaceSync still walks the file tree that TreesFileTree now owns.

The panel no longer consumes fileTree or loadingFiles, but useWorkspaceSync continues to build the tree and restore expanded directories through expandDirectory. TreesFileTree performs its own full walk. The workspace therefore loads twice on every invalidation. Add an option to useWorkspaceSync that disables tree synchronization, and enable it here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/components/sidepanel/WorkspacePanel.tsx` around lines 80 -
87, Update useWorkspaceSync to accept an option that disables file-tree
synchronization, including tree building and expandDirectory restoration, while
preserving its existing behavior by default. Pass this option in the
WorkspacePanel useWorkspaceSync call so TreesFileTree remains the sole owner of
file-tree loading.

381-393: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the unreachable workspace git-diff path.

selectDiff has no call sites. Remove selectedDiffPath, selectedGitDiff, and loadingGitDiff from the workspace viewer path, or connect selectDiff to a live producer.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/components/sidepanel/WorkspacePanel.tsx` around lines 381 -
393, Remove the unreachable workspace git-diff state and path by deleting
selectedDiffPath, selectedGitDiff, and loadingGitDiff along with their related
workspace viewer logic; alternatively, wire selectDiff to an active producer.
Keep the existing TreesFileTree and artifact rendering behavior unchanged.
packages/ui/src/components/sidepanel/viewer/DiffsPatchPane.tsx (1)

48-50: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use the file path as the React key.

key={index} ties the PatchDiff instance to the position in the list. When the patch changes and files are added or removed, React reuses the highlighted output of a different file at the same index. Derive the key from the diff --git header path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/components/sidepanel/viewer/DiffsPatchPane.tsx` around lines
48 - 50, Update the filePatches mapping in DiffsPatchPane to use each patch’s
path from its diff --git header as the PatchDiff React key instead of the array
index, ensuring keys remain stable when files are added, removed, or reordered.
packages/ui/src/components/sidepanel/viewer/DiffsEditorPane.tsx (1)

79-89: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Scope the save shortcut to the editor.

The listener is attached to window. It calls preventDefault() for Cmd/Ctrl+S while the pane is mounted, even when the focus is in the chat input or in another dialog. Attach the listener to the pane container instead, and check that the container holds the focus.

♻️ Proposed change
+  const containerRef = useRef<HTMLDivElement | null>(null);
+
   useEffect(() => {
     const onKeyDown = (event: KeyboardEvent) => {
       const isSave = (event.metaKey || event.ctrlKey) && (event.key === "s" || event.key === "S");
-      if (isSave) {
+      if (isSave && containerRef.current?.contains(document.activeElement)) {
         event.preventDefault();
         void handleSave();
       }
     };

Then set ref={containerRef} on the root div at line 92.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/components/sidepanel/viewer/DiffsEditorPane.tsx` around lines
79 - 89, Scope the Cmd/Ctrl+S handler in the save-shortcut useEffect to the
editor pane container instead of window, using the existing containerRef. Only
prevent the default and call handleSave when containerRef.current contains the
event target or otherwise holds focus, and attach/remove the listener on that
container. Set ref={containerRef} on the root div.
packages/ui/src/components/sidepanel/WorkspaceViewer.tsx (1)

43-43: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

createWorkspaceClient() runs during render in both new viewer components. The factory returns a new object on every render, so every useCallback that depends on the client is recreated on every render. WorkspacePanel.tsx line 71 already wraps the same factory in useMemo.

  • packages/ui/src/components/sidepanel/WorkspaceViewer.tsx#L43-L43: wrap the call in useMemo(() => createWorkspaceClient(), []) so enterEditMode keeps a stable identity.
  • packages/ui/src/components/sidepanel/viewer/DiffsEditorPane.tsx#L27-L27: wrap the call in useMemo(() => createWorkspaceClient(), []) so handleSave stops re-registering the keydown listener on every render.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/components/sidepanel/WorkspaceViewer.tsx` at line 43, Memoize
the createWorkspaceClient() result with useMemo(() => createWorkspaceClient(),
[]) in WorkspaceViewer.tsx at lines 43-43 so enterEditMode remains stable, and
apply the same change in DiffsEditorPane.tsx at lines 27-27 so handleSave does
not re-register the keydown listener on each render.
packages/ui/vite.config.ts (1)

59-59: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove stale Monaco API references. No source file imports Monaco, and no package manifest declares a Monaco dependency. Remove the unused monacoOptions prop and call site, then update the remaining Monaco-specific comments.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/vite.config.ts` at line 59, Remove the unused monacoOptions prop
and its call site, then clean up the remaining Monaco-specific comments and
stale references in the relevant configuration and component code. Preserve the
existing behavior for all non-Monaco dependencies, including the optimizeDeps
include entries.
apps/daemon/src/index.ts (1)

832-834: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

The preview 404 responses skip withCors.

The success path wraps the response with withCors, but both 404 paths do not. A cross-origin web UI cannot read those responses and reports a network error instead of a missing file.

♻️ Proposed change
-        if (!targetPath || !workspacePresenter.isPathAllowed(targetPath)) {
-          return new Response("Not found", { status: 404 });
-        }
+        const notFound = () => withCors(new Response("Not found", { status: 404 }));
+        if (!targetPath || !workspacePresenter.isPathAllowed(targetPath)) {
+          return notFound();
+        }
         try {
           const file = Bun.file(targetPath);
-          if (!(await file.exists())) return new Response("Not found", { status: 404 });
+          if (!(await file.exists())) return notFound();

Apply the same helper to the catch branch.

Also applies to: 843-845

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/daemon/src/index.ts` around lines 832 - 834, Update the preview
handler’s two 404 return paths—the targetPath validation branch and the catch
branch—to wrap their Response objects with the same withCors helper used by the
success path, preserving the existing 404 status and message.
apps/daemon/src/workspace/daemonWorkspacePresenter.ts (2)

227-231: 🔒 Security & Privacy | 🔵 Trivial | 💤 Low value

destroy leaves the authorization sets populated.

destroy clears watchRuntimes but not allowedPaths or allowedExactPaths. The desktop presenter clears allowedExactPaths in its destroy. Clear both sets so a disposed presenter cannot authorize a later isPathAllowed call from the HTTP preview endpoint.

♻️ Proposed change
   destroy(): void {
     const runtimes = Array.from(this.watchRuntimes.values());
     this.watchRuntimes.clear();
     for (const runtime of runtimes) void this.disposeRuntime(runtime);
+    this.allowedPaths.clear();
+    this.allowedExactPaths.clear();
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/daemon/src/workspace/daemonWorkspacePresenter.ts` around lines 227 -
231, Update daemonWorkspacePresenter.destroy to clear both allowedPaths and
allowedExactPaths in addition to watchRuntimes, ensuring subsequent
isPathAllowed calls cannot use stale authorization entries after disposal.

690-707: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

appendUntrackedDiffs spawns up to 100 git processes at once.

Promise.all starts one git diff --no-index per untracked file with no concurrency limit. A fresh clone or a large untracked directory produces a process burst that competes with the daemon's own work.

Run the diffs in small batches, or reduce UNTRACKED_FULL_DIFF_MAX_FILES.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/daemon/src/workspace/daemonWorkspacePresenter.ts` around lines 690 -
707, Limit concurrency in appendUntrackedDiffs when invoking runGitDiffNoIndex
instead of starting every diff through Promise.all at once. Process
untrackedFiles in small sequential batches with a modest fixed batch size,
preserve the existing maximum file cap and combined diff ordering, and continue
returning unstagedPatch when no diffs are produced or an error occurs.
apps/desktop/test/main/presenter/workspacePresenter.test.ts (1)

643-647: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

This test locks in the silent write denial.

The test asserts only that no file appears. It passes because writeFile returns without an error. If writeFile starts to throw on denial (see the comment on apps/desktop/src/main/presenter/workspacePresenter/index.ts), update this test to assert the rejection.

💚 Proposed update after the presenter change
   it("does not write outside an allowed workspace", async () => {
     const file = path.join(outsidePath, "nope.txt");
-    await presenter.writeFile(file, "x");
+    await expect(presenter.writeFile(file, "x")).rejects.toThrow();
     expect(fs.existsSync(file)).toBe(false);
   });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/test/main/presenter/workspacePresenter.test.ts` around lines 643
- 647, Update the “does not write outside an allowed workspace” test to assert
that presenter.writeFile rejects when given outsidePath, while retaining the
assertion that no file is created. Use the test’s existing presenter.writeFile
call and match the expected denial error behavior introduced in the presenter.
apps/daemon/src/dispatch/daemonDispatcher.ts (1)

1523-1625: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Guard on the presenter once and report a clear error.

Every workspace branch repeats workspacePresenter && route === .... If workspacePresenter is undefined, all fifteen branches fall through to Line 3001 and throw Unknown route: workspace.readFileText, which misreports the cause. The file already uses a clearer pattern for piProfiles (Line 1196).

♻️ Proposed change
+    if (route.startsWith("workspace.") && route !== workspaceBrowseDirectoryRoute.name && !workspacePresenter) {
+      throw new Error("Workspace presenter is unavailable");
+    }
+
-    if (workspacePresenter && route === workspaceRegisterRoute.name) {
+    if (workspacePresenter && route === workspaceRegisterRoute.name) {

Keep the per-branch workspacePresenter && for type narrowing, and rely on the new guard for the error message.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/daemon/src/dispatch/daemonDispatcher.ts` around lines 1523 - 1625, In
the workspace dispatch section of the daemon dispatcher, add a single guard
before the route-specific branches that detects an unavailable
workspacePresenter and reports a clear presenter-unavailable error for workspace
routes. Preserve each branch’s existing workspacePresenter && condition for type
narrowing and leave the individual route handling unchanged, following the
established piProfiles guard pattern.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/daemon/src/index.ts`:
- Around line 828-846: Update the preview response created in the workspace
preview handler to include X-Content-Type-Options: nosniff and a restrictive
sandbox Content-Security-Policy header, while preserving the existing inferred
content type and no-store cache behavior. Apply the same security headers to
every preview response path that serves workspace HTML, PDF, or SVG content.
- Line 944: Update the workspacePresenter.setBaseUrl call to reuse the host
normalization applied by pluginPresenter.setSettingsBaseUrl, ensuring wildcard
and IPv6 hosts resolve to browser-loadable URLs with correct IPv6 brackets and
an appropriate preview host.

In `@apps/daemon/src/workspace/daemonWorkspacePresenter.ts`:
- Around line 424-474: Update readFilePreview to enforce the existing
READ_TEXT_MAX_BYTES cap using the already available stats before reading
content. In the markdown and image branches, return null or the established
oversized-file response when stats.size exceeds the cap, preventing full reads
and base64 encoding for oversized files while preserving current behavior for
eligible files.
- Around line 761-795: Update collectSearchMatches to use fsp.lstat before
recursing so symbolic links are skipped rather than followed, and add the
SEARCH_MAX_DEPTH constant alongside SEARCH_MAX_RESULTS. Track recursion depth in
collectSearchMatches, stop when the maximum depth is reached, and pass the
incremented depth into recursive calls while preserving existing result-limit
behavior.

In `@apps/desktop/src/main/presenter/workspacePresenter/index.ts`:
- Around line 980-994: Update writeFile in
apps/desktop/src/main/presenter/workspacePresenter/index.ts (Lines 980-994) and
apps/daemon/src/workspace/daemonWorkspacePresenter.ts (Lines 572-577) to throw
an error when isPathAllowed rejects the path instead of returning, so
dispatchers do not report failed writes as successful. Update the matching
denial test in apps/desktop/test/main/presenter/workspacePresenter.test.ts
(Lines 643-647) to expect the rejection.
- Around line 980-1060: Read authorization currently permits markdown-linked
paths to become writable; add a write-only predicate based on
getWorkspaceRootForPath and replace isPathAllowed checks in writeFile,
createEntry, deletePath, and renameOrMovePath. Apply this change in
apps/desktop/src/main/presenter/workspacePresenter/index.ts lines 980-1060 and
apps/daemon/src/workspace/daemonWorkspacePresenter.ts lines 572-601; ensure
read/preview authorization remains unchanged while all write targets require a
registered workspace root.

In `@docs/features/trees-diffs-workspace/spec.md`:
- Around line 40-45: Align the Diffs-tab rendering contract across all three
documents: in docs/features/trees-diffs-workspace/spec.md lines 40-45, choose
either virtualized CodeView or selected-file PatchDiff and make AC-8 consistent
with that behavior; in docs/features/trees-diffs-workspace/plan.md lines 85-89,
update the DiffsPanel responsibility to the same contract; and in
docs/features/trees-diffs-workspace/tasks.md lines 51-55, update task 8.1’s
status and wording to match the implementation and revised acceptance criteria.

In `@docs/features/trees-diffs-workspace/tasks.md`:
- Line 84: Keep the desktop main WorkspacePresenter and its dispatcher
references in place; do not remove it or its route cases until
workspace.revealFileInFolder and workspace.openFile are handled on desktop or
supported by the daemon. Ensure both routes continue calling
runtime.workspacePresenter.

In `@packages/ui/src/components/sidepanel/DiffsPanel.tsx`:
- Around line 116-119: Update the workspacePath effect in DiffsPanel so it skips
its initial mount and only calls resetDiffsSelection and setPatch when
workspacePath changes afterward. Add and use a useRef-based first-render guard,
preserving the existing reset behavior for subsequent workspace changes.

In `@packages/ui/src/components/sidepanel/TreesFileTree.tsx`:
- Around line 98-119: Update the reload flow associated with collectPaths and
onInvalidated to debounce successive invalidation events using a nullable
reloadTimer, clearing the timer during cleanup. When an invalidation only
changes git status, reuse the previously collected path list instead of
rerunning the full tree walk; reserve collectPaths traversal for invalidations
that require structural refresh.
- Around line 296-312: Update the create actions in TreesFileTree’s New File and
New Folder MenuButton handlers to use the tree’s existing inline-create control
or application dialog instead of window.prompt. Ensure the selected parent path
and file-versus-folder flag are passed through to handleCreate, and retain
closing the menu after initiating creation.

In `@packages/ui/src/components/sidepanel/viewer/DiffsEditorPane.tsx`:
- Around line 61-77: Update handleSave to re-read filePath before
workspaceClient.writeFile and compare the current on-disk content with
originalRef.current. If they differ, prompt the user for confirmation and abort
the write when they decline; only write the editor buffer after confirmation or
when no conflict exists, preserving the existing save state and callbacks.

In `@packages/ui/src/components/sidepanel/WorkspaceViewer.tsx`:
- Around line 109-112: Expose DiffsEditorPane’s internal dirty state through an
optional onDirtyChange callback, invoking it whenever dirty changes. In
WorkspaceViewer, track that state and confirm with the user before exitEditMode
or resetting edit mode when openFilePath changes; only clear editMode and
editContent after confirmation, while preserving the existing behavior for clean
edits.

In `@packages/ui/src/components/trace/TraceDialog.tsx`:
- Around line 296-297: Update the TraceDialog rendering around DiffsCodePane to
add a size guard for formattedJson: render the existing syntax-highlighted
DiffsCodePane only for suitably small bodies, and render the full content as
plain text for large bodies to avoid main-thread tokenization. Preserve the
current trace.json display and layout, and use an appropriate existing
plain-text rendering pattern if available.

---

Minor comments:
In `@docs/features/trees-diffs-workspace/spec.md`:
- Around line 49-60: The route contract documentation is inconsistent with the
intended daemon/desktop split. In docs/features/trees-diffs-workspace/spec.md
lines 49-60, state that workspace filesystem, Git, edit, search, watcher, and
preview routes use the daemon, with only workspace.revealFileInFolder and
workspace.openFile remaining desktop-only. In
docs/features/trees-diffs-workspace/plan.md lines 120-123, replace the statement
that write routes return a desktop-only error to match this daemon-backed
contract.

In `@docs/features/trees-diffs-workspace/tasks.md`:
- Around line 57-60: Update the Tests section around item 9.2 to accurately
reflect renderer verification: either complete and document manual checks for
AC-1 through AC-10, including the listed tree and diff interactions, or add
renderer/end-to-end coverage for them. Do not claim coverage from presenter
tests alone or mark manual verification complete while it remains pending.

In `@packages/ui/src/components/markdown/useMarkdownLinkNavigation.ts`:
- Around line 132-143: Canonicalize the resolved path before diff selection in
the markdown link navigation flow, especially around setDiffsSelection and
openDiffs. Ensure the path representation matches the symlink paths used by
getGitStatus so active-file comparison and getGitDiff target the correct file,
while preserving the existing workspace and external-file branching.

In `@packages/ui/src/components/sidepanel/DiffsPanel.tsx`:
- Line 226: Update the DiffsPatchPane invocation to remove the redundant
themeStore.isDark ternary and pass the intended diffStyle value directly, or
select the correct alternate style if dark mode should differ; remove the
themeStore binding if it is unused after this change.

In `@packages/ui/src/components/sidepanel/TreesFileTree.tsx`:
- Around line 149-156: Update handleSelectionChange to ignore directory
selections before calling selectFile: detect the trailing slash on the selected
path and return early for directories. Preserve file selection behavior and only
pass normalized file paths to sidepanelStore.selectFile.

In `@packages/ui/src/components/sidepanel/viewer/DiffsCodePane.tsx`:
- Around line 5-10: Update the File rendering logic in DiffsCodePane to pass the
source language as the lang override using source.language ?? undefined, and add
source.language to the related useMemo dependency array so changes recompute the
rendered output.

In `@packages/ui/src/components/sidepanel/viewer/DiffsPatchPane.tsx`:
- Around line 16-24: Update splitIntoFilePatches to preserve the names of
hunk-less file-diff segments, including renames, mode-only changes, and
empty-file additions, while continuing to return renderable hunks separately. In
the DiffsPatchPane render flow, display the skipped file names above the patch
list and show “No changes” only when both renderable patches and skipped names
are empty.

In `@packages/ui/src/components/sidepanel/WorkspaceViewer.tsx`:
- Line 104: Update the comment above the edit-mode reset logic in
WorkspaceViewer to remove the outdated Monaco reference and identify
DiffsEditorPane, backed by `@pierre/diffs`, as the editor used for inline file
editing.
- Around line 114-127: Update enterEditMode in WorkspaceViewer to import and use
sonner’s toast when readFileText returns content: null, informing the user that
the file could not be loaded for editing before returning. Keep the existing
error logging for thrown failures, and ensure the loading state is still cleared
through finally.
- Around line 214-221: Update the edit-mode save flow in WorkspaceViewer so the
selected file preview is refreshed after the DiffsEditorPane save completes and
before exitEditMode switches back to read-only mode. Ensure the refreshed
preview is available before invoking exitEditMode, rather than passing
exitEditMode directly as onSaved.

---

Nitpick comments:
In `@apps/daemon/src/dispatch/daemonDispatcher.ts`:
- Around line 1523-1625: In the workspace dispatch section of the daemon
dispatcher, add a single guard before the route-specific branches that detects
an unavailable workspacePresenter and reports a clear presenter-unavailable
error for workspace routes. Preserve each branch’s existing workspacePresenter
&& condition for type narrowing and leave the individual route handling
unchanged, following the established piProfiles guard pattern.

In `@apps/daemon/src/index.ts`:
- Around line 832-834: Update the preview handler’s two 404 return paths—the
targetPath validation branch and the catch branch—to wrap their Response objects
with the same withCors helper used by the success path, preserving the existing
404 status and message.

In `@apps/daemon/src/workspace/daemonWorkspacePresenter.ts`:
- Around line 227-231: Update daemonWorkspacePresenter.destroy to clear both
allowedPaths and allowedExactPaths in addition to watchRuntimes, ensuring
subsequent isPathAllowed calls cannot use stale authorization entries after
disposal.
- Around line 690-707: Limit concurrency in appendUntrackedDiffs when invoking
runGitDiffNoIndex instead of starting every diff through Promise.all at once.
Process untrackedFiles in small sequential batches with a modest fixed batch
size, preserve the existing maximum file cap and combined diff ordering, and
continue returning unstagedPatch when no diffs are produced or an error occurs.

In `@apps/desktop/test/main/presenter/workspacePresenter.test.ts`:
- Around line 643-647: Update the “does not write outside an allowed workspace”
test to assert that presenter.writeFile rejects when given outsidePath, while
retaining the assertion that no file is created. Use the test’s existing
presenter.writeFile call and match the expected denial error behavior introduced
in the presenter.

In `@packages/ui/src/components/sidepanel/TreesFileTree.tsx`:
- Around line 194-215: Update handleCreate and handleDelete to call reload
immediately after their respective workspaceClient mutations succeed, ensuring
the tree refreshes without relying on watcher events. Preserve the existing
error handling and do not reload when the mutation fails; include reload in each
callback’s dependencies.

In `@packages/ui/src/components/sidepanel/viewer/DiffsEditorPane.tsx`:
- Around line 79-89: Scope the Cmd/Ctrl+S handler in the save-shortcut useEffect
to the editor pane container instead of window, using the existing containerRef.
Only prevent the default and call handleSave when containerRef.current contains
the event target or otherwise holds focus, and attach/remove the listener on
that container. Set ref={containerRef} on the root div.

In `@packages/ui/src/components/sidepanel/viewer/DiffsPatchPane.tsx`:
- Around line 48-50: Update the filePatches mapping in DiffsPatchPane to use
each patch’s path from its diff --git header as the PatchDiff React key instead
of the array index, ensuring keys remain stable when files are added, removed,
or reordered.

In `@packages/ui/src/components/sidepanel/WorkspacePanel.tsx`:
- Around line 80-87: Update useWorkspaceSync to accept an option that disables
file-tree synchronization, including tree building and expandDirectory
restoration, while preserving its existing behavior by default. Pass this option
in the WorkspacePanel useWorkspaceSync call so TreesFileTree remains the sole
owner of file-tree loading.
- Around line 381-393: Remove the unreachable workspace git-diff state and path
by deleting selectedDiffPath, selectedGitDiff, and loadingGitDiff along with
their related workspace viewer logic; alternatively, wire selectDiff to an
active producer. Keep the existing TreesFileTree and artifact rendering behavior
unchanged.

In `@packages/ui/src/components/sidepanel/WorkspaceViewer.tsx`:
- Line 43: Memoize the createWorkspaceClient() result with useMemo(() =>
createWorkspaceClient(), []) in WorkspaceViewer.tsx at lines 43-43 so
enterEditMode remains stable, and apply the same change in DiffsEditorPane.tsx
at lines 27-27 so handleSave does not re-register the keydown listener on each
render.

In `@packages/ui/vite.config.ts`:
- Line 59: Remove the unused monacoOptions prop and its call site, then clean up
the remaining Monaco-specific comments and stale references in the relevant
configuration and component code. Preserve the existing behavior for all
non-Monaco dependencies, including the optimizeDeps include entries.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2f989757-7e13-4094-8447-9d8855192b35

📥 Commits

Reviewing files that changed from the base of the PR and between 83f71eb and e6a1806.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (35)
  • README.md
  • apps/daemon/package.json
  • apps/daemon/src/dispatch/daemonDispatcher.ts
  • apps/daemon/src/index.ts
  • apps/daemon/src/workspace/daemonWorkspacePresenter.ts
  • apps/desktop/src/main/presenter/workspacePresenter/index.ts
  • apps/desktop/src/main/routes/index.ts
  • apps/desktop/test/main/presenter/workspacePresenter.test.ts
  • apps/landing/src/components/Spotlight.tsx
  • docs/features/trees-diffs-workspace/plan.md
  • docs/features/trees-diffs-workspace/spec.md
  • docs/features/trees-diffs-workspace/tasks.md
  • packages/shared-contracts/src/desktop-only.ts
  • packages/shared-contracts/src/domainSchemas.ts
  • packages/shared-contracts/src/routes.ts
  • packages/shared-contracts/src/routes/workspace.routes.ts
  • packages/shared/src/types/presenters/workspace.d.ts
  • packages/ui/api/WorkspaceClient.ts
  • packages/ui/package.json
  • packages/ui/src/components/markdown/useMarkdownLinkNavigation.ts
  • packages/ui/src/components/sidepanel/ChatSidePanel.tsx
  • packages/ui/src/components/sidepanel/DiffsPanel.tsx
  • packages/ui/src/components/sidepanel/TreesFileTree.tsx
  • packages/ui/src/components/sidepanel/WorkspacePanel.tsx
  • packages/ui/src/components/sidepanel/WorkspaceViewer.tsx
  • packages/ui/src/components/sidepanel/viewer/DiffsCodePane.tsx
  • packages/ui/src/components/sidepanel/viewer/DiffsEditorPane.tsx
  • packages/ui/src/components/sidepanel/viewer/DiffsPatchPane.tsx
  • packages/ui/src/components/sidepanel/viewer/WorkspaceCodePane.tsx
  • packages/ui/src/components/sidepanel/viewer/WorkspaceDiffView.tsx
  • packages/ui/src/components/sidepanel/viewer/diffsOptions.ts
  • packages/ui/src/components/trace/TraceDialog.tsx
  • packages/ui/src/components/workspace/WorkspaceFileNode.tsx
  • packages/ui/src/stores/ui/sidepanel.ts
  • packages/ui/vite.config.ts
💤 Files with no reviewable changes (3)
  • packages/ui/src/components/sidepanel/viewer/WorkspaceCodePane.tsx
  • packages/ui/src/components/workspace/WorkspaceFileNode.tsx
  • packages/ui/src/components/sidepanel/viewer/WorkspaceDiffView.tsx

Comment thread apps/daemon/src/index.ts
Comment on lines +828 to +846
// Workspace file preview (html/pdf/svg) served as raw bytes. The path must
// resolve inside a registered/allow-listed workspace; otherwise 404.
if (url.pathname === "/api/v1/workspace/preview" && request.method === "GET") {
const targetPath = url.searchParams.get("path");
if (!targetPath || !workspacePresenter.isPathAllowed(targetPath)) {
return new Response("Not found", { status: 404 });
}
try {
const file = Bun.file(targetPath);
if (!(await file.exists())) return new Response("Not found", { status: 404 });
return withCors(
new Response(file, {
headers: { "Content-Type": inferPreviewContentType(targetPath), "Cache-Control": "no-store" },
}),
);
} catch {
return new Response("Not found", { status: 404 });
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

The preview endpoint serves workspace HTML and SVG as active content on the daemon origin.

inferPreviewContentType returns text/html; charset=utf-8 for .html and image/svg+xml for .svg. The response comes from the same origin that serves the API and, in web mode, the web UI (Line 790). Any HTML or SVG file inside a registered workspace therefore runs script with access to that origin. A repository under review is untrusted input.

Add X-Content-Type-Options: nosniff and a sandboxing CSP to the preview response.

🔒 Proposed fix
           return withCors(
             new Response(file, {
-              headers: { "Content-Type": inferPreviewContentType(targetPath), "Cache-Control": "no-store" },
+              headers: {
+                "Content-Type": inferPreviewContentType(targetPath),
+                "Cache-Control": "no-store",
+                "X-Content-Type-Options": "nosniff",
+                "Content-Security-Policy": "sandbox; default-src 'none'; img-src data: blob:; style-src 'unsafe-inline'",
+              },
             }),
           );

Also applies to: 154-160

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/daemon/src/index.ts` around lines 828 - 846, Update the preview response
created in the workspace preview handler to include X-Content-Type-Options:
nosniff and a restrictive sandbox Content-Security-Policy header, while
preserving the existing inferred content type and no-store cache behavior. Apply
the same security headers to every preview response path that serves workspace
HTML, PDF, or SVG content.

Comment thread apps/daemon/src/index.ts
});

const serverPort = (server as any).port ?? port;
workspacePresenter.setBaseUrl(`http://${host}:${serverPort}`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

The preview base URL is wrong for wildcard and IPv6 hosts.

host can be 0.0.0.0, ::, or ::1 (see Lines 962-966 and the bracketing at Line 946). setBaseUrl interpolates host directly, so it produces http://0.0.0.0:9527 or http://:::9527. Neither is a URL a browser can load, so previewUrl breaks for HTML, PDF, and SVG previews whenever the daemon binds a wildcard or IPv6 address.

Reuse the same normalization already applied to pluginPresenter.setSettingsBaseUrl.

🐛 Proposed fix
   const serverPort = (server as any).port ?? port;
-  workspacePresenter.setBaseUrl(`http://${host}:${serverPort}`);
+  const previewHost = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host.includes(":") ? `[${host}]` : host;
+  workspacePresenter.setBaseUrl(`http://${previewHost}:${serverPort}`);
   if (!isNonLoopbackHost(host)) {
     const originHost = host === "::1" ? "[::1]" : host;
     pluginPresenter.setSettingsBaseUrl(`http://${originHost}:${serverPort}`);
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
workspacePresenter.setBaseUrl(`http://${host}:${serverPort}`);
const serverPort = (server as any).port ?? port;
const previewHost =
host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host.includes(":") ? `[${host}]` : host;
workspacePresenter.setBaseUrl(`http://${previewHost}:${serverPort}`);
if (!isNonLoopbackHost(host)) {
const originHost = host === "::1" ? "[::1]" : host;
pluginPresenter.setSettingsBaseUrl(`http://${originHost}:${serverPort}`);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/daemon/src/index.ts` at line 944, Update the
workspacePresenter.setBaseUrl call to reuse the host normalization applied by
pluginPresenter.setSettingsBaseUrl, ensuring wildcard and IPv6 hosts resolve to
browser-loadable URLs with correct IPv6 brackets and an appropriate preview
host.

Comment on lines +424 to +474
async readFilePreview(filePath: string): Promise<WorkspaceFilePreview | null> {
if (!this.isPathAllowed(filePath)) return null;
let stats: fs.Stats;
try {
stats = fs.statSync(filePath);
if (!stats.isFile()) return null;
} catch {
return null;
}

const normalizedPath = this.normalizePathForAccess(filePath);
const workspaceRoot = this.getWorkspaceRootForPath(normalizedPath);
const extension = path.extname(normalizedPath).toLowerCase();
const mimeType = inferMimeType(normalizedPath);

// Extension decides only the special preview kinds (markdown/html/pdf/svg/image).
// For everything else (source, config, unknown), sniff the content: a NUL byte
// in the leading bytes means binary; otherwise treat as text — default to text,
// detect binary by content rather than an extension allowlist.
const extensionKind = previewKindFromExtension(extension);
let kind: WorkspaceFilePreviewKind;
let content = "";
let thumbnail: string | undefined;

if (extensionKind === undefined) {
const isBinary = await sniffFileBinary(filePath);
kind = isBinary ? "binary" : "text";
if (kind === "text") {
try {
content = await fsp.readFile(filePath, "utf8");
} catch {
content = "";
}
}
} else {
kind = extensionKind;
if (kind === "markdown") {
try {
content = await fsp.readFile(filePath, "utf8");
} catch {
content = "";
}
} else if (kind === "image") {
try {
content = (await fsp.readFile(filePath)).toString("base64");
thumbnail = content;
} catch {
content = "";
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

readFilePreview reads the whole file with no size cap.

readFileText guards with READ_TEXT_MAX_BYTES (Line 561). readFilePreview does not. A large text file is read fully into memory, and a large image is additionally base64-encoded, which multiplies the size by about 1.33. Both then travel through the route response.

stats is already available at Line 428. Add the same cap before the read.

🛡️ Proposed fix
     if (extensionKind === undefined) {
+      if (stats.size > READ_TEXT_MAX_BYTES) {
+        kind = "binary";
+      } else {
       const isBinary = await sniffFileBinary(filePath);
       kind = isBinary ? "binary" : "text";
       if (kind === "text") {
         try {
           content = await fsp.readFile(filePath, "utf8");
         } catch {
           content = "";
         }
       }
+      }
     } else {

Apply an equivalent guard to the markdown and image branches.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/daemon/src/workspace/daemonWorkspacePresenter.ts` around lines 424 -
474, Update readFilePreview to enforce the existing READ_TEXT_MAX_BYTES cap
using the already available stats before reading content. In the markdown and
image branches, return null or the established oversized-file response when
stats.size exceeds the cap, preventing full reads and base64 encoding for
oversized files while preserving current behavior for eligible files.

Comment thread apps/daemon/src/workspace/daemonWorkspacePresenter.ts
Comment thread apps/desktop/src/main/presenter/workspacePresenter/index.ts
Comment on lines +98 to +119
const collectPaths = useCallback(
async (dirPath: string, depth: number, acc: string[]): Promise<void> => {
if (depth > MAX_TREE_DEPTH || acc.length > MAX_TREE_NODES) return;
let nodes: WorkspaceFileNode[];
try {
nodes = (await workspaceClient.expandDirectory(dirPath)) as WorkspaceFileNode[];
} catch {
return;
}
for (const node of nodes) {
if (acc.length > MAX_TREE_NODES) break;
const relativePath = toRelativePath(workspacePath, node.path);
// Trees infers "directory" from a trailing slash (canonical dir path), so
// mark dirs explicitly — otherwise empty folders render as files.
acc.push(node.isDirectory ? `${relativePath}/` : relativePath);
if (node.isDirectory) {
await collectPaths(node.path, depth + 1, acc);
}
}
},
[workspaceClient, workspacePath],
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Debounce the reload and avoid a full tree re-walk on every invalidation.

collectPaths walks the whole workspace with one sequential expandDirectory call per directory, up to 8000 nodes. reload runs on every onInvalidated event. A build or a branch switch emits many invalidation events, so the panel issues thousands of sequential IPC calls repeatedly. Add a debounce window and reuse the previous path list when only git status changed.

♻️ Proposed debounce
       off = workspaceClient.onInvalidated((payload) => {
         if (payload.workspacePath !== workspacePath) return;
-        void reload();
+        if (reloadTimer !== null) window.clearTimeout(reloadTimer);
+        reloadTimer = window.setTimeout(() => {
+          reloadTimer = null;
+          void reload();
+        }, 250);
       });

Declare let reloadTimer: number | null = null; next to off, and clear it in the cleanup function.

Also applies to: 269-273

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/components/sidepanel/TreesFileTree.tsx` around lines 98 -
119, Update the reload flow associated with collectPaths and onInvalidated to
debounce successive invalidation events using a nullable reloadTimer, clearing
the timer during cleanup. When an invalidation only changes git status, reuse
the previously collected path list instead of rerunning the full tree walk;
reserve collectPaths traversal for invalidations that require structural
refresh.

Comment on lines +296 to +312
label="New File"
icon="lucide:file-plus"
onClick={() => {
const name = window.prompt("New file name");
if (name) void handleCreate(item.path, name, false);
close();
}}
/>
<MenuButton
label="New Folder"
icon="lucide:folder-plus"
onClick={() => {
const name = window.prompt("New folder name");
if (name) void handleCreate(item.path, name, true);
close();
}}
/>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does Electron support window.prompt in the renderer process?

💡 Result:

Electron does not support window.prompt in the renderer process [1][2][3]. Attempts to call window.prompt will throw an error stating that the function is not supported [1][2][4]. Electron maintains this design for several reasons, primarily because the standard window.prompt implementation in web browsers is synchronous and blocks the main UI thread, which is generally avoided in Electron's architecture [5][4][6]. Additionally, Electron does not provide a built-in native equivalent, and there are no current plans to implement one [7][5]. If you need to prompt a user for input in an Electron application, the recommended approach is to build a custom solution [5]: 1. Create a separate BrowserWindow to act as a custom modal dialog [7][4]. 2. Use the inter-process communication (IPC) modules (ipcMain and ipcRenderer) to communicate between your renderer process and the main process to handle the input flow [4][8]. 3. For simpler tasks, you can also use the built-in dialog API, though it is not a direct drop-in replacement for window.prompt [9].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu
file="packages/ui/src/components/sidepanel/TreesFileTree.tsx"
printf '%s\n' '--- file outline ---'
ast-grep outline "$file" 2>/dev/null || true
printf '%s\n' '--- relevant source ---'
sed -n '1,380p' "$file"
printf '%s\n' '--- prompt and create-action references ---'
rg -n -C 3 'window\.prompt|handleCreate|New File here|New File|New Folder' packages/ui/src

Repository: dvaJi/argos

Length of output: 21104


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- Electron declarations and renderer setup ---'
rg -n -i -C 3 'electron|window\.prompt|prompt\s*=|prompt\(' \
  package.json packages apps electron . --glob '!node_modules/**' --glob '!dist/**' --glob '!build/**' \
  2>/dev/null | head -n 240
printf '%s\n' '--- relevant package manifests ---'
find . -maxdepth 4 -type f \( -name package.json -o -name '*electron*' \) -print | head -n 120

Repository: dvaJi/argos

Length of output: 16880


Replace window.prompt with the tree’s inline-create control or an application dialog. The three create actions call window.prompt, which Electron does not support in the renderer. They fail instead of collecting a name.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/components/sidepanel/TreesFileTree.tsx` around lines 296 -
312, Update the create actions in TreesFileTree’s New File and New Folder
MenuButton handlers to use the tree’s existing inline-create control or
application dialog instead of window.prompt. Ensure the selected parent path and
file-versus-folder flag are passed through to handleCreate, and retain closing
the menu after initiating creation.

Comment on lines +61 to +77
const handleSave = useCallback(async () => {
const editor = editorRef.current;
if (!editor || !dirty || saving) return;
setSaving(true);
try {
const text = editor.getText();
await workspaceClient.writeFile(filePath, text);
originalRef.current = text;
setDirty(false);
onSaved?.();
} catch (error) {
console.error("[DiffsEditorPane] save failed", error);
toast.error(`Failed to save ${fileBasename}`);
} finally {
setSaving(false);
}
}, [dirty, saving, editorRef, workspaceClient, filePath, onSaved, fileBasename]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

The save overwrites concurrent external changes.

handleSave writes the full editor buffer to filePath. It does not compare against the on-disk state. In this workspace an agent or an external tool can modify the same file while the editor is open. The watcher in DiffsPanel.tsx confirms that external writes occur. The save then discards those changes with no warning.

Detect the conflict before you write. Re-read the file and compare it against originalRef.current, then prompt the user if the two differ.

🛠️ Proposed change
     setSaving(true);
     try {
       const text = editor.getText();
+      const current = await workspaceClient.readFileText(filePath);
+      if (current.content !== null && current.content !== originalRef.current) {
+        toast.error(`${fileBasename} changed on disk. Reopen the file before you save.`);
+        return;
+      }
       await workspaceClient.writeFile(filePath, text);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const handleSave = useCallback(async () => {
const editor = editorRef.current;
if (!editor || !dirty || saving) return;
setSaving(true);
try {
const text = editor.getText();
await workspaceClient.writeFile(filePath, text);
originalRef.current = text;
setDirty(false);
onSaved?.();
} catch (error) {
console.error("[DiffsEditorPane] save failed", error);
toast.error(`Failed to save ${fileBasename}`);
} finally {
setSaving(false);
}
}, [dirty, saving, editorRef, workspaceClient, filePath, onSaved, fileBasename]);
const handleSave = useCallback(async () => {
const editor = editorRef.current;
if (!editor || !dirty || saving) return;
setSaving(true);
try {
const text = editor.getText();
const current = await workspaceClient.readFileText(filePath);
if (current.content !== null && current.content !== originalRef.current) {
toast.error(`${fileBasename} changed on disk. Reopen the file before you save.`);
return;
}
await workspaceClient.writeFile(filePath, text);
originalRef.current = text;
setDirty(false);
onSaved?.();
} catch (error) {
console.error("[DiffsEditorPane] save failed", error);
toast.error(`Failed to save ${fileBasename}`);
} finally {
setSaving(false);
}
}, [dirty, saving, editorRef, workspaceClient, filePath, onSaved, fileBasename]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/components/sidepanel/viewer/DiffsEditorPane.tsx` around lines
61 - 77, Update handleSave to re-read filePath before workspaceClient.writeFile
and compare the current on-disk content with originalRef.current. If they
differ, prompt the user for confirmation and abort the write when they decline;
only write the editor buffer after confirmation or when no conflict exists,
preserving the existing save state and callbacks.

Comment on lines +109 to +112
useEffect(() => {
setEditMode(false);
setEditContent("");
}, [openFilePath]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Unsaved edits are discarded without warning.

Two paths drop the editor content silently:

  • Line 109-112: when openFilePath changes, the effect sets editMode to false. DiffsEditorPane unmounts and its unsaved buffer is lost. The user can change the file selection in the tree while the editor is dirty.
  • Line 129-131: exitEditMode (the View button) closes the editor with no confirmation.

DiffsEditorPane tracks dirty internally, so the parent cannot detect the state. Expose the dirty state through a callback prop, then confirm before you leave edit mode.

🛠️ Sketch of the fix

In DiffsEditorPane.tsx, add an onDirtyChange?: (dirty: boolean) => void prop and call it when dirty changes. In WorkspaceViewer.tsx:

+  const [editDirty, setEditDirty] = useState(false);
+
   const exitEditMode = useCallback(() => {
+    if (editDirty && !window.confirm("Discard unsaved changes?")) return;
     setEditMode(false);
-  }, []);
+  }, [editDirty]);

Apply the same guard, or an autosave, before the file-change reset.

Also applies to: 129-131

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/components/sidepanel/WorkspaceViewer.tsx` around lines 109 -
112, Expose DiffsEditorPane’s internal dirty state through an optional
onDirtyChange callback, invoking it whenever dirty changes. In WorkspaceViewer,
track that state and confirm with the user before exitEditMode or resetting edit
mode when openFilePath changes; only clear editMode and editContent after
confirmation, while preserving the existing behavior for clean edits.

Comment on lines +296 to +297
<div className="flex-1 min-h-0 bg-muted/30">
<DiffsCodePane source={{ id: "trace-body", content: formattedJson, name: "trace.json" }} />

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Large trace bodies now highlight on the main thread.

DiffsCodePane renders <File ... disableWorkerPool /> (DiffsCodePane.tsx line 51). Shiki then tokenizes on the main thread. formattedJson contains the full request body, and the truncated flag on the trace record shows that these payloads can be large. A large body freezes the dialog and the rest of the renderer while it highlights. The previous implementation used a virtualized editor with a plain-text fallback.

Add a size guard that falls back to plain text.

🛠️ Proposed change
+const MAX_HIGHLIGHT_BYTES = 200_000;
...
               <div className="flex-1 min-h-0 bg-muted/30">
-                <DiffsCodePane source={{ id: "trace-body", content: formattedJson, name: "trace.json" }} />
+                {formattedJson.length > MAX_HIGHLIGHT_BYTES ? (
+                  <pre className="h-full overflow-auto p-3 text-xs whitespace-pre-wrap break-words">
+                    {formattedJson}
+                  </pre>
+                ) : (
+                  <DiffsCodePane source={{ id: "trace-body", content: formattedJson, name: "trace.json" }} />
+                )}
               </div>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div className="flex-1 min-h-0 bg-muted/30">
<DiffsCodePane source={{ id: "trace-body", content: formattedJson, name: "trace.json" }} />
<div className="flex-1 min-h-0 bg-muted/30">
{formattedJson.length > MAX_HIGHLIGHT_BYTES ? (
<pre className="h-full overflow-auto p-3 text-xs whitespace-pre-wrap break-words">
{formattedJson}
</pre>
) : (
<DiffsCodePane source={{ id: "trace-body", content: formattedJson, name: "trace.json" }} />
)}
</div>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/components/trace/TraceDialog.tsx` around lines 296 - 297,
Update the TraceDialog rendering around DiffsCodePane to add a size guard for
formattedJson: render the existing syntax-highlighted DiffsCodePane only for
suitably small bodies, and render the full content as plain text for large
bodies to avoid main-thread tokenization. Preserve the current trace.json
display and layout, and use an appropriate existing plain-text rendering pattern
if available.

…guard)

Greptile P1: split read-authorization from mutation-authorization — exact-file paths resolved from chat links authorize read/preview only, not write/delete/rename (desktop + daemon). Copilot: writeFile now throws on unauthorized so Save surfaces failures instead of reporting false success; search skips symlinked dirs and tracks visited paths to prevent workspace escape/cycles; TreesFileTree ignores directory selections; spec Non-Goals/AC-8 updated to match the daemon-port + PatchDiff implementation.
if (!workspacePath) return;
setLoading(true);
setError(null);
try {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-hooks-js/todo (error)

This component misses React Compiler's automatic memoization & re-renders more than it should. Rewrite the flagged code so the compiler can optimize it.

Fix → Todo: (BuildHIR::lowerStatement) Handle TryStatement with a finalizer ('finally') clause

Docs

async (filePath: string | null) => {
if (!workspacePath) return;
setLoadingPatch(true);
try {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-hooks-js/todo (error)

This component misses React Compiler's automatic memoization & re-renders more than it should. Rewrite the flagged code so the compiler can optimize it.

Fix → Todo: (BuildHIR::lowerStatement) Handle TryStatement with a finalizer ('finally') clause

Docs

// Reset selection when the workspace changes (different session/project).
useEffect(() => {
resetDiffsSelection();
setPatch("");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/no-adjust-state-on-prop-change (warning)

This effect adjusts state after a prop changes, so users briefly see the stale value.

Fix → Remove the adjustment effect by deriving values during render, resetting the component with a key, or updating related state in the event that changes the prop. Avoid tracking the previous prop in more state, which preserves the duplication. See https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes

Docs

// Reset selection when the workspace changes (different session/project).
useEffect(() => {
resetDiffsSelection();
setPatch("");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-hooks-js/set-state-in-effect (warning)

This synchronous effect update causes an extra render: Calling setState synchronously within an effect can trigger cascading renders. Prefer deriving or initializing the value before render. If the effect must read a browser API after mount, treat this as advisory or suppress it with // react-doctor-disable-next-line react-hooks-js/set-state-in-effect.

Fix → Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following:

  • Update external systems with the latest state from React.
  • Subscribe for updates from some external system, calling setState in a callback function when external state changes.

Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect).

Docs

// full-workspace patch by default (null = "All changes", an explicit user choice).
useEffect(() => {
if (!selectionReady) return;
void loadPatch(selectedPath);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-hooks-js/set-state-in-effect (warning)

This synchronous effect update causes an extra render: Calling setState synchronously within an effect can trigger cascading renders. Prefer deriving or initializing the value before render. If the effect must read a browser API after mount, treat this as advisory or suppress it with // react-doctor-disable-next-line react-hooks-js/set-state-in-effect.

Fix → Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following:

  • Update external systems with the latest state from React.
  • Subscribe for updates from some external system, calling setState in a callback function when external state changes.

Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect).

Docs


export function DiffsPatchPane({ patch, diffStyle = "unified", className }: DiffsPatchPaneProps) {
const base = useDiffsBaseOptions();
const filePatches = useMemo(() => splitIntoFilePatches(patch), [patch]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/react-compiler-no-manual-memoization (warning)

React Compiler can cache this value automatically. Verify that removing useMemo preserves behavior before simplifying it.

Fix → Profile compiler-managed code and remove useMemo, useCallback, or memo only when the manual cache no longer carries behavioral or performance intent.

Docs

const base = useDiffsBaseOptions();
const filePatches = useMemo(() => splitIntoFilePatches(patch), [patch]);

const options = useMemo(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/react-compiler-no-manual-memoization (warning)

React Compiler can cache this value automatically. Verify that removing useMemo preserves behavior before simplifying it.

Fix → Profile compiler-managed code and remove useMemo, useCallback, or memo only when the manual cache no longer carries behavioral or performance intent.

Docs

data-testid="diffs-patch-pane"
>
{filePatches.map((filePatch, index) => (
<PatchDiff key={index} patch={filePatch} options={options} disableWorkerPool />

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/no-array-index-as-key (warning)

Your users can see & submit the wrong data when this list reorders or filters, so use a stable id like key={item.id}, not the array index "index".

Fix → Use a stable id from the item, like key={item.id} or key={item.slug}. Index keys break when the list reorders or filters.

Docs

*/
export function useDiffsBaseOptions() {
const themeStore = useThemeStore();
return useMemo(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/react-compiler-no-manual-memoization (warning)

React Compiler can cache this value automatically. Verify that removing useMemo preserves behavior before simplifying it.

Fix → Profile compiler-managed code and remove useMemo, useCallback, or memo only when the manual cache no longer carries behavioral or performance intent.

Docs

@@ -227,8 +189,6 @@ export default function TraceDialog({ messageId, sessionId, onClose }: TraceDial
const resetState = useCallback(() => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-hooks-js/preserve-manual-memoization (error)

This component misses React Compiler's automatic memoization & re-renders more than it should: Compilation Skipped: Existing memoization could not be preserved. Rewrite the flagged code so the compiler can optimize it.

Fix → React Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. This value was memoized in source but not in compilation output.

Docs

@dvaJi
dvaJi merged commit 82d77e0 into master Aug 12, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants