Skip to content

feat(edge): Autonomy Edge on the desktop — account, cloud projects, version control and the AI assistant [DOPE-388] [DOPE-572] - #1056

Merged
Gustavohsdp merged 88 commits into
developmentfrom
feat/dope388/desktop-editor-cloud-login-project-sync-and-ai-on-cloud-credits
Sep 23, 2026
Merged

Gustavohsdp merged 88 commits into
developmentfrom
feat/dope388/desktop-editor-cloud-login-project-sync-and-ai-on-cloud-credits

Conversation

@Gustavohsdp

@Gustavohsdp Gustavohsdp commented Aug 28, 2026 •

Copy link
Copy Markdown
Contributor

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

  • Sign in with a provider in a BrowserWindow the editor owns, session held in the main process and exposed to the renderer through EdgeAccountPort.
  • The account is reachable from the start screen and from the workspace activity bar.
  • 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

  • The start screen lists the account's recent Edge projects in their own section, with a skeleton while the list loads and an invitation to sign in when signed out. A failing list never takes the start screen down with it.
  • Cloud projects open and save from the desktop through ProjectPort.
  • Upload to Cloud on a local project card: zips the project, offers the account's folder tree and a public/private choice, imports it through the multipart endpoint, and refreshes the cloud list on success. Offered only when signed in.

Version control (cloud projects only)

Every operation the web editor has, driven through VersionControlPort (19 methods) over 18 IPC channels:

