Skip to content

feat: RemoteAgent zero-cost path + desktop fixes + Workflow builder rebuild - #140

Merged
hoangsnowy merged 5 commits into
mainfrom
fix/web-remote-agent-provider
Aug 9, 2026
Merged

feat: RemoteAgent zero-cost path + desktop fixes + Workflow builder rebuild#140
hoangsnowy merged 5 commits into
mainfrom
fix/web-remote-agent-provider

Conversation

@hoangsnowy

@hoangsnowy hoangsnowy commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Change description

Trying to run the 5-agent pipeline on a paired dev machine's CLI — the one provider that spends zero server API tokens — turned out to be impossible on main. Five defects sat in a chain: each was only reachable once the previous one was cleared, which is why they ship together.

# Defect Symptom before
1 The Web host never loaded Modules.RemoteAgent, but Board renders a "Run on my machine" toggle whose session sets ProviderOverride = "RemoteAgent" and executes in the Web process LlmException: LLM provider 'RemoteAgent' … is not registered the moment a member used the toggle — a shipped control the host rendering it could not honour
2 /hubs/remote-agent was mapped only on the Api POST /hubs/remote-agent/negotiate405 on the Web origin. IRemoteAgentBroker is in-process with no backplane, so a runner paired to the Api was invisible to the Web's dispatch
3 RunnerEntity documents Pending → Paired (first successful connect), but nothing ever wrote "Paired" A machine that genuinely paired still read Pending forever — indistinguishable from the failure it looks like
4 RemoteAgentLlmClient took ITenantContext by constructor injection Cannot resolve 'ILlmClient' from root provider because it requires scoped service 'ITenantContext' on every host with a scoped tenant context — i.e. every real deployment
5 The runner launched the CLI with UseShellExecute = false, which does not apply PATHEXT, and npm installs the agent as claude.cmd / claude.ps1 — never claude.exe Win32Exception: The system cannot find the file specified on every dispatch on Windows

Two smaller items came out of the same pass: RemoteAgent was missing from the Settings → Providers dropdown (so the zero-token path was reachable only by editing config), and the runner logged a bare ok=False, building its error from stderr alone while CLI agents print the reason on stdout.

Notes on the shape of the fixes

  • The Web maps one endpoint, not MapModuleEndpoints(). It serves a UI, not the API's REST surface, so it opts in per endpoint exactly as PairingEndpoints already does. The hub stays anonymous by design — it authenticates the connection against the runner's salted pairing-token hash.
  • MarkPairedAsync bypasses the tenant query filter for the same reason FindForPairingAsync does (the handshake carries no authenticated tenant; the token is the credential), leaves a revoked runner untouched, and takes CancellationToken.None — the pairing already happened and a connection dropping a millisecond later must not roll it back.
  • RemoteAgentLlmClient now resolves the tenant per call inside a fresh scope, the same shape PooledChatLlmClient uses.

Type of change

  • New feature (new agent / endpoint / capability)
  • Bugfix
  • Refactor (no behavior change)
  • Documentation / README
  • Infrastructure / CI configuration

Test plan

  • dotnet build AgentOs.slnx --configuration Release0 warnings, 0 errors
  • dotnet test AgentOs.slnx --configuration Release839 passed, 0 failed, 14 skipped (832 on main; seven added here)
  • New tests: the Web assembly must reference Modules.RemoteAgent; a Web-like module set must resolve "RemoteAgent" through LlmClientFactory; a set without it must still fail loudly; Pending → Paired stamps last-seen; a revoked runner stays revoked; a runner from another tenant still updates; and the keyed RemoteAgent client resolves from the root provider under ValidateScopes.

End-to-end on the running Aspire stack

Full stack via dotnet run --project infra/AgentOs.AppHost, real Keycloak login, real Postgres.

Step Evidence
Hub reachable on the Web origin POST /hubs/remote-agent/negotiate 405 → 200 (Api unchanged at 200)
OIDC chain intact /account/login → 302 with request_uri=urn:ietf:… (PAR accepted)
Runner pairs to the Web [agent] connected to https://localhost:5180/hubs/remote-agent
UI reflects pairing Board → Runners flipped Pending → Paired
Provider selectable + persisted Settings → Providers shows RemoteAgent (paired dev machine CLI — 0 server tokens), saved encrypted per tenant, survives a stack restart
Dispatch reaches the local CLI [agent] Execute … (model=claude-haiku-4-5) / [agent] cli=claude cmd=claude prompt=stdin
Failures are legible [agent] -> … ok=False: exit 1: Not logged in · Please run /login

