Skip to content

Release 4.3.1 - #1136

Merged
JoaoGSP merged 133 commits into
mainfrom
development
Sep 23, 2026
Merged

JoaoGSP merged 133 commits into
mainfrom
development

Conversation

@JoaoGSP

@JoaoGSP JoaoGSP commented Sep 23, 2026 •

Copy link
Copy Markdown
Member

Release 4.3.1 — development → main.

Mirror of https://github.com/Autonomy-Logic/openplc-web/pull/778

Ships what accumulated on development since 4.3.0:

Version is 4.3.1 across all files (package.json, app-version, release/app, package-lock root) — web #776, editor #1134. Production main is currently 4.3.0.

Mirror release pair — merge together with the sibling repo. After both merge: tag v4.3.1 on openplc-editor main kicks the desktop build; openplc-web deploys to production automatically on this merge to main.

🤖 Generated with Claude Code

https://claude.ai/code/session_014wZXUSYRgVbkM7xyDu8e2U

Gustavohsdp and others added 30 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
[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
…esktop-editor-cloud-login-project-sync-and-ai-on-cloud-credits
thiagoralves and others added 23 commits September 22, 2026 10:59
**The repair never reached the projects it exists for.** An alias like
`Motor Start` is two identifiers to STruC++, so a POU bound to it does not
parse, its variable list comes back empty — and the cascade walks that list. It
ran after the reclassify pass, over a model that had nothing in it, so the
producer took its new name and the declaration kept the old one: exactly the
orphaned binding the repair is written to prevent.

It now runs before reclassify, with the device definitions moved ahead of it
because the board's pins are one of the producers that declare aliases. For a
POU the cascade cannot reach, the rename is applied to the declaration text
itself — the one repair that cannot go through the parser, because it exists for
text the parser cannot read. The POU then loads into the table, repaired,
instead of into the code view for the user to fix by hand.

Two more reads of the loader's payload where the store was meant: the reclassify
pass took the payload's `variablesText`, which wrote the pre-repair text back
over the repair inside the same load, and a POU the loader marked unparsed kept
that mark even once the repair had made it readable.

**Clause removal is anchored on STruC++'s spans instead of counting
characters.** Searching `text.toUpperCase()` to match `AT` case-insensitively
was not offset-safe — uppercasing is not length-preserving, so one `ß` in a
comment above the declaration moved every index and the splice landed in the
wrong place, writing `x : BOOL at ;` to disk. A clause can only sit between its
operand and the span before it, so the keyword is looked for in that window and
nowhere else. No whole-text search, no case folding of the subject, and a
comment written inside the clause survives.

**Block comments nest, and now the editor reads them that way.**
`(* outer (* inner *) tail *)` is one comment to STruC++; taking the first `*)`
truncated the Documentation to `outer (* inner`, and a POU whose leading
documentation nested was not recognised as a POU at all. One depth-counting
scan, shared by the trailing-comment reader, the whole-POU check and the `.dt`
field reader.

**The implicit reconcile refuses only what would make the fold-in wrong.** It
runs at the start of any table mutation, on whatever the code view holds. Having
it apply the whole set validator meant a legacy POU carrying one bad declaration
— a located VAR_OUTPUT — blocked every other edit in that POU, citing a variable
the user never touched and cannot reach from the table. It refuses a duplicate
name, which makes the text-to-model match ambiguous; the full set is still gated
where the user asks for that text to be taken: the explicit commit, the load, and
the reload.

Also: the `id` matching pass is gone. A declaration's identity here comes from
the parser, which builds its variables from text and has no id to give them, so
the comparison was always `undefined === something` — the docstring promised
id-stable matching the code never did. And the ordering pass reuses the scan
already in hand when nothing was spliced, which is the common case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01464qguGj42gWdmL2ZoK6Uh
… POU's shape

**The GVL had a parser of its own.** A line splitter, a header regex, a
declaration regex and a hand-written string-aware comment stripper — the exact
arrangement DOPE-650 exists to remove, in the one declaration editor the change
had not reached. Everything the variables table stopped losing, a GVL could
still lose: a `//` that mentions `(*`, a comment between declarations, a name
list sharing a line.

It reads through `parseVariableDeclarations` now, the same entry point the
variables table uses, with no new wrapper: STruC++ takes a bare `VAR_GLOBAL`
block inside the wrapper that is already there, and `VAR_GLOBAL` was already
mapped to the `global` class. What stays here is what a GVL has and a POU's VAR
block does not — the header qualifier (read verbatim out of the block's header
span, because the model round-trips the user's text and STruC++ reduces the
qualifiers to flags), several blocks merged into one list, and `{attribute …}`
pragmas blanked because STruC++ cannot lex a `{`.

The error strings are STruC++'s own now, and the tests say so. A hand-written
message per shape was the other half of having a parser per file; the compiler
decides what a GVL is, so it decides what is wrong with one — and its report
carries the line, which none of the old messages did. One behaviour change worth
naming: a member called `Ref` is now refused, because `REF` is a STruC++
keyword. It could not have compiled either way.

**The POU's shape was described four times.** Three copies in `pou-text-parser`
and a fourth in the loader's fallback, each with its own keyword map, header
regex and scan for the start of the VAR section — and they had already drifted:
the fallback sliced the declarations from the `VAR` keyword rather than from the
start of its line, so the indentation fix reached three paths and a POU that
failed to parse still came back re-indented. `POU_TYPE_KEYWORDS`,
`POU_END_KEYWORDS`, `matchPouHeader` and `extractVariablesSection` are declared
once and used by all four. No STruC++ here yet — that is the larger change, and
this one only removes the duplication.

`ELEMENTARY_TYPE_CONTEXT` is shared too, so the three text parsers cannot drift
on what counts as a base type.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01464qguGj42gWdmL2ZoK6Uh
The positional matching pass walked one cursor across every VAR block and
CONSUMED the candidates it rejected on the block check. So renaming a local and
an input in the same commit let the first rename eat the declaration belonging
to the second, which then fell through to delete-and-append: the line came back
re-rendered from the model, with the user's hand alignment gone and a `//`
comment rewritten as `(* … *)`.

The ordering pass put the line back in its place, which is why this looked
harmless in every trace — the damage is to the formatting, not the position.
Taking the first unclaimed candidate from the variable's own block cannot
consume another block's, and leftovers are already in document order, so it
still pairs positionally.

Also restores the reconcile early-out. It compared the buffer against a
re-canonicalisation of the model, which the buffer stopped being when the code
view started showing the user's own text — so the fast path was unreachable and
every table edit paid for a parse it did not need. It compares against the POU's
text first now, and falls back to the serialisation for a POU that has none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01464qguGj42gWdmL2ZoK6Uh
The review's own list, in order, and what each one now has holding it:

  - a declaration's line span swallowed a `VAR`/`END_VAR` sharing its line,
    so deleting the first declaration of `VAR a : INT; END_VAR` took the
    block with it. The span is clamped to the header and to `END_VAR`.
  - a new declaration was inserted at the start of the `END_VAR` line, which
    for a one-line block is IN FRONT of the block keyword. It goes before
    `END_VAR` itself when that line is shared.
  - a multi-line trailing comment was compared against a flattened copy of
    itself and so was rewritten — onto one line — on every patch. Both sides
    are flattened now, and an unchanged comment produces no edit.
  - `{…}` was blanked inside strings and comments, so a GVL member declared
    `STRING := '{0}'` loaded with a hollow initial value and was written back
    that way. The blanking pass is comment- and string-aware, and counts
    braces, because a pragma's own value may hold one.
  - `refineErrors` read `VAR_INPUT x : BOOL;` as a bad block qualifier and
    reported `x`, burying the real error. The qualifier line must be
    keywords only.
  - the `.dt` view looked for `(*` before `//` regardless of which came
    first, so `a : INT; // use (* x *)` lost its tail. It shares the
    variables view's reader.
  - a reload from disk left an open code-view buffer holding the pre-reload
    text, which the regenerate then preferred — reverting the external edit
    the reload exists to pick up. The buffer is replaced with the file's.
  - `renameAlias` rewrote the text case-insensitively while the model cascade
    matched exactly, so renaming `Pump` rebound `AT PUMP`. The alias is
    matched exactly; only the `AT` keyword is case-insensitive. A rename
    naming a `%` location is refused: that is not an alias.

And one found while verifying the above in the browser: a new variable was
inserted into the FIRST block of its class, so with two `VAR` blocks the
table showed it last and the text put it in the middle — and the next load,
which reads the order from the text, moved the row. It goes to the last.

The tests are two matrices rather than a case per bug: ten line shapes
against six operations, and the clause/comment surgery against every shape
of `AT`, `:=` and trailing comment. A new shape belongs in the table, not in
a new test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01464qguGj42gWdmL2ZoK6Uh
…endent

Review follow-up on #1132.

Extract `runWithoutDirtying(flag, fn)` so the guard's contract, in particular
lowering the flag in a `finally`, is pinned by a unit test that runs in CI.
The call sites themselves are still only covered by the e2e.

Rewrite the e2e so each test launches its own app against its own project
directory and Electron profile. They no longer depend on the order they run
in, which means a negative control reports a result per test instead of one
failure skipping the rest.

Fix the review nits: wait for the typed text instead of a fixed 500ms, drop an
assertion that only read back the file the test had just written, replace a
literal NBSP with an escape, fold the two near-identical poll helpers into one,
and stop matching the discard dialog with a regex that any button containing
"no" would satisfy. Trim the comments at both guard sites to one line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-file-sync-marks-pou-unsaved

fix(monaco): keep a disk-driven reload from marking an ST POU unsaved (DOPE-652)
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
Each one is a way the declaration text and the model could disagree, or the
text could be written back in a state that no longer parses.

**The patcher.**

  - Two clauses ADDED to one declaration are two inserts at one offset, and
    the one applied last ends up leftmost. Adding a location and an initial
    value together wrote `a : BOOL := TRUE AT %QX0.0;`, which does not parse,
    so the next patch regenerated the block and took the comments with it.
    The tie is broken on the order the fields are queued in.
  - The Documentation cell is free text and it was spliced between `(*` and
    `*)` unchecked: a `*)` in it closed the comment early and a lone `(*`
    opened a nested one that ran to the end of the file. The text is kept and
    the delimiters change instead — a line comment has no closer to collide
    with.
  - Clearing that cell wrote an empty string into the comment's inner span,
    leaving `(**)` and a bare `//`. The whole comment goes now, and the space
    in front of it.
  - A `\n` inserted into a CRLF file left it with mixed endings, and editing a
    `//` comment dropped that line's `\r` — it sat inside the comment's span.
    Inserts, the normaliser and the comment span all follow the file.

**The parser.**

  - The scan for a declaration's trailing comment was not string-aware, and it
    runs to the end of the line — over the NEXT declaration when two share it.
    `a : INT; url : STRING := 'http://x';` gave `a` the documentation `x';`,
    and normalising the line wrote `a : INT; //x';` into the user's file.
  - One comment ends a line, so it belongs to ONE declaration: the first on
    that line, the one the user wrote it after. Both were getting it.
  - `refineErrors` stripped comments line by line with a regex, so a comment
    spanning lines was half-removed and a string was not protected at all:
    `'use: STRING[5]'` replaced the real error with a STRING-length complaint
    about a line that had nothing wrong with it. It reads the source with the
    trivia blanked, offsets preserved.
  - `extractVariablesSection` took the whole line holding `VAR`, so a POU
    written `PROGRAM main VAR` on one line stored its own header inside the
    declarations: the POU failed to parse, opened as an unparsed fallback, and
    the next save wrote the header twice. It backs up over the indentation and
    no further.

**The store.**

  - `rearrangeVariables` was the one local mutator that never patched the
    text. The table showed the new order, the file kept the old one, and the
    next toggle to code view and back put the rows back.
  - A data type rename reached `variable.type` and stopped there: in table
    view the text kept the old type name and brought it back on the next
    toggle, and in code view the buffer was overwritten with
    `generateIecVariablesToString(...)`, which is the comment loss this change
    exists to remove. Both go through `regeneratePouVariablesText` now.
  - The legacy-alias repair rewrote memory and marked nothing unsaved, so
    saving the device tab alone wrote the new alias to the pin mapping while
    the POU kept `AT Motor Start` — and on the next open there was nothing
    left to repair from. The whole project is marked unsaved, because one
    rename spans the producer and every POU that binds it.

**The alias rule.** Which words an alias may not use is the parser's to say,
and it says far fewer than the editor's identifier list did. That list also
holds every standard function name, so opening a project renamed pins called
`Max`, `Step`, `TP`, `Left`, `Time` or `Limit` — every one of which STruC++
reads as an `AT` operand without complaint. `validateAliasName` asks the
parser: it writes the operand into a probe declaration and checks that exactly
one variable comes back with that location. `Set` is still refused, because
STruC++ refuses it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01464qguGj42gWdmL2ZoK6Uh
`AT <identifier>` — the editor's I/O alias form — plus the source spans the
declaration patcher works from (`addressSpan`, `nameSpans`, the inline ARRAY
type span) and the CJS parser bundle the Jest suites load the parser through.
All of it is STruCpp#242, released as v0.6.9.

Until now this branch required a build of that PR, so `unit-tests` was red on
every push and said so plainly: "The parser returned declarations in a shape
this editor cannot read." The suite runs green against the published release.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01464qguGj42gWdmL2ZoK6Uh
…s-text-source-of-truth

fix(variables): DOPE-650 declaration text as source of truth, and empty POUs compile
`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
An empty variable box matched every value symbol in scope, the expected-type
filter emptied the direct hits in a POU with no variable of that type, and the
zero-hits fallback then expanded library globals one level. In a new project
that offered OSCAT's SETUP.EXTENDED_ASCII on a BOOL contact: binding it was
accepted, validated green and compiled, and on a coil it emits an assignment
that rewrites a global six OSCAT string functions read.

Gate the member drill-down on a non-empty partial, and keep only candidates
whose root identifier the project declares: the POU interface, the resource
globals, the global variable lists and the SoftMotion axes, which mirror the
documents project-sync feeds the language server. strucpp registers library
globals in the same scope as the project's own with no provenance over the LSP,
so an allow-list is the only way to tell them apart.

Import the scoped-query leaf rather than the st-lsp barrel: the barrel pulls
ESM-only LSP packages the editor's Jest cannot transform.

DOPE-651

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017hqAw17awReo7Cpb45pwNP
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
…tor-cloud-login-project-sync-and-ai-on-cloud-credits

feat(edge): Autonomy Edge on the desktop — account, cloud projects, version control and the AI assistant [DOPE-388] [DOPE-572]
The blank-segment guard also fired for any text ending in a dot, since
splitExpression returns an empty segment for `GVL.`, `s.` or `TON0.` just as it
does for an empty box. A BOOL box holding `GVL.` over a list whose only member
is `TON0 : TON` therefore went empty instead of offering `GVL.TON0.Q`.

Gate on the box being unanchored as well: an empty box still names no instance
to reach into, but an anchor does. The existing tests missed it because every
anchored case they cover has a direct hit.

Raised in review on both PRs.

DOPE-651

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017hqAw17awReo7Cpb45pwNP
…l-autocomplete-library-globals

fix(graphical): stop suggesting library globals in LD/FBD variable boxes (DOPE-651)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wZXUSYRgVbkM7xyDu8e2U
@coderabbitai

coderabbitai Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (1)
  • development

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 071923e3-83ac-421a-8d13-ada78daa3eaa

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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

❤️ Share

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wZXUSYRgVbkM7xyDu8e2U
chore(release): merge main into development for 4.3.1
@JoaoGSP
JoaoGSP merged commit e74fbf8 into main Sep 23, 2026
16 checks passed
dcoutinho1328 added a commit that referenced this pull request Sep 28, 2026
…h-main

Merge pull request #1136 from Autonomy-Logic/development
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