Architectural Deepening Review

OpenHands Agent Canvas

Candidate modules to deepen — narrowing interfaces, hiding implementation, and sealing seams across a 990-file frontend.

repo: @openhands/agent-canvas reviewed: 2026-06-29 surface: 96,082 lines
module seam leakage deep module

Candidate deepenings

Seal the api seam

Strong Inbound leakage
  • ~56 component modules import past the seam, e.g.
  • settings/llm-profiles/llm-settings-local-view.tsx → ProfilesService
  • chat/git-control-bar.tsx → getStoredConversationMetadata

Before

flowchart TB
  comp["components"]
  hooks["hooks"]
  api["api implementation"]
  comp --> hooks
  hooks --> api
  comp -. reaches through .-> api
  linkStyle 2 stroke:#dc2626,stroke-width:2.5px
  classDef m fill:#e2e8f0,stroke:#64748b,stroke-width:1.5px,color:#0f172a;
  class comp,hooks,api m;

After

flowchart TB
  comp["components"]
  hooks["hooks · the only seam"]
  api["api implementation"]
  comp --> hooks
  hooks --> api
  classDef m fill:#e2e8f0,stroke:#64748b,stroke-width:1.5px,color:#0f172a;
  classDef deep fill:#e2e8f0,stroke:#0f172a,stroke-width:3.5px,color:#0f172a;
  class comp,api m;
  class hooks deep;

ProblemComponents depend on the api's implementation, so its internals can't change without breaking them.

SolutionRoute every call through the hooks seam, then forbid the import path so the seam stays sealed.

Wins

  • ▪Components testable in isolation
  • ▪Implementation free to change
  • ▪One enforced dependency direction
ADR: add an import/no-restricted-paths zone — components may not import api. Migrate offenders first, then enable.

Deepen conversation-service over the adapter

Strong Wide interface
  • src/api/agent-server-adapter.ts — 1,071 lines

Before — shallow, wide interface

flowchart TB
  callers["callers"]
  f1["toAppConversation"]
  f2["buildAgentTools"]
  f3["buildStartRequest"]
  f4["validateSubscription"]
  callers --> f1
  callers --> f2
  callers --> f3
  callers --> f4
  classDef m fill:#e2e8f0,stroke:#64748b,stroke-width:1.5px,color:#0f172a;
  class callers,f1,f2,f3,f4 m;

After — narrow interface, hidden depth

