Webviews are isolated browser contexts with no Node.js or vscode access.
Each panel is a Vite package here; its extension side is in
src/webviews/<panel>/. Package boundaries:
CODE_STRUCTURE.md.
webview-shared/README.md defines the IPC contract and the new-panel checklist.
- Never hand-roll
window.addEventListener("message", ...)orpostMessage. UseonNotification/sendCommand(vanilla) oruseIpc(React) from@repo/webview-shared. - Extension panels call both
buildCommandHandlersandbuildRequestHandlers({}is fine), so an API method without a handler fails to compile. - Panels that push data re-send it on visibility and theme changes with
onWhileVisible; hidden webviews lose state and canvases keep old colors.
The React Compiler runs on every build, so follow the rules of React: no reading or writing refs during render, no mutating props, state or rendered values, hooks called unconditionally. A component that breaks them silently loses its memoization.
- Put parameter defaults that read another prop
(
focused = adapter?.focusedId === row.node.id) in the body. useMemo/useCallbackare rarely needed; when kept they must list every dependency or lint fails.- Effects are a last resort. Compute derived values during render, handle
user actions in event handlers, and load requested data with React Query
(pushed data arrives through
useIpc). UseuseEffectonly to sync with an external system, and audit every dependency so it doesn't resubscribe on each update. - Build React Query keys from the panel's key factory (
queryKeysintasks/src/config.ts); never retype one as a literal, and export it if needed.isLoadingmeans no data yet andisFetchingincludes refetches: don't blank valid data while refetching. Invalidate every affected query after a mutation, even on partial failure. Prefermutate()with callbacks; never wrapmutateAsync()in an emptycatch. - Views handle loading (spinner, never blank), error (actionable message), empty (deliberate copy) and refetch (keep data, never reset input or selection). Show a fallback ("Unknown") for missing text.
- Every interactive element is keyboard reachable, including the reason a control is disabled.
- The accessible name contains the visible label (WCAG 2.5.3); don't replace
it with an unrelated
aria-label. - Generate IDs for labels and
aria-*withuseId; hardcoded IDs collide. - Hidden interactive elements leave the tab order too; prefer not rendering them.
- Check what a primitive does with
aria-*androlebefore setting them.
@repo/uiholds generic VS Code-style components (README). It imports no other workspace package and styles only through--ui-*tokens. Anything that knows about Coder belongs in a panel package. Reuse its components before writing new ones.- Stories sit next to components as
Foo.stories.tsxand render in captured VS Code themes (.storybook/README.md). - Pixel screenshots every story in CI after its
playfunction, in light and dark (pixel.jsonc);PIXEL_ALL_THEMES(@repo/ui's#storybook) inparameters.pixeladds the high-contrast themes. @repo/mocksand@repo/storybook-utilsare for stories and tests only.
Tests live in test/webview/<pkg>/ and run in jsdom (pnpm test:webview).
docs/TESTING.md has the general rules. These follow
Testing Library's guidance; apply them to tests you add or change:
- Use
const user = userEvent.setup()before rendering, notfireEvent, except for eventsuser-eventcan't produce. - Assert what the interaction did (IPC message, callback arguments, state); stories cover how it looks.
- Query by role and accessible name.
data-testidonly for elements with no role or name; neverquerySelectoror class names. - Use
find*instead ofwaitForaroundget*, andquery*only to assert absence. KeepwaitForto one assertion and no side effects. - Stay deterministic: time as a prop or fake timers, explicit locales.
- Stub globals with
vi.stubGlobal(undo in-test stubs withvi.unstubAllGlobals()), neverObject.defineProperty. - Wrap React Query components with
renderWithQuery(test/webview/render.tsx).