feat(edge): Autonomy Edge on the desktop — account, cloud projects, version control and the AI assistant [DOPE-388] [DOPE-572] - #1056
Conversation
…388]
The openplc-web editor authenticates purely by the `httpOnly` cookie Edge
leaves on a shared parent domain, and never handles a token itself. The
desktop renderer is not on that domain, so there is no cookie to inherit:
it has to hold the session. That single fact is why this flow is
token-based against the same API the web build reaches by cookie.
WHAT IS HELD WHERE. The refresh token is the durable half and is persisted
encrypted via `safeStorage`. The access token is kept in memory only and
deliberately never written down — it lives 7 days, so a copy on disk is a
week-long credential for whoever reads the file, and it can always be
re-minted in one round trip.
The store REFUSES to persist when the OS cannot encrypt. On a Linux box
with no keyring there is no key, and `encryptString` either throws or, on
some Electron versions, degrades to plaintext. Writing a bearer credential
to a world-readable JSON file is not an acceptable degradation, so the
session is kept in memory for the run and the user signs in again next
launch.
Three decisions in here are easy to regress and expensive when they break,
and each has a test naming it:
- the HTTP client resolves with the STATUS for every answer and rejects
only when the server never answered. A 401 is a wrong password, a 404
on the subscription route is an account with no plan, and a transport
failure established nothing. Collapsing those is what makes a two-second
network blip report a live session as signed out.
- an unverified address arrives as a 200 with a null access token. Read as
a failure, it sends someone with the right password hunting for a wrong
one.
- refresh tokens are single-use and rotate, so concurrent renewals collapse
onto ONE request rather than leaning on the server's replay window.
Restoring a session across restarts needs no separate step: the first read
renews from disk on its own.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6QpZdQSC511UjhgT1SWyY
…388] Google, Microsoft and Apple, from the desktop. WHY NOT THE SYSTEM BROWSER. Edge's OAuth callback hands the session over as `httpOnly` cookies scoped to `COOKIE_DOMAIN`, then redirects to a URL whose origin must match the server's `EDITOR_URL`. Nothing about the tokens travels in the redirect. So the standard native-app pattern — system browser plus a loopback listener — has nothing to catch: the tokens land in a cookie jar this process cannot read, inside a browser it does not control. Driving the flow in a `BrowserWindow` we own makes the jar ours, and Electron's cookie API reads `httpOnly` values. The window-open interception is what makes the SHARED dialog work here. It renders each provider as a `target='_blank'` link, which is exactly right on the web: the new tab shares Edge's cookie jar, so the session it establishes is the one the editor is already using. Before this, the desktop handed that link to the system browser and the click merely opened Edge — nothing came back, and the user returned to an editor that still said they were signed out. Now the shared component says WHERE to go and the platform decides HOW, with no desktop branch inside the mirrored surface. Completion is detected by the cookies appearing in our jar, not by matching an expected URL: the redirect target is the server's `EDITOR_URL`, which this process has no way to know, and on a desktop install is usually unreachable anyway. `did-fail-load` therefore counts — the cookies were set by the response that issued the redirect, so whether the redirect loaded is irrelevant. A fresh partition per attempt is not a detail: reusing one keeps the previous Google account signed in inside the window, so a user who picked the wrong account could never pick another. KNOWN LIMIT. Google's policy refuses OAuth in embedded browsers and can answer `disallowed_useragent` instead of a consent screen. The desktop-Chrome user agent here is what makes it work in practice, but it is a heuristic against a policy, not a contract. The durable fix is server-side — an Edge endpoint that exchanges a one-time code for tokens, letting this run in the real system browser the way RFC 8252 intends — and that change belongs to autonomy-edge. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6QpZdQSC511UjhgT1SWyY
`EdgeAccountPort` for the desktop, over IPC. Every call crosses to the main
process because that is where the session lives: the renderer is not on Edge's
origin, so it can neither inherit the shared-domain cookie nor issue the
request itself — the same reasoning that already sends the library catalog
through there.
WHY THE SESSION STATE MACHINE LIVES IN THE ADAPTER. On the web it belongs to
the fetch-with-renewal layer, which is the thing that learns a session died.
Here that layer is in the main process, so the renderer never observes a
renewal failing. What it does observe is the ANSWER to "who is signed in", and
that is enough to drive the same state: a definitive `no-session` after a live
one is an expiry, a `signed-in` read is a restoration. Deriving it from the
outcomes the adapter already returns keeps one source of truth instead of a
second channel for the main process to push events over.
Two distinctions in that machine are load-bearing, and both are tested:
- `unknown` — the question could not be asked — must not read as signed out,
or a network blip puts a prompt over a live session holding unsaved work.
- "never signed in" must not be worded as "your session expired", which is a
claim about a session the user never had. `markRestored` therefore clears
`absent` unconditionally: otherwise the initial value survives a successful
read and the NEXT expiry is worded as "you were never signed in" to someone
who demonstrably was.
`getEdgeWebUrl` is exported from the system adapter rather than re-derived, so
both readers resolve the same override; two copies would drift the moment one
gained a fallback the other did not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6QpZdQSC511UjhgT1SWyY
…one [DOPE-388] `hasEdgeAccount` was the only switch the activity bar offered, and it controlled two things at once: the account menu when signed in, and a BLOCKING sign-in dialog when signed out. That pairing is right for openplc-web, where an Edge account is required to reach a project at all. It is wrong for the desktop, which opens local projects from disk and works offline — turning it on there would greet every user who has not signed in with a modal they cannot dismiss, over an editor that needs nothing from Edge. So the distinction becomes explicit. `requiresEdgeAccount` decides only whether the dialog opens by ITSELF; both builds show the same control in the same slot, from the same component. The web keeps its behaviour exactly — the capability is true there — and the desktop offers the way in rather than imposing it. `EdgeSignInModal` gained `onOpenChange`, because a dialog the user opened has to be one they can also close. It stays optional: where an account is required the dialog IS the screen, and letting it close would leave someone looking at an editor with no project and no way back. The signed-out control reuses `ActivityBarButton` with the exit arrow's own `#B4D0FE` at the icons' default `size-5`. Signed out, this is one control among the bar's others and has no reason to look different from them. It is an icon rather than an empty avatar: that falls back to `?`, which reads as something being wrong rather than as a way in. Three comments that claimed the desktop editor has no Edge account are corrected rather than left to mislead the next reader. Mirrored byte-for-byte into openplc-editor / openplc-web; the surface comparison is part of CI. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6QpZdQSC511UjhgT1SWyY
The activity bar's account slot only exists once a project is open, so the screen a user actually lands on had no way in. This is the same account, the same dropdown and the same dialog, in the menu beside New Project and Open. IT NEVER OPENS BY ITSELF, on either build — unlike the activity bar, which does where an account is required. There a project was asked for and could not be reached without a session; here nothing has been asked for, and the start screen is usable with no account at all. Forcing a login onto it would block a screen that works without one. THE WHOLE ROW IS THE TRIGGER, avatar and name together. The name is what a person aims at, and having it outside the trigger meant clicking the obvious place did nothing and the user had to find a 20px photo to reach Sign out. `EdgeAccountMenu` therefore takes an optional `label` and `triggerClassName`; the activity bar keeps its bare avatar, which is an obvious target when it is the only thing in its column. Alignment comes from reproducing `MenuItem`'s geometry rather than borrowing it — the row cannot BE one, because the trigger is already a button and nesting buttons is invalid HTML. The leading glyph is `size-5`, matching this menu's 20px icons rather than the avatar's own `size-7` default. Width is `w-full min-w-48` instead of the `w-48` the other rows use: what lines these up is the left edge and the icon column, not the width, and a fixed 192px left barely 120px for a name. Mirrored byte-for-byte into openplc-editor / openplc-web. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6QpZdQSC511UjhgT1SWyY
…388] Mirror of openplc-web's move. Both directions of the Autonomy Edge file envelope now live in one shared module instead of inside the web adapter, which is what lets this repo read and write a cloud project without a second copy of the same format. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6QpZdQSC511UjhgT1SWyY
…sktop [DOPE-388] The cloud round trip: list what the account has, read one into the editor, write it back. ONE PLACE DECIDES WHICH WORLD A PROJECT BELONGS TO. `project.meta.path` is the single identifier every save flows through, and `isCloudProjectId` answers it by shape: local projects are always absolute filesystem paths, a cuid never is. Deciding that way rather than by a prefix we invent keeps the cloud identifier byte-identical to the API's own — which is what lets the SHARED save flow drive both worlds without a line of change. `saveFile` receives `projectId/relative/path` for a cloud project, exactly the contract the web adapter already uses, and an absolute path for a local one. The cloud reader returns the same `RawProjectFiles` the filesystem reader does, so the parsing after it is identical and nothing downstream knows the difference. `canEdit` comes from the server's own capabilities rather than being assumed: a project shared read-only must not offer a save that will be refused. A PARTIAL SAVE IS READ-MODIFY-WRITE, and the read is mandatory. The backend deletes by omission, so sending only the file that changed would wipe the rest of the project. There is a test for exactly that, and another asserting no write happens at all when the read failed — writing then would send an envelope built from nothing. `edgeAuthedRequest` is exported from the account service rather than reimplemented here, so renewal, the single-flight guard and the one retry on a revoked token live in one place. A remote list is narrowed field by field: a row missing an id would otherwise become a card that does nothing when clicked. Verified against api-staging end to end, not just in unit tests: sign in, list, open a real 8-POU project (`canEdit: true`), save a POU back, and re-read to confirm the content is byte-identical and no file was lost. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6QpZdQSC511UjhgT1SWyY
…screen [DOPE-388] Five at most, above the local Projects section. Clicking one opens it in the editor, and from there it edits and saves like any other project. Above the local list on purpose: this is the only place in either product where a person sees what is on their machine and what is on their account side by side, and reaching one from the other is the point. The existing filter box covers both sections, because someone searching for a project does not care which side of the line it is on. Opening goes through `openProjectByPath`, exactly as a local project does — the adapter decides which world the identifier belongs to, so neither this component nor the save flow afterwards knows the project came from the cloud. IT DOES NOT ASK WHO IS SIGNED IN. A second account hook beside the menu's would mean a second `/auth/me` on every start, and an empty list already means "nothing to show" — signed out, offline, or an account with no projects. So the section hides itself, and subscribes to the session's own restored/expired signal instead: the list appears the moment someone signs in through the menu and empties when they sign out, with no polling and no extra request. Five is a shortcut to recent work, not a project browser. Edge's own SPA is where someone goes to see everything. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6QpZdQSC511UjhgT1SWyY
…wn [DOPE-388] Found by running the app, not by reading it. The preload bundle and the renderer bundle are built separately, and a renderer newer than the main process called `edgeProjectsListRecent`, a channel that did not exist yet. The rejection escaped the `useEffect` that loads the list and took the WHOLE start screen with it — a full-screen React error overlay, local projects included. Two guards, because the failure had two halves. The adapter answers an empty list when the bridge has no such channel, which is the honest answer for a main process that predates the feature. The component catches anyway, because that `await` is the only thing between a failed IPC call and the screen a user lands on. A cloud list nobody asked for must never cost someone their local work. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6QpZdQSC511UjhgT1SWyY
…DOPE-388] The cloud card sat flush against the "Projects" heading below it, so that heading read as a label for the cards above rather than the start of its own section. `mb-10` (40px). Deliberately larger than the `mb-6` (24px) that separates a heading from its own cards: the gap BETWEEN two sections has to beat the gap INSIDE one, or the grouping is ambiguous. Measured at 40px in the running app. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6QpZdQSC511UjhgT1SWyY
…[DOPE-388] "Cloud" earns its place: the heading sits directly above the local "Projects" section, and the whole point of the two being adjacent is that a person can tell at a glance which side of the line a project is on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6QpZdQSC511UjhgT1SWyY
…DOPE-388]
The heading now stays put whether or not anyone is signed in, and a signed-out
user reads what the space is for: "Sign in with your Autonomy Edge account to
access Edge features".
THE TEXT COULD NOT BE CORRECT WITHOUT CHANGING THE CONTRACT, which is most of this
commit. `listRecentCloudProjects` used to answer with an array, and an empty one
meant three different things: nobody is signed in, the account has no projects,
and Edge could not be reached. Inviting a sign-in on "empty" would therefore tell
a signed-in user with an empty account to sign in, and — worse — tell someone who
is signed in and merely offline to go and fix their session. It is the same class
of bug `EdgeUserRead.unknown` exists to prevent, and the fix is the same shape: a
discriminated `CloudProjectsResult` with `ok` / `signed-out` / `unreachable` /
`unavailable`, so the caller can say the right thing.
One sentence per state, and each one is doing a job:
- signed out: the invitation.
- unreachable: names Edge as the problem and says the local projects below are
unaffected — which is what someone on this screen actually wants to know.
- empty account: points at Edge to create one, rather than implying a login is
missing.
- filtered to nothing: says the SEARCH found nothing, not that the account is
empty.
- first answer still in flight: nothing at all. A returning user's stored session
is usually about to resolve, and flashing "Sign in" at them first is worse than
a beat of nothing. The heading holds the space.
`unavailable` is the one case that still renders nothing: a main process predating
this feature has no such channel, and offering a sign-in that cannot help would be
worse than staying quiet.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6QpZdQSC511UjhgT1SWyY
…on [DOPE-388] A line of grey text converts nobody. Signed out, the reserved space now carries a tinted card with a headline, the reason an account is worth having, a primary **Sign in** button that opens the dialog in place, and a **Create an account** link for the visitor who has none. Connecting people to Edge is the point of this section existing at all. Tinted with the brand rather than a warning colour, because it is an invitation and not a problem. `blue-500` for the tint and not `brand`: the brand token is a `var()` holding a hex and Tailwind 3 cannot reliably apply an opacity modifier to it — the same substitution the account menu already makes, the same colour. Its own `EdgeSignInModal` instance, which is fine: this card and the account row in the menu are both signed-out-only, so a user reaches one or the other, never both at once. ALSO HARDENS THE SKEW GUARD, because running it caught a real failure. The adapter already refused a MISSING bridge channel; it now checks the returned SHAPE too. An older main process answers this call with a bare array, which falls through every branch of the section's state machine into "no cloud projects yet" — telling a signed-out user their account is empty. That is not hypothetical: it is exactly what a stale bundle showed on the first run of this code, while the menu beside it correctly said "Sign in". Verified in the running app: the card renders, the button opens the dialog, and the bridge reports `signed-out` rather than an empty account. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6QpZdQSC511UjhgT1SWyY
…t [DOPE-388] The card stopped at `max-w-xl` while the folder grid below it ran the full width, so the reserved space read as a narrow notice parked in a wide empty area rather than as the section it stands in for. Full width now, and centred: icon above the headline, the copy under it, the actions beneath. Measured in the running app — the card and the local Projects section share the same left and right edges (304 to 1292, inside the `pr-9` both sections carry). The CARD stretches; the SENTENCE does not. It keeps `max-w-xl` and centres inside the card, because a line of body text a thousand pixels wide is genuinely hard to read and widening the container is not a reason to widen the measure. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6QpZdQSC511UjhgT1SWyY
…-388] Two sentences instead of one joined by a dash. The em dash reads as machine-written to the people who will see this screen, and a sign-in invitation is the last place to spend credibility. Applies to user-visible copy specifically; code comments keep theirs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6QpZdQSC511UjhgT1SWyY
…e [DOPE-388] The 537 lines that turn two file versions into a node/edge diff lived in `backend/web/`, reachable only by the web build. The desktop needs the same answer for the same bytes, and copying them would have meant two implementations of a diff that must agree. Moved to `backend/shared/utils/`, which both products compare file by file. It only ever imported `@xyflow/react` types, so nothing about it was web-specific — it was in the wrong folder. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6QpZdQSC511UjhgT1SWyY
…OPE-388]
The editor already had every component — the shared surface carries the branch
switcher, the source-control panel, the stash and history sections — but no
implementation behind them: the adapter threw `not supported` on all seventeen
methods.
They now reach the same seventeen Edge routes the web build calls. That is the whole
design: the git repository lives beside the project on the server, so `carry`
conflict detection, stash semantics and restore stay implemented once, on the
server, and the two products cannot drift apart under load.
REBUILDING THE TYPED ERRORS IS THE POINT OF THE ADAPTER. The UI branches on
`error instanceof SwitchBranchCarryConflictError`, and IPC structure-clones the
value — the prototype does not survive, so every `instanceof` would quietly answer
false and a blocked branch switch would look like a button that does nothing. The
main process reports failures as data (`VersionControlFailure`) and the adapter
builds the real error back.
Scoped to cloud projects, which is what `isRemoteProjectPath` in the shared gate
enforces: a project opened from disk has no repository anywhere, so offering it
branches would be offering a button that cannot work. On the web every project is
an Edge project, so the term is always true there and nothing changes.
Verified against staging: all seventeen routes exercised. Two findings worth
recording — the working-tree routes really do reject a `branch` query param
("property branch should not exist"), which is why the adapter drops it as the web
adapter does; and `preview-switch-carry` answers 404 because of route ordering in
the Edge backend, which affects the web editor in production too and is not fixed
here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6QpZdQSC511UjhgT1SWyY
…its teardown [DOPE-388] "View all files" was reachable in the editor and had nowhere to go. The web opens `/history` in a new browser tab; the desktop has no router, so `openInNewWindow` produced a BrowserWindow onto a route that does not exist — an empty window in development and a missing `file://` in a packaged build. Rather than write a second screen, the page body moved to a shared `CommitHistoryView` that takes `onBack`/`onRestored` instead of navigating. The web is now a 35-line router wrapper over it; the desktop renders the same component as a layer over the workspace. One screen, both products. The platform difference sits where the port was designed to put it: the editor navigation adapter intercepts `/history` and turns the request into store state. The web keeps its real tab — this does not degrade it to an overlay. ALSO FIXES AN UNCAUGHT CRASH. `@monaco-editor/react` 4.7 disposes the two text models before the widget still holding them, and Monaco answers with an uncaught error that covers the screen the instant a diff unmounts. The defect was always there; only the web escaped it, because closing a browser tab tears the page down first. `keepCurrent*` now stops the library disposing anything and the teardown here does it in the order Monaco requires — order-independent, since whether React reaches this cleanup before the library's is its own business. Verified in the running app: no second window is created, the screen renders 17 files with its tree and search, and closing it with Monaco mounted leaves no error and no leaked model. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6QpZdQSC511UjhgT1SWyY
…e editor [DOPE-388] "Upload to Cloud" on a local project card, offered only to someone signed in — a menu entry that opens a dialog just to say "sign in first" is worse than no entry. Drives the same endpoint Edge's own import dialog does: `POST /projects/import`, multipart, with a zip and a destination folder. The difference is what the user has to do. On the web they are told to compress the folder themselves; here the project is already on disk with a path the editor holds, so the editor makes the archive. The destination is a tree rather than a dropdown, because choosing where a project lands is the decision the dialog exists for and a collapsed control hides the very structure being chosen from. Native radios underneath carry the keyboard navigation and screen-reader semantics. Private is preselected: publishing someone's control program to the world is not a default anyone should get by pressing Enter. Server limits are mirrored locally so a doomed upload fails before the user waits out a zip and a slow connection. Files the importer would not accept are dropped rather than fatal — a stray `.DS_Store` is no reason to refuse to publish someone's work — while a missing `project.json` is fatal, with its own message. A dropped connection is NOT reported as failure. The import is not idempotent, so an unanswered POST may have created the project; the copy tells the user to check Edge before retrying instead of inviting a duplicate. The publish is announced upward so the cloud list re-reads: the new project belongs at the top of a sibling section that cannot observe this. Verified against staging, including a real local project: 201, correct visibility, and the project landed in the nested folder chosen (`gitPath` confirmed). Test artifacts created were deleted afterwards. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6QpZdQSC511UjhgT1SWyY
…e row while loading [DOPE-388] Two problems found by driving the running app. THE MENU ENTRY LAGGED A SIGN-IN. Every consumer of `useEdgeAccount` holds its own state, and only the one whose dialog performed the sign-in called `refresh`. So the project card menu deciding whether to offer "Upload to Cloud" kept the signed-out answer until it happened to remount — which is why quitting the app, or opening a project and coming back, looked like the fix. The session already broadcasts this: a read that finds a user calls `markRestored()`. The hook now listens, which is the same signal the cloud project list uses. Not scoped to the signed-out state: a consumer that already believes someone is signed in still needs to re-read, because the account that just signed in may not be the one it was showing. THE CLOUD SECTION LOOKED EMPTY WHILE LOADING. It held the space with a blank 52px box, so the section read as empty rather than busy and the local projects below jumped when the real cards arrived. Placeholder cards the same size as the real ones now hold the row — three, which is enough to read as a row without claiming a count. Verified in the running app: the entry appears at the instant of sign-in with no remount, and its label is the brand blue (`rgb(4, 100, 251)`). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6QpZdQSC511UjhgT1SWyY
…ng the app [DOPE-388] Clicking "Merge" on a branch closed the open project and dropped the user on the start screen, unsaved edits gone. Reproduced in the running app: `location.href` ended at `/merge` and the workspace was empty. The cause was the editor navigation adapter treating an unknown in-app route as something to navigate to. Inside the Electron renderer, assigning `location.href` reloads the SPA shell — a deterministic outcome, as its comment claimed, but the outcome was discarding someone's work with nothing on screen to explain it. It now declines, and warns with the path so the missing interception is findable. Worth stating plainly: the broken path predated this branch, but nothing could reach it while `hasVersionControl` was false on the desktop. Turning version control on is what made it reachable, so this is a hole opened by that change. External URLs still open a window — that is how the editor reaches Edge's sign-up and profile pages, and refusing them would have traded one broken affordance for another. The entry itself is now withheld rather than left dead, behind a `hasBranchMerge` capability that is off for the desktop and on for the web. Deliberately NOT gated on `hasVersionControl`: the desktop genuinely has version control, and folding the two together would either take branches away from it or hand the entry back. Not rendered disabled either — a greyed-out row still promises a screen this build does not have. Three tests asserted the old behaviour, including one written earlier on this branch. They now assert the refusal, and say in their own comments that what they used to check was the defect. Merge remains unavailable in the editor. Porting the screen is a separate, much larger piece of work: the web page is 889 lines wired to its own API layer, and the port exposes neither merge nor branch-diff. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6QpZdQSC511UjhgT1SWyY
The correctly-ordered disposal lived inline in `FileDiffView`, which was enough while that was the only place Monacos diff editor was mounted. It is not going to stay the only place, and one copy per call site is exactly how the crash it prevents comes back. Moved to `use-diff-editor-teardown`, together with the model-path convention that keeps two mounted editors from sharing one pair of models. No behaviour change: `FileDiffView` does the same thing through the hook, and its nine tests pass untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6QpZdQSC511UjhgT1SWyY
Merge was the one version-control operation that never went through the port. The web page called its own API layer directly, so implementing the port could not have brought it along, and on the desktop the entry pointed at a route that did not exist — pressing it reloaded the renderer and closed the open project. The port now carries `getBranchDiffWithBase` and `mergeBranches`, with `MergeConflictError` for the 409 that asks for a decision per conflicting file. The desktop rebuilds that error after IPC, for the same reason the carry and stash conflicts do: a class does not survive a structured clone, and the screen opens its resolver on the type. The page body moved to a shared `BranchMergeView` taking `onBack`/`onMerged` instead of navigating, with its conflict resolver alongside it. The web is now a thin router wrapper; the desktop renders the same component over the workspace, and its navigation adapter intercepts `/merge` exactly as it already did `/history`. One screen, both products. Completing a merge reloads the project: the branch moved on the server, so what is in memory is behind it. VERIFIED IN THE RUNNING APP, twice, against staging. Created a branch through the UI, committed a change to it, opened merge from the branch menu, and completed it with "Merge and Delete". The server shows both merge commits on main, the change present, and both source branches gone. The first run also found a defect this brought in: the screen mounts Monaco directly, in two places, so closing it raised "TextModel got disposed before DiffEditorWidget model got reset" — the crash the earlier fix had only closed for `FileDiffView`. Both editors now use the shared teardown, and the second run closed with no errors at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6QpZdQSC511UjhgT1SWyY
The active branch is client state, one entry per project in `localStorage`, and nothing
checked it was still real. A branch deleted anywhere else — the web editor, another
machine, or a merge that removed its source — left the status bar naming it indefinitely.
Not merely cosmetic: the history section passes that name straight into
`listCommits({ branch })`, so a stale name means querying a branch the server no longer
has. The bar now reconciles against the real list on open and falls back to the default
branch, which is where the server puts a working tree whose branch went away.
An empty list is treated as "learned nothing" rather than "everything is gone", and a
failed request leaves the remembered name alone — resetting someone off their branch
because the network blipped would be worse than the staleness.
WHAT THIS DOES NOT FIX, and cannot from here. If the remembered branch still exists but
the servers checkout moved to a different one, the two stay out of step: the API reports
`defaultBranch` and each branchs head, but never which branch is checked out. Closing that
needs the backend to say so. Forcing a `switchBranch` on every project open was the
alternative and is worse — a write on open, and with `discard` it could throw away
server-side edits nobody asked to lose.
Verified in the running app, both directions: a planted name that does not exist is
corrected to `main` in the bar and in storage, and a planted name that does exist is left
untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6QpZdQSC511UjhgT1SWyY
…E-388] Saving a cloud project from the editor re-serialised the whole thing. Same meaning, different bytes — formatting, key order, whitespace — so a real project went from 62KB to 147KB and git reported every file as modified against HEAD. The web has never done this: it keeps each files bytes as loaded and echoes them back for anything the user did not edit. `pickContentForSave` is shared and has always known how, but it needs two maps per path — the serialization taken at load time and the bytes as they arrived — and the editor populated neither. It had no caller of `initBaseline` at all. So the bytes now travel: `readCloudProject` returns them, keyed the way the save flow asks (the same keys the web adapter builds), the open funnel keeps them, and the workspace screen establishes the sync point. KEYED ON THE RAW MAPS IDENTITY, not on the project path. A branch switch, restore, discard or stash reloads the same project: the path does not change but the loaded bytes do. The web is untouched in behaviour: it establishes this in its router page before the workspace mounts, so the guard finds it already done and leaves it alone. The condition is about state, not about which product is running. MEASURED IN THE RUNNING APP, against staging, by saving the same project twice: before 194285 -> 395993 bytes project.json 892 -> 960 11 files modified after 194285 -> 186181 bytes project.json 892 -> 892 1 file modified The one remaining change is `deleted README.md`, which is a separate and still-open defect — the save envelope carries no README, in either product. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6QpZdQSC511UjhgT1SWyY
[DOPE-388] The editor's project adapter rebuilt the opened-project payload
field by field and dropped `canEdit`. The store then fell back to "editable",
so every read-only guard the shared screens rely on was dead on the desktop:
a viewer of someone else's public cloud project got the full editing surface,
saved, and only learned the server disagreed when the write came back refused.
The web adapter never had the gap — it passes the parsed envelope straight
through. Staging sends the field (`capabilities: {"canEdit": true, ...}`);
the desktop simply threw it away on the way in.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6QpZdQSC511UjhgT1SWyY
[DOPE-388] Monaco cancels pending work by rejecting with an error it names `Canceled`, and every debounced contribution holds a Delayer whose promise is rejected on dispose with no catch attached. An editor torn down while one is armed leaves an unhandled rejection behind. Stashing from the source-control panel does exactly that: the stash reloads the project, the reload unmounts the open POU editor, and the word-occurrences highlighter's Delayer rejects. In a dev build the result is a full-screen overlay that sits above everything and swallows every click, so the app looks frozen until it is dismissed by hand. Two layers, because one cannot do it alone. The runtime guard suppresses the rejection wherever the app runs, but it cannot silence the dev overlay: the dev-server client registers its listener when the bundle boots, so it always runs first and `preventDefault` does not stop it. The overlay is filtered in the dev-server config instead. Both are deliberately narrow — only Monaco's own `Canceled` name is matched, and any other rejection still surfaces. The one existing workaround for this rejection turns the highlighter off outright, which is fine for the JSON manifest it guards and wrong for a POU, where highlighting a variable's other occurrences is the point. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6QpZdQSC511UjhgT1SWyY
[DOPE-388] All three described a desktop that no longer exists: a navigation adapter that fell back to `location.href`, and a build with version control but no merge screen. The merge screen is shared now and the adapter refuses unknown in-app paths outright. Each keeps the reason the code is shaped the way it is — the guards are still right, and the failure they prevent (an entry pointing at nothing, which used to close the open project) is why they stay. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F6QpZdQSC511UjhgT1SWyY
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (2)
Included review availability: Your plan provides up to 2 included reviews per hour; 0 remain after this review. WalkthroughThe pull request adds desktop Edge authentication, encrypted session storage, cloud project listing and upload, version-control transport, graphical diffs, branch merge and history screens, IPC validation, and Monaco editor cleanup. ChangesEdge account and cloud workflows
Version control and workspace
Supporting editor changes
Priority: ➖ Normal — Schedule the desktop Edge integration because it broadly adds account support, cloud project synchronization, uploads, and version control, with medium product severity. Estimated code review effort: 5 (Critical) | ~120 minutes Severity of issue fixed: Medium Sequence Diagram(s)sequenceDiagram
participant User
participant Renderer
participant MainProcess
participant EdgeAPI
User->>Renderer: Open cloud project or version-control screen
Renderer->>MainProcess: Invoke validated IPC operation
MainProcess->>EdgeAPI: Send authenticated request
EdgeAPI-->>MainProcess: Return project, diff, merge, or failure result
MainProcess-->>Renderer: Return serializable result
Renderer-->>User: Render project, diff, conflict, or error state
Merge Risk: 🟡 Moderate · up to This change adds cloud project saving and account-backed editor workflows, but unresolved save-reporting, IPC failure handling, and upload-memory behavior can cause lost edits or disrupted desktop sessions. These issues should be resolved before merge. 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 2📝 Generate docstrings 💡
🛠️ Fix failing CI checks 💡
🧪 Generate unit tests (beta)
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. A rabbit packs the project tight, Comment |
[DOPE-388] The CI format and lint checks run `prettier --check` and `eslint`
over `./src/**/*.{ts,tsx}`; both were failing on files this branch added.
Formatting only, no behaviour touched, and the shared surface still compares
byte-identical between the two repos.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F6QpZdQSC511UjhgT1SWyY
|
Thank you both — @JoaoGSP and @JulioSergioFS, and @marconetsf for the first round. Every finding is answered below, including the ones we decided against. Four rounds went by without a reply on the threads and that was wrong of us; this closes them. I have split the findings by who introduced the defect, because it changes what each one means. Three of the majors were already in Defects this branch introduced
Defects that were already in
|
| Finding | Where it came from |
|---|---|
R3 · open-external-link hands any URL to shell.openExternal |
The handler was added in October 2024 and reorganised into its current name in June 2025; this branch never touched it. Our earlier fix guarded the wrong door — the window-open handler — while the editor opens links over IPC. Both are guarded now, through one shared helper. |
| R3 · full save deletes unmodelled files, on the web | saveProject on development already built the envelope from the store and posted it without reading. The round-2 body listed the deleted README as known and shared behaviour, which was accurate then. Fixed on both sides now. |
| R3 · the merge failure message is lost | The web page this replaced read err.response?.data.message, which is also the root; Edge nests the Nest body under error, so the message was already being dropped before the move. The new adapter inherited the assumption rather than inventing it, and the old page is deleted by this branch. Unwrapped now, and only when error is an object so a bare body still reads. |
| R3 · the Monaco cancellation guard only caught rejections | The guard module itself landed on development in August with an unhandledrejection listener. The synchronous rethrow reached window as an error event and still surfaced as a runtime error overlay, which blocked the end-to-end run. The second listener is ours. |
Corrections to the pull-request body
The round-3 review is right that the body overstated one fix, and that is on me. It listed "unknown keys survive the round trip" as done when only saveFile did. The body is corrected: the item now says which save path did what, and the fix above closes it.
Not fixed, with the reason
- R1-10, the theme effect in
branch-merge-view.tsx. It came over verbatim from the web page, where it is that page's own theme sync when loaded standalone. In the editor it is redundant with the DisplayMenu and idempotent. Removing it changes the web's standalone behaviour, so it belongs to a change the web team can see, not to this one. - R2-9, unknown SSE frames dropped on the desktop. Deliberate. Every frame is validated at the IPC boundary and an unknown
typehas no member of the typed union to travel as, so forwarding it would only move the drop into the renderer. A comment now says so. - R2-12,
aiCountaccepts alimitthe DTO caps at 50. Latent: no caller passes one. If one appears, the 400's reason now survives, because of the unwrapping fix above. - R3-F, splitting
BranchMergeView(737 lines) andCommitHistoryView(348). Agreed, and taken as a follow-up ticket rather than done here: both were validated end to end this week, and a refactor of that size would put that evidence back in question. The shape is inherited from the pages they replaced. - R3 · "path encoding in the sibling modules". Checked all three:
edge-projectsandedge-aialready encode every interpolated segment, andedge-project-uploadinterpolates nothing into a path. Nothing to change — happy to be shown a case I missed. - R3 · "five still web-only test suites". I find one,
backend/shared/ethercat/__tests__/softmotion-e2e.test.ts, and the editor carries two ethercat suites of its own. If you have the other four, send them and they go across.
How the fixes were verified
The whole flow was driven again today against api-staging, on the merged branches, after every fix above.
The README.md case was set up deliberately: a file-based README.md was written to the cloud project through the API, outside anything the editor generates, and then put through a full save, a stash, a pop, a discard, a merge and a save that added a new file. It survived all six, and the project's root keys stayed README.md, devices, pous, project.json throughout. Before the fix the first full save removed it.
The rest of the run: sign-in, a new local project, the assistant writing a tank-level controller on the HYSTERESIS library block, Upload to Cloud, textual diff, commit, branch, an assistant edit on the branch, commit, stash, pop, discard, merge back into main through the merge screen, a ladder POU created and saved with its graphical diff opened, commit, conversation history listing and reloading an earlier conversation, and sign-out leaving the assistant showing a sign-in button rather than an error. ACU moved from 1192.39 to 1538.34.
Gates on the merged branches: editor 447 suites / 9,410 tests, web 410 files / 8,573 tests, every configured coverage threshold met in both, validate:arch clean, both typechecks clean, compare-surfaces total_diffs: 0 over 1,170 files.
Two things the run could not drive, and both are instrumentation rather than product. The ladder canvas does not respond to synthetic pointer events, so the graphical diff was exercised on an added-but-empty POU, where it correctly reports no graphical changes; the run of 2026-09-13 covered a ladder POU with real content. And the Changes panel refetches on mount, so it can read "No changes" while the activity-bar badge already shows the right count, until the tab is revisited. The badge is the one reading from the server.
Still open, and it is the thing that gates the merge
The Requirements Gathering document and the two Cybersecurity Risk Assessments are written and waiting on one decision: who reviews them technically. They will be published under Products › OpenPLC Editor › Demands with the assessment per repository, and DOPE-569, DOPE-513 and DOPE-572 updated in the same move. That is the last item, and it is ours to close.
Merge mechanics
The screenshots are out of the tree but still reachable in this branch's history, 4.6 MB across 46 objects. A merge commit carries them into development permanently and a squash does not, so the pair should be squashed unless someone wants the intermediate commits. development has been merged into both branches so the surface gate compares like with like.
One more thing, found while verifying your round-3 points and worth knowing about: the surface check itself was searching for the mirror pull request among the ten most recently opened ones, so this pair stopped being found once ten newer ones appeared in front of it, and every shared file read as a mismatch. It now looks the mirror up by branch name.
`call()` only reaches an `on409` handler when the status is already 409, so gating those handlers on a `hasConflicts` flag was both redundant and harmful: Edge rethrows a conflict as a plain Nest `ConflictException`, which carries no such flag, and its exception filter wraps the body under `error` — so the handler read an absent field at the wrong depth, returned null, and the typed conflict fell through to a generic HTTP failure. `carryConflict` and `mergeConflict` now return the conflict unconditionally and read only the detail from the body. `messageFromBody` unwraps the same envelope, matching what the web adapter already does, so a wrapped non-409 failure surfaces the server's reason instead of "Autonomy Edge answered N." Raised by @JoaoGSP in review round 3 and left unfixed in round 4. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Thanks for the write-up — and for splitting the findings by who introduced them. That distinction is worth having on the record, and I agree with where you drew the lines. Both majors check out at You asked for two things. 1. The dot-segment caseYou are right that So a single Reproduce with: node -e "for (const id of ['..','.','']) { const p='/projects/'+encodeURIComponent(id)+'/details'; console.log(JSON.stringify(id), p, '=>', new URL(p,'https://api.example.com').pathname) }"Two things keep this off the majors list: 2. The four test pathsAt the current heads these five are in The last one is the one you found. On the ethercat point: the editor does carry two suites in that directory, Of the other four, To reproduce the list: diff <(git -C openplc-editor ls-tree -r HEAD --name-only -- src/frontend src/middleware/shared src/backend/shared | grep -E '__tests__/|\.test\.' | sort) \
<(git -C openplc-web ls-tree -r HEAD --name-only -- src/frontend src/middleware/shared src/backend/shared | grep -E '__tests__/|\.test\.' | sort)One thing your fix did not fully close
It needs Edge to return a hostile value, so it is not urgent — but Nothing else from my side. The remaining items on my round-3 list are the documentation set, which you have already called the last gate, and the two component splits you have taken as a follow-up — I agree with deferring those rather than disturbing a validated branch. |
A stricter pass than the earlier one. A comment survives only if it states something the code cannot: an ordering constraint, a protocol quirk, a workaround for external behaviour, a reason it is not written the obvious way. Everything else goes, and what stays fits in 250 characters. Removed: section banners and ASCII dividers, module-opening essays, docstrings restating a signature TypeScript already declares, step narration, history and ticket keys, and commented-out dead handlers. Three comments were lying and are gone rather than shortened: a block in `ports/types.ts` documenting a `legacySlaveId` field that no longer exists, the `version-control-port.ts` docstring claiming the editor adapter is a no-op, and three JSDoc blocks in `ipc/main.ts` orphaned above symbols they never described. Scope is this branch's own comments. Comments already on `development` were restored where the pass had reached them, so the diff carries no unrelated churn. Comment text only: every file was re-printed from the TypeScript AST with comments stripped and compared against the previous commit, and no code token differs. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…king directory A cloud project is identified by its Edge id, not by a path, so every `join(projectPath, 'build', …)` produced a RELATIVE path. Node resolved it against `process.cwd()`: the repository root under `npm run dev`, where the file watcher then reloaded the renderer and closed the open project mid-build. Packaged, `cwd` is wherever the app was launched from, so the artifacts land somewhere arbitrary or the write is refused outright. Reproduced against api-staging: opening a cloud project and building left a 1.7 MB `<edge-id>/` directory in the repository root, 55 watcher events, and the editor back on the start screen. A directory from 2026-08-24 was still there, invisible to `git status` because everything under it matches `build/` in `.gitignore`. `resolveBuildWorkspace` sends a non-absolute project path to `userData/cloud-builds/<id>` and returns an absolute path unchanged, so local projects keep building beside their sources and stay incremental. The root is emptied at boot: the cloud is the source of truth for those projects. The two debug-map readers in `ipc/main.ts` resolve the same way. They were the reason a build could succeed and the debugger still fail on a file that was never going to be at that path. The project path itself is untouched. It is the identity a cloud project is addressed by — `isRemoteProjectPath` gates the save, version control and the assistant on it — so only the build's output moved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…esktop-editor-cloud-login-project-sync-and-ai-on-cloud-credits # Conflicts: # src/frontend/store/slices/shared/slice.ts
…rash Typing in a cloud project's ST POU with AI autocomplete on and pressing Tab filled the screen with a "Canceled" crash and a stack of pure Monaco internals (`DisposableStore.clear` → `CancellationTokenSource.cancel` → `Emitter.fire`). Nothing was broken. Disposing an inline-completion session with a request in flight cancels the token, and the promise racing it rejects with nothing behind it to catch — Monaco's ordinary path, and the stack on screen was that error's creation stack, not a failure site. The guard for this already existed and called `preventDefault()`, which drops only the browser's own reporting. `@pmmmwh/react-refresh-webpack-plugin` registers its own `window` listeners, never reads `defaultPrevented`, and — measured, not assumed — registers ahead of ours, so `stopImmediatePropagation` cannot reach it either. Its overlay is switched off instead: compile errors still surface through `devServer.client.overlay`, where this repo had already set `runtimeErrors: false`, so the two now agree rather than contradict. The guard keeps `stopImmediatePropagation` for listeners that register after it, and a real error is still reported — there is a test for each. Dev-only: the plugin is absent from the production renderer config, so no release was ever affected. Verified from a clean rebuild against api-staging: the reported sequence, then 15 rapid `if`+Tab with a request in flight, then 6 POU switches mid-request — no overlay in any of them, editor still functional. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ng directory
`ESIService.getEsiDir` joined straight onto the project path. For a project on
disk that is absolute and correct; for a cloud project the path IS the Edge id,
so `join('<id>', 'devices/esi')` is relative and Node resolves it against
`process.cwd()`. Uploading an ESI file wrote `repository.json` and the XML into
the repository root under `npm run dev`, and packaged, into whatever directory
the app happened to be launched from.
Same defect class as the compile pipeline writing `build/` into the working
directory; that fix covered the compiler and the debug-map readers, not ESI,
which reaches disk through its own service.
The obvious move was to reuse `resolveBuildWorkspace`, and it would have traded
a misplaced write for silent data loss: that root is emptied at every boot, which
is right for regenerable build output. ESI is not regenerable — `esi` appears
nowhere in `api-envelope.ts` or `edge-projects`, so the repository is uploaded by
hand and exists only on this machine. Hence a second, persistent root,
`userData/cloud-projects/<id>`, asserted in tests to be distinct from the build
one so a later refactor cannot quietly merge them.
An absolute path is returned unchanged, so a local project keeps its ESI files
beside its sources. The id becomes one safe path segment, hashed if it is not
already one, so no project path can escape the root.
Verified from a cold start against api-staging, caches and bundles rebuilt: the
repository root stays clean, the files land under `cloud-projects/<id>`, a local
project is unaffected, and after a restart the ESI repository is still there
while `cloud-builds` is empty.
`docs/cloud-project-local-files.md` records the reproduction, the reasoning, and
the four sites of the same class still open — `compileLibrary` and the library
build port write, and five compiler reads return empty for a cloud project,
which is how a build produces firmware with no IO.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The item carried no `onClick` — clicking it did nothing, on either build — so this removes an affordance, not a feature. The `VideoIcon` import went with it; nothing else references that icon. Shared surface, so it leaves the web start screen too, where the item was equally inert. The File menu's "Tutorials and examples" submenu is a different thing and is untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`monaco.editor.setTheme()` is global to the namespace, not to an instance, so the diff viewer asking for `vs`/`vs-dark` replaced the app's own theme everywhere. The POU editor behind it kept rendering with no rule for the ST tokens, and never recovered: its theme effect depends only on `shouldUseDarkMode`, which had not changed. Toggling dark mode by hand was the only way back. Every diff now asks for `openplc-light`/`openplc-dark` and registers them on mount, since a diff can be the first Monaco on the page. The POU editor also re-asserts its own theme when its tab becomes active, so it recovers from any editor that sets a theme, not just these four. `file-diff-view.tsx` predates this branch; the two `branches/` files are new here and replicated its pattern. Reproduced and measured on a cold build against staging, with the fix taken back out to confirm causation. Documented in docs/monaco-theme-leak.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… save Edge marks a project beyond the plan's private-project limit as locked and disables "open in editor" for it. The desktop knew none of that: the list dropped every field but id, name, language and date, so a locked project looked like any other. It opened, showed its uncommitted changes and let the user commit — which worked only because the API had the matching hole, fixed separately on the Edge side. The list now carries `locked`, read from `/me/overflow` — the same endpoint Edge's SPA drives its own lock from, so the two screens cannot disagree. The card is greyed with a padlock, and clicking it refuses with Edge's own sentence rather than opening a project every save would bounce. Failing to read the overflow list returns an empty set rather than propagating: not knowing must not cost the user their project list, and it fails safe, since an unmarked locked project is still refused by the API. Both places that surface Edge's 403 now translate it. The body carries `RESOURCE_OVER_LIMIT_AFTER_DOWNGRADE`, a contract token, and we were showing it to the user verbatim. Verified against staging with a plan whose private limit is 0: five locked projects render with the padlock and none of them opens. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K1qUCgS9vCwbPZJTjxuq7v
`executeSaveActiveFile` guarded on `editor.meta.name` being empty. It never is: the editor union's "nothing open" case is `type: 'available'`, and it carries the literal string 'available' as its `meta.name`. So the check passed, the save ran, and the start screen — with no project at all — answered `Error saving file: File "available" not found`. Two changes. The discriminant is what gets tested now, not the name, so the placeholder can never read as an open file. And with no project loaded (`path === ''`, which is what App.tsx renders the start screen on) the accelerator returns silently: a stray Ctrl+S is not a save that failed, and a toast about a missing file is noise on a screen that has no files. Inside a project with nothing open it still says "No file open", which is the honest answer there. Reproduced and fixed live: firing the accelerator on the start screen showed the reported error before the change and nothing after it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K1qUCgS9vCwbPZJTjxuq7v
…l ones The control ordered the local list by sorting the store's `recent` array in place. The cloud section does not read that array, so switching between Recent and Name re-arranged one list and left the other exactly as it was. The chosen order is lifted to the start screen now, the same way the search value already was, and both lists below the bar read it. It is applied to the page already fetched, not to the query. The server is asked for the most recently changed projects, so ordering by name re-arranges those five — it does not go looking for the alphabetically first ones. That matches what the section is: a shortcut to recent work, not a project browser. Verified in the running editor: the cloud cards go from Round5, Readme, Round4, Round3, Final on Recent to Final, Round3, Round4, Round5, Readme on Name, and back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K1qUCgS9vCwbPZJTjxuq7v
…esktop-editor-cloud-login-project-sync-and-ai-on-cloud-credits # Conflicts: # src/frontend/store/__tests__/shared-slice.test.ts
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K1qUCgS9vCwbPZJTjxuq7v
Tutorials had been removed because it did nothing. Documentation takes its place and opens the Edge docs (https://edge.autonomylogic.com/docs) through the system port, so the desktop hands it to the OS shell and the web to a tab. The icon follows the Folder icon's pattern: two-tone brand blue, 28-unit viewBox, the same size classes. It carries `shrink-0` because this is the longest label in the menu and the flex row squeezed the icon to zero width, which is how the first cut rendered a label with no icon at all. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K1qUCgS9vCwbPZJTjxuq7v
…l disk Open went straight to the local folder picker. A signed-in user's projects may live on Edge, filed into folders the start screen's five most recent never shows, so Open now asks which world first: the local picker it always had, or a dialog that walks the Edge folder tree the way Edge's own sidebar does. The dialog reads the flattened tree the upload flow already fetches and, per selected folder, that folder's projects (newest first, at the API's page cap of 50). Opening goes through the same call the start-screen cards use, so a project over the plan limit is refused the same way and with the same words. One new read on the port, optional like listCloudFolders: a build with no cloud channel keeps the Edge choice greyed out. The IPC handler checks the folder id rather than trusting it, and a bad one reads as could not list, never as an empty folder. The Radix trigger wraps the menu item in a div because Button does not forward a ref, and the menu anchors on that ref. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K1qUCgS9vCwbPZJTjxuq7v
…he cloud section The local list took a fixed share of the column (h-[52%]) inside a main that is overflow-hidden. That held while it was the only section. The cloud section above it is as tall as its rows, so with two rows of cloud projects the local section was pushed past the bottom of main: its own scroll reached the end while the last row of cards sat below the visible area, cut off. The column is a flex column now and the local list takes whatever height is left (flex-1 min-h-0), scrolling inside it, so nothing depends on how tall the section above happens to be. The column's top margin became padding for the same reason: with h-full, a margin pushed the box past the parent. Measured at 700px and 887px window heights, last card bottom at or above the viewport edge with the list scrolled to the end; it was 197px below before. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K1qUCgS9vCwbPZJTjxuq7v
…er editor can warn A project declares its libraries in project.libraries, and that list is what the other editor checks to raise the Missing libraries dialog. Placing a block from a library never wrote to it; only the Library Manager did. So a project could use a library's blocks, save, and open on the other side with the blocks rendering from their own node data, the library absent, and nothing to warn about. The build failed later with no explanation. Three changes close it. Placing a ladder or FBD block enables the library that owns it. The save derives libraries from the FB instances in the variables tables, both derived and user-data-type, since the ST parser spells an instance the second way, so a project saved before this rule gets the entry on the side that has the library installed. And project.json is regenerated rather than echoed byte-for-byte when that adds a library: the snapshot taken on open already carried the derived entry, so the file read as unchanged and the raw bytes, with no libraries at all, went back up. Only the side that has the library can name it: a block's node carries the block, not its library, and the mapping lives in the installed archives. That is enough. The ref is written here, saved, and the other side warns. Verified end to end on staging with the desktop (library installed) and openplc-web (not installed) on one project: the desktop save wrote the entry, and the web then opened the project with Missing libraries: demo-utils v1.0.0. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K1qUCgS9vCwbPZJTjxuq7v
ModalContent is `fixed inset-0 m-auto`, and `h-auto` on that box fills the viewport up to the max, so the dialog ran to 80vh with the lower half empty. `h-fit` sizes it to what it holds, the way the upload dialog already does. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K1qUCgS9vCwbPZJTjxuq7v
…in-project-sync-and-ai-on-cloud-credits
`npm run lint` passes locally, but CI lints `./src/**/*.{ts,tsx}` directly and
that catches simple-import-sort on this file: the Radix import landed above
React's.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1qUCgS9vCwbPZJTjxuq7v
…in-project-sync-and-ai-on-cloud-credits Conflicts resolved in parse-project-files.ts (development's extractVariablesSection refactor), shared/slice.ts (development's alias repair + earlier device load), save-actions.ts (development's reload-takes-file-text, plus this branch's SaveResult import) and save-actions.test.ts (both describe blocks kept). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K1qUCgS9vCwbPZJTjxuq7v
…; prettier Development's DOPE-650 emits an empty POU instead of refusing it, so the adapter test that fed a Python POU with no variables no longer produced an error. A triggered task with no source signal still does, and the guarantee under test (a failed transpile answers null) is unchanged. Also prettier --write on four files CI's format check flagged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K1qUCgS9vCwbPZJTjxuq7v
The Display > Refresh guard is not part of this PR; its test slipped into the merge commit by an unfiltered add and fails against the shipped menu. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K1qUCgS9vCwbPZJTjxuq7v
The desktop editor could not sign in to Autonomy Edge, could not open a cloud project, had every version-control component on screen with nothing behind them, and had no AI assistant at all. This branch closes all four. A cloud project now opens, saves, branches, commits, stashes, diffs and merges from the desktop exactly as it does in the web editor, and the assistant that builds and edits the project in the web editor runs on the desktop on the same account's cloud credits — because both builds run the same components and the same engine over the same ports.
Paired with
openplc-web#711 — both must be open for the Shared Surface Sync check to pass. The web CI compares the PR merge commit and the editor CI compares the PR head, so neither passes alone. The web PR carries the shared-surface half of this work.Two Jira tasks: DOPE-388 (account, cloud projects, version control) and DOPE-572 (the AI assistant on the desktop), which was folded into this branch because the assistant only makes sense once the account and cloud projects exist.
What is now in the editor
Autonomy Edge account
BrowserWindowthe editor owns, session held in the main process and exposed to the renderer throughEdgeAccountPort.hasAuthentication(does this build talk to Edge at all) is kept distinct from requiring an account, so the autonomy-node build is unaffected.Cloud projects
ProjectPort.Version control (cloud projects only)
Every operation the web editor has, driven through
VersionControlPort(19 methods) over 18 IPC channels:The surface is gated on
capabilities.hasVersionControl && projectCaps.hasVersionControl && isRemoteProjectPath(projectPath). A local project shows no source-control button and no branch bar, which is intentional: there is no server-side working tree to talk to.The AI assistant (DOPE-572)
Full parity with the web editor: chat that reads and writes the project through tools, inline completions in Monaco, conversation history persisted on Edge, credits/entitlements, telemetry, and the credit-exhaustion modal.
Where the code lives. The engine — the agentic loop, the eleven tools (
create_pou,update_pou_body,create_variable,update_variable,delete_variable,delete_pou,create_datatype,update_datatype,delete_datatype,read_project_state,read_pou_body), the tool executor, the context collectors, the inline-completion provider, telemetry and the conversation hooks (~3,700 lines) — moved fromsrc/middleware/adapters/web/services/ai/onto the shared surface atsrc/frontend/services/ai/, byte-identical in both repos. Only the transport is per platform:fetch/SSE, inadapters/web.src/backend/editor/edge-ai/that holds the Edge session, proxies/ai/chat,/ai/complete,/ai/warm,/ai/credits,/ai/telemetryand the conversation CRUD, and streams SSE frames to the renderer over 15edge-ai:*IPC channels (stream-start/event/end/error/stream-abortfor the stream, plus request/response channels for credits, usage, entitlements, warm, telemetry and five conversation operations). The renderer adapter (src/middleware/adapters/editor/ai-adapter.ts) turns those into theAIPortgenerator the shared engine consumes, and rebuildsAIRequestError(structured clone destroys the prototype) so the billing modal still pops oninstanceof.Contract changes on the shared surface.
AIPortgainedstreamChatEvents(the rawAISSEEventstream thatstreamChat/streamCompletionderive from),AIRequestErrormoved into the port as a class, andBillingErrorPayload.codenow names all four codesCreditGuardcan throw —insufficient_acu,subscription_inactive,rate_limit_exceeded,subscription_past_due.PlatformCapabilities.hasAIAssistantistruefor the editor.What is required to use it. An Autonomy Edge sign-in. Signed out, the panel says so ("Sign in to Autonomy Edge to use the assistant.") rather than failing. The assistant works on a local project (edits land on disk) and on a cloud project (edits land in the project's working tree and show up in Changes); conversation history is persisted only when the open project is a cloud project, because Edge keys conversations by project id.
The access token never crosses IPC. Every
edge-ai:*channel answersEdgeAiResultonly;edgeStreamRequestsharesprepareRequestwith the buffered path, so the cleartext-refusal guard covers the AI paths too.Navigation without a router
The editor has no SPA router.
/historyand/mergeare intercepted by the navigation adapter and become store state, rendered as full-bleed overlays over the workspace. Any other in-app path is refused rather than navigated to: assigninglocation.hrefinside the Electron renderer reloads the shell, which is what used to close the open project with unsaved edits still in it.Moved onto the shared surface
backend/web/so both builds compute the same answer from the same bytes.use-diff-editor-teardown.ts) rather than inline at each call site.Bugs found and fixed while validating
Each was reproduced by driving the running app, not by reading the code.
1. Merge closed the open project. Pressing Merge on a branch reloaded the renderer and dropped the user on the start screen with unsaved edits gone. The entry pointed at
/merge, a route the desktop does not have, and the adapter's fallback waslocation.href. Fixed by intercepting the path and refusing unknown ones.2. Merge was never implemented on the desktop. It was the one operation that never went through the port: the web page called its own API layer directly. Now a port method, with the screen shared.
3. Monaco crashed the diff viewer.
@monaco-editor/react4.7 disposes both text models before the widget, and the widget then throwsTextModel got disposed before DiffEditorWidget model got reset. Fixed withkeepCurrentOriginalModel/keepCurrentModifiedModel, an order-independent teardown of our own, and per-instance model paths.4. Saving rewrote every file. Saving a cloud project re-serialised the whole thing: same meaning, different bytes, so a 62KB project became 147KB and git reported all 11 files modified. Fixed by echoing the raw loaded bytes when a fresh serialisation is equivalent to the one at load time. Measured after the fix: one edit to one POU produced exactly one modified file.
5. A remembered branch outlived its branch. The active branch is client state in
localStorage, and nothing checked it was still real.6. Sign-in did not propagate. Signing in left Upload to Cloud hidden until restart.
7. The cloud list did not refresh after an upload.
8.
canEditwas dropped on the way in. The editor's project adapter leftcanEditout, so every read-only guard on the shared screens was dead on the desktop.9. A cancelled Monaco task read as a runtime error. Monaco's
Canceledrejection on dispose reachedwindowand, in a dev build, a full-screen overlay. Fixed with a narrow runtime guard plus a dev-server overlay filter.10. The dev server booted to a white screen. The overlay filter from bug 9 was passed as a function, which webpack-dev-server serialises and re-evaluates with
new Function— refused by the renderer's CSP (unsafe-eval). The filter is now a static setting.11. The History tab went blank on a valid answer.
listCommitsrequiredtotal/pagein the pagination envelope; the server sometimes omits them, and the strict schema turned a 200 into "unreadable response". The counters are now tolerant (z.number().catch(0)), and a schema miss is logged with its zod issues instead of swallowed.12. A local path was sent to Edge as a project id. The chat panel passed
meta.paththrough asprojectIdfor every project; on a local project that is an absolute path, and Edge answered 500. NowisRemoteProjectPath(path) ? path : undefined.Review findings addressed
Round 1 (@marconetsf, 28 Aug), round 2 (@JulioSergioFS, 11 Sep) and round 3 (@JoaoGSP, 11 Sep) — the reply on the PR goes item by item; this is the code side.
fetchUsermaps only 401/403 tono-session; every other non-2xx isunknowncompleteSignInforgets the session only onno-session;unknownreturnsfailedand keeps itApp.tsxcallsconfigureSaveResume(editorPorts.edgeAccount.session)project.jsonopens silentlyparseProjectFileswarns "project.json was missing or empty…"adoptTokensreadspersistedand logs a warning when the keychain refusedcookies.get({ url: getEdgeApiBaseUrl() })openInNewWindowtreats any scheme as external^https?:messageFromBodyunwrapsGlobalExceptionFilter's envelope before readingmessageWebContents(destroyed,render-process-gone,did-start-navigationon a main-frame non-same-document navigation) aborting every stream that renderer had open;handleWindowReloadaborts AI streams before reloadingsrc/frontend/services/ai/**is measured again in both repos — see Checkssubscription_past_dueunknown to both buildsuseCreateConversationlost its test['ai-conversations', projectId], return value, rejection without a storedidWarmCachelatch made a test unreachable__resetInlineCompletionsForTests()and abeforeEachrunTool ?? executeTooldefault branch untestedread_project_stateconsole.warnper timed-out completionconsole.debugservices/ai/index.tsbarrel with zero importersapi-envelopeemptieddeviceswhenserverswas nested (data loss on save)devices.servershas its own slot; a container that does not parse fails the readsaveCloudFiledid; the full save still posted a stripped envelope. Both saves now read the project first and lay the generated envelope over what the server holds (mergeEnvelopeOverExisting, shared, byte-identical in both repos). Modelled containers are still replaced wholesale, so a POU deleted in the editor does not come back. An absentproject.jsonis not posted as''.saveProject. Pre-existing ondevelopment, fixed here because it was raised.will-redirectunguardedwill-navigateandwill-redirect; a provider chaining through redirects was only checked on the first hop.open-external-linkIPC had no scheme checkisWebUrl()inbackend/editor/utils, applied to the IPC handler and the window-open handler. The handler dates from October 2024 and is untouched by this branch; the earlier fix had guarded the window-open door while the editor opens links over IPC.windowas anerrorevent and still raised a runtime-error overlay; the guard now listens to both.erroris an object so a bare body still reads.ci-sync.ymlresolves the mirror by branch name and scans further..dt .sfc .c .cpp .mdsegment()encodes and refuses/ ? # ..basic_textbackend accepted as encryption; stale ciphertext keptno-sessionunknownwill-navigate, cleartext guard on the provider URL, cookies filtered by domainshell.openExternalwithout a scheme check;reactivateUrla bare stringhttp(s)only;z.string().url()signed-out/unreachable; queue on the first, Save As on the secondas RawProjectFiles×3; untested upload/raw-files branchestranspile-from-portduplicated in both adapters, 13 castsbackend/shared/transpilers, no casts, 100% covered/historyand/mergelost their viewport heighth-[var(--app-vh)](verified in a browser)invalidateSTCachehad no callerNot changed, with the reason in the reply: R2-9 (dropping unknown SSE frames is deliberate — the desktop validates every frame at the IPC boundary; a comment was added), R2-12 (latent, no caller), R1-10 (the theme effect in
branch-merge-view.tsxis the web page's standalone theme sync; redundant but harmless in the editor, and removing it would alter the web). R2-18: the screenshots and the report are both out of the tree — an earlier version of this line said the report stayed, which is no longer true.Closed since that list was written: R2-16 —
invalidateSTCacheis called from the tool executor after every project-mutating tool, with a cache-identity guard ingraphical-context. R2-16's eleven shared suites are mirrored. R3-E — the comment pass is done, roughly 6,300 lines of commentary reduced to the house rule, comment text only. Still open: R3-F, splittingBranchMergeViewandCommitHistoryView, taken as a follow-up ticket rather than done here.How this was validated
The app was driven through the Chrome DevTools Protocol against
api-staging.autonomylogic.com, signed in as a real account.DOPE-388 — on real cloud projects (
Irrigation Controllerand a second large program with FBs in LD, FBD and ST). The report and its screenshots are kept outside the repository, per R2-18; what the run covered is summarised in the three paragraphs below rather than linked.DOPE-572 — one continuous flow on the final code, as the assistant would be used:
st_testcreated and its body written on disk.Blinker,DebounceandLatchcreated on Edge, visible in the explorer and in Changes./ai/credits: 0 → 161.87 → 592.89 debited across the session.Second full run, on the final code (2026-09-13). The whole flow again, from sign-in: new local project, the assistant writing a conveyor start/stop in ST, Upload to Cloud (4 files, nothing from neighbouring folders), the assistant creating a
MotorGuardfunction block with a TON and wiring it intomain, changes, textual diff, commit, branch, an assistant edit on the branch, commit, commit detail and its per-file diff, stash, pop, discard, merge back intomain, the graphical diff on a ladder POU (11 flows, 99 nodes), conversation history reloading, and the signed-out panel. ACU moved from 968.27 to 1192.39.Third full run, after the round-4 fixes (2026-09-15), against
api-staging. Sign-in, a new local project, the assistant writing a tank-level controller on the HYSTERESIS library block, Upload to Cloud, then the case the review called the sharp edge: a file-basedREADME.mdwas created on the cloud project through the API and survived a full save, a stash, a pop, a discard, a merge and a save that added a new file. Textual diff, commit, branch, an assistant edit on the branch, commit, stash, pop, discard, merge back intomainthrough the merge screen, a ladder POU created and saved and its graphical diff opened, commit, conversation history listing and reloading an earlier conversation, and sign-out leaving the assistant showing a sign-in button rather than an error. ACU moved from 1192.39 to 1538.34.Two things the run could not drive, both instrumentation rather than product: the ladder canvas does not respond to synthetic pointer events, so the graphical diff was exercised on an added-but-empty POU (it reports "No graphical changes detected", which is correct); and the Changes panel refetches on mount, so it can read "No changes" while the activity-bar badge already shows the right count until the tab is revisited. The badge is the one reading from the server.
Three fixes in this pull request were confirmed in that run: the Discard button is inside the panel and clickable, the signed-out composer says to sign in, and the upload packs only the project it was asked for.
A bug the run found, and this branch fixes. Disposing an editor cancels a token inside Monaco's emitter, which rethrows synchronously: that reaches the window as an uncaught error rather than a rejected promise, so the cancellation guard never saw it and the dev-server overlay covered the application until dismissed. The guard listens for both now.
The AI runs have no screenshot set in the repository; the numbers above come from the run's console and the
/ai/creditsresponses.Parity audit
Two agents compared the builds independently: 19/19 version-control port methods on both sides, all 18 IPC channels end to end, payloads field-identical; every web version-control surface reachable in the editor. For the AI:
compare-surfaces.pyreportsmatch: trueover 1,161 shared files, which is what proves the engine is the same code, not similar code.Checks
The mirror check needed a fix of its own.
ci-sync.ymllooked for the paired pull request among the ten most recently opened ones on the other repository, so this pair stopped being found once ten newer pull requests were opened in front of it, and every shared file then read as a mismatch againstdevelopment. It now looks the mirror up by branch name, which both sides share, and scans further.total_diffs: 0over 1,170 filesThe coverage gate is green, and the AI engine is under it:
src/frontend/services/ai/**was added tojest.config.jsonand to the web'svitest.config.tsat 97% statements / 98% lines / 97% functions (branches unmeasured, as for every other gated directory). Measured on the full suite: editor 98.6 / 99.3 / 98.1, web 98.4 / 99.1 / 98.8. That directory was measured by nothing when the engine moved onto the shared surface; the tool executor sat at 7% statements in both repos.Known gaps, not addressed here
ConflictException, so the carry-conflict screen is unreachable on both builds until the backend is fixed.preview-switch-carryreturns 404 (barrel ordering in autonomy-edge); the carry option is disabled in the branch-switch dialog on the desktop until it is fixed server-side.basic_textrefusal and the OAuth provider host allowlist are unit-tested against stubs, not exercised on a keyring-less Linux box or a live provider flow.@ArrayMaxSize(100)onmessages, which now surfaces with its real message.🤖 Generated with Claude Code