That last line is the remaining blocker and it is not a code path: the claude CLI on the dev machine has to be signed in once (claude/login). Before this PR the CLI never launched at all.

Checklist

  • dotnet build passes locally in Release mode
  • dotnet test passes
  • No secrets committed (API keys, connection strings)
  • README / docs updated if public behavior changed (inline comments at each seam)
  • Measurable results provided when related to agent output quality (N/A — wiring + diagnostics)

🤖 Generated with Claude Code


Follow-on work landed on this branch (same session)

After the RemoteAgent path worked end to end, this branch continued into the demo-blocking desktop bugs and a full Workflow-app rebuild. All verified on the running app (standalone Web via Node Playwright, light + dark; full Aspire stack for the RemoteAgent run).

Desktop / correctness fixes

  • Offline green-lie — Settings → Test connection rendered a green "OK · Offline" for a probe whose real provider never answered. Now gated by OfflineLlmClient.IsSubstituteFor; shows Failed + "nothing reached a real model".
  • Provider badges — every agent row (incl. the Orchestrator) now names the provider the call actually uses; forcing RemoteAgent no longer renders "Azure".
  • Minimize destroyed appsWindowHost dropped minimized / off-workspace windows from the render tree, so Blazor disposed them (a running pipeline, typed input, produced artifacts — gone). Windows now stay mounted and are hidden with CSS.

Workflow app rebuilt as an n8n / Opal-class visual builder

  • G1 — three-region layout: searchable node library (left) · canvas + run drawer (centre) · docked inspector (right). Slim toolbar, bigger default window.
  • G2 — ⌘K command palette + "+ Add" button, and auto-connect (adding while a node is selected wires the edge).
  • G3 — per-node run status dots + a Run section in the inspector (status / tokens / cost / failure reason).
  • G4 — session-scoped recent-runs list (replaces "history not persisted"); fit-to-view clamped so it never over-zooms. Undo/redo deferred.
  • G5 — RemoteAgent now reports estimated token counts (the CLI gives none) and the status bar shows the would-be metered-API cost next to the real $0 spend — "$0.0000 cost (≈ $0.0071 API)". Never presented as billed cost.

Cumulative test state: Release build 0 warnings / 0 errors; check-classes.ps1 clean; 847 tests pass, 14 skipped. The Workflow preview card was pushed to the Claude Design project (ui_kits/agentos/workflow.html).

hoangsnowy and others added 5 commits August 9, 2026 18:23
Board renders a "Run on my machine — your paired runner's CLI does the work
(0 server tokens)" toggle. Ticking it makes BoardApp.RunSession build an
IssueWorkRequest with ProviderOverride = "RemoteAgent", and that session executes
in the Web process (Task.Run -> ScopeFactory.CreateScope). But the Web host loaded
ten module assemblies and RemoteAgent was not among them, and the keyed
ILlmClient under "RemoteAgent" is registered by RemoteAgentModule alone. So the
toggle threw

    LLM provider 'RemoteAgent' (resolved to 'RemoteAgent') is not registered.
    Built-in: Claude | AzureOpenAI | MAF | RemoteAgent.

the moment a member used it — a shipped control the host rendering it could not
honour. Nothing catches this at compile time: the provider name is a string
crossing a DI seam.

Two changes, both on the Web host:

- Reference AgentOs.Modules.RemoteAgent and add it to AddModulesFromAssemblies,
  which registers the keyed provider, the in-process broker, and runner_shell.
