Skip to content

(MOT-4274) feat(editor): add the editor worker - #623

Open
rohitg00 wants to merge 8 commits into
mainfrom
feat/editor-worker
Open

(MOT-4274) feat(editor): add the editor worker#623
rohitg00 wants to merge 8 commits into
mainfrom
feat/editor-worker

Conversation

@rohitg00

@rohitg00 rohitg00 commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Adds an editor to the console. Open a folder, browse it, edit files, and see what changed, in the same window as the agent you are working with.

Screen.Recording.2026-07-30.at.14.16.23.mov

The workspace is shared rather than private to the browser: the files you have open, the folders you have expanded and the version each file was read at are held by the worker, so a file the agent opens appears in your tabs and a file you open is one the agent can see. A reload does not lose it.

A folder is the unit, not a repository. The tree, tabs, editor, finder and content search all work in a plain directory. When the folder happens to be a git repository you also get a branch label, change marks, diffs against HEAD, and commit, fetch, pull, push and stash.

Saving is guarded. If a file changed underneath since it was opened, nothing is written and the difference comes back for review instead of being overwritten.

Scope

Everything is inside editor/. No console files are touched, and the shared @iii-dev/console-ui contract is unchanged. The page is ordinary injectable UI: editor/ui/ builds two assets that register over console:script and console:style.

What the worker adds

Twenty editor::* functions. The worker performs no filesystem access of its own: reads, writes, moves, listings, content search and git all delegate to the shell worker, so its jail and denylist remain the only filesystem boundary. The workspace record lives in state. Runtime limits live in the configuration worker (Path B) and hot-reload without a restart.

What is left is the part a filesystem has no opinion about:

  • editor::diff is pure, two strings in and a patch out, so an agent can show what a write will do before making it.
  • editor::save refuses a write when the file moved since the editor::open it started from, and returns the divergence as a patch.
  • editor::move rewrites every open buffer and expanded folder at or under the path, and editor::delete closes them. This is why moves and deletes do not go through shell::fs directly: a buffer left pointing at a path that moved writes itself back there on the next save.
  • editor::find ranks paths, editor::search groups content matches by file.
  • editor::git::* covers status, hunks, show, commit, fetch, ff-only pull, push, stash and undo-last-commit. Pull is --ff-only on purpose, because a merge under open buffers produces a conflicted tree an editor cannot usefully show.

Rendering: code and diffs go through pierre

An earlier revision of this PR shipped a hand-rolled diff renderer and argued that @pierre/diffs could not be used, because bundling it into the worker asset measured 10.3 MB against the console's 8 MiB per-asset cap. That measurement was right and the conclusion drawn from it was wrong.

Almost none of that 10.3 MB is pierre. 71% is @shikijs/langs shipping 347 TextMate grammars, 12.5% is 65 themes, and 6% is the oniguruma wasm. Pierre's own code is 350 KB, or 3.3%. The cause is one line in shiki/bundle-full.mjs which statically references all three catalogs, so with splitting off every grammar is inlined.

Aliasing shiki's root bundle to a fine-grained equivalent with a 20-language allowlist, emptying the theme catalogs down to pierre-dark and pierre-light, and stubbing the unused wasm engine brings the asset to 1.995 MiB, 25% of the cap. cpp is deliberately excluded: cpp.mjs plus cpp-macro.mjs are 626 KB, a fifth of the whole asset, for a language no worker here is written in.

So the console's own diff rendering is now reused rather than reimplemented, with no console change and no widening of the shared contract:

  • Pierre's File backs a new read view, which is the default on open. The console's shared Monaco CodeEditor is deliberately chrome-less (lineNumbers: 'off', no minimap, no folding), which is correct for its own callers but meant code here rendered as unstyled text. Monaco stays for the edit view, since pierre is render-only.
  • Pierre's FileDiff backs every patch, replacing a renderer that printed diff --git, index and @@ lines as though they were file content.
  • Patches in the chat function-trigger-message render through the same component, at zero marginal bytes.
  • No stylesheet is added. Pierre inlines its styles into its own shadow root via adoptedStyleSheets, so there is no console:style asset for it and no leakage either way.

parsePatchFiles is used rather than PatchDiff, on purpose: PatchDiff throws unless the text yields exactly one file containing one diff, so a clean file and a multi-file patch would each take the page down.

Two build guards hold the size down, and both were verified to fail on purpose rather than assumed to work:

  • A 2.5 MiB budget on dist/page.js. A 4 MiB budget was measured and rejected: moving @pierre/theming's private collection path adds 1.35 MB and still passes it, which is exactly the regression a guard is for. The measured table for each interceptor failing alone is in a comment above the constant.
  • A smoke test that renders a TypeScript snippet through the real bundled highlighter path and fails the build if it stops producing coloured spans. This is what catches a shiki bump silently reverting the aliases, or a language quietly leaving the allowlist.

Both run in CI: editor/build.rs and the pnpm build script each invoke them.

Verified on a live rig

  • The 1.995 MiB asset is accepted by the console and served intact: /ui/editor/page.js returns 2,092,017 bytes, byte-identical to the build output.
  • Detection without cooperation: a file written through shell::fs::write, with no editor::* call at all, is picked up and rendered as a diff.
  • Shared workspace: files opened from the CLI appear as tabs in the console, and survive a worker restart.
  • Configuration hot-reload: changing a limit through configuration::set is honoured by the next call, no restart.
  • The conflict guard, content search, file create and delete with buffer cleanup, and the git write surface were each exercised against a real repository.

Notes for review

  • Reading functions are allowlisted in iii-permissions.yaml. Writes, the git write surface, repointing the workspace and closing another surface's buffer stay at the needs-approval default.
  • Registering configuration is a required boot step and stays one, since guessing limits nobody chose is worse than refusing to start. It no longer treats a timeout as permanent: a loaded engine answers late, and an exiting worker is restarted by the source watcher straight into the same race, which turns a slow boot into a crash loop. Five attempts with backoff, then fail carrying the last error.
  • tests/integration.rs refuses to run when an engine is already listening. It spawns its own worker, which would otherwise re-register the editor console assets on a developer's live engine and remove them again on teardown.
  • --url reads III_URL. Inside a sandbox the engine is on the VM gateway, never on the VM's loopback.
  • One deliberate inconsistency: the save-conflict dialog still renders highlighted source rather than a pierre diff. Pierre sizes itself against a scrollable flex parent and a dialog is neither, so a diff that outgrows the dialog would be worse than what is there. Marked in place as a decision.
  • Still unverified: nobody has reviewed pierre's File layout inside the [data-iii-ui="editor"] wrapper at narrow viewport widths. The container is a best guess and is the first thing to check.

Fixes MOT-4274

Summary by CodeRabbit

  • New Features

    • Added a shared editor workspace with file browsing, open buffers, search, diffs, Git tools, and console-based editing.
    • Added read, edit, and diff views with syntax highlighting and live workspace updates.
    • Added conflict-safe saves that prevent overwriting newer file changes.
    • Added upstream comparison support for Git hunks.
    • Added configurable editor limits and hot-reloaded settings.
    • Added change notifications for file updates and editor activity.
  • Documentation

    • Added comprehensive editor setup, usage, API, configuration, and development documentation.
  • Permissions

    • Enabled approved read-only editor operations for automated workflows.

@vercel

vercel Bot commented Jul 29, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
workers Ready Ready Preview Jul 30, 2026 1:00pm
workers-tech-spec Ready Ready Preview Jul 30, 2026 1:00pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

skill-check — worker

0 verified, 50 skipped (no docs/).

Layer Result
structure
vale
ai
render

Four for four. Nicely done.

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a new Rust editor worker with shared workspace state, delegated filesystem and Git operations, conflict-safe saves, schema contracts, push-based change events, and an embedded console UI. It also adds configuration, permissions, tests, documentation, and release workflow integration.

Changes

Editor worker foundation

