# Frontend (Vite/React SPA) — architecture and conventions Scoped guidance for `apps/web/`. Repo-wide rules (commit format, code-quality limits, etc.) live in the root `AGENTS.md`. ## Plugin authoring For plugin UI work, begin with the [canonical plugin authoring guide](../../docs/public/plugins-authoring.md). Follow: choose recipe → edit `manifest.yaml` → implement → validate → package → smoke test. The frontend contract pair is `../../docs/plans/plugins/PLUGIN-API.md` plus `lib/plugins/types.ts`; concrete shared Host UI exports are in `lib/plugins/host-api.ts`, and registration/cleanup behavior is in `lib/plugins/registry.ts` and `lib/plugins/host.ts`. Keep the guide and that contract pair synchronized; do not invent hooks such as task panels, task-menu actions, per-user `host.storage`, rich-text components, or Kanban-card injection when they are absent from the current source. ## UI Components **Shadcn Components:** Import from `@kandev/ui` package: ```typescript import { Badge } from "@kandev/ui/badge"; import { Button } from "@kandev/ui/button"; import { Dialog } from "@kandev/ui/dialog"; // etc... ``` **Do NOT** import from `@/components/ui/*` - always use `@kandev/ui` package. - Always prefer native shadcn components over custom implementations. - Check `apps/packages/ui/src/` for available components (pagination, table, dialog, etc.). - For data tables, use `@kandev/ui/table` with TanStack Table; use shadcn Pagination components. - Only create custom components when shadcn doesn't provide what's needed. ### Responsive and touch surfaces - Use `hooks/use-responsive-breakpoint.ts` for application layout decisions. Its mobile boundary matches the sidebar's 768px `md` boundary, and it also models tablet, compact desktop, full desktop, and pointer precision; do not substitute the UI package's generic `useIsMobile` hook. Tablet is a coarse-pointer fallback between `md` and `lg`, so no fine-pointer width reports it — a spec that needs the tablet layout has to emulate touch. - When a Tailwind visibility class gates the same surface the hook picks, both must use the same boundary. A `sm:` class paired with a hook-driven mobile branch leaves 640-767px in a state neither side renders. - Use `useTouchDrawer` when a hover/popover disclosure needs a coarse-pointer `Drawer` alternative. Width-based phone composition and pointer-based disclosure behavior are related but not interchangeable. - Existing Radix DropdownMenu and ContextMenu surfaces receive inset, safe-area-aware bottom-sheet treatment below 640px in `app/globals.css`. Reuse those primitives for contextual actions and add focused coverage for long or nested menus instead of creating a parallel mobile menu. - Mobile capability parity does not require desktop layout parity. Load `/mobile-parity` for the Kandev surface decision guide, mobile design contract, and verification requirements. ## Data Flow Pattern (Critical) ```text Go Boot Payload -> Hydrate Store -> Components Read Store -> Hooks Subscribe ``` **Never fetch data directly in components.** ## Store Structure (Domain Slices) ```text lib/state/ ├── store.ts # Root composition ├── default-state.ts # Default state + initial state merge ├── slices/ # Domain slices │ ├── kanban/ # boards, tasks, columns │ ├── session/ # sessions, messages, turns, worktrees │ ├── session-runtime/ # shell, processes, git, context │ ├── workspace/ # workspaces, repos, branches │ ├── settings/ # executors, agents, editors, prompts (incl. userSettings) │ ├── comments/ # code review diff comments │ ├── github/ # GitHub PRs, reviews │ ├── gitlab/ # GitLab MRs, watches, MR automation options │ └── ui/ # preview, connection, active state, sidebar views ├── hydration/ # SSR merge strategies hooks/domains/{kanban,session,workspace,settings,comments,github,gitlab}/ # Domain-organized hooks lib/api/domains/ # API clients ├── kanban-api, session-api, workspace-api, settings-api, process-api ├── plan-api, queue-api, workflow-api, stats-api, github-api ├── user-shell-api, debug-api, secrets-api, sprites-api, vscode-api ├── health-api, utility-api ``` **Key State Paths:** - `messages.bySession[sessionId]`, `shell.outputs[sessionId]`, `gitStatus.bySessionId[sessionId]` - `tasks.activeTaskId`, `tasks.activeSessionId`, `workspaces.activeId` - `repositories.byWorkspace`, `repositoryBranches.byRepository` Quick Chat stores server conversations in `quickChat.sessions` and browser-local terminals in `quickChat.terminalTabs`; `activeKind` and terminal IDs track selection. `quick-terminal-actions.ts` owns lifecycle/fallback; terminal descriptors never enter conversation APIs or get lost in reconciliation. **Hydration:** Go injects `window.__KANDEV_BOOT_PAYLOAD__` into the SPA shell before React mounts. `lib/state/hydration/merge-strategies.ts` has `deepMerge()`, `mergeSessionMap()`, `mergeLoadingState()` to avoid overwriting live client state. Pass `activeSessionId` to protect active sessions. For rebasing or finishing PRs written against the old Next.js runtime, follow [`docs/nextjs-spa-migration.md`](../../docs/nextjs-spa-migration.md). **Hooks Pattern:** Hooks in `hooks/domains/` encapsulate WS subscription + store selection. WS client deduplicates subscriptions automatically. ## WebSockets **Format:** `{id, type, action, payload, timestamp}`. Use subscription hooks only; the WS client auto-deduplicates. When changing task lifecycle WS handlers (`task.updated`, `task.deleted`, `task.state_changed`), check both kanban and Office surfaces. Archive/delete events may need to update kanban caches, `tasks.activeTaskId` / session pin state, recent/sidebar prefs, Office refetch triggers such as `setOfficeRefetchTrigger("tasks")`, and route redirects for `/t/:id`, `/tasks/:id`, and `/office/tasks/:id`. Add focused tests for every affected surface. ## Component conventions - **Framework adapters during Next removal:** Client components should import links, router hooks, dynamic imports, images, and theme hooks from the local adapter modules (`components/routing/*`, `lib/routing/*`, `components/theme/app-theme`) instead of importing `next/*` or `next-themes` directly. The routing/image/dynamic adapters now provide browser-native behavior for the Vite SPA while legacy Next entrypoints are phased out. - Components: <200 lines, extract to domain components, composition over props. - Hooks: domain-organized in `hooks/domains/`, encapsulate subscription + selection. - **Interactivity:** all buttons and links with actions must have `cursor-pointer` class. - **Self-documenting settings:** every setting must explain in visible, plain-language copy what changes, when the setting applies, and when the user should choose each non-obvious option. State important exclusions, precedence, cost, or destructive consequences next to the control when they can affect the decision. Do not rely on tooltips, external documentation, or implementation terms alone to teach the setting. - **Settings save coordination:** settings surfaces with local unsaved state must register a contributor with `useSettingsSaveContributor` (or use `SettingsPageTemplate`) so the shared floating **Save changes** control, navigation guard, and discard flow own persistence. Do not add page-local Save/Cancel controls. Contributor `save` callbacks must reject on failure so the coordinator can report an error; `discard` must restore the contributor's authoritative baseline. - **Dialog Enter-to-confirm:** the base `@kandev/ui` `DialogContent` / `AlertDialogContent` activate the dialog's semantic action on plain Enter (`packages/ui/src/lib/dialog-default-action.ts`), so per-dialog "submit on Enter" input handlers are unnecessary — let the base own it. Resolution: `AlertDialogAction` → an explicit `data-dialog-default-action` button → the single primary (`type="submit"` or `data-variant="default"|"destructive"`) button in `DialogFooter`. More than one primary candidate (counting disabled ones), a disabled resolved action, or one inside a `hidden`/`aria-hidden` subtree → no-op (never guesses). Left alone: `textarea`/contenteditable, Shift/Cmd/Ctrl/Alt+Enter, `event.repeat` auto-repeat, mid-IME composition (`isComposing` or keyCode 229), already-`preventDefault`ed events, and Enter fired from a focused interactive control that owns Enter (any action button — including outline/secondary like Copy/Back — `` is _not_ exempt — type-to-confirm dialogs rely on Enter firing the primary. Pass `enterConfirms={false}` to opt a dialog out; mark the intended button with `data-dialog-default-action` when a footer has several action buttons. - **Radix tooltip on disabled buttons:** disabled buttons do not receive pointer/focus events, so wrap the disabled `Button` in a focusable span and put `TooltipTrigger asChild` on that span: ```tsx {disabledReason} ``` Keep the wrapper focusable only while disabled; when enabled, the button itself owns focus. - **Interactive help inside Radix tooltips:** do not nest a `Tooltip` root inside another `TooltipContent` when the inner trigger must remain interactive. Tooltip roots under one provider coordinate open state, so the inner tooltip can close and unmount its parent before the pointer reaches it. Render the secondary help inline in the existing content or use a disclosure primitive with independent open state. Touch-pinned help must close on a second trigger tap, outside interaction, and Escape; verify desktop pointer and mobile-sized touch flows. - **Renaming a `data-testid`:** set the new id as `data-testid=""` and keep the old id as `data-legacy-testid=""`, then migrate e2e specs to the new id in the same PR. JSX rejects two `data-testid` attributes on one element, and Playwright's `getByTestId` only matches one attribute name — the `data-legacy-testid` alias lets existing specs keep selecting the element mid-migration. - **Dockview session panel activation:** session chat panels can become active through tab pointer/keyboard events, global tab-cycling shortcuts, reopen/menu actions, and Dockview close controls. When changing `tasks.activeSessionId` or active-session sync, audit all of those paths. Use store state in addition to Dockview `api.isActive`; the current session's chat tab may be Dockview-inactive while Files/Changes is active. Same-session clicks must not leave stale activation intent, and Dockview `.dv-default-tab-action` close controls should be treated as close/delete actions rather than session-switch intent. - **Conditional review-panel ownership:** the reusable `pr-detail` panel may be visible only while the active task has a linked PR or MR. A custom Default layout's canonical panel supplies the preferred group and tab index; it does not make an empty tab persistent. After review data hydrates, review loss removes any canonical panel regardless of how it entered the runtime layout. Restoration, maximized layouts, and a session's offered/dismissed marker defer or suppress automatic insertion; linked existing panels synchronize provider and review identity without moving them. - **GitHub PR status UI:** visual PR/CI status surfaces should use the shared helpers in `apps/web/components/github/pr-task-icon.tsx` (`hasPRChecksPassedForDisplay`, `hasPRChecksInProgressForDisplay`, `hasPRChecksPassedWithoutReviewWaitForDisplay`) instead of re-deriving status from `checks_state`, `checks_total`, or `checks_passing` locally. Aggregate check counts are a display-only fallback when `checks_state` is empty; they may make chips or task icons render passed/in-progress, but must not enable merge actions. Merge readiness must use `isPRReadyToMerge`, which requires GitHub's explicit `checks_state === "success"` rollup. When changing PR status behavior, update both `pr-task-icon.test.ts` and `pr-status-chip.test.tsx`. - **Task repository labels:** user-facing task/card repo chips should display a stable repo slug or name (`owner/repo` when known, otherwise the repo name), not a local filesystem path. Local clone paths or folder paths belong in hover/title/tooltip metadata. Tasks with no repository, or only a non-repo local folder, should not render a repo chip. ## Internationalization (i18n) **The migration is in progress, one directory per PR.** The runtime, the gates, and Settings → General → Appearance are done; most of the app still holds English literals. New user-facing copy must go through `t()` / `` wherever you write it, even in a directory that has not been migrated yet. User-facing strings are localized with i18next + react-i18next, keyed as `namespace:key`. Add the English text to `src/locales/en/.json`, then reference it with `t("settings:deleteExecutor")` (`useTranslation()` in components, the module-level `t` from `@/lib/i18n` in plain helpers). Use `` only for copy containing markup — and never a `t()` call inside its children, which shifts the message's tag indices. Never translate user/domain data, code identifiers, `data-testid`, or a literal that is also compared with `===`, used as a map key, or typed as a string-literal union. When a prop is both display copy and logic (`label: "Reviewers" | "Assignees"`), split it into a `kind`/`origin` discriminant plus a translated label rather than translating in place. `lib/i18n/provider.tsx` initializes i18next at module load. Do not remove that call: react-i18next suspends on an uninitialized instance and there is no Suspense boundary above the root, so the app renders a blank page with no error of any kind. Unit tests cannot catch it — `vitest.setup.ts` pre-initializes. Never write a plural ending yourself: use `t(key, { count })` with `_one`/`_other` keys. Passing the morpheme as a value (`{ s: n === 1 ? "" : "s" }`) is untranslatable — the plural rule ends up at the call site. Never assign `t()` to a module-level constant. It resolves at import, before a locale is active, and never updates on a switch — and the pseudo-locale cannot see it, because the text _is_ translated, just frozen. Store the key and resolve at render, or make the value a component. `check-module-scope-t.mjs` enforces it. `pnpm lint` fails on hardcoded UI strings (`i18next/no-literal-string` is an **error**), but **only on the `i18nGuardFiles` allowlist** in `eslint.i18n.options.mjs` — paths already migrated. That scoping is deliberate: a repo-wide error breaks every unrelated PR that adds a label, which is what made the first attempt at this migration unmergeable. **When you migrate a directory, append it to `i18nGuardFiles` in the same PR** — that is the step that stops it drifting back. Never delete an entry to make a build pass. Use `pnpm run lint:i18n ` to preview the guard on a path that is not on the list yet. Separately, `pnpm run i18n:ratchet` (pre-commit + CI) guards **new code everywhere**, regardless of the allowlist: a file you added must be clean outright, and a file you modified is judged on the lines you touched. Untouched literals are never reported, so it cannot ask you to migrate code you did not write — the same contract as `golangci-lint --new-from-rev` for Go. The rule **only sees literals in JSX** — `confirm()` arguments and copy in plain `.ts` helpers are invisible to it — and it **skips anything assigned to a SCREAMING_CASE identifier**, so `const ROWS = [{ label: "Disk usage" }]` passes silently. A clean lint is not proof a file is done. `pnpm run i18n:check` gates key/catalog drift, `` tag indices, inline plurals and module-scope `t()`, and the **pseudo-locale** (Settings → General → Appearance, dev/e2e) is the completeness check — any plain-English text under it was never externalized. The tooling needs **Node 24**. Full guide: [`docs/i18n.md`](../../docs/i18n.md); spec: [`docs/specs/platform/i18n.md`](../../docs/specs/platform/i18n.md). ## Markdown safety Any renderer that enables embedded raw HTML must pair `rehype-raw` immediately with `rehype-sanitize`, and enable that combination only on the intended surface. Do not broaden raw-HTML support to chat, comments, or other renderers without a separate security decision. Add regression coverage for permitted README markup and for stripping executable HTML and unsafe URLs. ## Code-quality limits Enforced by `apps/web/eslint.config.mjs` (warnings, will become errors): - Files: ≤600 lines · Functions: ≤100 lines - Cyclomatic complexity: ≤15 · Cognitive complexity: ≤20 - Nesting depth: ≤4 · Parameters: ≤5 - No duplicated strings (≥4 occurrences) · No identical functions · No unused imports - No nested ternaries When you hit a limit, extract a helper function, custom hook, or sub-component. Prefer composition over growing a single function. ## Plugin system The frozen frontend contract is `docs/plans/plugins/PLUGIN-API.md`; `lib/plugins/types.ts` is its TS mirror — the two must change together. `lib/plugins/registry.ts` is the reactive singleton `PluginRegistry`; every `register*` call there needs a matching cleanup entry in `unregisterPlugin` and `totalCount()`, or a disabled/uninstalled plugin leaks a stale registration. - **Task panels** (`registerTaskPanel`): one generic dockview component, `"plugin-panel"`, shared by every plugin — identity lives in `params: { pluginId, panelKey }` (id helpers in `lib/state/layout-manager/plugin-panels.ts`). `renderPanel` in `dockview-shared.tsx` and `dockview-panel-content.tsx` each get exactly one `"plugin-panel"` case (lookup tables, not switches). `PluginTaskPanel` (`components/task/`) resolves the registration behind a `PluginErrorBoundary`; `mobileEnabled: true` also renders it via the phone bottom nav (`session-mobile-bottom-nav.tsx`) with `presentation: "mobile"`. - **Kanban card contributions:** `registerTaskMenuAction({ group: "edit", ... })` turns the flat `Edit` item into an `Edit` submenu (`kanban-card-edit-submenu.tsx`); `registerComponent("task-card-indicators", ...)` renders beside `PRTaskIcon` via ``. - **`host.storage`:** authenticated per-user key/value storage (`lib/plugins/host-api.ts`), backed by `/api/plugins/{id}/user-state/...` (`docs/decisions/2026-08-01-per-user-plugin-storage.md`). `subscribe` (`lib/plugins/user-state-sync.ts`) wraps `registerWsHandler` with own-plugin filtering and own-tab echo suppression via a per-tab `writerId`. - **`host.ui.RichTextEditor`/`RichTextReadOnly`** (`components/editors/tiptap/rich-text-editor.tsx`): narrow Plan-panel-tiptap wrappers; update `PLUGIN-API.md` before widening props beyond `{ taskId, value, onChange, placeholder, className, testId }` / `{ value, className, testId }`. ## Testing notes - jsdom drops `secure` cookies over `http`, so `document.cookie` reads back empty. To assert a cookie write in a Vitest unit test, intercept the setter with `Object.defineProperty(document, "cookie", { set: ... })` and restore it after. - jsdom synthetic mouse events do not reliably open Radix Tooltip. In component tests, use `TooltipProvider` and assert keyboard focus; cover pointer hover in Playwright with `locator.hover()` and a visible `role="tooltip"`, keeping regressions that jsdom cannot exercise. - In Playwright tests, avoid strict locators that assume only one `terminal-panel` or `.xterm` exists. Mobile and dockview layouts can mount multiple terminal instances; scope to the active panel or use `.first()` / `.last()` deliberately with a comment or helper. Shared E2E helpers that inspect mounted React/DOM internals must also be scoped to the active panel/container, not global selectors, because hidden or stale mounted panels can coexist in dock/mobile layouts.