Area Operations
Branches list, create, switch (with carry or discard), delete, merge
Changes list, per-file diff (textual and graphical), select, commit, discard
Stash create, list, apply, pop, drop
History paginated commit list, commit detail, per-commit file view

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 from src/middleware/adapters/web/services/ai/ onto the shared surface at src/frontend/services/ai/, byte-identical in both repos. Only the transport is per platform:

  • Web (unchanged behaviour): cookie session + fetch/SSE, in adapters/web.
  • Desktop: a new main-process module src/backend/editor/edge-ai/ that holds the Edge session, proxies /ai/chat, /ai/complete, /ai/warm, /ai/credits, /ai/telemetry and the conversation CRUD, and streams SSE frames to the renderer over 15 edge-ai:* IPC channels (stream-start/event/end/error/stream-abort for 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 the AIPort generator the shared engine consumes, and rebuilds AIRequestError (structured clone destroys the prototype) so the billing modal still pops on instanceof.

Contract changes on the shared surface. AIPort gained streamChatEvents (the raw AISSEEvent stream that streamChat/streamCompletion derive from), AIRequestError moved into the port as a class, and BillingErrorPayload.code now names all four codes CreditGuard can throw — insufficient_acu, subscription_inactive, rate_limit_exceeded, subscription_past_due. PlatformCapabilities.hasAIAssistant is true for 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 answers EdgeAiResult only; edgeStreamRequest shares prepareRequest with the buffered path, so the cleartext-refusal guard covers the AI paths too.

Navigation without a router

The editor has no SPA router. /history and /merge are 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: assigning location.href inside 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

  • The graphical diff (537 lines turning two file versions into a node/edge diff) left backend/web/ so both builds compute the same answer from the same bytes.
  • The API envelope moved into the shared layer.
  • The commit-history screen, the branch merge screen and its text-conflict resolver are shared components now, not web pages.
  • Monaco's diff teardown lives in one module (use-diff-editor-teardown.ts) rather than inline at each call site.
  • The whole AI engine (above).

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 was location.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/react 4.7 disposes both text models before the widget, and the widget then throws TextModel got disposed before DiffEditorWidget model got reset. Fixed with keepCurrentOriginalModel/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. canEdit was dropped on the way in. The editor's project adapter left canEdit out, 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 Canceled rejection on dispose reached window and, 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. listCommits required total/page in 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.path through as projectId for every project; on a local project that is an absolute path, and Edge answered 500. Now isRemoteProjectPath(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.

Finding Fix
R1-1 · Edge 5xx read as an ended session fetchUser maps only 401/403 to no-session; every other non-2xx is unknown
R1-2 · a working sign-in discarded on a transient failure completeSignIn forgets the session only on no-session; unknown returns failed and keeps it
R1-3 · interrupted-save queue never wired on the desktop App.tsx calls configureSaveResume(editorPorts.edgeAccount.session)
R1-4 · project with no project.json opens silently parseProjectFiles warns "project.json was missing or empty…"
R1-6 · refresh-token persistence answer discarded adoptTokens reads persisted and logs a warning when the keychain refused
R1-7 · OAuth cookie harvest not scoped cookies.get({ url: getEdgeApiBaseUrl() })
R1-8 · upload ceiling comment contradicts the code comment corrected: the budget is enforced on uncompressed bytes, conservatively
R1-9 · openInNewWindow treats any scheme as external scheme test narrowed to ^https?:
R2-1 · every Edge error message lost on the desktop messageFromBody unwraps GlobalExceptionFilter's envelope before reading message
R2-2 / R2-11 · renderer reload leaks the AI stream and bills for it; one listener per stream one set of hooks per WebContents (destroyed, render-process-gone, did-start-navigation on a main-frame non-same-document navigation) aborting every stream that renderer had open; handleWindowReload aborts AI streams before reloading
R2-3 · AI engine left the coverage gate src/frontend/services/ai/** is measured again in both repos — see Checks
R2-4 · subscription_past_due unknown to both builds added to the shared union, the desktop schemas and the web parser
R2-7 · useCreateConversation lost its test three cases: invalidation of ['ai-conversations', projectId], return value, rejection without a store
R2-8 · two AI suites existed only in the web the provider and context-builder suites rewritten runner-agnostic and present in both repos
R2-10 · 429 handled differently by the two builds the web now attaches any recognised billing payload on a 429, as the desktop does
R2-13 · didWarmCache latch made a test unreachable __resetInlineCompletionsForTests() and a beforeEach
R2-14 · runTool ?? executeTool default branch untested a case that reaches the real executor via read_project_state
R2-15 · unconditional console.warn per timed-out completion demoted to console.debug
R2-17 · services/ai/index.ts barrel with zero importers deleted
R3 · api-envelope emptied devices when servers was nested (data loss on save) devices.servers has its own slot; a container that does not parse fails the read
R3 · save posted a stripped envelope, deleting README and unmodelled keys Corrected claim. An earlier version of this body said unknown keys survived the round trip. Only saveCloudFile did; 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 absent project.json is not posted as ''.
R4 · full save deleted unmodelled files on the web too same read-modify-write in the web saveProject. Pre-existing on development, fixed here because it was raised.
R4 · OAuth will-redirect unguarded one handler serves will-navigate and will-redirect; a provider chaining through redirects was only checked on the first hop.
R4 · open-external-link IPC had no scheme check isWebUrl() in backend/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.
R4 · Monaco cancellation guard only caught rejections the synchronous rethrow reached window as an error event and still raised a runtime-error overlay; the guard now listens to both.
R4 · merge failure body read at the root on the web unwraps Edge's exception-filter envelope, and only when error is an object so a bare body still reads.
R4 · mirror lookup found only the ten newest pull requests ci-sync.yml resolves the mirror by branch name and scans further.
R3 · Upload to Cloud skipped .dt .sfc .c .cpp .md allowlist mirrors Edge's
R3 · 18 unencoded URL segments in version control segment() encodes and refuses / ? # ..
R3 · Linux basic_text backend accepted as encryption; stale ciphertext kept refused; a non-persisted write deletes the old entry
R3 · renewal 5xx still read as no-session renewal that cannot complete is unknown
R3 · OAuth window without navigation boundary popups denied, host allowlist on will-navigate, cleartext guard on the provider URL, cookies filtered by domain
R3 · shell.openExternal without a scheme check; reactivateUrl a bare string http(s) only; z.string().url()
R3 · signed-out AI surface: red bubble, no sign-in, IPC per keystroke sign-in notice with button, no hydration, completions back off
R3 · failed cloud save never queued; AC4 unmet write carries signed-out/unreachable; queue on the first, Save As on the second
R3 · as RawProjectFiles ×3; untested upload/raw-files branches zod parse; tests added
R3 · transpile-from-port duplicated in both adapters, 13 casts one copy in backend/shared/transpilers, no casts, 100% covered
R3 · merge view: cast in a memo, "Conflicts remain in: ." typed memo; falls back to the server's message
R3 (web) · merge 409 mapping never matched any 409 is a conflict carrying the server's message
R3 (web) · /history and /merge lost their viewport height page wrappers supply h-[var(--app-vh)] (verified in a browser)
R3 (web) · Monaco guard installed twice once, from the shared config
R3-D · eleven shared suites existed only in the web rewritten without module mocks and present in both repos: 146 cases under jest and under vitest, none dropped
R3-E · comment volume full pass: 157 files, ~16,700 comment lines removed across both repos, comment text only
R2-16 · invalidateSTCache had no caller the project ST cache is keyed on the project's own identity and every mutating tool drops it

Not 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.tsx is 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 — invalidateSTCache is called from the tool executor after every project-mutating tool, with a cache-identity guard in graphical-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, splitting BranchMergeView and CommitHistoryView, 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 Controller and 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.

Suite Result
Open a cloud project, explorer, tabs, textual and graphical diff 9/9
Create branch, switch, switch back, merge entry, merge screen opens and exits 6/6
Changes, commit, history, stash, pop, discard pass
Local project shows no version-control surface pass

DOPE-572 — one continuous flow on the final code, as the assistant would be used:

  1. New local project on disk → asked the assistant for an ST program → st_test created and its body written on disk.
  2. Upload to Cloud on that project → opened the cloud copy from the start screen.
  3. Asked for function blocks → Blinker, Debounce and Latch created on Edge, visible in the explorer and in Changes.
  4. Created a branch, committed on it, confirmed the commits in History.
  5. ACU balance read before and after through /ai/credits: 0 → 161.87 → 592.89 debited across the session.
  6. Credit exhaustion: the modal opened with the server's own payload (code, message, CTA).
  7. Signed out: the panel shows the sign-in message and makes no request.

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 MotorGuard function block with a TON and wiring it into main, changes, textual diff, commit, branch, an assistant edit on the branch, commit, commit detail and its per-file diff, stash, pop, discard, merge back into main, 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-based README.md was 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 into main through 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/credits responses.

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.py reports match: true over 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.yml looked 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 against development. It now looks the mirror up by branch name, which both sides share, and scans further.

Check Result
Architecture Validation pass, both repos
Build Check / Complete Build pass
Format Check, Lint Check pass
Shared Surface Sync (+ dependencies, tooling, comparison script) pass, total_diffs: 0 over 1,170 files
Unit Tests + Coverage pass — editor 447 suites / 9,410 tests (20 skipped, 2 suites skipped); web 410 files / 8,573 tests. Every configured coverage threshold met in both repos.

The coverage gate is green, and the AI engine is under it: src/frontend/services/ai/** was added to jest.config.json and to the web's vitest.config.ts at 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

  • Switch-branch carry conflicts reach the client as a 500. Edge's switch use case does not convert the git worker's 409 into a ConflictException, so the carry-conflict screen is unreachable on both builds until the backend is fixed.
  • The active branch is not reconciled with the server's checkout on reopen. Needs a field the backend does not send yet.
  • preview-switch-carry returns 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.
  • The Linux basic_text refusal and the OAuth provider host allowlist are unit-tested against stubs, not exercised on a keyring-less Linux box or a live provider flow.
  • There is no tool-iteration cap on the agentic loop, on either platform; the effective ceiling is Edge's @ArrayMaxSize(100) on messages, which now surfaces with its real message.

🤖 Generated with Claude Code

Gustavohsdp and others added 28 commits August 24, 2026 16:31
…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
@coderabbitai

coderabbitai Bot commented Aug 28, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview 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

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 83d754aa-c2eb-4770-a177-d26aa2c66f4a

📥 Commits

Reviewing files that changed from the base of the PR and between 2003750 and 9e030d6.

📒 Files selected for processing (2)
  • configs/webpack/webpack.config.renderer.dev.ts
  • src/main.tsx

Included review availability: Your plan provides up to 2 included reviews per hour; 0 remain after this review.


Walkthrough

The 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.

Changes

Edge account and cloud workflows

Layer / File(s) Summary
Edge session and authentication
src/backend/editor/edge-account/*, src/main/main.ts, src/middleware/adapters/editor/edge-account-adapter.ts
Adds encrypted refresh-token storage, token renewal, HTTP transport, provider OAuth, session state handling, and account UI integration.
Cloud projects and uploads
src/backend/editor/edge-projects/*, src/backend/editor/edge-project-upload/*, src/backend/shared/project/*, src/frontend/components/_features/[start]/*
Adds cloud project reads, read-modify-write saves, folder listing, ZIP archive creation, multipart uploads, and start-screen cloud project surfaces.

Version control and workspace

Layer / File(s) Summary
Version-control transport and contracts
src/backend/editor/edge-version-control/*, src/middleware/shared/ports/version-control-port.ts, src/middleware/adapters/editor/version-control-adapter.ts
Adds authenticated branch, commit, stash, change, diff, and merge operations with serializable failure results and conflict reconstruction.
Workspace routing and merge screens
src/frontend/components/_features/[workspace]/*, src/frontend/screens/workspace-screen.tsx, src/middleware/adapters/editor/navigation-adapter.ts
Adds store-backed history and merge screens, graphical and text conflict resolution, branch capability gating, project reloads, and restore error display.

Supporting editor changes

Layer / File(s) Summary
Monaco lifecycle and startup handling
src/frontend/components/_features/[workspace]/editor/diff-viewer/*, src/main.tsx, configs/webpack/webpack.config.renderer.dev.ts
Installs the Monaco cancellation guard, suppresses runtime overlays, and disposes diff-editor models during teardown.
Platform and IPC contracts
src/middleware/shared/ports/*, src/main/modules/ipc/*, src/main/modules/store/index.ts
Adds capability flags, runtime schemas, Edge session storage validation, IPC handlers, and renderer bridge methods.

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
Loading

Merge Risk: 🟡 Moderate · up to 9e030

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)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 65.12% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 129 functions across 52 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly summarizes the primary changes: Autonomy Edge account support, cloud projects, version control, and the AI assistant. It is specific and related to the pull request.
Description check ✅ Passed The description is comprehensive and covers the changes, references, validation results, known gaps, review findings, and Jira tasks. It does not reproduce the template's DOD checklist, but the requir…
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/dope388/desktop-editor-cloud-login-project-sync-and-ai-on-cloud-credits

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

A rabbit packs the project tight,
Encrypted tokens rest at night,
Branches meet and conflicts mend,
Diff panes close when journeys end,
Cloud folders bloom before the end.

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

[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
@Gustavohsdp

Copy link
Copy Markdown
Contributor Author

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 development and this branch either carried the pattern to a new place or made it visible; one was ours.

Defects this branch introduced

Finding What we did
R3 · full save deletes unmodelled files, on the desktop saveCloudProject is new here — cloud projects did not exist on the desktop — and we copied the web's pattern of building the envelope from the store instead of fixing it. Both saves now read the project first and lay the generated envelope over what the server holds. Modelled containers are still replaced wholesale, so a POU deleted in the editor does not come back. Tests: README.md survives a full save; a failed read aborts before writing.
R3 · the upload allowlist widened the blast radius True, and worth saying plainly: accepting .md, .dt, .c and .cpp meant more files a full save could delete. That is fixed by the row above rather than by narrowing the allowlist.
R3 · will-redirect not on the OAuth allowlist The OAuth window is new here. will-navigate was guarded, redirects were not, and a provider chains through redirects — only the first hop was checked. One handler now serves both.
R2 · renderer reload leaked the AI stream and billed for it Per-renderer hooks (destroyed, render-process-gone, main-frame navigation) abort every stream that renderer had open.
R2 · Edge error messages lost on the desktop messageFromBody unwraps the exception filter's envelope, as the billing path already did.
R2 · the AI engine left the coverage gate on the way to the shared surface src/frontend/services/ai/** is measured again in both repos, at 97 statements / 98 lines / 97 functions.
E2E · Discard unreachable, signed-out composer lying, upload packing neighbouring projects All three found by driving the app, all three fixed. The upload one was the worst: publishing from a folder that held other projects packed them all and Edge adopted the last nested manifest.

Defects that were already in development

We fixed them because they were raised here, and because this branch made two of them reachable from a new place. Saying so is not deflection: whoever reads this later should know the blame does not sit with this feature.

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 type has 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, aiCount accepts a limit the 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) and CommitHistoryView (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-projects and edge-ai already encode every interpolated segment, and edge-project-upload interpolates 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>
@JoaoGSP

JoaoGSP commented Sep 15, 2026

Copy link
Copy Markdown
Member

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 0475e0ac8 / 82951f6a9: saveCloudProject reads before it writes and the README.md test pins it, and parseMergeErrorBody unwraps the envelope with the bare-body fallback intact. will-redirect and the isWebUrl helper on both doors are in too.

You asked for two things.

1. The dot-segment case

You are right that edge-projects and edge-ai encode every interpolated segment, and "unencoded" was the wrong word from me. The narrower point is that encodeURIComponent does not touch . or .. — they are unreserved characters, so they survive encoding and new URL then resolves them as path navigation. Run against the real base:

".."      -> /projects/../details         => /details
"."       -> /projects/./details          => /projects/details
""        -> /projects//details           => /projects//details
"%2e%2e"  -> /projects/%252e%252e/details => /projects/%252e%252e/details   (safe, double-encoded)
"../.."   -> /projects/..%2F../details    => /projects/..%2F../details      (safe, the slash is encoded)

So a single .. is the only shape that actually escapes, and it escapes cleanly: GET /projects/../details reaches /details on the authenticated session. The call sites are edge-projects/index.ts:127 and :260, and edge-ai/index.ts:611, :625 and :633 — that last one is a DELETE.

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: projectId comes from the store rather than from user input, so reaching it needs a compromised renderer, and there is no known path that supplies .. today. It is defence in depth, and the reason I would still close it is consistency — your own segment() in edge-version-control already refuses '', . and .., so the three modules currently disagree about the same class of input. Exporting segment() and using it in the other two is a small change that makes the rule one rule.

2. The four test paths

At the current heads these five are in openplc-web and not in openplc-editor:

src/frontend/components/_atoms/edge-avatar/__tests__/edge-avatar.test.tsx
src/frontend/components/_atoms/provider-icons/__tests__/provider-icons.test.tsx
src/frontend/components/_organisms/edge-sign-in-modal/__tests__/edge-sign-in-modal.test.tsx
src/frontend/services/__tests__/resume-save-after-sign-in.test.ts
src/backend/shared/ethercat/__tests__/softmotion-e2e.test.ts

The last one is the one you found. On the ethercat point: the editor does carry two suites in that directory, generate-softmotion.test.ts and validate-ethercat-config.test.ts, but softmotion-e2e.test.ts is not one of them, so it is a genuine third rather than a miscount.

Of the other four, resume-save-after-sign-in.test.ts and edge-sign-in-modal.test.tsx are the ones I would prioritise: they cover save-resume and sign-in, which are the centre of this feature, and resume-save-after-sign-in.ts is byte-identical in both repos. None of the four uses a module mock, so nothing blocks mirroring them as they stand.

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

reactivateUrl is z.string().url() now, which is better, but zod's .url() is new URL() in a try/catch — I ran it against this repo's own zod and it accepts javascript:alert(1), file:///etc/passwd and data:text/html,x. On the desktop isWebUrl catches that at the openExternal door, so the editor is covered. The web build has no equivalent door: AcuExhaustionModal.tsx:114 renders href={ctaUrl} directly, and ctaUrl is reactivateUrl when the billing error is subscription_inactive. A javascript: href there executes on click.

It needs Edge to return a hostile value, so it is not urgent — but .refine((u) => /^https?:/.test(u)) on that field costs a line and closes it for both builds.

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.

Gustavohsdp and others added 22 commits September 16, 2026 11:10
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
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
`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
@Gustavohsdp
Gustavohsdp merged commit 527559b into development Sep 23, 2026
12 checks passed
@Gustavohsdp
Gustavohsdp deleted the feat/dope388/desktop-editor-cloud-login-project-sync-and-ai-on-cloud-credits branch September 23, 2026 11:03
This was referenced Sep 23, 2026
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.

5 participants