flowchart TB
  callers["callers"]
  cs["conversation-service
createConversation()"] s1["serializers"] s2["config-builder"] s3["request-factory"] callers --> cs cs --> s1 cs --> s2 cs --> s3 classDef m fill:#e2e8f0,stroke:#64748b,stroke-width:1.5px,color:#0f172a; classDef deep fill:#e2e8f0,stroke:#0f172a,stroke-width:3.5px,color:#0f172a; class callers,s1,s2,s3 m; class cs deep;

ProblemOne file exposes serialization, config-building, request construction, and subscription auth as a broad flat interface.

SolutionHide the four concerns behind a narrow conversation-service interface; the seams already follow function lines.

Wins

  • ▪Callers learn one entry point
  • ▪Each concern unit-testable
  • ▪Cleanest single-file split

Lift a backend-connection module out of the form

Strong Trapped implementation
  • src/components/features/backends/backend-form-modal.tsx — 1,071 lines

Before — implementation inside UI

flowchart TB
  modal["backend-form-modal"]
  v["host validation / IPv6"]
  t["connection testing"]
  o["device-flow OAuth"]
  modal --- v
  modal --- t
  modal --- o
  classDef m fill:#e2e8f0,stroke:#64748b,stroke-width:1.5px,color:#0f172a;
  class modal,v,t,o m;

After — narrow interface hides it

flowchart TB
  modal["backend-form-modal
(render only)"] conn["backend-connection
connect(host)"] modal --> conn classDef m fill:#e2e8f0,stroke:#64748b,stroke-width:1.5px,color:#0f172a; classDef deep fill:#e2e8f0,stroke:#0f172a,stroke-width:3.5px,color:#0f172a; class modal m; class conn deep;

ProblemValidation, normalization, testing, and OAuth are trapped inside a render module and can't be exercised without it.

SolutionMove them behind a connect(host) interface; the form only renders and calls it.

Wins

  • ▪Connection logic reusable elsewhere
  • ▪Validation testable without DOM
  • ▪Smaller, legible render path

Restore locality across the context modules

Strong Split locality
  • src/context/ — 2 modules  ·  src/contexts/ — 4 modules

Before — two homes

flowchart TB
  subgraph a["src/context/"]
    n["navigation"]
    s["scroll"]
  end
  subgraph b["src/contexts/"]
    bk["active-backend"]
    ws["conversation-websocket"]
  end
  classDef m fill:#e2e8f0,stroke:#64748b,stroke-width:1.5px,color:#0f172a;
  class n,s,bk,ws m;

After — one home, trivia folded into stores

flowchart TB
  subgraph b["src/contexts/"]
    bk["active-backend"]
    ws["conversation-websocket"]
  end
  st["stores (navigation, scroll)"]
  classDef m fill:#e2e8f0,stroke:#64748b,stroke-width:1.5px,color:#0f172a;
  class bk,ws,st m;

ProblemSingular and plural directories hold the same kind of module, so callers can't predict where one lives.

SolutionKeep one directory; fold the two trivial UI contexts into existing stores.

Wins

  • ▪One predictable location
  • ▪No drift between halves
  • ▪Fewer module kinds

Re-home utils.ts by locality

Moderate Low cohesion
  • src/utils/utils.ts — 865 lines, 38 unrelated exports

Before — grab-bag, no interface

flowchart TB
  u["utils.ts
git · status · dom · task · mobile"] classDef m fill:#e2e8f0,stroke:#64748b,stroke-width:1.5px,color:#0f172a; class u m;

After — cohesive modules

flowchart TB
  g["git.ts"]
  s["status.ts"]
  d["dom.ts"]
  t["task.ts"]
  classDef m fill:#e2e8f0,stroke:#64748b,stroke-width:1.5px,color:#0f172a;
  class g,s,d,t m;

ProblemUnrelated helpers share one module, so it has no coherent interface and absorbs every new helper.

SolutionGroup exports by locality into domain modules; keep cn() where everything imports it.

Wins

  • ▪Helpers found by name
  • ▪No default dumping ground
  • ▪Cohesive, searchable modules

Top recommendation

Deepen the conversation-websocket module behind an event-dispatch seam

The single highest-leverage deepening: the most central, most-coupled module in the codebase presents a wide interface that mutates eight stores directly.

Highest leverage
  • src/contexts/conversation-websocket-context.tsx — 1,089 lines
  • also resolves: conversation state owned by three modules at once

Before — wide interface, direct mutation

flowchart LR
  ws["conversation-websocket-context"]
  s1["event store"]
  s2["error store"]
  s3["optimistic store"]
  s4["state store"]
  s5["command store"]
  s6["browser store"]
  s7["metrics store"]
  ws --> s1
  ws --> s2
  ws --> s3
  ws --> s4
  ws --> s5
  ws --> s6
  ws --> s7
  linkStyle 0,1,2,3,4,5,6 stroke:#dc2626,stroke-width:2px
  classDef m fill:#e2e8f0,stroke:#64748b,stroke-width:1.5px,color:#0f172a;
  class ws,s1,s2,s3,s4,s5,s6,s7 m;

After — one seam, hidden coordination

flowchart LR
  ws["websocket-connection
(connection only)"] d["ConversationEventDispatcher
dispatch(event)"] st["stores"] ws -. dispatch .-> d d --> st classDef m fill:#e2e8f0,stroke:#64748b,stroke-width:1.5px,color:#0f172a; classDef deep fill:#e2e8f0,stroke:#0f172a,stroke-width:3.5px,color:#0f172a; class ws,st m; class d deep;

ProblemThe module bundles connection, event dispatch, queueing, and cache work, and reaches directly into eight stores — so every consumer couples to the whole lifecycle and any store change ripples back in.

SolutionIntroduce a ConversationEventDispatcher adapter as the one seam translating events into store writes; the connection module then exposes only connect / disconnect.

Wins

  • ▪Store coordination hidden behind one seam
  • ▪Dispatch logic testable without sockets
  • ▪Connection and queueing split apart
  • ▪Opens path to one state owner
ADR: highest-risk deepening — gate on a written decision and strong tests around the socket path. Declare the React Query cache the canonical owner of server-derived conversation state; the dispatcher writes into it, leaving Zustand for UI-only state. Sequence after the lower-risk candidates land.