Layer / File(s) Summary
Crate, runtime, configuration, and bus foundation
editor/Cargo.toml, editor/src/{bus,config,configuration,workspace,manifest,main}.rs
Defines the worker package, remote operation bus, hot-reloaded limits, workspace session state, manifest output, startup retries, and graceful shutdown.
Worker metadata and build assets
editor/{build.rs,config.yaml,iii.worker.yaml}
Adds worker deployment metadata, local engine configuration, and conditional compile-time UI asset building.
Core editor operations
editor/src/{diff,fuzzy,git,lang,tree}.rs, editor/src/functions/*
Adds unified diff generation, fuzzy path ranking, Git status/hunk parsing, language detection, tree flattening, upstream comparisons, and UTF-8-safe patch truncation.

Workspace change observation

Layer / File(s) Summary
Push-based workspace events
editor/src/{events,observe,lib}.rs
Registers editor::changed, observes filesystem-writing hooks, derives change metadata and previews, and emits subscriber notifications.
Event-driven console synchronization
editor/ui/src/lib/events.ts, editor/ui/src/page/index.tsx
Subscribes to state and changed events, refreshes buffers and Git overlays, and displays the latest observed edit.

Console UI

Layer / File(s) Summary
UI package and build pipeline
editor/ui/{package.json,tsconfig.json,build.mjs}, pnpm-workspace.yaml, editor/src/ui.rs
Adds the TypeScript package, esbuild bundling, reduced Shiki/theme catalogs, size and highlighting checks, watch/analyze modes, and embedded console asset registration.
Editor page and trigger cards
editor/ui/page.tsx, editor/ui/src/{page,api}.tsx, editor/ui/src/function-trigger-message/*
Adds the editor page, typed worker API, read/edit/diff surfaces, Pierre diff rendering, workspace synchronization, and function-call result cards.
UI styling
editor/ui/styles.css
Updates reader layout styling, removes page-owned diff presentation rules, and adds live-change indicator styling.

Contracts, validation, and integration

Layer / File(s) Summary
Wire contracts and schema catalog
editor/src/functions/types.rs, editor/src/surface.rs, editor/tests/schemas.rs, editor/tests/support/*
Defines editor request/response types, publishes typed schemas in deterministic order, and validates committed golden snapshots.
Schema fixtures and executable validation
editor/tests/golden/schemas/*, editor/tests/{manifest,integration}.rs
Adds schemas for workspace, filesystem, diff, search, Git, open/save, and move operations, plus manifest and readiness-aware integration tests.

Release and documentation

Layer / File(s) Summary
Worker documentation and permissions
README.md, editor/README.md, editor/skills/SKILL.md, iii-permissions.yaml
Documents the editor workspace, APIs, configuration, UI behavior, delegated operations, and read-only permission rules.
Release workflow integration
.github/workflows/create-tag.yml, .github/workflows/release.yml
Adds editor to selectable worker tags and enables releases for editor/v* tags.

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

Possibly related PRs

Suggested labels: no-ticket

Poem

I’m a rabbit with buffers tucked neat,
Diffing each carrot from root to leaf.
Events hop softly, tabs stay in line,
Conflicts get guarded by mtimes fine.
The console now blooms in a burrow of light—
Editor magic, compiled just right!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding the editor worker, and is concise and specific.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/editor-worker

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.

@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: 15

🧹 Nitpick comments (8)
editor/src/fuzzy.rs (2)

93-98: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

PENALTY_LEADING_MAX doubles as the trailing clamp. Naming reads as a bug at the trailing site; a separate PENALTY_TAIL_MAX constant (same value) documents the intent.

🤖 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 `@editor/src/fuzzy.rs` around lines 93 - 98, Define a separate PENALTY_TAIL_MAX
constant with the same value as PENALTY_LEADING_MAX, then update the trailing
penalty calculation in the fuzzy scoring function to use PENALTY_TAIL_MAX while
retaining PENALTY_LEADING_MAX for the leading calculation.

56-67: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Case folding is ASCII-only despite the "case-insensitive" contract.

to_ascii_lowercase leaves non-ASCII untouched, so é/É (or any non-Latin path) never fold. to_lowercase() returns an iterator, so a cheap approximation is comparing qc.to_lowercase().eq(hc.to_lowercase()) only when either char is non-ASCII.

🤖 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 `@editor/src/fuzzy.rs` around lines 56 - 67, Update the character comparison in
the fuzzy-matching loop around query and haystack characters to support
non-ASCII case folding while retaining the ASCII fast path. Replace the
ASCII-only comparison with direct lowercase character comparison for ASCII
inputs, and use lowercase iterator equality when either character is non-ASCII
so pairs such as `é` and `É` match.
editor/src/workspace.rs (1)

91-106: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

remap can create duplicate buffer/expanded entries.

Moving a.rs onto a path that is already open (b.rs) leaves two buffers with path == "b.rs"; close then removes both and upsert only refreshes the first. Same for expanded. A dedup pass after the rewrite keeps the invariant that upsert maintains.

♻️ Optional dedup after remap
         for path in self.expanded.iter_mut() {
             if let Some(next) = remapped(path, from, to) {
                 *path = next;
                 changed += 1;
             }
         }
+        let mut seen = std::collections::HashSet::new();
+        self.buffers.retain(|b| seen.insert(b.path.clone()));
+        let mut seen_dirs = std::collections::HashSet::new();
+        self.expanded.retain(|p| seen_dirs.insert(p.clone()));
         changed
🤖 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 `@editor/src/workspace.rs` around lines 91 - 106, Update Workspace::remap to
deduplicate both buffers and expanded paths after applying remapped paths,
preserving only one entry per path so the invariant expected by upsert and close
remains intact. Keep the existing changed-count behavior for rewritten entries.
editor/src/git.rs (1)

79-89: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

split_at(1) on a non-ASCII token would panic. git only ever emits +N -N here, so this is defensive only, but rest.split_whitespace() feeding token.split_at(1) panics on a multi-byte leading char. token.strip_prefix('+') / strip_prefix('-') avoids the sharp edge entirely.

🤖 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 `@editor/src/git.rs` around lines 79 - 89, Update the branch status token
parsing in the `# branch.ab ` handling to replace `split_at(1)` with safe
`strip_prefix('+')` and `strip_prefix('-')` checks. Parse the remaining numeric
text for ahead/behind counts, and ignore tokens with neither prefix without
risking a panic on non-ASCII input.
editor/src/functions/mod.rs (1)

1195-1212: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

editor::git::show returns unbounded content. Every other read path here is capped (max_file_bytes, max_diff_bytes); git show <rev>:<path> streams a whole blob straight into the response, including binary blobs as lossy text. Consider capping against cfg.max_file_bytes with a truncated flag, matching editor::open.

🤖 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 `@editor/src/functions/mod.rs` around lines 1195 - 1212, Update the git show
handling around bus.git and GitShowOutput to cap returned content at
cfg.max_file_bytes, matching editor::open’s read behavior. Track whether the
blob was truncated with the existing output contract’s truncated flag, and
ensure binary or oversized content does not stream unbounded data into the
response.
editor/tests/schemas.rs (1)

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

Validate descriptions per request property.

rendered.contains("description") can pass because an unrelated nested definition has a description, even when a request field loses its own documentation. Parse the schema and assert each top-level request property has a description.

Suggested test adjustment
-        let rendered = serde_json::to_string(&spec.request_schema).expect("schema serializes");
-        if !rendered.contains("properties") {
+        let schema = serde_json::to_value(&spec.request_schema).expect("schema serializes");
+        let Some(properties) = schema.get("properties").and_then(|v| v.as_object()) else {
             continue;
-        }
-        assert!(
-            rendered.contains("description"),
-            "{}: request schema lost its field descriptions",
-            spec.function_id
-        );
+        };
+        for (field, property) in properties {
+            assert!(
+                property.get("description").and_then(|v| v.as_str()).is_some(),
+                "{}.{}: request field lost its description",
+                spec.function_id,
+                field
+            );
+        }
🤖 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 `@editor/tests/schemas.rs` around lines 86 - 97, Update
schemas_with_fields_carry_field_descriptions to parse each request_schema into a
JSON value and inspect its top-level properties individually. For every property
under the request schema’s properties object, assert that the property contains
a description, while preserving the existing catalog iteration and function_id
context in failures.
editor/ui/src/page/index.tsx (1)

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

Poll re-reads every open buffer's full contents every 3s.

refreshBuffers calls api.open for each buffer on every tick regardless of whether it changed, so N open tabs cost N full file reads (plus the editor::git::hunks call the delta effect re-runs on each status change). The workspace view already carries mtime per buffer — gate the read on it.

♻️ Suggested gating
       const ws = await api.workspace()
+      const known = new Map(buffers.map((b) => [b.path, b.mtime]))
       setBuffers(ws.buffers)
       for (const buffer of ws.buffers) {
         const local = draftsRef.current[buffer.path]
         if (!local) continue
+        // Unchanged mtime means the bytes we already hold are current.
+        if (known.get(buffer.path) === buffer.mtime) continue
         const file = await api.open(buffer.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 `@editor/ui/src/page/index.tsx` around lines 246 - 276, Update refreshBuffers
to use each workspace buffer’s mtime to skip api.open when the buffer has not
changed since the locally tracked draft metadata. Only re-read and update the
draft when the workspace mtime differs, while preserving stale and flashed
handling for changed content; ensure the stored draft state retains the latest
mtime for subsequent polling checks.
editor/build.rs (1)

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

pnpm install isn't pinned to the lockfile.

Running plain pnpm install during a cargo build lets pnpm silently update pnpm-lock.yaml if it's out of sync with ui/package.json, instead of failing fast. For a release build script (invoked across up to 9 cross-compile targets per README.md), that risks non-reproducible installs across jobs. Consider --frozen-lockfile (or the pnpm equivalent) so drift surfaces as a build failure rather than a silent lockfile mutation.

🔧 Proposed fix
     let status = Command::new(&pnpm)
-        .args(["install"])
+        .args(["install", "--frozen-lockfile"])
         .current_dir(&ui_dir)
🤖 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 `@editor/build.rs` around lines 62 - 74, Update the pnpm invocation in the
build script’s Command chain to pass the frozen-lockfile option alongside
install, ensuring lockfile/package manifest drift fails the build instead of
updating pnpm-lock.yaml. Preserve the existing spawn and non-success status
handling.
🤖 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 `@editor/README.md`:
- Line 3: Complete or remove the dangling sentence fragment directly beneath the
README title; if retained, rewrite it as a grammatically complete sentence that
clearly states the intended relationship between shell::fs::write and editor::*.
- Around line 129-165: Update the Configuration section’s statement about config
files to acknowledge the committed editor/config.yaml as a local/test seed used
by development and integration tests, while clearly distinguishing it from
production runtime configuration stored by the configuration worker. Preserve
the existing explanation that --config is an optional seed and does not
overwrite stored runtime values.

In `@editor/src/bus.rs`:
- Around line 153-184: Update read so the size obtained from the shell::fs::read
response is compared with max_bytes before constructing ChannelReader or calling
read_all(), returning the existing truncated FileRead result without opening or
buffering the channel when oversized. Preserve normal channel reading for files
within the limit and ensure the documented pre-read size-check behavior matches
the implementation.

In `@editor/src/functions/mod.rs`:
- Around line 687-692: Update the patch bounding logic in the worker around the
`patch` variable so truncation never uses a byte index inside a UTF-8 codepoint.
When `patch.len()` exceeds `cfg.max_diff_bytes`, reduce the limit to the
greatest valid char boundary at or before that byte limit, then truncate using
that boundary while preserving the existing size cap.
- Around line 803-817: Update the session persistence logic around
session.collapse so save_session runs whenever either buffers were closed or the
expanded state changed. Track whether collapse mutated the session, or otherwise
compare the relevant state before and after collapsing, while preserving the
existing close behavior.

In `@editor/src/functions/types.rs`:
- Around line 481-502: Update the mtime conflict-version flow used by
editor::open and SaveInput::expected_mtime to avoid Unix-second collisions:
return and compare nanosecond-precision metadata or an opaque content
version/hash, while preserving intentional overwrite behavior when
expected_mtime is omitted. Regenerate the associated schemas after updating the
public type definitions.

In `@editor/src/git.rs`:
- Around line 116-128: Update the unmerged-entry parsing in the status parser to
use splitn(10, ' ') and read the path from fields index 9, so conflict rows are
added to report.entries. Add a parse_status test covering an input such as “u YY
sub ... path” and verify the resulting conflicted StatusEntry.

In `@editor/src/main.rs`:
- Around line 89-94: Update Bus construction and its Git-operation methods so
Bus retains access to the live ConfigCell rather than the boot-time
git_timeout_ms value. In each Bus-mediated Git invocation, read the current
timeout from ConfigCell at call time, ensuring apply_config updates are observed
while preserving existing timeout behavior.
- Around line 99-102: Update the startup flow surrounding
configuration::register_config_trigger so a registration failure is returned or
propagated instead of merely logged with tracing::warn. Ensure main startup
aborts and exposes the error to the supervisor, preventing the worker from
serving requests with stale limits.

In `@editor/src/tree.rs`:
- Around line 26-53: Update walk so traversal is bounded by a budget charged for
every visited child, including directories, rather than relying only on
out.len(). Use the existing limit as the candidate bound while adding or
propagating a separate node-budget counter, and stop recursion once that budget
is exhausted without changing file candidate emission.

In `@editor/tests/golden/schemas/editor.git.hunks.json`:
- Around line 44-52: Update the context_lines description in the
editor.git.hunks schema to state that omitted values use the configured
diff_context_lines setting, rather than documenting 3 as the default. Keep the
existing guidance about passing 0 and hunk range widening, while describing 3
only as the shipped initial value if needed.

In `@editor/tests/integration.rs`:
- Around line 52-87: Update boot() to retain the spawned iii Child and
explicitly terminate and reap it if worker creation fails, instead of
propagating through .ok()? with the engine still running. Replace the fixed
startup sleeps in boot() with polling that waits until the engine and worker are
ready, using bounded retries or a timeout so readiness is deterministic under
load.

In `@editor/tests/schemas.rs`:
- Around line 1-12: The module documentation in the schemas test incorrectly
states there are six editor functions; update that wording to reflect all 19
functions registered by editor::surface::catalog(), or describe them as all
registered editor::* functions. Keep the existing snapshot and regeneration
guidance unchanged.

In `@editor/ui/src/page/index.tsx`:
- Around line 25-39: Remove the unused Diff named import from the static
`@iii-dev/console-ui` import. Preserve DiffPane’s runtime resolution through
host.components.Diff so older console shims can load the page without
module-linking failures.

In `@editor/ui/styles.css`:
- Around line 305-319: Update the `.ed-close` visibility rules so an inactive
tab reveals its close button when the tab receives keyboard focus within, by
adding a `.ed-tab:focus-within .ed-close` selector alongside the existing hover
and active-tab selectors.

---

Nitpick comments:
In `@editor/build.rs`:
- Around line 62-74: Update the pnpm invocation in the build script’s Command
chain to pass the frozen-lockfile option alongside install, ensuring
lockfile/package manifest drift fails the build instead of updating
pnpm-lock.yaml. Preserve the existing spawn and non-success status handling.

In `@editor/src/functions/mod.rs`:
- Around line 1195-1212: Update the git show handling around bus.git and
GitShowOutput to cap returned content at cfg.max_file_bytes, matching
editor::open’s read behavior. Track whether the blob was truncated with the
existing output contract’s truncated flag, and ensure binary or oversized
content does not stream unbounded data into the response.

In `@editor/src/fuzzy.rs`:
- Around line 93-98: Define a separate PENALTY_TAIL_MAX constant with the same
value as PENALTY_LEADING_MAX, then update the trailing penalty calculation in
the fuzzy scoring function to use PENALTY_TAIL_MAX while retaining
PENALTY_LEADING_MAX for the leading calculation.
- Around line 56-67: Update the character comparison in the fuzzy-matching loop
around query and haystack characters to support non-ASCII case folding while
retaining the ASCII fast path. Replace the ASCII-only comparison with direct
lowercase character comparison for ASCII inputs, and use lowercase iterator
equality when either character is non-ASCII so pairs such as `é` and `É` match.

In `@editor/src/git.rs`:
- Around line 79-89: Update the branch status token parsing in the `# branch.ab
` handling to replace `split_at(1)` with safe `strip_prefix('+')` and
`strip_prefix('-')` checks. Parse the remaining numeric text for ahead/behind
counts, and ignore tokens with neither prefix without risking a panic on
non-ASCII input.

In `@editor/src/workspace.rs`:
- Around line 91-106: Update Workspace::remap to deduplicate both buffers and
expanded paths after applying remapped paths, preserving only one entry per path
so the invariant expected by upsert and close remains intact. Keep the existing
changed-count behavior for rewritten entries.

In `@editor/tests/schemas.rs`:
- Around line 86-97: Update schemas_with_fields_carry_field_descriptions to
parse each request_schema into a JSON value and inspect its top-level properties
individually. For every property under the request schema’s properties object,
assert that the property contains a description, while preserving the existing
catalog iteration and function_id context in failures.

In `@editor/ui/src/page/index.tsx`:
- Around line 246-276: Update refreshBuffers to use each workspace buffer’s
mtime to skip api.open when the buffer has not changed since the locally tracked
draft metadata. Only re-read and update the draft when the workspace mtime
differs, while preserving stale and flashed handling for changed content; ensure
the stored draft state retains the latest mtime for subsequent polling checks.
🪄 Autofix (Beta)

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: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b60aef25-b7af-4d01-8746-716f58080c91

📥 Commits

Reviewing files that changed from the base of the PR and between f5c5495 and 640c519.

⛔ Files ignored due to path filters (2)
  • editor/Cargo.lock is excluded by !**/*.lock
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (65)
  • .github/workflows/create-tag.yml
  • .github/workflows/release.yml
  • README.md
  • console/web/public/vendor/console-ui.js
  • console/web/src/components/ui/Diff.tsx
  • console/web/src/lib/console-api.ts
  • console/web/src/lib/console-ui-conformance.test.ts
  • editor/Cargo.toml
  • editor/README.md
  • editor/build.rs
  • editor/config.yaml
  • editor/iii.worker.yaml
  • editor/skills/SKILL.md
  • editor/src/bus.rs
  • editor/src/config.rs
  • editor/src/configuration.rs
  • editor/src/diff.rs
  • editor/src/functions/mod.rs
  • editor/src/functions/types.rs
  • editor/src/fuzzy.rs
  • editor/src/git.rs
  • editor/src/lang.rs
  • editor/src/lib.rs
  • editor/src/main.rs
  • editor/src/manifest.rs
  • editor/src/surface.rs
  • editor/src/tree.rs
  • editor/src/ui.rs
  • editor/src/workspace.rs
  • editor/tests/golden/schemas/editor.buffers.close.json
  • editor/tests/golden/schemas/editor.buffers.list.json
  • editor/tests/golden/schemas/editor.create.json
  • editor/tests/golden/schemas/editor.delete.json
  • editor/tests/golden/schemas/editor.diff.json
  • editor/tests/golden/schemas/editor.find.json
  • editor/tests/golden/schemas/editor.git.commit.json
  • editor/tests/golden/schemas/editor.git.hunks.json
  • editor/tests/golden/schemas/editor.git.show.json
  • editor/tests/golden/schemas/editor.git.stash.json
  • editor/tests/golden/schemas/editor.git.status.json
  • editor/tests/golden/schemas/editor.git.sync.json
  • editor/tests/golden/schemas/editor.git.undo-commit.json
  • editor/tests/golden/schemas/editor.move.json
  • editor/tests/golden/schemas/editor.open.json
  • editor/tests/golden/schemas/editor.save.json
  • editor/tests/golden/schemas/editor.search.json
  • editor/tests/golden/schemas/editor.tree.json
  • editor/tests/golden/schemas/editor.workspace.get.json
  • editor/tests/golden/schemas/editor.workspace.open.json
  • editor/tests/integration.rs
  • editor/tests/manifest.rs
  • editor/tests/schemas.rs
  • editor/tests/support/mod.rs
  • editor/ui/build.mjs
  • editor/ui/package.json
  • editor/ui/page.tsx
  • editor/ui/src/function-trigger-message/index.tsx
  • editor/ui/src/lib/api.ts
  • editor/ui/src/page/index.tsx
  • editor/ui/styles.css
  • editor/ui/tsconfig.json
  • iii-permissions.yaml
  • packages/console-ui/component-names.mjs
  • packages/console-ui/index.d.ts
  • pnpm-workspace.yaml

