-
-
Notifications
You must be signed in to change notification settings - Fork 0
Write a plugin user interface
A plugin's user interface is a separate bundle that the running server loads at start-up, with no change to the host code. This page shows the slots you can fill, the rules that keep your styling correct, and a worked example from the repository.
A plugin ships its own built ESM bundle. A running Radd server loads that
bundle at runtime, with zero edits to the host. The mechanism is native-ESM
module federation. The host page carries an import map, and the host
publishes its own instances of React, React Query, and the plugin SDK on
globalThis.__RADD_SHARED__. A plugin's bundle keeps bare imports such as
import React from "react". The browser's import map resolves each one to a
small shim that re-exports the host's instance:
// AUTO-GENERATED by scripts/gen-shared-shims.mjs — do not edit.
// Re-exports the host singleton for "react" from the shared federation scope.
const M = globalThis.__RADD_SHARED__ && globalThis.__RADD_SHARED__["react"];
if (!M) throw new Error("radd federation: shared module react not registered by host");— web/public/shared/react.js (generated by web/scripts/gen-shared-shims.mjs)
@radd/plugin-sdk is the only frontend package a plugin's UI code may import.
Do not import web/src or another plugin's code. The SDK is itself one of
the shared singletons, so every plugin and the host read and write the same
slot registry.
Two copies of React in the same page break hooks and context. Two copies of
the slot registry mean a plugin registers into a store the host never reads,
so nothing renders. web/src/shared-runtime.ts populates
globalThis.__RADD_SHARED__ first, before the host imports any remote:
globalThis.__RADD_SHARED__ = {
react: React,
"react/jsx-runtime": ReactJsxRuntime,
"react/jsx-dev-runtime": ReactJsxDevRuntime,
"react-dom": ReactDom,
"react-dom/client": ReactDomClient,
"@tanstack/react-query": ReactQuery,
"@tanstack/react-router": ReactRouter,
"@radd/plugin-sdk": PluginSdk,
};— web/src/shared-runtime.ts
The slot registry itself keeps a second guard. It stores its one instance on
globalThis.__RADD_SLOT_REGISTRY__. Even if a bundling accident produces two
copies of the registry module, both copies share the one store:
const GLOBAL_KEY = "__RADD_SLOT_REGISTRY__";
type GlobalWithRegistry = typeof globalThis & { [GLOBAL_KEY]?: SlotRegistry };
const g = globalThis as GlobalWithRegistry;
const registry: SlotRegistry = g[GLOBAL_KEY] ?? (g[GLOBAL_KEY] = new SlotRegistry());— web/packages/plugin-sdk/src/slots.tsx
The host renders a named <Slot id="…"> anchor and does not know which
plugin, if any, fills it. A plugin calls registerSlot(id, contribution) to
attach. Every slot id is a member of SlotId in @radd/plugin-sdk.
| Slot id | Renders into | Props | Host anchor |
|---|---|---|---|
SlotId.issueTitleAction |
Issue header, next to Star/Watch/Flag | { item, project } |
web/src/routes/item-detail.tsx |
SlotId.issuePanelSection |
Issue right rail, above the fields | { item, project } |
web/src/components/items/IssueProperties.tsx |
SlotId.issueRailBottom |
Issue right rail, below the fields | { item, project } |
web/src/components/items/IssueProperties.tsx |
SlotId.issueTab |
Activity tab bar, next to Comments/History/VCS |
{ item, project }, needs title
|
web/src/components/items/ActivityPanel.tsx |
SlotId.viewHeader |
A view's header toolbar |
{ view, items } — items is the view's loaded, permission-scoped issues |
web/src/routes/view.tsx |
SlotId.viewType |
A whole saved-view type, matched by match = the view type key |
{ view, items } |
web/src/routes/view.tsx |
SlotId.routePage |
A full page at a nav path, matched by match = the pathname |
{ path } |
web/src/components/shell/PluginPage.tsx |
SlotId.settingsPage |
A full page under Settings, matched by match = the pathname |
{ path } |
web/src/components/shell/SettingsPluginPage.tsx |
SlotId.settingsSection |
A section inside an existing settings page, matched by match = the page key |
{} |
added by the settings page that accepts it |
SlotId.profileSection |
The user's Profile page | {} |
web/src/routes/settings/profile.tsx |
SlotId.pluginManagerSection |
The plugin's own row in Settings → Plugins, matched by match = the plugin's registry name |
{ plugin, pluginId } |
web/src/routes/settings/plugins.tsx |
SlotId.sidebarNav |
Left sidebar navigation | {} |
web/src/components/shell/Sidebar.tsx, driven by the backend nav manifest |
SlotId.dashboardWidget |
A dashboard widget type, matched by match = the widget type key |
{ config, widget, filterQuery } |
web/src/components/dashboards/WidgetCard.tsx |
SlotId.itemAction |
An entry in an item's action menu | { item } |
menu host |
A contribution is one object:
{ id, render, order?, match?, title?, icon?, label?, toggleable? }. Each
field's job:
-
id— unique within your plugin. -
order— sorts section and tab slots. Lower renders first; the default is 100. -
match— selects a page or type slot. -
titleandicon— label a tab or menu slot. -
label— names the contribution in the enable and disable lists. -
toggleable: false— marks a contribution as your plugin's own control surface, so it does not list or hide itself.
Every contribution renders inside its own error boundary. If a contribution throws, only that contribution disappears — the host view keeps rendering.
class SlotErrorBoundary extends Component<
{ plugin: string; children: ReactNode },
{ failed: boolean }
> {
state = { failed: false };
static getDerivedStateFromError() {
return { failed: true };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error(`[radd-plugin-sdk] slot contribution from "${this.props.plugin}" crashed`, error, info);
}
render() {
if (this.state.failed) return null;
return this.props.children;
}
}— web/packages/plugin-sdk/src/slots.tsx
The host runtime loader imports your bundle. It then calls your exported
activate(ctx) with a context that carries your plugin's name and a
registerSlot function already tagged with that name:
function buildContext(name: string): PluginContext {
return {
plugin: name,
registerSlot: (slot, contribution) => registerSlot(slot, contribution, { plugin: name }),
};
}— web/src/lib/plugin-loader.ts
Two authoring styles exist. Prefer the declarative contributions array —
every attachment is one row, so the whole footprint of your plugin's UI is
visible at a glance:
export default definePlugin({
contributions: [
{ slot: SlotId.issueTitleAction, render: () => <Button small>Note</Button> },
{ slot: SlotId.issueTab, title: "Notes", render: ({ item }) => <MyTab item={item}/> },
{ slot: SlotId.routePage, match: "/notes", render: () => <MyPage/> },
],
});— docs/plugin-ui.md
The loader registers each row of contributions first, then calls
activate(ctx) if your module also exports one. Use activate only for
registration that depends on a runtime condition, such as a feature flag or
an async check.
An administrator can disable a plugin, or its bundle can drop out of the
enabled set. Either way, the loader calls your deactivate(ctx) if you
exported one, then removes every contribution you registered:
function unloadRemote(name: string): void {
const entry = loaded.get(name);
if (entry?.module?.deactivate) {
try {
entry.module.deactivate(buildContext(name));
} catch (error) {
console.error(`[radd] plugin UI "${name}" deactivate() threw:`, error);
}
}
unregisterPlugin(name);
loaded.delete(name);
}— web/src/lib/plugin-loader.ts
unregisterPlugin(name) removes every slot contribution the plugin made, in
one call. deactivate only needs to undo something outside the slot
registry — a timer, a subscription you started by hand. This happens live:
the plugin's UI leaves the page immediately when an administrator disables
it, and returns immediately when they enable it again.
Your manifest declares a ui_api_version string. Before the loader imports
your bundle, it checks that string against the host SDK's UI_API_VERSION
constant:
export function isUiApiCompatible(pluginUiApiVersion: string): boolean {
const wanted = major(pluginUiApiVersion);
if (wanted < 0) return false;
return wanted === major(UI_API_VERSION);
}— web/packages/plugin-sdk/src/version.ts
The major version number alone decides compatibility: 1.4.0 and 1.0.0
are compatible, 2.0.0 is not. On an incompatible major, the loader never
imports the bundle. It logs a warning and marks the remote incompatible —
the plugin's UI does not load, and the rest of the page keeps working:
if (!isUiApiCompatible(version)) {
console.warn(
`[radd] plugin "${name}" UI (ui_api_version=${version}) is incompatible with this host — not loaded`,
);
loaded.set(name, { name, status: RemoteStatus.incompatible, error: `ui_api_version ${version}` });
return;
}— web/src/lib/plugin-loader.ts
Set your ui_api_version to match the SDK major your bundle was built
against. Raise it only when the host raises its own major, which happens on
a breaking SDK change.
The SDK exposes --radd-* CSS variables (tokens.css) and a matching JS
object (tokens). Each token maps onto the host's own theme variables, so a
plugin's UI switches between light and dark with the rest of the page. A
federated bundle renders into the host DOM and inherits these :root
variables automatically.
--radd-accent: var(--accent-fill, #6366f1);
--radd-accent-hover: var(--accent-fill-hover, #818cf8);
--radd-accent-fg: #ffffff;
--radd-focus: var(--accent-focus, #818cf8);— web/packages/plugin-sdk/src/styles/tokens.css
export const tokens = {
bg: "var(--radd-bg)",
panel: "var(--radd-panel)",
text: "var(--radd-text)",
accent: "var(--radd-accent)",
accentHover: "var(--radd-accent-hover)",
border: "var(--radd-border)",
// …
} as const;— web/packages/plugin-sdk/src/tokens.ts
--radd-accent reads the host's per-theme accent scale, not a fixed color.
This is what makes a plugin's accent fill lighten on hover in dark mode and
darken on hover in light mode. The host's own controls do the same.
Rule: a plugin must style with SDK tokens and primitives, never a raw
palette value or a hardcoded hex. Use tokens.text, not #dadada. Use the
supplied Button, TextField, Select, Chip, Card, Modal, Spinner,
and EmptyState primitives before you write your own control. A hardcoded
color looks correct in the one theme you tested and wrong in the other. The
host codebase follows the same rule (see "Frontend conventions" in
CLAUDE.md). Spec 94 locks this decision. A grep over every remote's build
output checks for hardcoded hex.
A contribution can be turned off in two independent scopes. A contribution renders only when it is enabled in both.
Instance-wide (administrator). Mount <GlobalContributionToggles plugin="my-name" pluginId={pluginId} /> inside a pluginManagerSection slot,
with match set to your plugin's registry name. It appears under your
plugin's row in Settings → Plugins. A piece turned off here disappears for
every account, and drops out of that piece's per-user toggle list too. It
persists to PUT /plugins/{id}/contribution-settings, which writes into the
plugin's own InstalledPlugin.config:
@router.put("/{plugin_id}/contribution-settings", response_model=ContributionSettings)
async def set_contribution_settings(
plugin_id: str, body: ContributionSettings, session: Session, user: CurrentUser
) -> ContributionSettings:
"""Replace a plugin's instance-wide-disabled set (`"<slot>::<id>"` keys). Admin only."""
_require_admin(user)
saved = await service.set_contribution_settings(session, plugin_id, body.disabled)
return ContributionSettings(disabled=saved)— server/src/radd/modules/pluginmgr/router.py
Per-user. Mount <UserContributionToggles plugin="my-name" /> inside a
profileSection slot. Each account turns the instance-enabled pieces on or
off for itself. The list here shows only pieces enabled instance-wide. The
per-user list never offers a piece the administrator switched off. It
persists to PUT /auth/me/preferences, so the choice follows the account
across browsers:
@auth_router.put("/me/preferences")
async def put_preferences(
patch: dict[str, Any], user: CurrentUser, session: Session
) -> dict[str, Any]:
"""Merge `patch` into the user's preferences (shallow). Returns the full merged dict."""
merged = {**(user.preferences or {}), **patch}
user.preferences = merged— server/src/radd/modules/auth/router.py
A contribution is opt-in on both scopes. The kernel forces nothing. A plugin
that mounts neither widget has no toggle UI at all, and its row in
Settings → Plugins shows only Enable and Disable. Give the toggle widgets
themselves toggleable: false, so they do not appear in their own list and
cannot hide themselves.
The row for a plugin that mounts GlobalContributionToggles gains an
expandable panel here, at SlotId.pluginManagerSection, with an On/Off
switch per toggleable contribution.
A turned-off contribution must also disappear from every host surface that lists options from the backend manifest, not only from its own slot. The SDK exposes the disabled keys through two hooks:
-
useDisabledNavPaths()returns the set ofroute.pageandsettings.pagepaths that are currently turned off, in either scope. The sidebar and the Settings sidebar drop the nav link for a disabled path. A direct visit to that path shows a notice instead of an endless spinner:
export function PluginPage() {
const pathname = useLocation({ select: (l) => l.pathname });
const match = useSlotMatch(SlotId.routePage, pathname);
const disabled = useDisabledNavPaths().has(pathname);
if (!match) {
if (disabled) return <MissingPluginType typeKey={pathname} kind="page" disabled />;
return (
<div style={{ display: "flex", justifyContent: "center", padding: "4rem" }}>
<Spinner />
</div>
);
}
return <>{match.contribution.render({ path: pathname })}</>;
}— web/src/components/shell/PluginPage.tsx
-
useDisabledMatches(slot)returns the disabledmatchkeys for any keyed slot. The view-type and dashboard-widget-type dropdowns filter out a disabled key. A turned-off type stops appearing when you create or edit a view or a widget.
A view or widget can already reference a type whose plugin is now disabled or uninstalled, or whose specific contribution is now off. Radd shows the same notice, not a blank or broken render:
export function MissingPluginType({
typeKey,
kind,
disabled = false,
}: {
typeKey: string;
kind: "view" | "widget" | "page";
disabled?: boolean;
}) {
return (
<Callout kind="warning" /* … */>
{disabled ? (
<>This {kind} has been turned off. …</>
) : (
<>The "{typeKey}" {kind} type is no longer available — the plugin that provided it was
disabled or uninstalled. …</>
)}
</Callout>
);
}— web/src/components/shell/MissingPluginType.tsx
The disabled flag marks the second case. Either the type is gone entirely,
because its plugin is uninstalled or disabled. Or the plugin is present, and
only this one contribution is off.
You add a searchable field to the query language on the backend, not the frontend. Your plugin's Python manifest declares a resolver:
slq_fields=(SlqFieldSpec(name="note", label="Note body", item_ids=note_item_ids),),— examples/acme-notes/src/acme_notes/init.py
Once declared, note ~ "text" works everywhere SLQ runs — saved views, the
query bar, the useItemsQuery hook — with no change on the frontend side.
The @radd/plugin-sdk export list carries no SLQ-registration function. A
plugin's UI code calls nothing to make this happen. It only benefits from
the field once the backend manifest declares it.
TODO(verify): confirm with a future SDK change whether a frontend-side SLQ registration API is ever added; as of this SDK version (
UI_API_VERSION1.0.0) it is not exported.
Build every remote with one command from web/:
node scripts/build-all.mjs
This command runs, in order:
- link
@radd/plugin-sdkintoweb/node_modulesand regenerate the/shared/*.jsshims - build the host (
tsc -b && vite build) - find every
<plugin>/ui/directory underserver/src/radd/modules/andexamples/, and build each one with its ownvite.config.mjs
console.log("== prepare federation ==");
run(process.execPath, [resolve(here, "prepare-federation.mjs")]);
console.log("\n== build host ==");
run(bin("tsc"), ["-b"]);
run(bin("vite"), ["build"]);
// …
const uiDirs = SCAN_ROOTS.flatMap((r) => findUiDirs(r)).sort();
for (const uiDir of uiDirs) {
const label = uiDir.replace(repoRoot + "/", "");
console.log(`\n== build remote: ${label} ==`);
ensureNodeModules(uiDir);
run(bin("tsc"), ["-p", resolve(uiDir, "tsconfig.json")]);
run(bin("vite"), ["build"], uiDir);
}— web/scripts/build-all.mjs
A plugin's own vite.config.mjs is three lines, using a factory the SDK
ships for exactly this purpose:
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { raddRemote } from "@radd/plugin-sdk/vite";
export default raddRemote(dirname(fileURLToPath(import.meta.url)));— examples/acme-notes/src/acme_notes/ui/vite.config.mjs
The output is one file: <plugin>/ui/dist/remoteEntry.js. Radd serves it at
/plugins/<name>/remoteEntry.js, straight from the plugin's own directory —
there is no separate asset pipeline to register the file with.
The output must actually be in the image. Radd's .gitignore excludes
ui/dist/, because build output does not belong in source control. That
once meant production served nothing at /plugins/<name>/:
# Plugin UI remotes (spec 94) live in the SERVER tree (<module>/ui) and build IN
# PLACE to <module>/ui/dist, which the app serves at /plugins/<name>/. build-all
# scans ../server/src/radd/modules, so the repo layout is mirrored here — a bare
# `npm run build` produces the host only, which is how every plugin remote 404'd
# in production while working locally (ui/dist is gitignored, so nothing was
# copied in either).
COPY server/src/radd/modules /build/server/src/radd/modules
RUN node scripts/build-all.mjs \
&& find /build/server -type l -name node_modules -delete
— Containerfile
The Containerfile's web build stage fixes this. It runs build-all.mjs
inside the image, against a copy of server/src/radd/modules staged into
the build context. The image build produces every builtin plugin's remote
before the runtime stage copies it into place:
COPY --from=web /build/server/src/radd/modules /app/server/src/radd/modules
— Containerfile
If you are shipping a plugin as part of the Radd source tree (under
server/src/radd/modules/<name>/ui/), this happens for you automatically —
build-all.mjs discovers it.
An external plugin ships as its own package, in its own repository,
installed with uv pip install -e <path>. Place its
ui/dist/remoteEntry.js wherever your deployment serves /plugins/<name>/
from. Build it again after every change. A stale build here causes the same
failure, one layer up.
examples/acme-notes/ is a full plugin, outside the Radd source tree, built
against nothing but the public SDK and radd.sdk. It is spec 94's
acceptance example. Install it, and it adds all of the following, with no
edit to Radd core:
- an entity
- a nav page
- an issue-panel section
- a settings page
- an SLQ field
- a saved-view type
- a dashboard widget type
The Python manifest (examples/acme-notes/src/acme_notes/__init__.py):
plugin = RaddPlugin(
id="acme.notes",
name="acme-notes",
version="1.0.0",
api_version="1.0.0",
core=False,
description="Example external plugin: issue notes + a Notes page (own project, own web build).",
depends_on=("projects", "auth", "events", "items"),
entities=(NOTE,),
routers=(router,),
slq_fields=(SlqFieldSpec(name="note", label="Note body", item_ids=note_item_ids),),
view_types=(ViewTypeSpec(key="acme.notes", label="Notes review"),),
widget_types=(WidgetTypeSpec(key="acme.recent-notes", label="Most Recent Notes"),),
ui=PluginUiManifest(
nav=(
NavItemSpec(
key="acme-notes", label="Notes", path="/notes", icon="sticky-note",
section="main", requires=("item.read",), order=60,
),
NavItemSpec(
key="acme-notes-settings", label="Notes", path="/settings/acme-notes",
icon="sticky-note", section="settings", requires=("item.read",), order=90,
),
),
remote="/plugins/acme-notes/remoteEntry.js",
ui_api_version="1.0.0",
),
)— examples/acme-notes/src/acme_notes/init.py
The UI entry (examples/acme-notes/src/acme_notes/ui/src/index.tsx) lists
every attachment as one row:
export default definePlugin({
contributions: [
{ slot: SlotId.routePage, id: "notes-page", match: "/notes", label: "Notes page", render: () => <NotesPage /> },
{ slot: SlotId.settingsPage, id: "settings-page", match: "/settings/acme-notes", label: "Settings page", render: () => <NotesSettings /> },
{ slot: SlotId.issuePanelSection, id: "rail-section", order: 40, label: "Issue rail section", render: ({ item }) => <NotesSection item={item as Item} /> },
{ slot: SlotId.issueRailBottom, id: "rail-bottom", label: "Issue rail (below fields)", render: ({ item }) => <RailNote item={item as Item} /> },
{ slot: SlotId.issueTab, id: "issue-tab", title: "Notes", label: "Issue Notes tab", render: ({ item }) => <NotesSection item={item as Item} /> },
{ slot: SlotId.issueTitleAction, id: "title-button", label: "Issue title button", render: ({ item }) => <TitleButton item={item as Item} /> },
{ slot: SlotId.viewHeader, id: "view-header", label: "View header summary", render: ({ items }) => <NotesViewPanel items={(items as Item[]) ?? []} /> },
{ slot: SlotId.viewType, id: "view-type", match: "acme.notes", label: "Notes-review view type", render: ({ items }) => <NotesReviewView items={(items as Item[]) ?? []} /> },
{ slot: SlotId.dashboardWidget, id: "recent-widget", match: "acme.recent-notes", label: "Recent-notes widget", render: () => <RecentNotesWidget /> },
{
slot: SlotId.pluginManagerSection, id: "admin-toggles", match: "acme-notes",
label: "Admin availability toggles", toggleable: false,
render: ({ pluginId }) => (
<GlobalContributionToggles plugin="acme-notes" pluginId={String(pluginId ?? "")} />
),
},
{
slot: SlotId.profileSection, id: "profile-section",
label: "Profile section", toggleable: false,
render: () => <NotesProfileSection />,
},
],
});— examples/acme-notes/src/acme_notes/ui/src/index.tsx
Its package.json depends on @radd/plugin-sdk by file path. An external
plugin outside the monorepo would instead take a published version:
{
"dependencies": {
"@radd/plugin-sdk": "file:../../../../../web/packages/plugin-sdk"
},
"devDependencies": {
"@vitejs/plugin-react": "^6.0.3",
"react": "^19.2.7",
"vite": "^8.1.5"
}
}— examples/acme-notes/src/acme_notes/ui/package.json
web/scripts/render-proof.mjs is a headless-Chromium proof that drives a
real browser end to end. It signs in, opens an issue, and checks that a
plugin's contribution actually reached the DOM — not only that the build
succeeded:
const p = await evalInPage(sessionId,
`(()=>({dom:!!document.querySelector(${sectionSel}),active:(globalThis.__RADD_SLOT_REGISTRY__&&globalThis.__RADD_SLOT_REGISTRY__.activePlugins)?globalThis.__RADD_SLOT_REGISTRY__.activePlugins():[],importMap:!!document.querySelector('script[type="importmap"]')}))()`);— web/scripts/render-proof.mjs
It checks, in the running browser:
- the plugin's remote is listed in
GET /capabilities -
globalThis.__RADD_SLOT_REGISTRY__.activePlugins()names the plugin - the contributed section is present in the DOM
- the page carries an import map
-
isUiApiCompatibleaccepts a compatible minor and refuses an incompatible major - disabling the plugin through
POST /plugins/{id}/disableremoves the section from the DOM with no reload, and re-enabling it brings the section back
const uiApiVersionGate = await evalInPage(sessionId,
`(()=>{const m=globalThis.__RADD_SHARED__&&globalThis.__RADD_SHARED__["@radd/plugin-sdk"];return m?{compatible_1:m.isUiApiCompatible("1.4.0"),incompatible_2:m.isUiApiCompatible("2.0.0"),version:m.UI_API_VERSION}:null;})()`);— web/scripts/render-proof.mjs
To check by hand, open the browser console on any page and run:
window.__RADD_SLOT_REGISTRY__.activePlugins()This returns the names of every plugin with at least one live contribution.
If your plugin's name is missing, check the Network tab for a 404 on
/plugins/<name>/remoteEntry.js. That means the build did not reach the
server — see "The build" above. Or check the console for a warning naming a
ui_api_version mismatch — see the version gate above.
- Server settings — the administrator side: enabling, disabling, and per-contribution toggles from Settings → Plugins.
- Write a backend plugin — the entity, event, and permission registration a UI plugin's manifest builds on.
Mirrored from project.radd-hq.com on 2026-09-12. Documentation is written there; this copy is regenerated by scripts/publish_wiki.py and hand edits do not survive it.
-
Developer guide
- Architecture: the kernel and plugins
- Develop, test and deploy
- Events and consumers
- Permissions and access control
- The MCP server
- The query language for developers
- The REST API and authentication
- Write a backend plugin
- Write a page editor extension
- Write a plugin user interface
- Write an automation node
-
Release notes
- 0.36.4
- 0.36.3
- 0.36.2
- 0.36.1
- 0.36.0
- 0.35.0
- 0.34.0
- 0.33.0
- 0.32.0
- 0.31.1
- 0.31.0
- 0.30.0
- 0.29.0
- 0.28.0
- 0.27.0
- 0.26.0
- 0.25.1
- 0.25.0
- 0.24.1
- 0.24.0
- 0.23.1
- 0.23.0
- 0.22.0
- 0.21.0
- 0.20.0
- 0.19.0
- 0.18.1
- 0.18.0
- 0.17.2
- 0.17.1
- 0.17.0
- 0.16.0
- 0.15.0
- 0.14.1
- 0.14.0
- 0.13.1
- 0.13.0
- 0.12.0
- 0.11.0
- 0.10.0
- 0.9.2
- 0.9.1
- 0.9.0
- 0.8.1
- 0.8.0
- 0.7.1
- 0.7.0
- 0.6.6
- 0.6.5
- 0.6.4
- 0.6.3
- 0.6.2
- 0.6.1
- 0.6.0
- 0.5.0
- 0.4.1
- 0.4.0
- 0.3.2
- 0.3.0
- 0.2.0
- 0.1.0
-
User guide
- AI features
- Attachments
- Automations
- Cycles and releases
- Instance settings
- Intake forms and the portal
- Notifications and the inbox
- Personal settings
- Project settings
- Projects
- Reports and dashboards
- Search and the query language
- Start here
- The application window
- The card designer
- The roadmap
- The service desk
- The wiki
- Time logging and the timesheet
- Views
- Work items