- Map /hubs/remote-agent explicitly. The Web deliberately does not call
  MapModuleEndpoints (it serves a UI, not the API's REST surface), so it opts in
  one endpoint at a time, exactly as PairingEndpoints already does. The hub has to
  exist on this origin because IRemoteAgentBroker is in-process with no backplane:
  a runner paired to the Api's hub is invisible to the Web's dispatch. The hub
  stays anonymous by design — it authenticates the connection itself against the
  runner's salted pairing-token hash.

Verification: Release build 0 warnings / 0 errors; 835 tests pass, 14 skipped
(832 before, plus the three added here). On the running Aspire stack,
POST /hubs/remote-agent/negotiate on the Web origin went from 405 to 200 while
the Api's stayed 200, and the OIDC chain still returns 302 with request_uri=urn:.

The new tests pin the seam three ways: the Web assembly must reference
Modules.RemoteAgent, the Web-like module set must resolve "RemoteAgent" through
LlmClientFactory, and a module set without it must still fail loudly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
RunnerEntity documents "Pending → Paired (first successful connect) → Revoked",
but no code ever wrote "Paired". RemoteAgentHub.OnConnectedAsync verified the
token, registered the connection with the broker, and left the row alone — so a
dev machine that had genuinely paired still read "Pending" in Board's Runners
table forever. The UI reported a broken pairing that in fact worked, which is
indistinguishable from the failure it looks like.

IRunnerDirectory gains MarkPairedAsync, called from the hub only after the
presented token has been verified. It bypasses the tenant query filter for the
same reason FindForPairingAsync does — the handshake carries no authenticated
tenant, the token is the credential — and it leaves a revoked runner untouched.
It is passed CancellationToken.None rather than Context.ConnectionAborted: the
pairing already happened, and a connection dropping a millisecond later must not
roll the fact back.

Tracked load + SaveChanges instead of ExecuteUpdate: the connect path touches one
row, so the extra round-trip does not matter, and it keeps working on the EF
InMemory provider the tests use.

Verification: Release build 0 warnings / 0 errors; 838 tests pass, 14 skipped
(three added here, covering Pending → Paired with a last-seen stamp, a revoked
runner staying revoked, and a runner belonging to another tenant still updating).
End to end on the running Aspire stack: the runner reported "connected to
https://localhost:5180/hubs/remote-agent" and Board's Runners table flipped from
Pending to Paired.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Selecting the paired dev machine as the LLM provider failed at three separate
points. Each one only became reachable once the previous was cleared, so they are
fixed together.

1. Settings → Providers listed Claude / AzureOpenAI / MAF but not RemoteAgent,
   even though LlmClientFactory treats it as a first-class keyed provider and it
   is the only one that spends no server API tokens. The zero-token path was
   reachable only by editing config. Added to the dropdown.

2. RemoteAgentLlmClient took ITenantContext by constructor injection, so resolving
   it from the root provider threw "Cannot resolve 'ILlmClient' from root provider
   because it requires scoped service 'ITenantContext'" on any host with a scoped
   tenant context — which is every real deployment (Keycloak's HttpTenantContext).
   The Test-connection probe hit it immediately. It now takes the provider and
   resolves the tenant per call inside a fresh scope, the same shape
   PooledChatLlmClient already uses; LlmClientScopeValidationTests covers the
   pooled clients and now covers this one too.

3. The runner could not launch the CLI on Windows at all. With
   UseShellExecute = false, .NET does not apply PATHEXT, and npm installs the
   agent as claude.cmd / claude.ps1 — never claude.exe — so ProcessStartInfo
   ("claude") threw "The system cannot find the file specified" on every dispatch.
   The runner now probes PATH with PATHEXT, as a shell would, and leaves rooted or
   already-extensioned commands untouched.

Verification: Release build 0 warnings / 0 errors; 839 tests pass, 14 skipped. On
the running Aspire stack the whole chain is live — the runner logs "Execute …
(model=claude-haiku-4-5)" and "cli=claude cmd=claude prompt=stdin" for a probe
issued from the desktop, so Web → hub → runner → local CLI is connected. The CLI
itself then reports "Not logged in · Please run /login", which is a per-machine
credential step, not a code path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The runner console printed only "ok=False", so CLI-not-on-PATH, CLI-not-logged-in
and a plain non-zero exit were indistinguishable from the one place an operator
actually watches while pairing a machine. It also built its error from stderr
alone, but CLI agents routinely print the reason on stdout and exit non-zero —
claude's "Not logged in · Please run /login" is exactly that — so the server
received a bare "exit 1: " with the one actionable line dropped.

The failure line now carries the error, and the error falls back to stdout when
stderr is empty. On the running stack a probe from the desktop now logs

    [agent] -> ddb36bce… ok=False: exit 1: Not logged in · Please run /login

which is what the previous commit's PATH fix made reachable: before it, the CLI
never launched at all.

839 tests pass, 14 skipped; Release build 0 warnings / 0 errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nswered

LlmClientFactory appends the keyless Offline client to the END of every failover
chain when Llm:OfflineFallback is on, so a call can "succeed" with canned,
schema-valid text while nothing reached a model. Settings → Providers → Test
connection set _testOk = true unconditionally on any non-throwing call, so
probing an unreachable provider rendered a green "OK · Offline" chip. A
connectivity probe that greenlights an unreachable provider asserts the opposite
of what happened; the failure it hides is less damaging than the false assurance.
The Api's /llm/test had the same bug and additionally reported client.Provider —
the chain's PRIMARY — rather than the provider that actually answered.

OfflineLlmClient.IsSubstituteFor(requested, answered) names the condition once:
Offline answered for a provider that is not Offline. Both hosts gate on it, both
now label the responder from the response, and the failure message says what
happened and why ("Llm:OfflineFallback is on. Nothing reached a real model.")
rather than leaving an operator to infer it from a provider name in a chip.

Asking for Offline and getting Offline stays a success, and failover between two
REAL providers stays a success — only the canned stand-in is a lie.

Release build 0 warnings / 0 errors; 847 tests pass, 14 skipped (six added here).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@hoangsnowy
hoangsnowy merged commit a489524 into main Aug 9, 2026
4 checks passed
@hoangsnowy hoangsnowy changed the title fix(remote-agent): Make Run-on-my-machine work end to end feat: RemoteAgent zero-cost path + desktop fixes + Workflow builder rebuild Aug 9, 2026
hoangsnowy added a commit that referenced this pull request Aug 10, 2026
…140) (#142)

* fix(web): Badge each agent with the provider the call actually uses

PipelineStudio rendered a per-agent provider chip from a hardcoded map
(Requirement/Qa/Aggregate = anthropic, Coding/Testing = azure). A Force provider
overrides every agent's own setting — LlmClientFactory resolves the forced key,
not Agents:<Stage>:Provider — so while one is active that map does not merely go
stale, it contradicts the run. Forcing RemoteAgent still rendered "Azure" next to
Coding and Testing, telling the operator their work went to a cloud endpoint when
every token had been produced by the CLI on their own machine. On a run whose
entire point is "0 server tokens", that is the one label that must not lie.

ProvName now returns the effective Force provider when there is one (read from
the same LlmConfigView snapshot the "Live · provider" chip and the run itself
use), and falls back to the per-agent map otherwise.

Verified against the run that exposed it: with Force = RemoteAgent, the pipeline
completed as Requirement (7 FR, 2 entities) -> Coding (17 files) -> Testing
(21 tests, ~85% cov) -> QA (pass, 0.87) -> Aggregate at 0 tok / $0.0000 / 193.3s,
every call dispatched to the paired runner's claude CLI.

Release build 0 warnings / 0 errors; 847 tests pass, 14 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(web): Badge the Orchestrator row from the effective provider too

The leader row's chip was the literal "Claude" in markup, so after the previous
commit the five specialist rows correctly read RemoteAgent while the Orchestrator
above them still named Anthropic — and the leader row is the first thing a viewer
reads. The colour class was also still driven by the hardcoded per-agent map, so a
badge could read "RemoteAgent" while tinted as Azure.

Both now derive from the effective config: LeaderProv returns the Force provider
when one is set, and ProvCss maps any provider name onto the two badge classes
that actually exist in app.css (.prov.anthropic / .prov.azure) rather than
inventing a class no stylesheet defines — a phantom class renders unstyled, which
design-system.md calls out as a past cause of broken windows.

Verified on the running Aspire stack with Force = RemoteAgent: all six rows
(Orchestrator + the five specialists) read RemoteAgent, matching the
"RemoteAgent (forced)" toolbar chip. check-classes.ps1 reports no phantom classes
(514 defined, all static .razor tokens resolve). Release build 0 warnings /
0 errors; 847 tests pass, 14 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(shell): Minimizing a window no longer destroys the app inside it

WindowHost rendered only `!w.Minimized && w.Workspace == ActiveWorkspace`. Blazor
disposes a component the moment it leaves the render tree, so minimizing a window
— or switching virtual workspace — threw away everything it held: a pipeline run
in flight, the story typed into it, the artifacts already produced. The
server-side work kept going with nowhere to land. On a desktop metaphor, minimize
must hide a window, not kill the app inside it.

The stylesheet had assumed this all along: `.appwin.minimized { display: none }`
has existed since the chrome was written, and was simply unreachable. WindowHost
now renders every open window and lets CSS hide it; AppFrame adds `off-workspace`
for a window parked on another workspace, with a matching rule next to the
minimized one.

Verified on the running Aspire stack: with a probe string typed into the Agents
window, clicking Minimize leaves the window mounted (`appwin focused minimized`,
`display: none`) with the textarea value intact — where it previously vanished.
check-classes.ps1 clean (515 defined, all static .razor tokens resolve); Release
build 0 warnings / 0 errors; 847 tests pass, 14 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(web): Rebuild the Workflow app as an n8n/Opal-class visual builder

The Workflow editor put a 14-chip "Add step" strip across the top, floated the
inspector over the node it was editing, and stacked new nodes on top of each
other via a modulo formula — it read as a diagram toy, not a builder, and the
user could not tell what a run produced without opening the separate Pipeline
app. This restructures it into the three-region layout every serious builder
(n8n, Google Opal, OpenAI Agent Builder) uses, and surfaces run results in place.

Layout (G1):
- LEFT — a searchable node library, grouped Agents / Flow / Data / Control, each
  node a card with icon, name and a one-line hint, colour-coded to the category
  it will paint on the canvas. Replaces the always-on chip strip (progressive
  disclosure — the palette is there when you want a node, not in your face).
- CENTER — the canvas, with a proper zoom cluster (in / % / out / fit), the
  minimap, and an empty-state hint when there are no nodes.
- RIGHT — a docked, persistent inspector that pushes the canvas rather than
  covering it; shows an empty-state when nothing is selected.
- Toolbar slimmed to workflow-switch, name, live-provider chip, Save, Run, and an
  overflow "⋯" menu for Duplicate / panel toggle / Delete. Default window grown
  1080×660 → 1320×840 so the canvas is actually usable.

Run visibility (G3):
- Each node carries a live status dot in its head (spinner while running, ✓ done,
  ✕ failed, – skipped) so progress reads on the card, not only in the log.
- The docked inspector gains a Run section: selecting a node shows its status,
  its metric line (tokens / cost / elapsed) and, on failure, the reason — so
  "what did this node produce / why did it fail" is answerable without leaving
  the canvas. RunMessage is captured per node for that.

Node insertion no longer stacks: a new node lands to the right of the selected
(or rightmost) node on the same row, so it reads as the next step.

Verified on the standalone Web with Node Playwright, light + dark: the three
regions render correctly, the docked inspector populates on node-select, and a
run lights every node's status dot (green ✓) with per-node token/cost meta plus
a "RUN · DONE" section in the dock. Release build 0 warnings / 0 errors;
check-classes.ps1 clean (553 defined, all static .razor tokens resolve); 847
tests pass, 14 skipped. Pushed the matching preview card to the Claude Design
project (ui_kits/agentos/workflow.html).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(llm): Estimate tokens + would-be API cost on the zero-cost RemoteAgent path

Running on a paired dev machine spends the member's own flat subscription, so the
server cost is genuinely $0 — the whole point of the RemoteAgent provider. But the
CLI reports no usage, so every run read "0 tok · $0.0000", which looks broken and
hides the one number a demo wants: what this work would have cost on a metered API.

Two honest additions, neither of which claims a billed spend:

- RemoteAgentLlmClient now estimates token counts from the text it actually sent
  and received (~4 chars/token, the standard English heuristic) and reports them on
  the response + span. CostUsd stays 0m — the estimate is deliberately NOT folded
  into billed cost. "0 tok" becomes a real figure everywhere tokens are shown.
- The Agents status bar accumulates, per call, what those tokens WOULD cost priced
  by the model each agent used (CostCalculator). When nothing was billed but tokens
  were spent, it shows "$0.0000 cost (≈ $X API)" — the saving, clearly labelled,
  never as actual spend. For real cloud providers the estimate equals the billed
  cost, so the parenthetical only appears on the $0 path.

Verified on the standalone Web with Node Playwright: a pipeline run shows
"1964 tok (1532↑ / 432↓) · $0.0000 cost (≈ $0.0071 API)". Two RemoteAgent tests
that pinned the old "0 tokens" contract were updated to the new intent (zero cost,
estimated tokens > 0). Release build 0 warnings / 0 errors; check-classes.ps1
clean; 847 tests pass, 14 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(web): Command palette + auto-connect for the Workflow builder

Building a workflow still meant clicking a node in the rail, then dragging an edge
by hand — and there was no keyboard-first way to add a node at all. This adds the
two interactions that make n8n / Opal feel fluid.

Command palette (⌘K / Ctrl+K, or the toolbar "+ Add"):
- A centred quick-add over the canvas. Type to filter every node type by name or
  hint, ↑/↓ to move the highlight, Enter to add, Esc to close. The global key is a
  document-level listener (workflow-keys.js) routed back to the component through a
  JSInvokable; registration is best-effort and CircuitSafe, so a missing script
  degrades to the "+ Add" button rather than crashing the circuit.

Auto-connect:
- Adding a node while one is selected now also draws the edge from it to the new
  node — the "add the next step" gesture — so a chain is one action per step
  instead of add-then-drag. The edge is written to the graph the same way
  BuildDiagram materialises persisted ones, so it survives Save/reload. Combined
  with the earlier no-stack positioning, new nodes land to the right of the anchor
  and wire up automatically.

Verified on the standalone Web with Node Playwright: Ctrl+K opens the palette,
typing "eval" narrows it to Evaluator with the keyboard highlight and ↵ hint, and
adding a node while Testing Agent was selected wired Testing→Print with a visible
edge. Release build 0 warnings / 0 errors; check-classes.ps1 clean (564 defined);
847 tests pass, 14 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(web): Real recent-runs list + honest fit-to-view for the Workflow builder

Two canvas-polish items from the builder rebuild.

Recent runs:
- The Recent Runs tab showed a bare counter that reset every time the window
  closed, with the label "demo — history not persisted". It now records each run
  (workflow name, time, completed/stopped, a one-line outcome) in the
  OrchestrationStore, keyed per tenant and bounded to 25. The list is session-scoped
  and in-memory — it survives closing and reopening the Workflow window, which the
  old counter did not — and the label says exactly that ("this session — full
  history lives in the Pipeline run store") rather than pretending to be durable.
  Each row shows a green/red status dot, the name, the outcome and the timestamp.

Fit-to-view:
- "Fit" could zoom IN past 2.6× when the diagram library measured a small or
  just-added bounding box, which looked broken. It is now clamped so fit never
  exceeds 1:1 — it frames the whole graph or stops at 100%, never zooms in.

Verified on the standalone Web with Node Playwright: two runs produce two
timestamped rows in Recent Runs (badge "2", green dots), and Fit reframes the full
five-node graph at 56% instead of over-zooming. Release build 0 warnings / 0
errors; check-classes.ps1 clean (569 defined); 847 tests pass, 14 skipped.

Deferred: canvas undo/redo — the one remaining builder item, held back because a
correct command-history stack over the diagram is a larger, riskier change best
done on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(web): Undo/redo for the Workflow canvas (⌘Z / ⌘⇧Z)

The one deferred builder item. Snapshot-based, not a command stack: each
structural edit deep-copies the whole graph (nodes + edges + positions) to JSON
and pushes it, so undo is "restore this snapshot + rebuild the canvas" with no
per-operation inverse to get wrong — the graph is already the serializable source
of truth. Covers the operations that go through the app: add node, delete node,
and the auto-connect that rides on an add. A manual canvas edge-draw or node drag
folds into the next snapshot rather than being individually undoable, which keeps
this free of re-entrant diagram-event coupling. History is bounded to 50.

Toolbar gains undo/redo icon buttons (disabled when their stack is empty) and the
global ⌘Z / ⌘⇧Z / ⌘Y shortcuts route through workflow-keys.js to OnUndoKey /
OnRedoKey.

One non-obvious fix this required: the DiagramCanvas was keyed by graph id alone,
so restoring the SAME graph after an undo left the canvas bound to the previous
BlazorDiagram instance and nothing re-rendered. The key now includes a version
counter bumped on every BuildDiagram, so an undo/redo of the current graph forces
a fresh canvas. Two undo/redo arrow icons added to the shared Icon component.

Verified on the standalone Web with Node Playwright: adding a node takes the graph
5→6, ⌘Z returns it to 5, ⌘⇧Z back to 6 — with the toolbar buttons and minimap
reflecting each step. Release build 0 warnings / 0 errors; check-classes.ps1 clean
(569 defined); 847 tests pass, 14 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant