24 KiB
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. Follow: choose recipe → edit manifest.yaml → implement → validate → package → smoke test. The independently consumable author contract is @kandev/plugin-sdk in ../packages/plugin-sdk; ../../docs/plans/plugins/PLUGIN-API.md and lib/plugins/types.ts document and implement host compatibility. 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. New and official plugins use typed host.context reads and never copy/import private AppState or Zustand slice shapes. Extend the SDK, host implementation, contract docs, and exact-consumer compatibility test together.
UI Components
Shadcn Components: Import from @kandev/ui package:
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/tablewith 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.tsfor application layout decisions. Its mobile boundary matches the sidebar's 768pxmdboundary, and it also models tablet, compact desktop, full desktop, and pointer precision; do not substitute the UI package's genericuseIsMobilehook. Tablet is a coarse-pointer fallback betweenmdandlg, 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
useTouchDrawerwhen a hover/popover disclosure needs a coarse-pointerDraweralternative. 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-parityfor the Kandev surface decision guide, mobile design contract, and verification requirements.
Data Flow Pattern (Critical)
Go Boot Payload -> Hydrate Store -> Components Read Store -> Hooks Subscribe
Never fetch data directly in components.
Browser capability boundaries
- Use
generateUUID()for client-only non-security IDs, notcrypto.randomUUID(); usecopyToClipboard()for copy actions, notnavigator.clipboard.writeText(). Keep fallbacks non-security; test missing or rejected capabilities with anrgaudit.
Store Structure (Domain Slices)
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.activeIdrepositories.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.
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.
Task overview vs. session detail: Shared task rows read Task.statusSummary and task.status_summary.updated; rich streams stay session-detail-only. Extend the bounded projection per the spec and ADR.
Branch-scoped task state: For live worktree/session state plus task_prs, key by (repository, checked-out branch), not task/repository alone. branch_switched invalidates prior status/commits; reject late results with a generation/identity guard and preserve siblings. Historical PRs affect Changes only when repository_id and normalized head_branch match; Review/PR history may still show them. Test single/multi-repo cases and desktop/mobile Changes behavior.
HTTP/WS cache races: When HTTP hydrates a cache also updated by WebSockets, guard responses with per-scope revision and request/workspace generation; discard or refresh stale responses and cover deferred responses. useEnsureTaskSession re-fires session.ensure when an open task page sees zero sessions after a prior ensure, so deleting the last session from that page spawns a replacement; test zero-session states through the backend/API instead.
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 importingnext/*ornext-themesdirectly. 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. - Code-host dashboards: GitHub, GitLab, and plugin code-host pages must use
the provider-neutral primitives in
components/integrations/for change-request lists, rows, toolbars, scope controls, task preset menus, and linked-task indicators. Use the shared semanticIntegrationIconglyphs instead of copying first-party SVG paths. Keep provider API/state logic in adapters; do not fork row anatomy or add dashboard review/launch flows outside the native task dialog and registered review surface. Plugins may override create transport only through an authenticated action; host verifies repository, session, and worktree branch authority. - Code-host task status: registered providers publish
ReviewItemSummary.taskStatus. Host chrome renders shared topbar, composer, popover/drawer, eager linked-row summaries, hover/focus refresh, and semantic colors. Initial refreshes are leased and deduplicated; do not add provider color fields, visual slots, or pollers; usechange-request-*anatomy. - Code-host review detail: GitHub and compatible review providers render
components/integrations/change-request-detail.tsx(also exposed ashost.ui.ChangeRequestDetail). Providers own normalized data/capabilities/actions, not parallel headers, review/check/comment sections, scroll containers, or mobile geometry. - Code-host task links: keep one-field pull/merge-request linking in
components/integrations/task-change-request-link-form.tsx. First-party providers compose it directly; plugins callhost.openTaskLinkDialog. Link submenu children name the target only (for example,Bitbucket Pull Request) and preserve their registered provider icon. - Interactivity: all buttons and links with actions must have
cursor-pointerclass. - 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 useSettingsPageTemplate) so the shared floating Save changes control, navigation guard, and discard flow own persistence. Do not add page-local Save/Cancel controls. Contributorsavecallbacks must reject on failure so the coordinator can report an error;discardmust restore the contributor's authoritative baseline. - Dialog Enter-to-confirm: the base
@kandev/uiDialogContent/AlertDialogContentactivate 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 explicitdata-dialog-default-actionbutton → the single primary (type="submit"ordata-variant="default"|"destructive") button inDialogFooter. More than one primary candidate (counting disabled ones), a disabled resolved action, or one inside ahidden/aria-hiddensubtree → no-op (never guesses). Left alone:textarea/contenteditable, Shift/Cmd/Ctrl/Alt+Enter,event.repeatauto-repeat, mid-IME composition (isComposingor keyCode 229), already-preventDefaulted events, and Enter fired from a focused interactive control that owns Enter (any action button — including outline/secondary like Copy/Back —<select>, combobox, or a listbox option / menu item). Only a slot-markedalert-dialog-cancel/dialog-closeis treated as a dismiss control and overridden (the Radix-focuses-Cancel case). A plain single-line<input>is not exempt — type-to-confirm dialogs rely on Enter firing the primary. PassenterConfirms={false}to opt a dialog out; mark the intended button withdata-dialog-default-actionwhen a footer has several action buttons. - Radix tooltip on disabled buttons: disabled buttons do not receive pointer/focus events, so wrap the disabled
Buttonin a focusable span and putTooltipTrigger asChildon that span:Keep the wrapper focusable only while disabled; when enabled, the button itself owns focus.<Tooltip> <TooltipTrigger asChild> <span tabIndex={disabled ? 0 : -1} className="inline-flex"> <Button disabled={disabled}>Run</Button> </span> </TooltipTrigger> <TooltipContent>{disabledReason}</TooltipContent> </Tooltip> - Interactive help inside Radix tooltips: do not nest a
Tooltiproot inside anotherTooltipContentwhen 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: usedata-legacy-testidfor the old id while migrating specs; JSX and Playwright only support onedata-testidattribute. - Dockview session activation: audit pointer/keyboard tabs, shortcuts,
reopen/menu actions, and close controls; combine store state with
api.isActive, clear same-session intent, and treat default-tab close as delete rather than session switching. - Conditional review panels: show
pr-detailonly for active tasks with a linked PR/MR; default layouts only provide preferred placement. Hydrated review loss removes canonical panels, while restoration/maximized and offered/dismissed markers suppress insertion; existing panels sync identity without moving. - Dockview environment switching: reconcile ephemeral panels before restoring views;
correlate ID-less groups by stable ID or position. Treat
chat/session:*as semantic only with a non-nullactiveSessionId. - GitHub PR status UI: use the shared
pr-task-icon.tsxdisplay helpers andisPRReadyToMerge; aggregate counts are display-only and cannot enable merges. Updatepr-task-icon.test.tsandpr-status-chip.test.tsxwith behavior changes. - GitHub PR associations: retain terminal/merged siblings for tabs/unlink;
derive
openPRsonly for aggregate status/automation and test desktop/mobile terminal unlink plus two-to-one collapse/focus. - Task repository labels: user-facing task/card repo chips should display a
stable repo slug or name (
owner/repowhen 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)
Externalization is complete. A hardcoded user-facing literal is a
regression, not leftover migration work. New copy goes through t() / <Trans>
wherever you write it.
Add English keys to src/locales/en/<namespace>.json; use useTranslation() in
components and module-level t only inside plain helper calls. <Trans> is only
for markup, and a t() child corrupts its tag indices. Do not translate domain
data, identifiers, test IDs, discriminants, comparison tokens, or map keys; split
display copy from logic first. Keep lib/i18n/provider.tsx module
initialization; removing it blanks the app. Use _one/_other plural keys,
never English suffixes. Never capture t() in a module-level constant; it
freezes the boot locale. No Unicode em dash (U+2014) in copy or locale values.
pnpm lint fails on hardcoded UI strings: i18next/no-literal-string is an
error on every .ts/.tsx file (tests, *.test-helpers.*, *.test-utils.*
and e2e/** excluded). It was scoped to i18nGuardFiles during the migration;
measured at zero violations across all 2560 source files, it was widened.
i18nGuardFiles remains the migration record and the lint:i18n <path> preview
scope: append when you externalize a path, never delete an entry
(check-guard-allowlist.mjs rejects that). i18n:ratchet guards new/changed
lines independently. The rule sees only JSX literals; SCREAMING_CASE tables,
plain .ts helpers, parameter defaults and toast/setter arguments are gated by
scripts/check-nonjsx-copy.mjs, which scans the whole tree by exclusion.
Silence a legitimate one with // i18n-exempt: <reason> (required) as a //
LINE comment — the detector's pattern is line-anchored, so a marker inside a
/** */ block is silently ignored.
Real-locale catalogs gate. pt-pt, zh-cn, zh-hk, zh-tw are complete;
check-i18n-keys.mjs fails on a missing/extra key, a dropped {{placeholder}}
or <n> tag, an empty value, or a value identical to English. Untranslatable
values are handled in two tiers: those looksLikeCopy rejects as non-copy need
no declaration; prose that reads the same in the target language goes in
src/locales/<locale>/_verbatim.json (or the shared _verbatim.json) with a
mandatory reason — reasonless or stale entries are errors. For zh-tw/zh-hk run
pnpm run i18n:zh-hant, do not hand-translate.
i18n:check also gates key/catalog drift, <Trans> indices, inline plurals,
module-scope t(), em dashes, and the pseudo-locale check. Needs Node 24.
Guide: docs/i18n.md; spec
docs/specs/platform/requirements/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 public frontend contract is apps/packages/plugin-sdk; docs/plans/plugins/PLUGIN-API.md
and lib/plugins/types.ts are its detailed host implementation — all three must change
together. lib/plugins/registry.ts is the reactive singleton PluginRegistry; every
register* call needs matching cleanup 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 inparams: { pluginId, panelKey }(id helpers inlib/state/layout-manager/plugin-panels.ts).renderPanelindockview-shared.tsxanddockview-panel-content.tsxeach get exactly one"plugin-panel"case (lookup tables, not switches).PluginTaskPanel(components/task/) resolves the registration behind aPluginErrorBoundary;mobileEnabled: truealso renders it via the phone bottom nav (session-mobile-bottom-nav.tsx) withpresentation: "mobile". - Task contributions:
registerTaskMenuAction({ group: "edit", ... })adds card-only actions to theEditsubmenu. Group"primary"adds flat actions to card and desktop/mobile task-row menus. Card indicator/tag slots stay card-specific;task-row-metadatais generic for sidebar and/tasksrows. - Sidebar workspace actions:
registerComponent("sidebar-workspace-actions", ...)renders after Quick Terminal/Quick Chat in the desktop sidebar's New Task row and in the shared phone navigation sheet, forwardingSidebarWorkspaceActionsSlotPropswithpresentation: "desktop" | "mobile"; mobile plugin controls own a 44px touch target and accessible name. 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) wrapsregisterWsHandlerwith own-plugin filtering and own-tab echo suppression via a per-tabwriterId.host.ui.RichTextEditor/RichTextReadOnly(components/editors/tiptap/rich-text-editor.tsx): narrow Plan-panel-tiptap wrappers; updatePLUGIN-API.mdbefore widening props beyond{ taskId, value, onChange, placeholder, className, testId }/{ value, className, testId }.
Testing notes
vitest.config.tspinsprocess.env.NODE_ENV = "test", and that line is load-bearing. React exportsact()only from its development build, so underNODE_ENV=production— which the runtime image sets (Dockerfile) and every container/agent shell inherits, while CI's image does not —@testing-library/reactfalls back toreact-dom/test-utilsand everyrender()/renderHook()throwsTypeError: React.act is not a functionbefore any assertion, with CI still green.vitest-environment.test.tsxand thevitest.setup.tspreflight fail by name if the pin goes, and theRun testsstep in.github/workflows/frontend-tests.ymlexportsNODE_ENV=productionon purpose so those guards fire in CI too rather than only in a container.- E2E waits name the cause, they do not budget for the effect. The suite's dominant flake is
await expect(x).toBeEnabled({ timeout: 30_000 })— an assertion on a UI shadow of a backend event whose hand-picked budget expires under load.e2e/helpers/causal-waits.tshas one primitive per transport (waitForHttp,watchWs().waitForEvent,watchWs().waitForResponse): arm it before the action, await it after, then assert the UI with its default timeout. "The backend reached state X" needs no primitive —expect.pollagainste2e/helpers/api-client.tsalready reads the backend, not the DOM.watchWs(page)only sees sockets opened after it is called, so it goes before the firstpage.goto(). Confirm a causal chain by probing a live run with a throwawaypage.on("response")logger; reading the components predicts it wrong often enough to matter. The only sanctioned wall-clock wait isdwell(page, ms, category, reason), ordwell(ms, category, reason)where noPageexists (fixtures, api-client retries) —categoryis the closedDwellCategoryunion (negative-assertionis permanent,unverifiedis debt to drive to zero),reasonsays why no event exists, no options; a delay inside apage.route()handler isinjectLatency(ms, reason)instead, since the delay is the stimulus rather than a wait; rawpage.waitForTimeoutand promise sleeps are not sanctioned. Guide and worked examples:e2e/README.md. The sleep ban is enforced in two layers, the same shape as the i18n guards, and the conversion is complete so both cover the whole tree.eslint-rules/no-unsanctioned-sleep.mjsis an AST rule (e2e/has ~700test.setTimeout()calls, Playwright's per-test timeout setter, which a regex cannot separate from a sleep — nor a sleep from aPromise.raceguard); it is an error across all ofe2e/viae2eSleepGuardFiles, and it also rejects adwell/injectLatencynot imported frome2e/helpers/causal-waits, since a failed import otherwise lints clean and throws on exactly the loaded shard the wait existed to survive.pnpm run e2e:sleep-ratchet(CI + pre-commit) is the second layer and judges the change, not the file. The guard only ever widens — never narrow it to make a build pass;e2e-sleep-wiring.test.tsasserts coverage via ESLint's own config resolution and fails if the CI step or hook disappears. Details:e2e/README.md("How this is enforced"). - jsdom secure cookies need cookie-setter interception; Radix Tooltip tests use keyboard focus, while Playwright covers pointer hover with
locator.hover(). Scope terminal selectors to the active panel/container; mobile and dockview may mount multiple instances, so shared helpers must not use global selectors.