Skip to content

Proposal: decompose large apps/web component files into vertical slices (ports in-slice, transport in providers/, DTOs in contracts) #5201

Description

@leonaburime-ucla

Summary

Several apps/web component files have grown into god-components that are hard to test and change. SettingsDialog.tsx is ~8,500 lines (its main component alone is ~4,300 lines with ~30 useState), and it has leaked into an accidental utility module — other files import runtime helpers out of it (ConnectorSection, mergeProviderModelOptions, providerModelsCacheKey). It's not the only one: FileViewer.tsx (~12.8k), ProjectView.tsx (~8.7k), MemorySection.tsx (~2.6k, a single ~1,900-line function with 39 useState), and others share the shape.

I'd like to propose a consistent decomposition pattern for these files and get maintainer feedback before opening any PR. I'm not asking to rename the app or adopt a new methodology — the proposal is to extend the layering this repo already has and finish an extraction that's already half-done (some settings sections — PrivacySection, DesignSystemsSection, RoutinesSection — already live in their own files; most don't).

The pattern is vertical slicing — colocating everything for one capability (its state, logic, UI, and the interface to its data) in a single features/<capability>/ folder, instead of smearing it across components/, hooks/, and providers/. It's the well-known organizing principle behind Feature-Sliced Design. To be clear about scope: I'm proposing the principle (capability-cohesive folders + enforced boundaries), not adopting FSD's full layer taxonomy (app/pages/widgets) or its steiger linter — those would collide with the Next.js App Router's reserved app/ and duplicate this repo's hand-rolled guard scripts. The repo already has an (empty) apps/web/src/features/ directory, so this just populates the home that's already there.

The core observation

The problem isn't file length — it's state that leaked upward. Most of SettingsDialog's ~30 shell useState aren't dialog state; they're section state (BYOK, provider-model fetch, AMR wallet, agent diagnostics) that was never given its own home, so it piled into the parent. Relocating each cluster into the section that owns it collapses the shell to ~5 useState almost as a side effect. MemorySection is the proof that extraction alone is cosmetic: it was already pulled into its own file and it's still a 1,900-line blob — because the JSX moved but the state never got decomposed.

Proposed structure — extend the seams that already exist

This repo is already coarsely layered; the god-files just collapse those layers back into one file. Four homes, each of which already exists:

Concern Home (already in the repo)
Wire DTOs + SSE event unions (shared web↔daemon) packages/contracts/src/api/
Transport adapters — fetch, EventSource/SSE, OAuth browser bridges apps/web/src/providers/
Ports, pure rules, UI-only types, state hooks, components apps/web/src/features/<slice>/ (currently a stub — one file)
Tests apps/web/tests/features/<slice>/ (per the repo's "src is source-only" rule)

Reasoning, in plain terms:

  1. Wire DTOs go to contracts, never in a slice. The daemon is a proven second consumer of these shapes — that's why contracts exists. Owning them per-slice means either duplication (drift → runtime wire bugs) or re-export theater.
  2. API adapters go to providers/, not in the slice. The endpoints these files call are already shared across many surfaces — /api/memory is fetched from ~6 components, /api/mcp/install-info from both SettingsDialog and UseEverywhereModal. A hand-rolled guard can't catch a contributor writing a fresh duplicate fetch() in a new slice (there's no import to count), so keeping transport in one place per resource is the only way to prevent divergent retry/error/auth handling. The slice reaches transport only through a small dependencies.ts that binds a provider to a port.
  3. The slice owns its port (ports.ts) — the interface it depends on. This is the load-bearing decision (see testing below).
  4. UI-only logic stays in the slice — view-models, "is Save enabled", dirty/signature checks, filters → rules.ts (pure) and types.ts. These have no business in a package the daemon depends on. The forwardRef imperative-handle type (e.g. McpClientSectionHandle) is a React-component contract → exported from the slice barrel, not contracts.
  5. Cross-cutting cfg/autosave → a SettingsConfigProvider context, so sections stop prop-drilling cfg/setCfg.
  6. Hooks stay feature-local — no shared/global hook layer. When two slices need similar logic, each owns its own hook rather than importing a shared one; reuse isn't a goal, because slices tend to need highly specific behavior and duplicated wiring is cheap and safe to let diverge. The sharing rule is asymmetric on purpose: share only what correctness forces — wire DTOs (divergence = a wire break) and transport adapters (divergence = a retry/auth bug) — and duplicate mere convenience, since a hook is just an injected port composed with pure rules. To be explicit: this introduces no new data-layer dependency — no TanStack/SWR; caching stays hand-rolled and behavior-preserving.