Comment thread editor/README.md Outdated
Comment thread editor/README.md
Comment thread editor/src/bus.rs
Comment on lines +153 to +184
/// Read a file's text through the channel `shell::fs::read` returns.
///
/// The size check runs on the stat that comes back with the channel ref,
/// before a byte is pulled, so an oversized file costs one round trip
/// rather than streaming megabytes we intend to throw away.
pub async fn read(&self, path: &str, max_bytes: usize) -> Result<FileRead, Error> {
let value = self
.call("shell::fs::read", json!({ "path": path }), 30_000)
.await?;

let size = value.get("size").and_then(Value::as_u64).unwrap_or(0);
let mtime = value.get("mtime").and_then(Value::as_i64).unwrap_or(0);
let channel_ref: StreamChannelRef = value
.get("content")
.cloned()
.ok_or_else(|| Error::Handler("shell::fs::read returned no content channel".into()))
.and_then(|v| {
serde_json::from_value(v).map_err(|e| {
Error::Handler(format!("shell::fs::read content is not a channel ref: {e}"))
})
})?;

let reader = ChannelReader::new(&self.ws_url, &channel_ref);
let bytes = reader.read_all().await?;
let _ = reader.close().await;

let truncated = bytes.len() > max_bytes;
let slice = if truncated {
&bytes[..max_bytes]
} else {
&bytes[..]
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

The documented pre-read size check does not exist; the whole file is streamed into memory.

The doc comment claims the size check runs on the stat "before a byte is pulled", but size (Line 163) is never compared to max_bytesread_all() buffers the entire file and the cap is applied afterwards. A multi-GB blob is fully materialized before being thrown away.

🛡️ Bail out on the stat before opening the channel
         let size = value.get("size").and_then(Value::as_u64).unwrap_or(0);
         let mtime = value.get("mtime").and_then(Value::as_i64).unwrap_or(0);
+        // Refuse before streaming: the stat already told us it is too big.
+        if size > max_bytes as u64 {
+            let reader = ChannelReader::new(&self.ws_url, &serde_json::from_value::<StreamChannelRef>(
+                value.get("content").cloned().unwrap_or(Value::Null),
+            ).map_err(|e| Error::Handler(format!("shell::fs::read content is not a channel ref: {e}")))?);
+            let _ = reader.close().await;
+            return Err(Error::Handler(format!(
+                "{path} is {size} bytes, over the {max_bytes}-byte read cap"
+            )));
+        }

If truncated reads must stay supported, keep read_all() but at least reconcile the comment with the behaviour.

📝 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
/// Read a file's text through the channel `shell::fs::read` returns.
///
/// The size check runs on the stat that comes back with the channel ref,
/// before a byte is pulled, so an oversized file costs one round trip
/// rather than streaming megabytes we intend to throw away.
pub async fn read(&self, path: &str, max_bytes: usize) -> Result<FileRead, Error> {
let value = self
.call("shell::fs::read", json!({ "path": path }), 30_000)
.await?;
let size = value.get("size").and_then(Value::as_u64).unwrap_or(0);
let mtime = value.get("mtime").and_then(Value::as_i64).unwrap_or(0);
let channel_ref: StreamChannelRef = value
.get("content")
.cloned()
.ok_or_else(|| Error::Handler("shell::fs::read returned no content channel".into()))
.and_then(|v| {
serde_json::from_value(v).map_err(|e| {
Error::Handler(format!("shell::fs::read content is not a channel ref: {e}"))
})
})?;
let reader = ChannelReader::new(&self.ws_url, &channel_ref);
let bytes = reader.read_all().await?;
let _ = reader.close().await;
let truncated = bytes.len() > max_bytes;
let slice = if truncated {
&bytes[..max_bytes]
} else {
&bytes[..]
};
/// Read a file's text through the channel `shell::fs::read` returns.
///
/// The size check runs on the stat that comes back with the channel ref,
/// before a byte is pulled, so an oversized file costs one round trip
/// rather than streaming megabytes we intend to throw away.
pub async fn read(&self, path: &str, max_bytes: usize) -> Result<FileRead, Error> {
let value = self
.call("shell::fs::read", json!({ "path": path }), 30_000)
.await?;
let size = value.get("size").and_then(Value::as_u64).unwrap_or(0);
let mtime = value.get("mtime").and_then(Value::as_i64).unwrap_or(0);
// Refuse before streaming: the stat already told us it is too big.
if size > max_bytes as u64 {
let reader = ChannelReader::new(&self.ws_url, &serde_json::from_value::<StreamChannelRef>(
value.get("content").cloned().unwrap_or(Value::Null),
).map_err(|e| Error::Handler(format!("shell::fs::read content is not a channel ref: {e}")))?);
let _ = reader.close().await;
return Err(Error::Handler(format!(
"{path} is {size} bytes, over the {max_bytes}-byte read cap"
)));
}
let channel_ref: StreamChannelRef = value
.get("content")
.cloned()
.ok_or_else(|| Error::Handler("shell::fs::read returned no content channel".into()))
.and_then(|v| {
serde_json::from_value(v).map_err(|e| {
Error::Handler(format!("shell::fs::read content is not a channel ref: {e}"))
})
})?;
let reader = ChannelReader::new(&self.ws_url, &channel_ref);
let bytes = reader.read_all().await?;
let _ = reader.close().await;
let truncated = bytes.len() > max_bytes;
let slice = if truncated {
&bytes[..max_bytes]
} else {
&bytes[..]
};
🤖 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 `@editor/src/bus.rs` around lines 153 - 184, Update read so the size obtained
from the shell::fs::read response is compared with max_bytes before constructing
ChannelReader or calling read_all(), returning the existing truncated FileRead
result without opening or buffering the channel when oversized. Preserve normal
channel reading for files within the limit and ensure the documented pre-read
size-check behavior matches the implementation.

Comment thread editor/src/functions/mod.rs Outdated
Comment thread editor/src/functions/mod.rs
Comment on lines +44 to +52
"context_lines": {
"default": null,
"description": "Unchanged lines kept around each hunk in `patch`. Defaults to 3, which reads well. Pass 0 for ranges that match a gutter exactly — with context, a hunk's reported range widens to include it.",
"format": "uint32",
"minimum": 0.0,
"type": [
"integer",
"null"
]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Document the configurable context default.

Line 46 hard-codes 3, but omitted context_lines uses the live diff_context_lines configuration. Describe that setting instead; 3 is only the shipped initial value.

Proposed fix
-        "description": "Unchanged lines kept around each hunk in `patch`. Defaults to 3, which reads well. Pass 0 for ranges that match a gutter exactly — with context, a hunk's reported range widens to include it.",
+        "description": "Unchanged lines kept around each hunk in `patch`. Defaults to the editor's configured `diff_context_lines` value (3 initially). Pass 0 for ranges that match a gutter exactly — with context, a hunk's reported range widens to include it.",
📝 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
"context_lines": {
"default": null,
"description": "Unchanged lines kept around each hunk in `patch`. Defaults to 3, which reads well. Pass 0 for ranges that match a gutter exactly — with context, a hunk's reported range widens to include it.",
"format": "uint32",
"minimum": 0.0,
"type": [
"integer",
"null"
]
"context_lines": {
"default": null,
"description": "Unchanged lines kept around each hunk in `patch`. Defaults to the editor's configured `diff_context_lines` value (3 initially). Pass 0 for ranges that match a gutter exactly — with context, a hunk's reported range widens to include it.",
"format": "uint32",
"minimum": 0.0,
"type": [
"integer",
"null"
]
🤖 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 `@editor/tests/golden/schemas/editor.git.hunks.json` around lines 44 - 52,
Update the context_lines description in the editor.git.hunks schema to state
that omitted values use the configured diff_context_lines setting, rather than
documenting 3 as the default. Keep the existing guidance about passing 0 and
hunk range widening, while describing 3 only as the shipped initial value if
needed.

Comment on lines +52 to +87
async fn boot() -> Option<Harness> {
let iii_bin = which::which("iii").ok()?;

if engine_already_running() {
eprintln!(
"skipping: an engine is already listening on 127.0.0.1:49134. \
This test spawns its own worker, which would re-register the \
editor console assets on that engine and remove them again on \
teardown."
);
return None;
}

let iii = Command::new(&iii_bin)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.ok()?;

sleep(Duration::from_millis(800)).await;

let worker = Command::new(env!("CARGO_BIN_EXE_editor"))
.args(["--url", ENGINE_WS])
.args([
"--config",
concat!(env!("CARGO_MANIFEST_DIR"), "/config.yaml"),
])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.ok()?;

sleep(Duration::from_millis(1500)).await;

Some(Harness { iii, worker })
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

boot() can leak an orphaned iii engine process, and relies on fixed sleeps for readiness.

If Command::new(&iii_bin).spawn() succeeds (line 65-69) but the subsequent worker .spawn() at line 73-82 fails, boot() returns None via .ok()? before a Harness is ever constructed. std::process::Child::drop does not kill the child process, so the already-spawned iii engine is left running, still bound to 127.0.0.1:49134. Because engine_already_running() (line 44-50) checks that exact port, every subsequent run of this test on that machine/runner will silently self-skip forever — a rare failure mode with a very sticky, hard-to-diagnose effect (until someone manually kills the orphan).

Separately, the fixed sleep(800ms) / sleep(1500ms) / sleep(500ms) calls (lines 71, 84, 97) are a readiness-polling anti-pattern; under a loaded CI runner these could be too short and cause intermittent failures rather than a deterministic wait.

🛠️ Sketch: kill `iii` on early failure
     let iii = Command::new(&iii_bin)
         .stdout(Stdio::null())
         .stderr(Stdio::null())
         .spawn()
         .ok()?;

     sleep(Duration::from_millis(800)).await;

-    let worker = Command::new(env!("CARGO_BIN_EXE_editor"))
+    let mut iii = iii;
+    let worker = match Command::new(env!("CARGO_BIN_EXE_editor"))
         .args(["--url", ENGINE_WS])
         .args([
             "--config",
             concat!(env!("CARGO_MANIFEST_DIR"), "/config.yaml"),
         ])
         .stdout(Stdio::null())
         .stderr(Stdio::null())
         .spawn()
-        .ok()?;
+    {
+        Ok(w) => w,
+        Err(_) => {
+            let _ = iii.kill();
+            let _ = iii.wait();
+            return None;
+        }
+    };
📝 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
async fn boot() -> Option<Harness> {
let iii_bin = which::which("iii").ok()?;
if engine_already_running() {
eprintln!(
"skipping: an engine is already listening on 127.0.0.1:49134. \
This test spawns its own worker, which would re-register the \
editor console assets on that engine and remove them again on \
teardown."
);
return None;
}
let iii = Command::new(&iii_bin)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.ok()?;
sleep(Duration::from_millis(800)).await;
let worker = Command::new(env!("CARGO_BIN_EXE_editor"))
.args(["--url", ENGINE_WS])
.args([
"--config",
concat!(env!("CARGO_MANIFEST_DIR"), "/config.yaml"),
])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.ok()?;
sleep(Duration::from_millis(1500)).await;
Some(Harness { iii, worker })
}
async fn boot() -> Option<Harness> {
let iii_bin = which::which("iii").ok()?;
if engine_already_running() {
eprintln!(
"skipping: an engine is already listening on 127.0.0.1:49134. \
This test spawns its own worker, which would re-register the \
editor console assets on that engine and remove them again on \
teardown."
);
return None;
}
let iii = Command::new(&iii_bin)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.ok()?;
sleep(Duration::from_millis(800)).await;
let mut iii = iii;
let worker = match Command::new(env!("CARGO_BIN_EXE_editor"))
.args(["--url", ENGINE_WS])
.args([
"--config",
concat!(env!("CARGO_MANIFEST_DIR"), "/config.yaml"),
])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
{
Ok(w) => w,
Err(_) => {
let _ = iii.kill();
let _ = iii.wait();
return None;
}
};
sleep(Duration::from_millis(1500)).await;
Some(Harness { iii, worker })
}
🤖 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 `@editor/tests/integration.rs` around lines 52 - 87, Update boot() to retain
the spawned iii Child and explicitly terminate and reap it if worker creation
fails, instead of propagating through .ok()? with the engine still running.
Replace the fixed startup sleeps in boot() with polling that waits until the
engine and worker are ready, using bounded retries or a timeout so readiness is
deterministic under load.

Comment thread editor/tests/schemas.rs Outdated
Comment thread editor/ui/src/page/index.tsx
Comment thread editor/ui/styles.css
Comment on lines +305 to +319
[data-iii-ui='editor'] .ed-close {
border: 0;
background: transparent;
color: var(--color-ink-ghost);
font-size: 13px;
line-height: 1;
padding: 0 2px;
cursor: pointer;
visibility: hidden;
}

[data-iii-ui='editor'] .ed-tab:hover .ed-close,
[data-iii-ui='editor'] .ed-tab[data-active='true'] .ed-close {
visibility: visible;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Close button is unreachable by keyboard on inactive tabs.

visibility: hidden removes .ed-close from the focus order, so it only becomes tabbable once the tab is hovered or activated. Add a focus-within reveal so keyboard users can reach it directly.

♿ Proposed fix
 [data-iii-ui='editor'] .ed-tab:hover .ed-close,
+[data-iii-ui='editor'] .ed-tab:focus-within .ed-close,
 [data-iii-ui='editor'] .ed-tab[data-active='true'] .ed-close {
   visibility: visible;
 }
📝 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
[data-iii-ui='editor'] .ed-close {
border: 0;
background: transparent;
color: var(--color-ink-ghost);
font-size: 13px;
line-height: 1;
padding: 0 2px;
cursor: pointer;
visibility: hidden;
}
[data-iii-ui='editor'] .ed-tab:hover .ed-close,
[data-iii-ui='editor'] .ed-tab[data-active='true'] .ed-close {
visibility: visible;
}
[data-iii-ui='editor'] .ed-close {
border: 0;
background: transparent;
color: var(--color-ink-ghost);
font-size: 13px;
line-height: 1;
padding: 0 2px;
cursor: pointer;
visibility: hidden;
}
[data-iii-ui='editor'] .ed-tab:hover .ed-close,
[data-iii-ui='editor'] .ed-tab:focus-within .ed-close,
[data-iii-ui='editor'] .ed-tab[data-active='true'] .ed-close {
visibility: visible;
}
🤖 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 `@editor/ui/styles.css` around lines 305 - 319, Update the `.ed-close`
visibility rules so an inactive tab reveals its close button when the tab
receives keyboard focus within, by adding a `.ed-tab:focus-within .ed-close`
selector alongside the existing hover and active-tab selectors.

@rohitg00
rohitg00 force-pushed the feat/editor-worker branch from 640c519 to bcb3be8 Compare July 29, 2026 15:46
@rohitg00 rohitg00 changed the title (MOT-4274) feat(editor): add the editor worker and its console page (MOT-4274) feat(editor): add the editor worker Jul 29, 2026
Adds a shared code workspace: a folder, the buffers open against it, and
which folders are expanded, held in the state worker so it is a fact on
the bus rather than something a browser tab happens to remember. A file
an agent opens appears in the user's tabs; a file the user opens is one
the agent can see.

The unit is a folder, not a repository. The tree, tabs, editor, finder
and content search all work in a plain directory; git adds a branch
label and change marks when the root happens to be a repo, and nothing
else changes when it is not.

No filesystem access of its own. Reads, writes, moves, listings, content
search and git all delegate to the shell worker, so its jail and
denylist stay the one boundary, and both the recursive walk and the grep
are shell's rather than a second implementation. What this adds is the
model on top: a unified diff (pure — two strings in, a patch out), path
ranking, a save that refuses to clobber a file that moved since it was
opened, a move that rewrites every open buffer beneath it, and a delete
that closes them. Those last two are why moves and deletes do not go
through shell::fs directly: a buffer left pointing at a path that moved
or vanished writes itself back there on the next save.

The git write surface is deliberately narrow — commit, fetch, ff-only
pull, push, stash, undo-last-commit. Pull is --ff-only because a merge
under open buffers produces a conflicted tree an editor cannot usefully
show; anything beyond this set stays with shell::exec.

The injected console page is a second view of the same workspace: file
tree with a Files/Search switch and a git action bar, tabs, the shared
Monaco editor, an unsaved-diff toggle, and the conflict guard surfaced
as a dialog. Folder expansion round-trips through the worker, so it
survives a reload. The page polls the working tree, so an edit landing
from anywhere lights the row up and pulls an untouched tab forward.
editor::* calls render as themselves in chat: a diff as a diff, a save
as a file card with its line counts.

--url reads III_URL, which the worker manager injects — inside a sandbox
the engine is on the VM gateway, never on the VM's loopback.

Reading functions are allowlisted in iii-permissions.yaml. The writes,
the git write surface, the workspace repoint, and closing someone else's
buffer stay at the needs_approval default.
Unmerged porcelain v2 entries were never reported. The `u` line carries
nine columns before the path, not ten, so every conflicted file was
silently dropped from the status. Covered by a test.

The patch cap called String::truncate on a byte index, which panics the
moment a diff contains a multibyte character and the cap lands inside
it. Cut back to the last char boundary instead.

editor::git::hunks hard-coded three context lines rather than reading
the configured diff_context_lines, and its schema documented the shipped
default instead of the setting.

git_timeout_ms was copied into the bus at boot, making it the one field
that ignored a configuration change despite the docs promising every
field hot-reloads. It is now shared through an atomic the reload path
publishes to.

A failed configuration trigger bind was a warning, which left the worker
serving requests with limits that could never change. It is fatal now.

editor::delete discarded a collapse when no buffer happened to close,
because the save was gated on the buffer list rather than on the record.

The read path claimed to check size before pulling bytes but streamed
the whole file and truncated afterwards. It now stops draining once past
the cap.

The tree walk bounded emitted files but not visited nodes, so a deep
tree of empty directories was traversed in full regardless of the limit.

The integration test could leak an engine process when the worker failed
to spawn, because nothing owned the child until both had started.

Tab close buttons used visibility:hidden, which removes them from the
focus order, so an inactive tab could not be closed from the keyboard.

Also removes a stray line that a test write left in the worker README,
and corrects a stale function count in the schema test's module doc.

@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: 2

🧹 Nitpick comments (1)
editor/src/main.rs (1)

80-88: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Fatal config boot deviates from the repo-wide worker convention.

Sibling workers (shell/storage/email) log a warning and fall back to WorkerConfig::default() on config-load failure, registering in an inert state rather than exiting. Here both register_config and fetch_config failures abort startup, so a slow-starting configuration worker takes editor down with it. If the stricter behaviour is intentional, that is fine — but it is a per-worker deviation worth calling out explicitly (or aligning).

Based on learnings: worker binaries should handle config-load failures by logging tracing::warn! and falling back to WorkerConfig::default() rather than exiting.

🤖 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 `@editor/src/main.rs` around lines 80 - 88, Update the editor startup flow
around configuration::register_config and configuration::fetch_config to match
sibling worker behavior: log configuration failures with tracing::warn!, fall
back to WorkerConfig::default(), and continue startup in the inert state instead
of propagating errors and exiting.

Source: Learnings

🤖 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 `@editor/ui/src/page/index.tsx`:
- Around line 812-836: Update the row classification logic in the shown map
callback so lines representing the “\ No newline at end of file” marker are
classified as meta rows, like the existing diff metadata prefixes. Ensure these
marker rows do not assign or increment lineNo, while preserving numbering for
actual context and added lines.
- Around line 244-274: Update refreshBuffers to use each workspace buffer’s
mtime before calling api.open: compare it with the locally tracked buffer
metadata and only re-read paths whose mtime has advanced. Preserve the existing
draft/stale/flashed updates for files that are re-read, and store the latest
mtime so unchanged tabs avoid repeated bus round-trips and full file reads.

---

Nitpick comments:
In `@editor/src/main.rs`:
- Around line 80-88: Update the editor startup flow around
configuration::register_config and configuration::fetch_config to match sibling
worker behavior: log configuration failures with tracing::warn!, fall back to
WorkerConfig::default(), and continue startup in the inert state instead of
propagating errors and exiting.
🪄 Autofix (Beta)

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: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e2a3bd21-5b07-4d27-91d3-a31d58b90892

📥 Commits

Reviewing files that changed from the base of the PR and between 640c519 and b5398f3.

⛔ Files ignored due to path filters (2)
  • editor/Cargo.lock is excluded by !**/*.lock
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (59)
  • .github/workflows/create-tag.yml
  • .github/workflows/release.yml
  • README.md
  • editor/Cargo.toml
  • editor/README.md
  • editor/build.rs
  • editor/config.yaml
  • editor/iii.worker.yaml
  • editor/skills/SKILL.md
  • editor/src/bus.rs
  • editor/src/config.rs
  • editor/src/configuration.rs
  • editor/src/diff.rs
  • editor/src/functions/mod.rs
  • editor/src/functions/types.rs
  • editor/src/fuzzy.rs
  • editor/src/git.rs
  • editor/src/lang.rs
  • editor/src/lib.rs
  • editor/src/main.rs
  • editor/src/manifest.rs
  • editor/src/surface.rs
  • editor/src/tree.rs
  • editor/src/ui.rs
  • editor/src/workspace.rs
  • editor/tests/golden/schemas/editor.buffers.close.json
  • editor/tests/golden/schemas/editor.buffers.list.json
  • editor/tests/golden/schemas/editor.create.json
  • editor/tests/golden/schemas/editor.delete.json
  • editor/tests/golden/schemas/editor.diff.json
  • editor/tests/golden/schemas/editor.find.json
  • editor/tests/golden/schemas/editor.git.commit.json
  • editor/tests/golden/schemas/editor.git.hunks.json
  • editor/tests/golden/schemas/editor.git.show.json
  • editor/tests/golden/schemas/editor.git.stash.json
  • editor/tests/golden/schemas/editor.git.status.json
  • editor/tests/golden/schemas/editor.git.sync.json
  • editor/tests/golden/schemas/editor.git.undo-commit.json
  • editor/tests/golden/schemas/editor.move.json
  • editor/tests/golden/schemas/editor.open.json
  • editor/tests/golden/schemas/editor.save.json
  • editor/tests/golden/schemas/editor.search.json
  • editor/tests/golden/schemas/editor.tree.json
  • editor/tests/golden/schemas/editor.workspace.get.json
  • editor/tests/golden/schemas/editor.workspace.open.json
  • editor/tests/integration.rs
  • editor/tests/manifest.rs
  • editor/tests/schemas.rs
  • editor/tests/support/mod.rs
  • editor/ui/build.mjs
  • editor/ui/package.json
  • editor/ui/page.tsx
  • editor/ui/src/function-trigger-message/index.tsx
  • editor/ui/src/lib/api.ts
  • editor/ui/src/page/index.tsx
  • editor/ui/styles.css
  • editor/ui/tsconfig.json
  • iii-permissions.yaml
  • pnpm-workspace.yaml
🚧 Files skipped from review as they are similar to previous changes (48)
  • editor/tests/golden/schemas/editor.git.stash.json
  • editor/ui/tsconfig.json
  • editor/tests/golden/schemas/editor.buffers.list.json
  • editor/ui/build.mjs
  • editor/tests/golden/schemas/editor.delete.json
  • editor/tests/golden/schemas/editor.git.undo-commit.json
  • editor/tests/golden/schemas/editor.search.json
  • editor/config.yaml
  • editor/tests/golden/schemas/editor.git.sync.json
  • editor/src/surface.rs
  • editor/tests/golden/schemas/editor.buffers.close.json
  • editor/src/manifest.rs
  • editor/tests/manifest.rs
  • pnpm-workspace.yaml
  • editor/iii.worker.yaml
  • editor/ui/page.tsx
  • editor/tests/golden/schemas/editor.git.show.json
  • editor/src/lib.rs
  • editor/tests/golden/schemas/editor.move.json
  • editor/src/lang.rs
  • editor/ui/package.json
  • editor/ui/styles.css
  • editor/tests/support/mod.rs
  • editor/tests/golden/schemas/editor.git.hunks.json
  • editor/tests/schemas.rs
  • editor/tests/golden/schemas/editor.git.commit.json
  • editor/tests/golden/schemas/editor.diff.json
  • editor/src/ui.rs
  • editor/tests/golden/schemas/editor.find.json
  • editor/src/diff.rs
  • editor/tests/golden/schemas/editor.save.json
  • editor/src/tree.rs
  • editor/ui/src/lib/api.ts
  • editor/tests/golden/schemas/editor.workspace.open.json
  • editor/build.rs
  • editor/tests/golden/schemas/editor.open.json
  • iii-permissions.yaml
  • editor/Cargo.toml
  • editor/src/fuzzy.rs
  • editor/src/git.rs
  • editor/src/config.rs
  • editor/tests/integration.rs
  • editor/README.md
  • README.md
  • editor/src/workspace.rs
  • editor/src/configuration.rs
  • editor/src/functions/mod.rs
  • editor/src/functions/types.rs

Comment thread editor/ui/src/page/index.tsx Outdated
Comment on lines +244 to +274
const refreshBuffers = useCallback(async () => {
try {
const ws = await api.workspace()
setBuffers(ws.buffers)
for (const buffer of ws.buffers) {
const local = draftsRef.current[buffer.path]
if (!local) continue
const file = await api.open(buffer.path)
if (file.content === local.base) continue
const edited = local.draft !== local.base
setDrafts((prev) => ({
...prev,
[buffer.path]: edited
? { ...local, stale: true }
: { base: file.content, draft: file.content, truncated: file.truncated, stale: false },
}))
setFlashed((prev) => ({ ...prev, [buffer.path]: Date.now() }))
}
} catch {
// Transient; the next tick tries again.
}
}, [api])

useEffect(() => {
const id = setInterval(() => {
void refreshGit()
void refreshBuffers()
}, POLL_MS)
void refreshGit()
return () => clearInterval(id)
}, [refreshGit, refreshBuffers])

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

Every open tab is fully re-read from disk every 3 s.

refreshBuffers calls api.open(buffer.path) for each open buffer on every poll tick, so N tabs cost N bus round-trips plus N full file bodies every 3 seconds — and each editor::open also has a 30 s client timeout, so a slow tick can overlap the next. The workspace already returns each buffer's mtime; compare that first and only re-read the paths whose mtime advanced.

♻️ Re-read only when mtime moved
       const ws = await api.workspace()
       setBuffers(ws.buffers)
       for (const buffer of ws.buffers) {
         const local = draftsRef.current[buffer.path]
         if (!local) continue
+        if (buffer.mtime === seenMtimeRef.current[buffer.path]) continue
+        seenMtimeRef.current[buffer.path] = buffer.mtime
         const file = await api.open(buffer.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 `@editor/ui/src/page/index.tsx` around lines 244 - 274, Update refreshBuffers
to use each workspace buffer’s mtime before calling api.open: compare it with
the locally tracked buffer metadata and only re-read paths whose mtime has
advanced. Preserve the existing draft/stale/flashed updates for files that are
re-read, and store the latest mtime so unchanged tabs avoid repeated bus
round-trips and full file reads.

Comment thread editor/ui/src/page/index.tsx Outdated
editor::git::hunks defaulted to the configured diff_context_lines after
the last round of review fixes, which widened every reported range by
that many lines. `Hunk` is documented as the ranges a gutter paints, so
the default is back to zero and a caller wanting a readable patch asks
for context explicitly. The console page does exactly that.

The char-boundary truncate had no test. It has three now, including a
string of nothing but multibyte characters swept across every cap.

The integration test relied on a fixed sleep for worker readiness. Since
the worker moved to the configuration worker it registers its functions
only after a register and a fetch, both of which retry with backoff on a
cold engine, so the sleep was no longer long enough and the test failed
the first time it actually ran rather than skipping. It now polls until
the function answers.

@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: 1

🤖 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 `@editor/tests/integration.rs`:
- Around line 102-126: Update the polling loop around the trigger call to derive
the remaining time until deadline and cap both the trigger timeout and the 250
ms retry sleep to that remaining budget. Ensure the final attempt and sleep
cannot extend beyond the requested budget while preserving the existing retry
and error-handling behavior.
🪄 Autofix (Beta)

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: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: fb9331e6-7ad9-48cd-aebd-bd15afca4475

📥 Commits

Reviewing files that changed from the base of the PR and between b5398f3 and 3752a5a.

📒 Files selected for processing (5)
  • editor/src/functions/mod.rs
  • editor/src/functions/types.rs
  • editor/tests/golden/schemas/editor.git.hunks.json
  • editor/tests/integration.rs
  • editor/ui/src/lib/api.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • editor/src/functions/types.rs

Comment on lines +102 to +126
let deadline = std::time::Instant::now() + budget;
let mut last: Option<String> = None;
while std::time::Instant::now() < deadline {
let call = client.trigger(TriggerRequest {
function_id: "editor::diff".into(),
payload: json!({
"before": "a\nb\nc\n",
"after": "a\nB\nc\n",
"path": "sample.txt",
}),
action: None,
timeout_ms: Some(5_000),
});
match timeout(Duration::from_secs(10), call).await {
Ok(Ok(value)) => return Some(value),
Ok(Err(e)) => {
let text = e.to_string();
if !text.contains("not found") {
panic!("editor::diff failed: {text}");
}
last = Some(text);
}
Err(_) => last = Some("trigger timed out".to_string()),
}
sleep(Duration::from_millis(250)).await;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Keep polling within the requested budget.

The final attempt always gets 10 seconds plus a 250 ms sleep, so a 30-second budget can take roughly 40 seconds. Cap both durations by the remaining time.

Proposed fix
     while std::time::Instant::now() < deadline {
+        let remaining = deadline.saturating_duration_since(std::time::Instant::now());
         let call = client.trigger(TriggerRequest {
             function_id: "editor::diff".into(),
             payload: json!({
@@
-        match timeout(Duration::from_secs(10), call).await {
+        match timeout(remaining.min(Duration::from_secs(10)), call).await {
@@
-        sleep(Duration::from_millis(250)).await;
+        let remaining = deadline.saturating_duration_since(std::time::Instant::now());
+        sleep(remaining.min(Duration::from_millis(250))).await;
     }
📝 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
let deadline = std::time::Instant::now() + budget;
let mut last: Option<String> = None;
while std::time::Instant::now() < deadline {
let call = client.trigger(TriggerRequest {
function_id: "editor::diff".into(),
payload: json!({
"before": "a\nb\nc\n",
"after": "a\nB\nc\n",
"path": "sample.txt",
}),
action: None,
timeout_ms: Some(5_000),
});
match timeout(Duration::from_secs(10), call).await {
Ok(Ok(value)) => return Some(value),
Ok(Err(e)) => {
let text = e.to_string();
if !text.contains("not found") {
panic!("editor::diff failed: {text}");
}
last = Some(text);
}
Err(_) => last = Some("trigger timed out".to_string()),
}
sleep(Duration::from_millis(250)).await;
let deadline = std::time::Instant::now() + budget;
let mut last: Option<String> = None;
while std::time::Instant::now() < deadline {
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
let call = client.trigger(TriggerRequest {
function_id: "editor::diff".into(),
payload: json!({
"before": "a\nb\nc\n",
"after": "a\nB\nc\n",
"path": "sample.txt",
}),
action: None,
timeout_ms: Some(5_000),
});
match timeout(remaining.min(Duration::from_secs(10)), call).await {
Ok(Ok(value)) => return Some(value),
Ok(Err(e)) => {
let text = e.to_string();
if !text.contains("not found") {
panic!("editor::diff failed: {text}");
}
last = Some(text);
}
Err(_) => last = Some("trigger timed out".to_string()),
}
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
sleep(remaining.min(Duration::from_millis(250))).await;
🤖 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 `@editor/tests/integration.rs` around lines 102 - 126, Update the polling loop
around the trigger call to derive the remaining time until deadline and cap both
the trigger timeout and the 250 ms retry sleep to that remaining budget. Ensure
the final attempt and sleep cannot extend beyond the requested budget while
preserving the existing retry and error-handling behavior.

Replaces the page's polling with the platform's push primitives, and adds
the remote comparison that was missing.

The page ran a three-second interval asking for git status and re-reading
every open tab. That was wrong on every axis: a git invocation per tick
whether or not anything had happened, blind to changes outside a git
repository, and reporting the settled state rather than the edit. Two
bindings replace it, both garbage-collected with the tab. The `state`
trigger type covers the workspace record, which already lived in `state`
under scope `editor`, so every buffer open, close, save and folder toggle
was already emitting an event the page ignored. A new `editor::changed`
trigger type covers file changes, fanned out to subscribers in the shape
the github worker uses for `github::called`.

The observer is what makes those events real. The workspace only ever
reflected files routed through editor::open, and agents do not do that:
they call coder::update-file and shell::fs::write, because that is what
their prompts point at. So the editor was blind to exactly the edits it
exists to show. It now binds harness::hook::post-trigger over shell::*
and coder::*, maps the write verbs to a touched path, and emits. Reads
are not changes and are filtered out; shell::exec is deliberately not
guessed at, because a command can write anything and its argv does not
say what, so inferring would produce phantom events. Those still surface
through git status.

Two things fall out of the hook payload. metadata.fs_scope.root is the
session's own workspace, so the editor follows the agent instead of
needing a root set by hand. call.function_id names the cause, so a
surface can say what did it. The hook is fail-open and always continues:
a viewer must never be able to hold or deny a write.

editor::git::hunks gains against: upstream, diffing the working tree
against @{upstream} for everything not yet pushed. git's own name for it,
so it works on any branch without the caller knowing the remote.
… the observer

The observer emitted an event for every write it saw, but the event was
empty. `report` computed a root-relative path and then handed that to the
read, which resolves through shell's own working directory rather than
the session root. For a session rooted anywhere other than shell's cwd
the read missed, the error was swallowed by the fallback, and the event
went out with added: 0 and no patch. That is indistinguishable from the
hook never firing.

Reads now go out absolute; only the reported path stays relative to the
root, which is what a surface wants to display.

The reason this took a rig boot and a harness turn to find is that the
emitter logged only failures, so a delivered-but-empty event left no
trace at all. Every branch now says what it did: no call payload, not a
write, no subscribers, and a successful delivery with its counts. An
observer without observability was the actual defect.

Verified end to end against a live rig: the hook fires, the event is
delivered to the page's subscription carrying the real line counts, and
the page renders it.

@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: 2

🤖 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 `@editor/src/events.rs`:
- Around line 188-200: Update register_changed_trigger to handle the Result from
iii.register_trigger_type instead of discarding it. Match the registration
outcome, log the success message only when registration succeeds, and emit a
warning with the error on failure, following the existing observe::bind pattern.

In `@editor/ui/src/page/index.tsx`:
- Around line 268-299: Replace the direct refresh calls in the onState and
onChanged handlers with a shared short trailing debounce that coalesces event
bursts into one refreshBuffers/refreshGit execution. Ensure pending timers are
cleaned up when the component unmounts, and prevent overlapping refreshes or
stale responses from overwriting newer state.
🪄 Autofix (Beta)

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: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7a617b69-0ee3-4e90-9a68-d14abe09a388

📥 Commits

Reviewing files that changed from the base of the PR and between 3752a5a and e76498e.

⛔ Files ignored due to path filters (1)
  • editor/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (12)
  • editor/Cargo.toml
  • editor/src/events.rs
  • editor/src/functions/mod.rs
  • editor/src/functions/types.rs
  • editor/src/lib.rs
  • editor/src/main.rs
  • editor/src/observe.rs
  • editor/tests/golden/schemas/editor.git.hunks.json
  • editor/ui/src/lib/events.ts
  • editor/ui/src/page/index.tsx
  • editor/ui/styles.css
  • observer-proof.txt

Comment thread editor/src/events.rs
Comment on lines +188 to +200
pub fn register_changed_trigger(iii: &Arc<IIIClient>) -> ChangedEmitter {
let subscribers = SubscriberSet::new();
let _ = iii.register_trigger_type(RegisterTriggerType::new(
CHANGED,
"Fires when a file in the workspace changes, whoever changed it — \
including an agent that never called this worker.",
ChangedTriggerHandler {
subscribers: subscribers.clone(),
},
));
tracing::info!(trigger_type = CHANGED, "registered trigger type");
ChangedEmitter::new(iii.clone(), subscribers)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Swallowed register_trigger_type result hides a fully broken push-event feature.

The Result is discarded and "registered trigger type" is logged unconditionally, so if registration fails (e.g. a restart race re-registering the same type), editor::changed becomes silently undeliverable — ChangedEmitter::emit will just no-op (line 141-144) with only a debug! never seen in production, and there is no error signal anywhere. This is the trigger type the entire push-based UI feature depends on. observe::bind right next to this already follows the correct pattern (matching on the Result and logging warn on failure) — this call should do the same.

🐛 Proposed fix
 pub fn register_changed_trigger(iii: &Arc<IIIClient>) -> ChangedEmitter {
     let subscribers = SubscriberSet::new();
-    let _ = iii.register_trigger_type(RegisterTriggerType::new(
+    match iii.register_trigger_type(RegisterTriggerType::new(
         CHANGED,
         "Fires when a file in the workspace changes, whoever changed it — \
          including an agent that never called this worker.",
         ChangedTriggerHandler {
             subscribers: subscribers.clone(),
         },
-    ));
-    tracing::info!(trigger_type = CHANGED, "registered trigger type");
+    )) {
+        Ok(_) => tracing::info!(trigger_type = CHANGED, "registered trigger type"),
+        Err(e) => tracing::warn!(
+            error = %e,
+            trigger_type = CHANGED,
+            "failed to register the editor::changed trigger type; push updates will not work"
+        ),
+    }
     ChangedEmitter::new(iii.clone(), subscribers)
 }
📝 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
pub fn register_changed_trigger(iii: &Arc<IIIClient>) -> ChangedEmitter {
let subscribers = SubscriberSet::new();
let _ = iii.register_trigger_type(RegisterTriggerType::new(
CHANGED,
"Fires when a file in the workspace changes, whoever changed it — \
including an agent that never called this worker.",
ChangedTriggerHandler {
subscribers: subscribers.clone(),
},
));
tracing::info!(trigger_type = CHANGED, "registered trigger type");
ChangedEmitter::new(iii.clone(), subscribers)
}
pub fn register_changed_trigger(iii: &Arc<IIIClient>) -> ChangedEmitter {
let subscribers = SubscriberSet::new();
match iii.register_trigger_type(RegisterTriggerType::new(
CHANGED,
"Fires when a file in the workspace changes, whoever changed it — \
including an agent that never called this worker.",
ChangedTriggerHandler {
subscribers: subscribers.clone(),
},
)) {
Ok(_) => tracing::info!(trigger_type = CHANGED, "registered trigger type"),
Err(e) => tracing::warn!(
error = %e,
trigger_type = CHANGED,
"failed to register the editor::changed trigger type; push updates will not work"
),
}
ChangedEmitter::new(iii.clone(), subscribers)
}
🤖 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 `@editor/src/events.rs` around lines 188 - 200, Update register_changed_trigger
to handle the Result from iii.register_trigger_type instead of discarding it.
Match the registration outcome, log the success message only when registration
succeeds, and emit a warning with the error on failure, following the existing
observe::bind pattern.

Comment on lines +268 to +299
const { onState, onChanged } = useWorkspaceEvents(host)

// The workspace record lives in `state`, so every buffer and expansion change
// arrives as an event. One read on mount seeds the git overlay; after that
// nothing is polled.
useEffect(() => {
void refreshGit()
}, [refreshGit])

// A change to this worker's `state` scope means the shared workspace moved:
// an agent opened or closed something, or saved. Re-read it once per event
// rather than on a timer.
useEffect(
() =>
onState(() => {
void refreshBuffers()
}),
[onState, refreshBuffers],
)

// A file changed, however it changed. The event carries enough to light the
// row up immediately; the git overlay is refreshed for the marks.
useEffect(
() =>
onChanged((event: ChangedEvent) => {
setFlashed((prev) => ({ ...prev, [event.path]: Date.now() }))
setLastChange(event)
void refreshBuffers()
void refreshGit()
}),
[onChanged, refreshBuffers, refreshGit],
)

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

Push events removed the implicit throttle on refreshBuffers/refreshGit without adding a replacement.

Previously these ran at most once per 3s poll tick. Now onState and onChanged invoke them directly per event, with no debounce/coalescing and no in-flight guard. refreshBuffers already does a full api.open() round trip per open tab (flagged in prior review); for the scenario this feature explicitly targets — "an agent writing files in a loop" — this can now fire many times per second, and unguarded overlapping calls can also let a stale response overwrite a fresher one.

♻️ Proposed fix: coalesce bursts with a short trailing debounce
+  const refreshTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
+  const scheduleRefresh = useCallback(() => {
+    if (refreshTimer.current) clearTimeout(refreshTimer.current)
+    refreshTimer.current = setTimeout(() => {
+      void refreshBuffers()
+      void refreshGit()
+    }, 200)
+  }, [refreshBuffers, refreshGit])
+
   useEffect(
     () =>
       onState(() => {
-        void refreshBuffers()
+        scheduleRefresh()
       }),
-    [onState, refreshBuffers],
+    [onState, scheduleRefresh],
   )

   useEffect(
     () =>
       onChanged((event: ChangedEvent) => {
         setFlashed((prev) => ({ ...prev, [event.path]: Date.now() }))
         setLastChange(event)
-        void refreshBuffers()
-        void refreshGit()
+        scheduleRefresh()
       }),
-    [onChanged, refreshBuffers, refreshGit],
+    [onChanged, scheduleRefresh],
   )
📝 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 { onState, onChanged } = useWorkspaceEvents(host)
// The workspace record lives in `state`, so every buffer and expansion change
// arrives as an event. One read on mount seeds the git overlay; after that
// nothing is polled.
useEffect(() => {
void refreshGit()
}, [refreshGit])
// A change to this worker's `state` scope means the shared workspace moved:
// an agent opened or closed something, or saved. Re-read it once per event
// rather than on a timer.
useEffect(
() =>
onState(() => {
void refreshBuffers()
}),
[onState, refreshBuffers],
)
// A file changed, however it changed. The event carries enough to light the
// row up immediately; the git overlay is refreshed for the marks.
useEffect(
() =>
onChanged((event: ChangedEvent) => {
setFlashed((prev) => ({ ...prev, [event.path]: Date.now() }))
setLastChange(event)
void refreshBuffers()
void refreshGit()
}),
[onChanged, refreshBuffers, refreshGit],
)
const { onState, onChanged } = useWorkspaceEvents(host)
const refreshTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const scheduleRefresh = useCallback(() => {
if (refreshTimer.current) clearTimeout(refreshTimer.current)
refreshTimer.current = setTimeout(() => {
void refreshBuffers()
void refreshGit()
}, 200)
}, [refreshBuffers, refreshGit])
// The workspace record lives in `state`, so every buffer and expansion change
// arrives as an event. One read on mount seeds the git overlay; after that
// nothing is polled.
useEffect(() => {
void refreshGit()
}, [refreshGit])
// A change to this worker's `state` scope means the shared workspace moved:
// an agent opened or closed something, or saved. Re-read it once per event
// rather than on a timer.
useEffect(
() =>
onState(() => {
scheduleRefresh()
}),
[onState, scheduleRefresh],
)
// A file changed, however it changed. The event carries enough to light the
// row up immediately; the git overlay is refreshed for the marks.
useEffect(
() =>
onChanged((event: ChangedEvent) => {
setFlashed((prev) => ({ ...prev, [event.path]: Date.now() }))
setLastChange(event)
scheduleRefresh()
}),
[onChanged, scheduleRefresh],
)
🤖 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 `@editor/ui/src/page/index.tsx` around lines 268 - 299, Replace the direct
refresh calls in the onState and onChanged handlers with a shared short trailing
debounce that coalesces event bursts into one refreshBuffers/refreshGit
execution. Ensure pending timers are cleaned up when the component unmounts, and
prevent overlapping refreshes or stale responses from overwriting newer state.

observer-proof.txt was written by an agent while verifying the
file-change observer end to end, and was committed by mistake. It is
evidence of a probe, not part of the worker.
rohitg00 added 2 commits July 30, 2026 13:59
The read-only view used the console's shared Monaco editor, which is
deliberately chrome-less: lineNumbers off, no minimap, no folding, no
glyph margin. Code read as unstyled text and diffs carried no colour, so
the page failed at the one job it exists for.

Pierre's File now backs a new read view, the default on open, and
FileDiff backs every patch. That replaces a hand-rolled renderer which
printed diff --git, index, and @@ headers as though they were file
content. Monaco stays for the edit view, since pierre is render-only.
Patches in the chat function-trigger message render through the same
component, at zero marginal bytes.

parsePatchFiles is used rather than PatchDiff on purpose: PatchDiff
throws unless the text yields exactly one file with one diff, so a clean
file and a multi-file patch would each take the page down.

Bundling pierre naively costs 10.3 MB, over the console's 8 MiB asset
cap. But 71% of that is @shikijs/langs shipping 347 TextMate grammars
and 12.5% is 65 themes; pierre itself is 350 KB. Aliasing shiki's root
bundle to a 20-language allowlist, emptying the theme catalogs down to
pierre-dark and pierre-light, and stubbing the unused oniguruma wasm
brings the asset to 1.995 MiB, 25% of the cap. No console change was
needed, and no stylesheet: pierre inlines its styles into its own shadow
root.

Two build guards hold that line, both verified to fail on purpose: a
2.5 MiB budget, and a smoke test that renders a TypeScript snippet and
fails the build if it stops tokenising. A 4 MiB budget was measured and
rejected, because moving @pierre/theming's private collection path adds
1.35 MB and still passes it, which is precisely the regression the guard
exists to catch.
Registering configuration stays a required boot step, and should: with
no authoritative config there is nothing to run against, and quietly
guessing limits nobody chose is worse than refusing to start.

Treating a timeout as permanent is the defect. A loaded engine answers
the registration late, the worker exits, and the source watcher restarts
it straight into the same race, so a slow boot becomes a crash loop that
never converges. Seen on a live rig as "registering editor
configuration: invocation timed out" repeating across reboots, with the
worker never reaching the ready state.

Five attempts with linear backoff, then fail carrying the last error
instead of a generic one.

@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: 2

🧹 Nitpick comments (1)
editor/ui/src/page/index.tsx (1)

920-954: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

LocalDiff rebuilds its own API client. The parent already holds api = useMemo(() => createApi(host), [host]); passing api in instead of host would drop the duplicate createApi and the extra prop. Optional.

🤖 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 `@editor/ui/src/page/index.tsx` around lines 920 - 954, Update LocalDiff to
accept the parent’s existing api client instead of host, remove its local
createApi/useMemo setup and unused host prop, and pass the api client from the
parent while preserving the existing diff effect behavior.
🤖 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 `@editor/src/main.rs`:
- Around line 114-119: Update the configuration bootstrap flow around the loaded
result and retry loop to avoid propagating the final error when loading fails.
Preserve the five-retry behavior, then log a warning and use
WorkerConfig::default() as the fallback; retain the successfully loaded
configuration unchanged.

In `@editor/ui/src/page/index.tsx`:
- Around line 876-883: Replace length-only Pierre cache keys with a shared
32-bit rolling content-hash helper. In editor/ui/src/page/index.tsx lines
876-883, add the helper and use it in FileView's cacheKey as path plus
hash(contents); in lines 898-918, use p plus hash(patch) for PatchView's
cacheKeyPrefix; apply the same p plus hash(patch) substitution in
editor/ui/src/function-trigger-message/index.tsx lines 79-103 within Patch.

---

Nitpick comments:
In `@editor/ui/src/page/index.tsx`:
- Around line 920-954: Update LocalDiff to accept the parent’s existing api
client instead of host, remove its local createApi/useMemo setup and unused host
prop, and pass the api client from the parent while preserving the existing diff
effect behavior.
🪄 Autofix (Beta)

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: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 99d71d8d-2ed6-4b33-b934-04d34d10d325

📥 Commits

Reviewing files that changed from the base of the PR and between e76498e and 2d08b00.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (6)
  • editor/src/main.rs
  • editor/ui/build.mjs
  • editor/ui/package.json
  • editor/ui/src/function-trigger-message/index.tsx
  • editor/ui/src/page/index.tsx
  • editor/ui/styles.css

Comment thread editor/src/main.rs
Comment on lines +114 to +119
let cfg = loaded.ok_or_else(|| {
anyhow::anyhow!(
"{}",
last_err.unwrap_or_else(|| "editor configuration unavailable".to_string())
)
})?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Avoid terminating the worker when configuration bootstrap fails.

After five retries, this path exits instead of serving with WorkerConfig::default(). A temporary configuration-worker outage can therefore turn into an editor restart loop. Preserve the retry loop, but fall back to a safe default unless this worker is intentionally exempt from the repository-wide convention.

Based on learnings, worker binaries in this repository should warn and fall back to WorkerConfig::default() when configuration loading fails.

🤖 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 `@editor/src/main.rs` around lines 114 - 119, Update the configuration
bootstrap flow around the loaded result and retry loop to avoid propagating the
final error when loading fails. Preserve the five-retry behavior, then log a
warning and use WorkerConfig::default() as the fallback; retain the successfully
loaded configuration unchanged.

Source: Learnings

Comment on lines +876 to +883
function FileView({ path, contents, themeType }: { path: string; contents: string; themeType: 'light' | 'dark' }) {
const file = useMemo(() => ({ name: path, contents, cacheKey: `${path}:${contents.length}` }), [contents, path])
return (
<div className="ed-reader">
<File file={file} options={{ ...READ_OPTIONS, themeType }} disableWorkerPool />
</div>
)
}

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

Pierre cache keys are derived from content length alone in all three wrappers. Every key handed to pierre's render cache uses contents.length / patch.length as its only content-dependent component, so two distinct payloads of equal length alias to the same cache entry and pierre serves the earlier render. Introduce one cheap content-hash helper (a 32-bit rolling hash is O(n) once per memo recompute) and use it at each site.

  • editor/ui/src/page/index.tsx#L876-L883: add the helper here and build FileView's cacheKey as ${path}:${hash(contents)} so an equal-length edit invalidates the cached render.
  • editor/ui/src/page/index.tsx#L898-L918: pass p${hash(patch)} as PatchView's cacheKeyPrefix instead of p${patch.length}.
  • editor/ui/src/function-trigger-message/index.tsx#L79-L103: same substitution in Patch, where equal-length patches from different feed messages are the most likely collision.
📍 Affects 2 files
  • editor/ui/src/page/index.tsx#L876-L883 (this comment)
  • editor/ui/src/page/index.tsx#L898-L918
  • editor/ui/src/function-trigger-message/index.tsx#L79-L103
🤖 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 `@editor/ui/src/page/index.tsx` around lines 876 - 883, Replace length-only
Pierre cache keys with a shared 32-bit rolling content-hash helper. In
editor/ui/src/page/index.tsx lines 876-883, add the helper and use it in
FileView's cacheKey as path plus hash(contents); in lines 898-918, use p plus
hash(patch) for PatchView's cacheKeyPrefix; apply the same p plus hash(patch)
substitution in editor/ui/src/function-trigger-message/index.tsx lines 79-103
within Patch.

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.

1 participant