Enforcement stays in this repo's existing idiom — a hand-rolled guard script (like check-cross-app-imports.ts) wired into pnpm guard: no fetch/EventSource/window/localStorage in feature or pure files; one transport home per route; no cross-slice internal imports. No new external linter.

Why this is easier to test

Today, testing a section means mounting a ~2k-line component and mocking global fetch (or module paths). Under this structure, the slice depends on an in-slice port, so:

  • Hooks/orchestration test against a hand-written fake implementing ports.ts — no fetch mocking, no MSW, no vi.mock by file path.
  • Pure rules (rules.ts — enablement, dirty checks, filtering) test with zero test doubles.
  • Fixtures are typed against contracts, so a wire-shape change breaks the test at compile time instead of at runtime.
  • Transport (providers/) gets its own thin tests against contract fixtures.
  • The forwardRef handle is tested through the port fake, no DOM needed.

Suggested rollout — canary first

Rather than start on the 8,500-line dialog, I'd validate the pattern on MemorySection.tsx first: it's self-contained, has a single consumer, already has a test, and is a pure specimen of the disease. If the pattern holds there (and the team likes it), apply it to the dialog in small, test-gated, behavior-preserving PRs (helpers → sections → hooks).

Example: MemorySection.tsx under the proposed structure

packages/contracts/src/api/
  memory.ts                    # config/index/tree/entry/extraction DTOs + MemoryEvent SSE union
  connectors.ts                # discovery + suggestion DTOs

apps/web/src/providers/
  memory.ts                    # /api/memory, /config, /index, /tree, /extractions
  memory-events.ts             # /api/memory/events  (EventSource lives ONLY here)
  memory-connectors.ts         # /api/memory/connectors/suggest
  connectors-discovery.ts      # /api/connectors/discovery
  connectors-auth.ts           # OAuth window-message + localStorage pending-auth bridge

apps/web/src/features/memory/
  index.ts                     # barrel — exports MemorySection only
  MemorySection.tsx            # orchestrator: tabs, modals, flash
  dependencies.ts              # binds providers/* -> ports (only file importing providers)
  ports.ts                     # MemoryApiPort, MemoryEventsPort
  types.ts                     # UI view-models (edit-session, tree VM) derived from contracts
  rules.ts                     # pure: index dirty-check, entry filtering, config validation
  hooks/
    useMemoryConfig.ts         # enabled / chatExtraction / profile / rewrite / verify / rootDir
    useMemoryIndex.ts          # index, indexDraft, editing            (edit session)
    useMemoryEntries.ts        # entries, memoryTree, preview*, filter  (browse)
    useExtractions.ts          # extractions  (+ subscribes via MemoryEventsPort)
    useNowClock.ts             # nowClock
  components/
    ConfigPanel.tsx  IndexEditor.tsx  EntryTree.tsx  EntryPreview.tsx
    ExtractionsPanel.tsx  AddEntryModal.tsx  AdvancedModal.tsx
  connectors/                  # nested sub-slice (the ~15-state connector sub-domain)
    ports.ts  types.ts  rules.ts  dependencies.ts
    hooks/
      useConnectors.ts  useConnectorAuth.ts  useConnectorExtraction.ts
    components/
      ConnectorsPanel.tsx  ConnectorCard.tsx  SuggestionList.tsx

apps/web/tests/features/memory/  # ports faked here; SSE + OAuth driven through fakes
  rules.test.ts  useMemoryIndex.test.ts  useExtractions.test.ts  connectors/*.test.ts

Why providers/ and the DTOs sit outside the feature folder: these endpoints are already consumed by multiple surfaces (/api/memory from ~6 components, /api/mcp/install-info from 2), so a shared home is the less scattered option — putting the adapter inside one slice would force the other consumers to either reach into that slice or duplicate the fetch (which then drifts on retry/error/auth). The slice stays self-contained through its in-slice port (ports.ts) — the interface it depends on — while the transport that satisfies that port is shared; if the slice is deleted, ts-prune sweeps any now-orphaned adapter.

The nested connectors/ sub-slice exists because it's a ~15-state sub-domain with its own endpoints and OAuth lifecycle — a bounded context inside the bounded context.

Questions for maintainers

  1. Is decomposing these large apps/web files something you'd welcome PRs for, and is this the direction you'd want — or do you have an existing convention/preference I should follow instead?
  2. Any objection to populating features/ this way, and to a new pnpm guard script enforcing the transport/slice boundaries?
  3. Is MemorySection a good first canary, or would you prefer a different starting file?
  4. Anything in the four-homes split you'd draw differently (e.g. where SSE/OAuth browser bridges live)?

Happy to adjust the shape to match your taste before writing any code.

Metadata

Metadata

Assignees

No one assigned

    Labels

    questionFurther information is requested

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions