feat!: finalize v2 release preparation

BREAKING CHANGE: prepare Homarr 2.0.0 release metadata and release safeguards.
This commit is contained in:
ajnart committed 2026-08-30 19:08:54 +02:00
1 parent 485474cf1d
commit 63280c936a
34 files changed
+3561 -3623

No files matched your search

+40
View File
@@ -0,0 +1,40 @@
---
name: codebase-context
description: Navigate Homarr's monorepo architecture and reuse shared packages. Use when locating code, choosing a package boundary, understanding runtime services, tRPC, databases, widgets, integrations, cron jobs, Custom Widgets, Workshop, or checking @homarr/common before creating a utility.
---
# Homarr Codebase Context
Orient from the current checkout before editing. Treat package manifests, exports, and source as authoritative when they differ from this snapshot.
## Workflow
1. Confirm the branch and inspect the nearest `package.json`, existing sibling modules, and package exports.
2. Search `@homarr/common` before creating a general-purpose helper. Read [references/common.md](references/common.md) for every public entrypoint and export.
3. Read [references/architecture.md](references/architecture.md) when the task crosses packages, runtimes, routing, data, widgets, integrations, cron jobs, Custom Widgets, or Workshop.
4. Keep the change in the deepest package that owns the behavior. Reuse public entrypoints rather than deep-importing implementation files.
5. Re-run targeted searches before documenting counts, consumers, or dependency edges; these change faster than the architectural seams.
## Current toolchain
- Use Node `24.18.0` from `mise.toml` and pnpm `11.15.1` from `package.json`.
- Use pnpm workspaces with Turborepo. Workspace packages use the `@homarr/` scope and catalog-managed dependencies.
- Use Next.js App Router, TypeScript, tRPC, Drizzle, Mantine v9, Tabler icons, Jotai, TanStack Query, next-intl, Vitest, and Playwright.
- Use oxlint and oxfmt. Mantine is the application UI system; Tailwind is limited to the docs app.
- `pnpm dev` starts only `@homarr/nextjs`. Start a standalone runtime explicitly when the task needs it.
## High-value seams
- Server/RSC tRPC: `@homarr/api/server`
- Client tRPC hooks: `@homarr/api/client`
- Main router: `packages/api/src/root.ts`
- MCP eager router: `packages/api/src/mcp.ts`
- Database schemas: `packages/db/schema/{sqlite,mysql,postgresql}.ts`
- Widget registry: `packages/widgets/src/registry.ts`
- Widget loading manifest: `packages/widgets/src/manifest.ts`
- Integration definitions: `packages/definitions/src/integration.ts`
- Integration factory: `packages/integrations/src/base/creator.ts`
- Cron jobs: `packages/cron-jobs/src/jobs/`
- App routes: `apps/nextjs/src/app/[locale]/`
When a code change affects users, invoke `documentation-sync`. When exposing tRPC through MCP, invoke `mcp-integration`.
@@ -0,0 +1,4 @@
interface:
display_name: "Homarr Codebase Context"
short_description: "Navigate Homarr architecture and shared APIs"
default_prompt: "Use $codebase-context to locate the right Homarr package and reuse existing shared APIs."
@@ -0,0 +1,110 @@
# Homarr architecture
## Contents
- [Applications and runtimes](#applications-and-runtimes)
- [Package map](#package-map)
- [Dependency direction](#dependency-direction)
- [Core patterns](#core-patterns)
- [Commands and conventions](#commands-and-conventions)
## Applications and runtimes
| Path | Role |
| ---------------- | ---------------------------------------------------------------------------------------- |
| `apps/nextjs` | Main Next.js application and HTTP APIs on port 3000 |
| `apps/docs` | Docusaurus documentation site on port 3003 |
| `apps/tasks` | Cron-job initialization and scheduling runtime; it does not expose the old port-3002 API |
| `apps/websocket` | tRPC WebSocket subscriptions on port 3001 |
| `apps/workshop` | Go/PocketBase Workshop service and provider integration |
`pnpm dev` runs only `@homarr/nextjs`. Use `pnpm --filter @homarr/tasks dev`, `pnpm --filter @homarr/websocket dev`, or `pnpm dev:docs` when a task needs those runtimes.
In production, `apps/nextjs/src/instrumentation-node.ts` starts the tasks and WebSocket runtimes inside the Node process. Nginx listens on port 7575, proxies `/websockets` to port 3001, and sends other traffic to port 3000. Redis can run internally or externally.
## Package map
### Foundation and domain
- `core`: environment validation, database and Redis factories, logging, infrastructure.
- `definitions`: domain enums, integration definitions, widget kinds, permissions.
- `common`: shared utilities with environment-specific entrypoints. Search it before adding helpers.
- `validation`: shared Zod schemas.
- `server-settings`: typed server-setting keys and access.
### Data, identity, and transport
- `db`: Drizzle schemas, queries, migrations, and three database drivers.
- `auth`: Auth.js/NextAuth configuration, sessions, providers, and API keys.
- `api`: tRPC routers, contexts, MCP metadata extraction, OpenAPI, and client/server entrypoints.
- `redis`: Redis clients, pub/sub channels, and caches.
- `cron-jobs-core`, `cron-jobs`, `cron-job-status`: scheduling primitives, job implementations, and status transport.
### Backends and external systems
- `integrations`: typed clients for external applications.
- `request-handler`: shared server request flows.
- `docker`, `ping`, `image-proxy`, `icons`, `analytics`: bounded infrastructure capabilities.
- `custom-widgets`: Custom JSX v2 schema, validation, runtime, and security contracts.
- `workshop`: TypeScript Workshop client, backend contracts, and schemas used by Homarr.
### UI and composed features
- `ui`: shared Mantine components, theme, and hooks. Search here before creating a primitive.
- `form`, `forms-collection`: Zod-backed form setup and reusable forms.
- `modals`, `modals-collection`: modal primitives and feature modals.
- `boards`: board state, edit mode, and cache coordination.
- `settings`, `spotlight`, `notifications`, `onboarding`: cross-feature UI flows.
- `widgets`: widget definitions, dynamic component loaders, and shared widget UI.
- `translation`: next-intl configuration and locale catalogs.
- `cli`: Node operations CLI. `tools/homarr-dev` is the separate Go developer CLI.
## Dependency direction
Prefer these directions; verify the exact dependency in package manifests before changing a boundary.
1. Put infrastructure in `core` and stable domain names in `definitions`.
2. Put reusable, dependency-light helpers in `common`; use its public environment-specific entrypoints.
3. Build persistence in `db`, identity in `auth`, transport in `redis`, and external clients in `integrations`.
4. Compose server behavior in `api`, `cron-jobs`, and request-handler packages.
5. Build primitives in `ui`, `form`, and `modals`; compose product surfaces in widgets and collection packages.
6. Keep app entrypoints in `apps/*` thin. Avoid moving reusable behavior into `apps/nextjs` when a package already owns it.
## Core patterns
### tRPC and MCP
`packages/api/src/root.ts` lazily composes the application router. Server components use `@homarr/api/server`; client components use `@homarr/api/client`. MCP extraction requires the eager router in `packages/api/src/mcp.ts`, so MCP-enabled subrouters must be imported and registered there directly. Use the `mcp-integration` skill for exposure and security rules.
### Databases
Drizzle schemas live in parallel files under `packages/db/schema/`. `DB_DRIVER` selects `better-sqlite3`, `mysql2`, or `node-postgres`; migrations live in matching directories under `packages/db/migrations/`. Apply schema behavior consistently across all supported drivers unless the task is explicitly driver-specific.
### Widgets
`packages/widgets/src/registry.ts` owns the explicit widget-module and component-loader registries. `packages/widgets/src/manifest.ts` caches those loaders and exposes definitions, components, and combined resources. Widget modules use `createWidgetDefinition` and typed option builders. Data commonly comes from tRPC queries or Redis-backed cron results. Use existing shared widget components before creating local variants.
### Integrations
`packages/definitions/src/integration.ts` owns `integrationDefs` and `IntegrationKind`. `packages/integrations/src/base/creator.ts` owns `createIntegrationAsync`; concrete integrations implement the shared base contract. Keep credentials and permission checks on server-owned paths.
### Cron jobs
Jobs live in `packages/cron-jobs/src/jobs/` and use `createCronJob`. `apps/tasks` initializes and starts the job group. Job results and status can flow through Redis and tRPC; the retired port-3002 management-API model is no longer part of the architecture.
### Routing and localization
User routes live below `apps/nextjs/src/app/[locale]/`; the locale segment does not add a URL prefix. Route groups organize home and board surfaces. Administrative routes live under `manage`, Custom Widget configuration has a token route, and Workshop has a management surface.
### Custom Widgets and Workshop
`packages/custom-widgets` owns the Custom JSX v2 contract. `packages/workshop` is the Homarr-side client/contract package; `apps/workshop` is the separate Go/PocketBase service. Use the `homarr-custom-widget` skill for authoring and security constraints.
## Commands and conventions
- Read current scripts from the root or package `package.json`; avoid caching commands that do not exist.
- Use `pnpm dev` for Next.js, `pnpm dev:docs` for docs, and `pnpm dev:cli -- <args>` for the Go developer CLI.
- Use `pnpm docker:dev:up` for the Redis development dependency.
- Use package-filtered typechecks or focused tests for the touched behavior.
- Use `~/*` only inside the Next.js app. Use public `@homarr/*` entrypoints across package boundaries.
- Keep Mantine application styles separate from the Tailwind-based docs app.
@@ -0,0 +1,86 @@
# `@homarr/common` public API
## Contents
- [Reuse workflow](#reuse-workflow)
- [Entrypoints](#entrypoints)
- [Root exports](#root-exports)
- [Types exports](#types-exports)
- [Server exports](#server-exports)
- [Client and environment exports](#client-and-environment-exports)
## Reuse workflow
Before writing a utility, search both the public name and the behavior:
```bash
rg -n "export (const|function|class|type|interface)|export \*" packages/common
rg -n "@homarr/common" apps packages
```
Import only from an exported package entrypoint. Do not deep-import `@homarr/common/src/*`. Check `packages/common/package.json` and the source again when adding or renaming an export; this inventory describes the current repository tree.
## Entrypoints
| Import | Source | Environment |
| ----------------------- | ---------------------------- | ----------------------------------------------------------------------------------- |
| `@homarr/common` | `index.ts` -> `src/index.ts` | Shared root; includes React hooks and some Next/Mantine-aware helpers |
| `@homarr/common/types` | `src/types.ts` | Type-only utilities |
| `@homarr/common/server` | `src/server.ts` | Node/server-only security, encryption, request, error, and bounded-response helpers |
| `@homarr/common/client` | `src/client.ts` | Client-facing Next server action export |
| `@homarr/common/env` | `env.ts` | Validated package environment |
## Root exports
| Area | Public exports | Purpose |
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
| Objects | `objectKeys`, `objectEntries`, `hashObjectBase64` | Typed key/entry iteration and stable query-key hashing |
| Strings | `capitalize`, `isNullOrWhitespace`, `bestMatch`, `normalizeImageName`, `getImageMatchRank` | Text normalization and matching |
| Cookies | `parseCookies`, `setClientCookie` | Cookie parsing and browser cookie serialization |
| Arrays | `splitToNChunks`, `splitToChunksWithNItems` | Split by number of groups or maximum group size |
| Dates | `formatDuration`, `toValidDate`, `isDateWithin` | Duration formatting, safe date coercion, relative-window checks |
| Timing | `Stopwatch` | Elapsed-time measurement and formatting |
| React hooks | `useTimeAgo`, `useIntegrationConnected` | Relative time and integration freshness state |
| URLs | `removeTrailingSlash`, `extractBaseUrlFromHeaders`, `getPortFromUrl`, `isAbsoluteUrl`, `SAFE_NEW_TAB_REL`, `getSafeApplicationUrl`, `getSafeAppHref` | URL normalization, safe navigation, ports, and new-tab policy |
| Numbers and bytes | `formatNumber`, `randomInt`, `formatBytes`, `formatBytesPair`, `formatByteRate`, `metricToImperial`, `imperialToMetric` | Compact numbers, byte displays, rates, and unit conversion |
| Legacy bytes | `humanFileSize` | Deprecated; use the `formatBytes*` helpers in new code |
| Byte types | `ByteUnitSystem`, `FormatBytesOptions` | Options for byte formatting |
| Errors | `extractErrorMessage`, `FlattenError` | Unknown-error messages and flattenable domain errors |
| Theme | `getMantineColor` | Resolve a Mantine color and shade |
| Functions | `isFunction` | Unknown-value function guard |
| Grid | `GridAlgorithmItem`, `generateResponsiveGridFor` | Responsive nested-section grid placement |
| IDs | `createId` | CUID2 ID creation |
The root deliberately does not re-export `types`, `server`, `client`, or `env`; use their named entrypoints.
## Types exports
`@homarr/common/types` exports:
- `MaybePromise<T>`
- `AtLeastOneOf<T>`
- `Modify<T, R>`
- `RemoveReadonly<T>`
- `MaybeArray<T>`
- `Inverse<T>`
- `inferSearchParamsFromSchema<TSchema>`
## Server exports
`@homarr/common/server` exports:
- Security: `generateSecureRandomToken`.
- Encryption: `encryptSecret`, `decryptSecret`, `decryptSecretWithKey`, `createKeyedFingerprint`, `verifyKeyedFingerprint`.
- Requests: `userAgent`, `DeviceType`, `ipAddressFromHeaders`.
- Bounded bodies: `readBoundedJsonResponseAsync`.
- Parse errors: `ParseError`, `ParseErrorHandler`, `ZodParseErrorHandler`, `JsonParseErrorHandler`.
- HTTP handlers: `HttpErrorHandler`, `FetchHttpErrorHandler`, `matchErrorCode`, `OFetchHttpErrorHandler`, `AxiosHttpErrorHandler`, `TsdavHttpErrorHandler`, `OctokitHttpErrorHandler`.
- HTTP errors: `RequestError`, `ResponseError`, `requestErrorMap`.
- HTTP error types: `AnyRequestError`, `AnyRequestErrorInput`, `RequestErrorInput`, `RequestErrorType`, `RequestErrorReason`, `AnyRequestErrorReason`, `RequestErrorCode`.
`NodeFetchHttpErrorHandler` exists as an implementation file but is not exported by the public server entrypoint. Add an intentional public export before using it across packages.
## Client and environment exports
- `@homarr/common/client` exports `revalidatePathActionAsync`, a Next server action for page revalidation.
- `@homarr/common/env` exports `env` with validated `NODE_ENV`, `SECRET_ENCRYPTION_KEY`, and `NO_EXTERNAL_CONNECTION` values.
@@ -0,0 +1,31 @@
---
name: datatable-migration
description: Migrate Homarr tables from mantine-react-table to mantine-datatable. Use when converting an MRT table, changing DataTable columns, sorting, expansion, context menus, responsive visibility, or widget table layout persistence.
---
# DataTable Migration
Migrate one table at a time and preserve its behavior, permissions, translations, responsive layout, and error states.
## Workflow
1. Read the source table, its callers, translations, tests, and nearby migrated tables.
2. Search current consumers and shared patterns instead of relying on a cached candidate list:
```bash
rg -l 'mantine-react-table' apps packages --glob '*.{ts,tsx}'
rg -l 'from "mantine-datatable"' apps packages --glob '*.{ts,tsx}'
```
3. For a widget table, reuse `packages/widgets/src/common/homarr-data-table.tsx` and `use-persisted-table-layout.ts`. Preserve their shared sizing, edit-mode, styling, hydration, and debounced persistence behavior.
4. Read [references/migration-guide.md](references/migration-guide.md) for the conversion map, sorting, responsive columns, persistence, row interactions, and hardening rules.
5. Keep the existing data and mutation contracts. Convert the view layer without broad unrelated cleanup.
6. Validate the migrated table at its narrow and wide sizes and run only the focused formatter, typecheck, and existing tests that cover the changed table.
## Completion criteria
- No MRT imports, types, hooks, or translated-table helpers remain in the migrated component.
- Sorting, row identity, loading, empty, error, selection, expansion, and actions match the previous behavior.
- Widget tables use the shared Homarr wrapper and persistence hooks instead of local copies.
- User-visible strings remain translated and interactive controls remain accessible.
- Documentation is updated through `documentation-sync` when user-facing behavior changes.
@@ -0,0 +1,4 @@
interface:
display_name: "Homarr DataTable Migration"
short_description: "Migrate Homarr tables to Mantine DataTable"
default_prompt: "Use $datatable-migration to migrate this Homarr table while preserving behavior and shared widget patterns."
@@ -0,0 +1,214 @@
# Mantine DataTable migration guide
## Contents
- [Choose the table seam](#choose-the-table-seam)
- [Convert imports and columns](#convert-imports-and-columns)
- [Implement sorting](#implement-sorting)
- [Use the shared widget table](#use-the-shared-widget-table)
- [Persist widget column layout](#persist-widget-column-layout)
- [Adapt columns to widget width](#adapt-columns-to-widget-width)
- [Preserve row interactions](#preserve-row-interactions)
- [Harden the result](#harden-the-result)
- [Migration checklist](#migration-checklist)
## Choose the table seam
Inspect current source before choosing an abstraction:
```bash
rg -l 'mantine-react-table' apps packages --glob '*.{ts,tsx}'
rg -n 'HomarrDataTable|usePersistedTableLayout|useTableLayoutPersistence' packages/widgets/src
```
Use `DataTable` directly for management pages and other non-widget surfaces unless that area has a narrower wrapper. For widget tables, use these shared modules:
- `packages/widgets/src/common/homarr-data-table.tsx`
- `packages/widgets/src/common/homarr-data-table.css`
- `packages/widgets/src/common/use-persisted-table-layout.ts`
Use downloads, Docker, and Beszel System Table as current examples. The wrapper owns transparent board-card styling, proportional/fixed table layout, paired-column resize previews, standard scroll-area props, cell padding, and edit-mode pointer behavior. Keep those decisions centralized.
## Convert imports and columns
Replace MRT primitives and types:
```typescript
// Before
import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
import type { MRT_ColumnDef } from "mantine-react-table";
// After
import { DataTable } from "mantine-datatable";
import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable";
```
Convert each column deliberately:
| MRT | Mantine DataTable |
| ------------------------ | ------------------------------------ |
| `accessorKey` | `accessor` |
| `header` | `title` |
| `size` | `width` |
| `enableSorting` | `sortable` |
| `Cell: ({ row }) => ...` | `render: (record) => ...` |
| table-body-cell props | `cellsStyle` or shared wrapper props |
```typescript
const columns: DataTableColumn<Row>[] = [
{
accessor: "name",
title: t("items.name.columnTitle"),
width: 200,
sortable: true,
ellipsis: true,
render: (record) => <Text>{record.name}</Text>,
},
];
```
Pass records and columns directly:
```tsx
<DataTable records={records} columns={columns} />
```
Memoize columns when they depend on translations, options, permissions, or responsive configuration. Keep every dependency explicit.
## Implement sorting
Mantine DataTable reports sort state; the component still owns the sorted records.
```typescript
const [sortStatus, setSortStatus] = useState<DataTableSortStatus<Row>>({
columnAccessor: "name",
direction: "asc",
});
const sortedRecords = useMemo(() => {
const multiplier = sortStatus.direction === "desc" ? -1 : 1;
return records.toSorted((left, right) => left.name.localeCompare(right.name) * multiplier);
}, [records, sortStatus.direction]);
```
Use an accessor-specific comparator map when columns contain different types. Preserve the original table's null ordering and stable tie-breaker. If options configure the default sort, synchronize `sortStatus` when those options change.
## Use the shared widget table
Render widget tables through `HomarrDataTable`:
```tsx
<HomarrDataTable
records={sortedRecords}
columns={effectiveColumns}
isEditMode={isEditMode}
cellPadding={`${size.cellPadding}px 8px`}
sortStatus={sortStatus}
onSortStatusChange={setSortStatus}
fetching={isFetching && records.length === 0}
noRecordsText={t("errors.noItems")}
idAccessor={(record) => record.uniqueKey}
/>
```
Let the wrapper own its default border, hover, resize, scroll-area, height, transparency, and pointer-event behavior. Extend its props only when multiple widget tables need the same behavior. Keep widget-specific colors and row content in the widget.
## Persist widget column layout
Add hidden `columnOrder` and `columnWidths` string options to the widget definition when users can drag or resize columns. Hidden options still require translation labels.
Build the save callback with `useTableLayoutPersistence`; it updates local options and writes to the board item only when the user has change access and the board/item IDs exist:
```typescript
const persistLayout = useTableLayoutPersistence({
boardId,
hasChangeAccess,
itemId,
saveItemOptions,
setOptions,
});
```
Hydrate, validate, merge, and debounce layout changes with `usePersistedTableLayout`:
```typescript
const { effectiveColumns, storeKey } = usePersistedTableLayout({
columns,
columnAccessors,
columnOrder: options.columnOrder,
columnWidths: options.columnWidths,
itemId,
storeKeyPrefix: "downloads-table",
onLayoutChange: persistLayout,
});
```
Pass `effectiveColumns` to the table and `storeKey` through the DataTable storage prop used by the local example. Do not recreate JSON parsing, allowed-accessor filtering, hydration guards, store-key resets, hidden-column merging, width normalization, or delayed saves in a widget component.
## Adapt columns to widget width
Derive a small size configuration from the widget's measured width. Keep breakpoints ordered and return one complete configuration:
```typescript
interface SizeConfig {
cellPadding: number;
showSpeed: boolean;
showState: boolean;
}
const getSizeConfig = (width: number): SizeConfig => {
if (width < 300) return { cellPadding: 2, showSpeed: false, showState: false };
if (width < 500) return { cellPadding: 4, showSpeed: false, showState: true };
return { cellPadding: 4, showSpeed: true, showState: true };
};
const size = getSizeConfig(width);
```
Prefer clear `if` branches or a breakpoint lookup in production code when more than two tiers exist. Filter or mark columns hidden from one typed visibility map so option selection and width rules are both applied. Preserve the original accessor list for persistence even when a column is temporarily hidden.
## Preserve row interactions
### Context menus
Use Mantine `Menu` and `Portal` with `onRowContextMenu`. Store the pointer coordinates and selected record, prevent the browser menu, and close on scroll, Escape, action completion, or outside interaction. Use `closeOnItemClick={false}` only for a menu item with an inline confirmation step.
Disable the row context-menu handler in edit mode. Keep action permission checks from the original table and report mutation failures with the shared notification helper.
### Expansion
Use DataTable `rowExpansion` and a stable `idAccessor`. Avoid a competing `onRowClick` when `rowExpansion.trigger` is `"click"`; the handlers conflict. Keep expansion content resilient to missing optional fields.
### Row identity
Use a composite accessor when IDs can collide across integrations:
```tsx
idAccessor={(record) => `${record.integration.id}:${record.id}`}
```
## Harden the result
- Guard ratios and percentages against zero denominators.
- Catch `navigator.clipboard.writeText()` failures in non-secure contexts.
- Parse persisted or server-provided JSON through the shared persistence helper, not an unchecked cast.
- Use a small `Box` for a status dot instead of an empty dotted `Badge`.
- Keep tooltip targets clipped inside ellipsis cells and choose a position that remains inside narrow widgets.
- Give every `ActionIcon` an `aria-label` and toggle controls an `aria-pressed` state.
- Translate column titles, detail labels, states, actions, errors, empty states, and hidden option labels.
- Preserve `isError`, empty, initial-loading, background-refresh, and mutation-error behavior separately.
- Keep mutation invalidation and error handling shared when multiple row actions use the same flow.
- Validate action permissions at the procedure boundary; hiding a row action is not authorization.
## Migration checklist
1. Inventory the table's columns, sorting, filters, selection, actions, expansion, pagination, responsive behavior, translations, and permissions.
2. Replace MRT imports and convert column definitions.
3. Implement explicit sorting and stable row identity.
4. Use `HomarrDataTable` for widgets; use the nearest existing direct-DataTable pattern elsewhere.
5. Use shared widget persistence hooks when columns are draggable or resizable.
6. Preserve narrow-width visibility without losing saved layout for hidden columns.
7. Restore context menus, expansion, loading, empty, error, and mutation feedback.
8. Restore translations and accessibility attributes.
9. Remove obsolete MRT-only hooks or dependencies only when no consumers remain.
10. Validate the affected table at narrow and wide widths, then run only its focused checks.
@@ -0,0 +1,51 @@
---
name: documentation-sync
description: Keep Homarr's Docusaurus documentation aligned with user-facing code. Use when adding or changing integrations, widgets, APIs, environment variables, CLI commands, authentication, permissions, cron jobs, settings, or UI behavior that users must understand.
---
# Documentation Sync
Update `apps/docs/` in the same change as user-facing behavior. Inspect adjacent pages and the docs types before copying a pattern; the source and docs evolve together.
## Map the change
| Code change | Documentation target |
| ----------------------------------- | -------------------------------------------------------------------- |
| New integration | `apps/docs/docs/integrations/<slug>/index.mdx` and `index.ts` |
| New widget | `apps/docs/docs/widgets/<slug>/index.mdx` and `index.ts` |
| Changed public or management API | `apps/docs/docs/management/api/index.mdx` |
| New or changed environment variable | Relevant page under `apps/docs/docs/advanced/` or installation docs |
| New or changed CLI command | `apps/docs/docs/advanced/command-line/` |
| Authentication/provider change | Relevant SSO or authentication page under `apps/docs/docs/advanced/` |
| UI or workflow change | Relevant getting-started, management, widget, or integration page |
| Cron-job behavior | `apps/docs/docs/management/tasks.mdx` |
| Permission behavior | `apps/docs/docs/management/users.mdx` and the affected feature page |
| Custom Widget or Workshop behavior | Relevant Custom Widget, Assistant, or Workshop documentation |
If no existing row fits, search by the user-visible term and update the page where a user would look for the behavior:
```bash
rg -n "<term>" apps/docs/docs apps/docs/src
```
## Follow existing typed patterns
For an integration:
- Add or update `apps/docs/docs/integrations/<slug>/index.ts` with the local `IntegrationDefinition` pattern.
- Add or update `index.mdx` with the established `IntegrationHeader`, `IntegrationCapabilites`, and `IntegrationSecrets` components as applicable.
- Reuse metadata from `@homarr/definitions` when the docs app already exposes it.
For a widget:
- Add or update `apps/docs/docs/widgets/<slug>/index.ts` with the local `WidgetDefinition` pattern.
- Add or update `index.mdx` with the established `WidgetHeader`, `WidgetConfig`, and `AddingWidget` components as applicable.
- Document options, required integrations, permissions, empty states, and changed behavior that affect setup or use.
## Completion criteria
1. Describe the shipped behavior, not an implementation plan.
2. Keep names, defaults, paths, screenshots, links, and prerequisites consistent with code.
3. Update every affected page and remove superseded guidance.
4. Run the narrowest useful docs validation. Use `pnpm turbo build --filter=@homarr/docs` when links, MDX, generated definitions, or navigation can fail; otherwise run the docs package formatter on touched files.
5. Treat broken links and anchors as failures; Docusaurus checks them strictly.
@@ -0,0 +1,4 @@
interface:
display_name: "Homarr Documentation Sync"
short_description: "Keep user-facing docs aligned with code"
default_prompt: "Use $documentation-sync to map this Homarr code change to the required documentation updates."
+1 -1
View File
@@ -1,6 +1,6 @@
---
name: homarr-custom-widget
description: Author safe Homarr Custom JSX v2 widgets from API documentation.
description: Author, validate, preview, install, and configure safe Homarr Custom JSX v2 widgets. Use when creating a widget from API documentation, testing its states and actions, installing one from Workshop, or configuring sources and credentials through Homarr.
---
# Homarr Custom Widget
@@ -0,0 +1,4 @@
interface:
display_name: "Homarr Custom Widget"
short_description: "Build and deploy safe Custom JSX widgets"
default_prompt: "Use $homarr-custom-widget to author, validate, preview, and configure a safe Custom JSX v2 widget."
+98
View File
@@ -0,0 +1,98 @@
---
name: mcp-integration
description: Expose Homarr tRPC procedures safely through MCP. Use when adding or changing tRPC procedures under packages/api, adding MCP metadata, registering an eager MCP router, shaping tool inputs and descriptions, or reviewing MCP authorization, sensitive data, and destructive actions.
---
# MCP Integration
Expose a procedure only when an AI client should call it. Preserve the procedure's existing authorization and add MCP-specific safeguards for sensitive or destructive behavior.
## Assess exposure
Prefer:
- Data queries: list, search, get, health, stats, and summaries.
- User-requested actions with explicit permission checks and bounded inputs.
- Serializable results that do not reveal credentials or internal-only state.
Skip subscriptions, session/onboarding internals, file streams, blobs, credential values, and procedures whose authorization or audit behavior is unclear. Ask for a security review when a mutation is destructive, tenant boundaries are ambiguous, or returned data is sensitive.
## Add metadata
Place `.meta()` before `.input()` and `.query()` or `.mutation()`:
```typescript
getAll: protectedProcedure
.meta({
mcp: {
enabled: true,
description: "List resources the current user can access, including stable IDs used by resource_get",
},
})
.input(z.object({ limit: z.number().int().min(1).max(100).default(50) }))
.query(async ({ ctx, input }) => {
// Preserve normal procedure authorization.
});
```
No-input procedures can omit `.input()`; the MCP extractor accepts an empty object for them.
For mutations, state the effect and required permission in the description:
```typescript
remove: permissionRequiredProcedure
.meta({
mcp: {
enabled: true,
description: "Delete one resource by ID. Requires full access; get the ID from resource_all",
},
})
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
// Perform the permission-scoped action.
});
```
## Write discoverable descriptions
Include every item that applies:
- What the tool returns or changes.
- Supported integrations or services.
- Required IDs and the tool that returns them.
- Permission requirements and the meaning of permission fields.
- Important bounds, confirmation semantics, or irreversible effects.
Use precise domain language. A description such as `Get calendar events` is insufficient because it omits supported services, required IDs, and result scope.
## Shape inputs for clients
- Use a top-level `z.object({...})` for parameterized tools.
- Bound strings, arrays, numeric ranges, pagination, and payload sizes.
- Use defaults where a safe, unsurprising value exists.
- Prefer explicit fields over a top-level union or discriminated union.
- Keep identifiers stable and describe how to obtain them.
- Keep secrets out of inputs unless the procedure is explicitly a credential-configuration flow with server-owned encryption and non-return guarantees.
## Register the eager router
The application router in `packages/api/src/root.ts` is lazy. MCP tool extraction is synchronous, so add an eager import and registration in `packages/api/src/mcp.ts`:
```typescript
import { resourceRouter } from "./router/resource";
export const mcpRouter = createTRPCRouter({
resource: resourceRouter,
});
```
Register the smallest router that owns the enabled procedures. If a parent router uses `lazy()`, import the needed subrouter directly. Metadata on a procedure is not enough; a procedure absent from `mcpRouter` is not exposed.
## Validate the tool surface
1. Confirm the procedure still enforces its ordinary auth and permission boundary.
2. Confirm `packages/api/src/mcp.ts` eagerly includes the procedure.
3. Confirm the generated tool name, description, and JSON schema are unambiguous.
4. Run the focused API MCP spec when the tool list or extraction changes: `pnpm test packages/api/src/test/mcp.spec.ts`.
5. Run the focused route spec when protocol or transport behavior changes: `pnpm test apps/nextjs/src/app/api/mcp/[transport]/route.spec.ts`.
6. If manually probing `/api/mcp/<transport>`, use a scoped API key and avoid printing credentials or secret-bearing results.
@@ -0,0 +1,4 @@
interface:
display_name: "Homarr MCP Integration"
short_description: "Expose tRPC procedures safely through MCP"
default_prompt: "Use $mcp-integration to expose this Homarr tRPC procedure as a safe, discoverable MCP tool."
+40
View File
@@ -0,0 +1,40 @@
---
name: release-homarr
description: Prepare, verify, publish, or recover a Homarr release. Use when changing the root version, merging dev into main, running Semantic Release, publishing GitHub or GHCR artifacts, manually redeploying an existing version, checking release CI, or coordinating a major release such as v2.
---
# Release Homarr
Treat the Git tag, GitHub release, multi-platform GHCR image, and announcement as separate gates. Verify each one against the same commit and version.
## Release paths
- A push to `dev` builds the mutable `dev` image. It does not create a Git tag or GitHub release.
- A push to `main` runs Semantic Release. Merge `dev` into `main` only when the release should start.
- A manual dispatch redeploys the latest existing tag reachable from the selected `main` or `beta` ref. It does not calculate or create a new release.
- A `beta` push creates a prerelease through the configured Semantic Release channel.
## Prepare a major release
1. Set the root `package.json` version to the intended major, such as `2.0.0`. Do not change private workspace package versions unless they are published independently.
2. Ensure commits since the latest production tag contain a conventional breaking signal: `type!:` or a `BREAKING CHANGE:` footer. Package metadata alone does not make Semantic Release choose a major.
3. Merge the release work into `dev` and let its CI and preview image complete. This is a release candidate, not a production release.
4. Verify the final `dev` head and open the deliberate `dev` to `main` release PR. Preserve a breaking signal when squashing or rebasing stacked branches.
5. Merge that PR only at the planned announcement time. The `main` push starts the production release workflow.
## Verify publication
For the exact `main` release commit, verify:
1. Semantic Release created the expected `vX.Y.Z` tag and draft GitHub release.
2. AMD64 and ARM64 builds succeeded.
3. The versioned and channel GHCR tags resolve to one multi-platform manifest containing both architectures.
4. Release assets were extracted from the same release ref.
5. The GitHub release became public only after publication completed.
6. The configured announcement ran, or publish the planned human announcement after every prior gate passes.
Do not rerun an older workflow without comparing its commit and computed version to the intended release. A stale rerun can republish an older reachable tag.
## Recovery
Rerun only the failed jobs from the exact release workflow when the release commit and version are correct. Use manual dispatch only to rebuild an existing release intentionally; it is not a substitute for a missing Semantic Release run.
@@ -0,0 +1,4 @@
interface:
display_name: "Release Homarr"
short_description: "Safely prepare and publish Homarr releases"
default_prompt: "Use $release-homarr to verify and publish a Homarr release."
+1
View File
@@ -0,0 +1 @@
../.agents/skills
-208
View File
@@ -1,208 +0,0 @@
---
description: Comprehensive Homarr codebase architecture and conventions reference
alwaysApply: true
---
# Homarr Codebase Context
Homarr is an open-source, self-hosted dashboard for managing homelab services. It integrates with 50+ self-hosted apps (media servers, download clients, DNS, NAS, etc.) and provides a drag-and-drop widget-based UI.
## Tech Stack
- **Monorepo**: pnpm workspaces + Turborepo
- **Framework**: Next.js (App Router, `output: "standalone"`) — T3 Stack
- **Language**: TypeScript throughout
- **ORM**: Drizzle (supports SQLite via better-sqlite3, MySQL, PostgreSQL)
- **API**: tRPC (HTTP + WebSocket subscriptions) with superjson + OpenAPI bridge via `trpc-to-openapi`
- **Auth**: NextAuth v5 (database sessions, Credentials/LDAP/OIDC providers)
- **UI**: Mantine v9 (not Tailwind) + Tabler icons
- **State**: Jotai (atoms), TanStack Query (server state via tRPC)
- **Realtime**: WebSocket server (`ws`) for tRPC subscriptions, backed by Redis pub/sub
- **Cron**: `node-cron` in a standalone Fastify service (`apps/tasks`)
- **i18n**: next-intl with `[locale]` dynamic segment (prefix mode: `"never"`, locale from cookie)
- **Testing**: Vitest + jsdom, Playwright for E2E
- **Lint/Format**: oxlint + oxfmt (not ESLint/Prettier)
- **Docs**: Docusaurus 3 in `apps/docs/` (`@homarr/docs`)
- **Package manager**: pnpm 11.15.1, Node >= 24.18.0
## Repository Structure
```
homarr/
├── apps/
│ ├── nextjs/ # Main Next.js application (port 3000)
│ ├── docs/ # Docusaurus 3 documentation site (@homarr/docs)
│ ├── tasks/ # Cron job runner + Fastify tRPC API (port 3002)
│ └── websocket/ # Standalone tRPC WebSocket server (port 3001)
├── packages/
│ ├── api/ # tRPC appRouter, procedures, OpenAPI
│ ├── auth/ # NextAuth config, providers, session, API keys
│ ├── db/ # Drizzle schema (3 DB drivers), migrations, queries
│ ├── core/ # Env validation, DB/Redis driver factories, logging
│ ├── definitions/ # Domain enums: WidgetKind, IntegrationKind, permissions
│ ├── widgets/ # Dashboard widget definitions, components, and lazy registry
│ ├── integrations/ # Integration classes (HTTP clients to external apps)
│ ├── redis/ # Redis pub/sub channels, caching abstractions
│ ├── translation/ # next-intl setup, locale configs, lang JSON files
│ ├── ui/ # Shared Mantine components, theme, hooks
│ ├── validation/ # Shared zod schemas for API/forms
│ ├── common/ # Shared utilities, IDs, errors
│ ├── cron-jobs/ # Cron job implementations (25+ jobs)
│ ├── cron-jobs-core/ # Cron scheduling primitives
│ ├── cron-job-api/ # tRPC router for job management (start/stop/trigger)
│ ├── cron-job-status/ # Cron status via Redis
│ ├── boards/ # Board context, edit mode, cache updater
│ ├── modals/ # Modal primitives on Mantine
│ ├── modals-collection/ # Feature modals (apps, boards, docker, etc.)
│ ├── form/ # useZodForm (Mantine + zod resolver)
│ ├── forms-collection/# Reusable form UIs (new app, icon picker, upload)
│ ├── spotlight/ # Command palette / search with multiple modes
│ ├── request-handler/ # Server request handlers (feeds, integrations)
│ ├── notifications/ # Mantine notifications wrapper
│ ├── docker/ # Dockerode-based Docker access
│ ├── icons/ # Icon DB/repo integration
│ ├── image-proxy/ # Image proxy + caching
│ ├── ping/ # Reachability / ping utilities
│ ├── analytics/ # Server-side analytics (Umami)
│ ├── server-settings/ # Server setting keys/types
│ ├── settings/ # User-facing settings UI context
│ └── cli/ # Node CLI for ops (brocli)
├── tooling/
│ ├── typescript/ # Base tsconfig
│ └── github/ # CI setup action
├── development/ # Dev docker-compose (Redis, MySQL, PostgreSQL)
├── e2e/ # E2E test specs
└── Dockerfile # Multi-stage production build
```
## Three Runtime Services
| Service | Port | Role |
|---------|------|------|
| Next.js | 3000 | Main web app (SSR + API routes) |
| WebSocket | 3001 | tRPC subscriptions via `ws` |
| Tasks | 3002 | Cron job runner + management API via Fastify |
In Docker, nginx listens on **7575** and proxies to all three. The tasks service authenticates via `CRON_JOB_API_KEY` header.
## Package Dependency Layers
1. **Foundation**: `core` (env, DB drivers, Redis client, logging) → no `@homarr` deps
2. **Domain**: `definitions` → `common` → `core`
3. **Data**: `db` → `core`, `definitions`, `common`
4. **Auth**: `auth` → `db`, `definitions`, `core`, `validation`
5. **Cross-cutting**: `translation`, `validation`, `redis`, `server-settings`
6. **Feature backends**: `integrations`, `request-handler`, `cron-jobs`, `docker`, `ping`
7. **API surface**: `api` → pulls in auth, db, redis, integrations, etc.
8. **UI stack**: `ui`, `modals`, `form`, `notifications`, `boards`, `spotlight`
9. **Composed features**: `widgets`, `modals-collection`, `forms-collection`
## Key Patterns
### tRPC
- **Server caller** (RSC): `import { api } from "@homarr/api/server"` → cached caller with auth context
- **Client hooks**: `import { clientApi } from "@homarr/api/client"` → React Query hooks
- **Router structure**: `packages/api/src/root.ts` defines top-level namespaces (`user`, `board`, `widget`, `integration`, `docker`, `kubernetes`, etc.)
- **Procedures**: `publicProcedure`, `protectedProcedure`, `permissionRequiredProcedure`, `onboardingProcedure`
- **WebSocket subscriptions**: Client uses `wsLink` when `type === "subscription"`, connects to port 3001
### Multi-Database Support
Drizzle schemas exist in three parallel implementations:
- `packages/db/schema/sqlite.ts`
- `packages/db/schema/mysql.ts`
- `packages/db/schema/postgresql.ts`
Selected at runtime via `DB_DRIVER` env (`better-sqlite3` | `mysql2` | `node-postgres`). Migrations are per-engine in `packages/db/migrations/{sqlite,mysql,postgresql}/`.
### Widget System
Widget kinds live in `packages/definitions/src/widget.ts`. Integration selection and authorization metadata lives in
`packages/definitions/src/widget-integration-map.ts`. Each widget module exports a typed `definition` and
`componentLoader` created with `createWidgetDefinition(...).withDynamicImport(...)`.
Register widgets in all three explicit maps in `packages/widgets/src/registry.ts`: the type-only module map, literal
module loaders, and literal component loaders. The two runtime loader maps are deliberately separate so
`packages/widgets/src/manifest.ts` can request a widget's definition chunk and component chunk in parallel while
keeping every lazy import statically discoverable by Next.js. The manifest owns promise caching and clears rejected
entries so later loads can retry. Do not restore an eager root `widgetImports` map.
### Integration System
Integrations are defined in `packages/definitions/src/integration.ts` (`integrationDefs`) and instantiated via `createIntegrationAsync` in `packages/integrations/src/base/creator.ts`. Each extends abstract `Integration` class with `testConnectionAsync` and typed methods.
### Cron Job System
Jobs defined in `packages/cron-jobs/src/jobs/`. Each job:
- Uses `createCronJob` with a cron expression
- Publishes results to Redis channels
- Widget subscriptions consume those channels via tRPC subscriptions
- Job management (start/stop/trigger) via `cronJobApi` HTTP client to port 3002
### Modals
Created with `createModal(component).withOptions({ defaultTitle })` from `@homarr/modals`. Opened via `useModalAction(SomeModal)`. Feature modals live in `packages/modals-collection/`.
### Forms
`useZodForm` from `@homarr/form` wraps Mantine `useForm` with zod validation. Shared form UIs in `packages/forms-collection/`.
### Routing (App Router)
- `apps/nextjs/src/app/[locale]/` — all UI routes under locale segment
- Route groups: `(home)`, `(content)`, `(board)` — don't affect URLs
- Key routes: `boards/[name]`, `manage/...` (admin), `auth/login`, `init` (onboarding), `widgets/[kind]`
- Locale driven by cookie (no URL prefix)
### Auth Providers
Configured via `AUTH_PROVIDERS` env. Supports: `credentials` (local), `ldap`, `oidc`. Database sessions with httpOnly cookies. API key auth for programmatic access.
## Environment Variables
Key env vars (from `.env.example`):
- `DB_DRIVER`: `better-sqlite3` | `mysql2` | `node-postgres`
- `DB_URL`: Database connection string
- `AUTH_SECRET`: NextAuth secret
- `SECRET_ENCRYPTION_KEY`: AES-256-CBC for integration secrets
- `AUTH_PROVIDERS`: Comma-separated (`credentials`, `ldap`, `oidc`)
- `CRON_JOB_API_KEY`: Shared key between Next.js and tasks service
- `REDIS_*`: Redis connection (host, port, password)
- `ENABLE_KUBERNETES`: Optional Kubernetes integration
## Development Commands
- `pnpm dev` — runs all three services in parallel via Turborepo
- `pnpm docker:dev` — starts Redis + MySQL + PostgreSQL containers
- `pnpm db:push` — push schema to SQLite (dev)
- `pnpm db:studio` — Drizzle Studio
- `pnpm test` — Vitest unit tests
- `pnpm test:e2e` — Playwright E2E
- `pnpm lint` / `pnpm format` — oxlint + oxfmt
- `pnpm dev:docs` — Docusaurus docs site (port 3003)
- `pnpm typecheck` — tsc --noEmit across all packages
## Conventions
- Workspace packages use `@homarr/` scope
- All versions managed via pnpm `catalog:` in workspace
- Path alias `~/*` → `./src/*` in Next.js app
- Mantine for all UI (no Tailwind) — primary color is red, `autoContrast: true`
- Icons from `@tabler/icons-react`
- Drag-and-drop via `@dnd-kit/*`
- `typescript.ignoreBuildErrors: true` in next.config (types checked separately via `typecheck`)
- `serverExternalPackages`: `dockerode`, `isomorphic-dompurify`, `jsdom`
## Documentation (`apps/docs`)
Docusaurus 3 site at `apps/docs/`, package name `@homarr/docs`.
- Run with `pnpm dev:docs` from root
- Build with `pnpm turbo build --filter=@homarr/docs`
- Uses Tailwind CSS 3 for custom pages (not Mantine)
- Imports `@homarr/definitions` for type-safe integration/widget metadata
- When adding or modifying widgets, integrations, cron jobs, API routes, env vars, or user-facing features, you MUST also update corresponding docs in `apps/docs/docs/`
- Integration docs: `apps/docs/docs/integrations/<slug>/index.mdx` + `index.ts`
- Widget docs: `apps/docs/docs/widgets/<slug>/index.mdx` + `index.ts`
- Strict link checking: broken links/anchors cause build failure
-50
View File
@@ -1,50 +0,0 @@
---
description: Keep documentation in sync with code changes
globs:
- packages/definitions/src/**
- packages/widgets/src/**
- packages/integrations/src/**
- packages/api/src/**
- apps/nextjs/src/**
- apps/docs/**
---
# Documentation Sync Rule
When modifying code that affects user-facing behavior, you MUST also update the documentation in `apps/docs/`.
## What triggers a doc update
| Code change | Doc location |
|---|---|
| New integration | `apps/docs/docs/integrations/<slug>/index.mdx` + `index.ts` |
| New widget | `apps/docs/docs/widgets/<slug>/index.mdx` + `index.ts` |
| Changed API surface | `apps/docs/docs/management/api.mdx` |
| New/changed env vars | `apps/docs/docs/advanced/` |
| New CLI commands | `apps/docs/docs/advanced/command-line/` |
| Changed auth providers | `apps/docs/docs/advanced/` SSO pages |
| UI/UX changes | Relevant getting-started or management pages |
| New cron job | `apps/docs/docs/management/tasks.mdx` |
| Changed permissions | `apps/docs/docs/management/users.mdx` |
## Integration doc pattern
Each integration has a folder under `apps/docs/docs/integrations/<slug>/`:
- `index.ts` — exports a typed `IntegrationDefinition` with name, description, iconUrl, path
- `index.mdx` — uses shared components: `IntegrationHeader`, `IntegrationCapabilites`, `IntegrationSecrets`
- Import metadata from `@homarr/definitions` where possible
## Widget doc pattern
Each widget has a folder under `apps/docs/docs/widgets/<slug>/`:
- `index.ts` — exports a typed `WidgetDefinition` with icon, name, description, path, configuration
- `index.mdx` — uses shared components: `WidgetHeader`, `WidgetConfig`, `WidgetAdding`
## Development
- Run docs locally: `pnpm dev:docs` from root
- Build docs: `pnpm turbo build --filter=@homarr/docs`
- The docs app can import from `@homarr/definitions` for type-safe integration/widget metadata
- Docusaurus strict mode throws on broken links — fix any broken references before committing
@@ -1,612 +0,0 @@
# Migrating Tables from mantine-react-table to mantine-datatable
This document captures every pattern, fix, and decision from the downloads widget rework (PR #6430). Use it as a playbook when porting other tables.
## Migration Candidates
Current `mantine-react-table` consumers that could be migrated:
**Widgets (in `packages/widgets/`):**
- `docker/component.tsx` — Docker container table
- `media-server/component.tsx` — Media server sessions
**Admin pages (in `apps/nextjs/src/app/[locale]/manage/`):**
- `tools/docker/docker-table.tsx`
- `tools/tasks/_components/tasks-table.tsx`
- `tools/api/components/api-keys.tsx`
- `users/_components/user-list.tsx`
- `users/invites/_components/invite-list.tsx`
- `tools/kubernetes/` — 8 Kubernetes resource tables
**Modals:**
- `packages/modals-collection/src/search-engines/request-media-modal.tsx`
**Shared hook to remove once all migrated:**
- `packages/ui/src/hooks/use-translated-mantine-react-table.ts`
---
## 1. Core Migration: mantine-react-table → mantine-datatable
### Import changes
```typescript
// BEFORE
import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
import type { MRT_ColumnDef } from "mantine-react-table";
// AFTER
import { DataTable, useDataTableColumns } from "mantine-datatable";
import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable";
```
### Column definition shape
```typescript
// BEFORE (MRT)
const columns: MRT_ColumnDef<MyRow>[] = [
{
accessorKey: "name",
header: "Name",
size: 200,
enableSorting: true,
Cell: ({ row }) => <Text>{row.original.name}</Text>,
},
];
// AFTER (mantine-datatable)
const columns: DataTableColumn<MyRow>[] = [
{
accessor: "name", // not accessorKey
title: "Name", // not header
width: 200, // not size
sortable: true, // not enableSorting
render: (record) => <Text>{record.name}</Text>, // not Cell
},
];
```
Key differences:
- `accessorKey` → `accessor`
- `header` → `title`
- `size` → `width`
- `Cell: ({ row }) =>` → `render: (record) =>`
- `enableSorting` → `sortable`
- `enableColumnActions` → does not exist (no column action menus)
- `mantineTableBodyCellProps` → `cellsStyle` in `defaultColumnProps`
### Table component
```typescript
// BEFORE
const table = useTranslatedMantineReactTable({ columns, data, ... });
return <MantineReactTable table={table} />;
// AFTER
return (
<DataTable
records={data}
columns={columns}
sortStatus={sortStatus}
onSortStatusChange={setSortStatus}
...
/>
);
```
### Sorting is manual
mantine-datatable does NOT sort for you. You must sort the data yourself:
```typescript
const [sortStatus, setSortStatus] = useState<DataTableSortStatus<MyRow>>({
columnAccessor: "name",
direction: "asc",
});
const sortedData = useMemo(() => {
const mult = sortStatus.direction === "desc" ? -1 : 1;
return [...data].toSorted((a, b) => {
const aVal = a[sortStatus.columnAccessor];
const bVal = b[sortStatus.columnAccessor];
if (typeof aVal === "string") return aVal.localeCompare(bVal as string) * mult;
return ((aVal as number) - (bVal as number)) * mult;
});
}, [data, sortStatus]);
// Pass sortedData to records, not data
<DataTable records={sortedData} sortStatus={sortStatus} onSortStatusChange={setSortStatus} />
```
---
## 2. Making Tables Transparent (Widget Context)
Widgets sit inside board cards that have user-controlled opacity. The table must be fully transparent so the card background shows through. mantine-datatable defaults to `var(--mantine-color-body)` which is opaque.
Create a CSS file alongside the component:
```css
/* styles.css */
.my-table,
.my-table .mantine-datatable-table,
.my-table .mantine-datatable-table thead,
.my-table .mantine-datatable-table tbody,
.my-table .mantine-datatable-table tfoot,
.my-table th,
.my-table td {
background-color: transparent !important;
}
.my-table .mantine-datatable-table tr {
background-color: transparent;
}
/* Hover: translucent instead of opaque */
.my-table .mantine-datatable-table tbody tr:hover {
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
}
/* Headers: frosted glass effect */
.my-table th {
white-space: nowrap;
background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important;
backdrop-filter: blur(8px);
}
/* Cell overflow */
.my-table td {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Hide sort icon until hover */
.my-table .mantine-datatable-header-cell-sortable-icon {
opacity: 0;
transition: opacity 150ms;
}
.my-table th:hover .mantine-datatable-header-cell-sortable-icon {
opacity: 1;
}
/* Row expansion area */
.my-table .mantine-datatable-row-expansion-cell {
background-color: color-mix(in srgb, var(--mantine-color-body) 40%, transparent) !important;
}
```
Import it: `import "./styles.css";` and apply via `className="my-table"`.
---
## 3. Responsive Column Visibility
Use a lookup table of width breakpoints instead of media queries. The widget receives `width` as a prop.
```typescript
interface SizeConfig {
fontSize: "xs" | "sm";
iconSize: number;
cellPadding: number;
showSpeedColumns: boolean;
showTimeColumn: boolean;
showStateColumn: boolean;
}
const SIZE_BREAKPOINTS: { maxWidth: number; config: SizeConfig }[] = [
{ maxWidth: 300, config: { fontSize: "xs", iconSize: 12, cellPadding: 2, showSpeedColumns: false, showTimeColumn: false, showStateColumn: false } },
{ maxWidth: 500, config: { fontSize: "xs", iconSize: 14, cellPadding: 4, showSpeedColumns: false, showTimeColumn: true, showStateColumn: false } },
];
const DEFAULT_SIZE_CONFIG: SizeConfig = { fontSize: "sm", iconSize: 14, cellPadding: 4, showSpeedColumns: true, showTimeColumn: true, showStateColumn: true };
function getSizeConfig(width: number): SizeConfig {
for (const { maxWidth, config } of SIZE_BREAKPOINTS) {
if (width < maxWidth) return config;
}
return DEFAULT_SIZE_CONFIG;
}
// Usage: memoize it
const size = useMemo(() => getSizeConfig(width), [width]);
```
Then use a visibility check lookup table for columns:
```typescript
const columnVisibilityChecks: Partial<Record<string, (ctx: ColumnContext) => boolean>> = {
upSpeed: (ctx) => ctx.hasTorrents && ctx.size.showSpeedColumns,
downSpeed: (ctx) => ctx.size.showSpeedColumns,
time: (ctx) => ctx.size.showTimeColumn,
state: (ctx) => ctx.size.showStateColumn,
};
function shouldIncludeColumn(accessor: string, ctx: ColumnContext): boolean {
if (!ctx.optionsColumnSet.has(accessor)) return false;
const check = columnVisibilityChecks[accessor];
if (check && !check(ctx)) return false;
return true;
}
```
---
## 4. Column Persistence (Drag/Resize → Server)
`useDataTableColumns` stores column order and widths in localStorage by default. For widgets, this must persist server-side so it works across devices.
### Hidden widget options
In `index.ts`, add hidden text options:
```typescript
columnOrder: factory.text({ defaultValue: "" }),
columnWidths: factory.text({ defaultValue: "" }),
```
With visibility override:
```typescript
{
columnOrder: { shouldHide: () => true },
columnWidths: { shouldHide: () => true },
}
```
### Translation keys required
Even hidden options need translation keys or the translation test fails:
```json
"widget.mywidget.option.columnOrder.label": "Column order",
"widget.mywidget.option.columnWidths.label": "Column widths"
```
### Dynamic storeKey
Use a key that includes `itemId` and sorted column names. This prevents cross-widget localStorage bleeding:
```typescript
const storeKey = `mytable-${itemId ?? "preview"}-${[...options.columns].toSorted().join(",")}`;
```
### Hydration from server → localStorage
On mount, seed `useDataTableColumns` with server-persisted values. Use a `hydrated` ref to prevent the persist effect from immediately writing defaults back:
```typescript
const { effectiveColumns, columnsOrder, columnsWidth, setColumnsOrder, setMultipleColumnWidths } =
useDataTableColumns<MyRow>({ key: storeKey, columns });
const lastStoreKey = useRef(storeKey);
const hydrated = useRef(false);
useEffect(() => {
if (lastStoreKey.current !== storeKey) {
lastStoreKey.current = storeKey;
hydrated.current = false;
}
if (hydrated.current) return;
if (savedOrder.length > 0) setColumnsOrder(savedOrder);
if (Object.keys(savedWidths).length > 0) {
setMultipleColumnWidths(
Object.entries(savedWidths).map(([accessor, w]) => ({ accessor, width: w })),
);
}
requestAnimationFrame(() => { hydrated.current = true; });
}, [storeKey, savedOrder, savedWidths, setColumnsOrder, setMultipleColumnWidths]);
```
### Persist localStorage → server
Watch for changes and write back:
```typescript
const prevOrder = useRef(columnsOrder);
const prevWidths = useRef(columnsWidth);
useEffect(() => {
if (!hydrated.current) return;
const orderChanged = JSON.stringify(columnsOrder) !== JSON.stringify(prevOrder.current);
const widthsChanged = JSON.stringify(columnsWidth) !== JSON.stringify(prevWidths.current);
prevOrder.current = columnsOrder;
prevWidths.current = columnsWidth;
if (!orderChanged && !widthsChanged) return;
if (orderChanged) persistOption({ columnOrder: JSON.stringify(columnsOrder) });
if (widthsChanged) {
const widthMap: Record<string, number> = {};
for (const entry of columnsWidth) {
const key = Object.keys(entry)[0];
if (!key) continue;
const w = entry[key as keyof typeof entry];
if (typeof w === "number") widthMap[key] = w;
else if (typeof w === "string" && w.endsWith("px")) widthMap[key] = parseInt(w, 10);
}
persistOption({ columnWidths: JSON.stringify(widthMap) });
}
}, [columnsOrder, columnsWidth, persistOption]);
```
The `persistOption` helper calls both `setOptions` (local state) and `saveItemOptions` (server mutation).
---
## 5. Context Menu (Right-Click)
Do NOT install `mantine-contextmenu`. Use Mantine's `Menu` + `Portal` directly:
```typescript
interface ContextMenuState {
x: number;
y: number;
item: MyRow;
}
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const handleContextMenu = useCallback(
({ record, event }: { record: MyRow; event: React.MouseEvent }) => {
event.preventDefault();
setContextMenu({ x: event.clientX, y: event.clientY, item: record });
},
[],
);
// On DataTable:
<DataTable onRowContextMenu={handleContextMenu} />
// Render:
{contextMenu && <RowContextMenu state={contextMenu} onClose={closeContextMenu} />}
```
The menu component uses `Portal` for correct z-index:
```tsx
function RowContextMenu({ state, onClose }: { state: ContextMenuState; onClose: () => void }) {
return (
<Portal>
<Menu opened onClose={onClose} closeOnItemClick={false} position="right-start" offset={0}>
<Menu.Target>
<Box style={{ position: "fixed", left: state.x, top: state.y, width: 0, height: 0 }} />
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={() => { /* action */ onClose(); }}>Action</Menu.Item>
</Menu.Dropdown>
</Menu>
</Portal>
);
}
```
**Key fix:** Use `closeOnItemClick={false}` if any menu item needs a two-step confirmation (like delete → confirm). Close the menu on scroll: `onScroll={() => { if (contextMenu) closeContextMenu(); }}`.
---
## 6. Row Expansion
```typescript
<DataTable
rowExpansion={{
trigger: "click",
allowMultiple: true,
collapseProps: {
transitionDuration: 200,
animateOpacity: true,
transitionTimingFunction: "ease-out",
},
content: ({ record, collapse }) => <ExpandedRow item={record} collapse={collapse} />,
}}
/>
```
**Critical:** Do NOT set `onRowClick` when using `rowExpansion` with `trigger: "click"`. They conflict and the expansion won't work.
---
## 7. Common Pitfalls & Fixes
### Status indicator dots
Don't use `<Badge variant="dot" />` without children — it renders a pill with empty space. Use a plain `Box`:
```tsx
<Box
w={8}
h={8}
style={{
borderRadius: "50%",
backgroundColor: `var(--mantine-color-${color}-filled)`,
flexShrink: 0,
}}
/>
```
### Tooltip overflow on long text columns
When a column has `ellipsis: true`, the tooltip target element can still extend beyond the cell bounds if inner elements don't clip. Fix:
```tsx
<Tooltip position="bottom-start" ...>
<Group style={{ overflow: "hidden" }}>
...
</Group>
</Tooltip>
```
Use `position="bottom-start"` instead of `position="right"` so the tooltip never escapes the widget horizontally.
### Dark mode tooltips
Mantine tooltips default to the theme's tooltip color which may be white in light mode and stay white in dark mode. Always set `color="dark"` on informational tooltips inside widgets.
### Division by zero
Guard all ratio calculations:
```typescript
if (item.size > 0) ratio = item.sent / item.size;
if (totalDown > 0) ratio = totalUp / totalDown;
```
### Composite idAccessor
When rows can come from multiple integrations, IDs may collide. Use a composite key:
```typescript
idAccessor={(record) => `${record.integration.id}:${record.id}`}
```
### JSON option parsing
Always wrap in try/catch:
```typescript
function parseJsonOption<T>(value: string | undefined, fallback: T): T {
if (!value) return fallback;
try { return JSON.parse(value) as T; }
catch { return fallback; }
}
```
### Clipboard API
`navigator.clipboard.writeText()` can throw in non-HTTPS contexts. Always catch:
```typescript
void navigator.clipboard.writeText(text).catch(() => {});
```
### Sync sortStatus with widget options
If the widget has configurable default sort, sync it:
```typescript
useEffect(() => {
setSortStatus({ columnAccessor: options.defaultSort, direction: defaultSortDirection });
}, [options.defaultSort, defaultSortDirection]);
```
---
## 8. Accessibility
- All `ActionIcon` components need `aria-label`
- Filter toggles (client badges, status badges) need `aria-pressed={isActive}`
- `textSelectionDisabled` on DataTable prevents accidental text selection during drag/resize
---
## 9. Error Handling
- Every mutation needs an `onError` callback with `showErrorNotification`
- The `saveItemOptions` mutation (column persistence) also needs `onError`
- Check `isError` on the main query and show a fallback UI
- Use a shared `mutationOptions` object to avoid repeating `onSettled`/`onError`:
```typescript
const mutationOptions = {
onSettled: () => void utils.myQuery.invalidate(),
onError: () => showErrorNotification({ title: t("errors.actionFailed"), message: t("errors.actionFailedMessage") }),
};
```
---
## 10. i18n
- No hardcoded strings. Every user-visible string goes through `useScopedI18n`
- Column titles: `t("items.{accessor}.columnTitle")`
- Detail labels: `t("items.{accessor}.detailsTitle")`
- States: `t("states.{state}")`
- Actions: `t("actions.{action}")`
- Errors: `t("errors.{errorKey}")`
- Hidden options still need `.label` translation keys
---
## 11. Edit Mode
Disable pointer events on the table during edit mode to prevent conflicts with the board's drag-and-drop:
```typescript
let tablePointerEvents: React.CSSProperties["pointerEvents"];
if (isEditMode) tablePointerEvents = "none";
let rowContextMenuHandler = handleContextMenu;
if (isEditMode) rowContextMenuHandler = undefined;
<DataTable style={{ pointerEvents: tablePointerEvents }} />
```
---
## 12. DataTable Props Reference (Commonly Used)
```typescript
<DataTable
records={sortedData}
columns={effectiveColumns}
storeColumnsKey={storeKey}
withTableBorder={false}
borderRadius={0}
highlightOnHover
striped="odd"
stripedColor={{ dark: "dark.7", light: "gray.0" }}
highlightOnHoverColor={{ dark: "dark.5", light: "gray.1" }}
verticalAlign="center"
fetching={isFetching && data.length === 0}
loaderBackgroundBlur={2}
fz={size.fontSize}
textSelectionDisabled
sortStatus={sortStatus}
onSortStatusChange={setSortStatus}
noRecordsText={t("errors.noItems")}
idAccessor={(record) => record.uniqueKey}
height="100%"
className="my-table"
defaultColumnProps={{
noWrap: true,
draggable: true,
resizable: true,
cellsStyle: () => ({ padding: `${size.cellPadding}px 8px` }),
}}
scrollAreaProps={{ type: "auto", scrollbarSize: 6 }}
rowBackgroundColor={(record) => conditionalBgMap[record.state]}
onRowContextMenu={handleContextMenu}
rowExpansion={{ ... }}
onScroll={() => { if (contextMenu) closeContextMenu(); }}
/>
```
---
## 13. Layout Stability
- Don't use `table-layout: fixed` — it fights mantine-datatable's resize logic
- Give non-name columns explicit `width` values so only the name column flexes
- Use `ellipsis: true` on the name column — it handles `text-overflow: ellipsis` internally
- Names should truncate at the cell boundary, not a character count, so the layout is stable regardless of content length
- `flexShrink: 0` on badges/icons prevents them from being squeezed by long names
---
## 14. Checklist for Each Migration
1. Replace imports (`mantine-react-table` → `mantine-datatable`)
2. Convert column definitions (`accessorKey` → `accessor`, `header` → `title`, `Cell` → `render`, etc.)
3. Implement manual sorting with `useState` + `useMemo`
4. Add CSS file for transparent backgrounds (if widget context)
5. Add responsive column visibility (if widget context)
6. Add hidden options + hydration logic (if column persistence needed)
7. Add translation keys (including hidden option labels)
8. Add context menu if row actions exist
9. Add row expansion if detail view exists
10. Add error handling on all mutations
11. Add accessibility attributes
12. Test at multiple widget widths
13. Update docs in `apps/docs/docs/widgets/` if applicable
14. Run `pnpm turbo typecheck`
15. Run translation spec — catches missing keys
-103
View File
@@ -1,103 +0,0 @@
---
description: How to expose tRPC procedures as MCP tools for AI clients
globs: packages/api/src/router/**/*.ts
alwaysApply: false
---
# MCP Integration Guide
Every new tRPC procedure that provides useful functionality should be exposed as an MCP tool so AI assistants (Claude, Cursor, etc.) can use it. This is a first-class feature of Homarr.
## Adding MCP to a procedure
Add `.meta({ mcp: { enabled: true, description: "..." } })` to the procedure chain:
```typescript
// Read-only tool (query)
myProcedure: protectedProcedure
.meta({ mcp: { enabled: true, description: "Get all items with their status and metadata" } })
.input(z.object({ limit: z.number().default(50) }))
.query(async ({ ctx, input }) => { ... })
// Write tool (mutation)
myAction: protectedProcedure
.meta({ mcp: { enabled: true, description: "Delete an item by ID. Requires interact permission on the integration" } })
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => { ... })
// No-input procedure (z.void) — works fine, no .input() needed
getAll: protectedProcedure
.meta({ mcp: { enabled: true, description: "List all resources" } })
.query(async ({ ctx }) => { ... })
```
The `.meta()` call must come **before** `.input()` and `.query()`/`.mutation()` in the chain.
## Writing good descriptions
The description is the **only thing** an AI sees to decide when and how to use the tool. Write it like you're explaining to a colleague who has never used Homarr.
**Must include:**
- What the tool returns or does
- Which integrations/services it works with (if applicable)
- How to get required IDs (e.g., "Use integration_all to get the integrationId")
- Permission requirements (if non-obvious)
**Good:**
```
"Get calendar events for upcoming and recent media releases. Fetches from all connected Sonarr (TV), Radarr (movies), Lidarr (music), and Readarr (books) integrations. Requires integrationIds from integration_all"
```
**Bad:**
```
"Get calendar events"
```
**For tools that return permission fields**, explain what they mean:
```
"List all integrations. Returns permissions.hasUseAccess (read) and permissions.hasInteractAccess (actions) — false means the API key lacks that permission, not an error"
```
## Registering in the MCP router
After adding `.meta()` to procedures, you must register the router in `packages/api/src/mcp.ts`. This file uses **eager imports** (no `lazy()`) because MCP tool extraction requires synchronous access to all procedure definitions.
```typescript
// packages/api/src/mcp.ts
import { myNewRouter } from "./router/my-new-feature";
export const mcpRouter = createTRPCRouter({
// ... existing routers
myFeature: myNewRouter,
});
```
**If your router is nested** (e.g., widget sub-routers), import and register the sub-router directly — do not import a parent that uses `lazy()`.
## What to expose vs. what to skip
**Expose:**
- Data queries (list, search, get by ID)
- Actions users would ask an AI to do (toggle, create, delete, approve)
- Status checks (health, stats, summaries)
**Skip:**
- Subscriptions (WebSocket-only, not supported by MCP)
- Internal procedures (session management, onboarding steps)
- File upload/download procedures
- Procedures that return non-serializable data (streams, blobs)
## Input schema rules
- Use `z.object({...})` for inputs — this maps cleanly to MCP tool parameters
- `z.void()` (no `.input()`) works — the MCP handler patches these to accept empty objects
- Avoid `z.union()` or `z.discriminatedUnion()` at the top level — AI clients struggle with these
- Use `.default()` on optional fields so the AI doesn't need to guess values
## Checklist for new features
1. Add `.meta({ mcp: { enabled: true, description: "..." } })` to relevant procedures
2. Import and register the router in `packages/api/src/mcp.ts` (eager, no lazy)
3. Write descriptions that explain what the tool does, what it needs, and what it returns
4. If the tool requires an ID from another tool, say which one in the description
5. Test with `curl` against `/api/mcp/mcp` to verify the tool appears in `tools/list`
+22 -2
View File
@@ -13,7 +13,7 @@ on:
type: boolean
required: false
default: true
description: Send notifications
description: Send notifications when redeploying an existing release
permissions:
contents: write # Required to update package.json version
@@ -41,7 +41,7 @@ jobs:
git_ref: ${{ steps.read-git-ref.outputs.ref || github.ref }}
skipped: ${{ env.SKIP_RELEASE }}
steps:
- run: echo "Skipping release for workflow_dispatch event"
- run: echo "Manual dispatch redeploys the latest existing release without creating a new one"
if: env.SKIP_RELEASE == 'true'
# The below generated version fallback represents a normalized branch name, for example "feature/branch-name" -> "feature-branch-name"
- run: echo "version="$(echo ${{github.ref_name}} | sed 's/[^a-zA-Z0-9\-]/-/g') >> "$GITHUB_OUTPUT"
@@ -69,6 +69,26 @@ jobs:
with:
node-version: 24.18.0
package-manager-cache: false
- name: Verify major release intent
if: env.SKIP_RELEASE == 'false'
run: |
latest_tag="$(git describe --tags --abbrev=0)"
latest_major="${latest_tag#v}"
latest_major="${latest_major%%.*}"
package_version="$(jq --raw-output '.version' package.json)"
package_major="${package_version%%.*}"
if (( package_major <= latest_major )); then
exit 0
fi
if git log --format='%s%n%b' "${latest_tag}..HEAD" \
| grep --extended-regexp --quiet '(^[[:alnum:]-]+(\([^)]*\))?!:)|(^BREAKING[ -]CHANGE:)'; then
exit 0
fi
echo "::error::package.json declares ${package_version}, but commits since ${latest_tag} contain no breaking-change signal. Use a conventional ! commit or BREAKING CHANGE footer so Semantic Release creates the intended major version."
exit 1
- name: Install dependencies
if: env.SKIP_RELEASE == 'false'
run: pnpm install --frozen-lockfile
-3
View File
@@ -76,9 +76,6 @@ apps/nextjs/public/images/background.png
.opencode
.agents/*
!.agents/skills/
.agents/skills/*
!.agents/skills/homarr-custom-widget/
!.agents/skills/homarr-custom-widget/**
skills-lock.json
# docusaurus
+8
View File
@@ -0,0 +1,8 @@
{
"mcpServers": {
"mantine": {
"command": "npx",
"args": ["-y", "@mantine/mcp-server"]
}
}
}
+19 -3
View File
@@ -7,8 +7,9 @@ homarr/
├── apps/
│ ├── nextjs/ # Main Next.js application (port 3000)
│ ├── docs/ # Docusaurus 3 documentation site (@homarr/docs)
│ ├── tasks/ # Cron job runner + Fastify tRPC API (port 3002)
│ └── websocket/ # Standalone tRPC WebSocket server (port 3001)
│ ├── tasks/ # Cron-job initialization and scheduling runtime
│ ├── websocket/ # Standalone tRPC WebSocket server (port 3001)
│ └── workshop/ # Go/PocketBase Workshop service
├── packages/
│ ├── api/ # tRPC appRouter, procedures, OpenAPI
│ ├── auth/ # NextAuth config, providers, session, API keys
@@ -40,6 +41,9 @@ homarr/
│ ├── analytics/ # Server-side analytics (Umami)
│ ├── server-settings/ # Server setting keys/types
│ ├── settings/ # User-facing settings UI context
│ ├── custom-widgets/ # Custom JSX v2 schema, validation, and runtime
│ ├── onboarding/ # Onboarding studio and setup flow
│ ├── workshop/ # Homarr-side Workshop client and contracts
│ └── cli/ # Node CLI for ops (brocli)
├── tooling/
│ ├── typescript/ # Base tsconfig
@@ -59,7 +63,7 @@ When modifying user-facing code, you MUST also update the corresponding document
- New integration → `apps/docs/docs/integrations/<slug>/index.mdx` + `index.ts`
- New widget → `apps/docs/docs/widgets/<slug>/index.mdx` + `index.ts`
- Changed API → `apps/docs/docs/management/api.mdx`
- Changed API → `apps/docs/docs/management/api/index.mdx`
- New/changed env vars → `apps/docs/docs/advanced/`
- New CLI commands → `apps/docs/docs/advanced/command-line/`
- Auth changes → `apps/docs/docs/advanced/` SSO pages
@@ -82,9 +86,21 @@ When modifying user-facing code, you MUST also update the corresponding document
- Lint: oxlint (not ESLint)
- Format: oxfmt (not Prettier)
- UI: Mantine (not Tailwind) — Tailwind is only used in docs app
- Mantine: use the project-scoped MCP server in `.mcp.json` for current v9 APIs, and check `packages/ui/` before creating a new primitive.
- Icons: @tabler/icons-react
- Docs app can import from `@homarr/definitions` for shared types
- Run `pnpm dev:cli -- dev` to browse local `homarr:*` images and remote PR images.
- Run `pnpm dev:cli -- build <name>` from a Homarr checkout to build `homarr:<name>` with rebuild provenance.
- Run `pnpm dev:cli -- build --pr <number>` to build a PR locally from a temporary checkout.
- Run Go checks from `tools/homarr-dev` with `go test ./...` and `go vet ./...`.
## Agent Skills
Portable skills live in `.agents/skills/`. Read the relevant `SKILL.md` before working in that domain; detailed references are loaded only when needed. Claude-compatible discovery is provided through `.claude/skills`.
- `codebase-context` — architecture, package boundaries, and shared utilities
- `documentation-sync` — required user-facing documentation updates
- `mcp-integration` — safe tRPC-to-MCP exposure
- `datatable-migration` — Mantine DataTable migration patterns
- `homarr-custom-widget` — safe Custom JSX v2 authoring
- `release-homarr` — release preparation, publication, and recovery
@@ -0,0 +1,192 @@
"use client";
import type { RefObject } from "react";
import { useContext, useLayoutEffect, useMemo, useRef } from "react";
import type { Unstable_TriggerItem } from "@assistant-ui/react";
import {
ComposerPrimitive,
useAui,
unstable_useMentionAdapter,
unstable_useSlashCommandAdapter,
unstable_useTriggerPopoverScopeContext,
} from "@assistant-ui/react";
import { Text, ThemeIcon } from "@mantine/core";
import { IconAt, IconChevronRight } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
import classes from "./assistant-panel.module.css";
import { AssistantDirectiveEntitiesContext, contextIcons } from "./assistant-message-content";
import { getNearestTriggerScrollTop } from "./assistant-trigger-scroll";
const TriggerPopoverAutoScroll = ({ viewportRef }: { viewportRef: RefObject<HTMLDivElement | null> }) => {
const { activeCategoryId, categories, highlightedIndex, isSearchMode, items, open, query } =
unstable_useTriggerPopoverScopeContext();
useLayoutEffect(() => {
if (!open) return;
const viewport = viewportRef.current;
const highlightedItem = viewport?.querySelector<HTMLElement>("[data-highlighted]");
if (!viewport || !highlightedItem) return;
const viewportRect = viewport.getBoundingClientRect();
const itemRect = highlightedItem.getBoundingClientRect();
const viewportTop = viewportRect.top + viewport.clientTop;
const viewportBottom = viewportTop + viewport.clientHeight;
viewport.scrollTop = getNearestTriggerScrollTop({
scrollTop: viewport.scrollTop,
viewportTop,
viewportBottom,
itemTop: itemRect.top,
itemBottom: itemRect.bottom,
});
}, [activeCategoryId, categories, highlightedIndex, isSearchMode, items, open, query, viewportRef]);
return null;
};
const TriggerItem = ({ item, index }: { item: Unstable_TriggerItem; index: number }) => {
const Icon =
typeof item.metadata?.icon === "string" && item.metadata.icon in contextIcons
? contextIcons[item.metadata.icon as keyof typeof contextIcons]
: IconAt;
return (
<ComposerPrimitive.Unstable_TriggerPopoverItem className={classes.triggerItem} item={item} index={index}>
<ThemeIcon size="sm" variant="light" color="gray">
<Icon size={13} />
</ThemeIcon>
<div className={classes.triggerItemText}>
<Text size="sm" fw={600} lineClamp={1}>
{item.label}
</Text>
{item.description && (
<Text size="xs" c="dimmed" lineClamp={1}>
{item.description}
</Text>
)}
</div>
</ComposerPrimitive.Unstable_TriggerPopoverItem>
);
};
export const ComposerTriggers = () => {
const t = useI18n("assistant");
const aui = useAui();
const mentionViewportRef = useRef<HTMLDivElement>(null);
const slashViewportRef = useRef<HTMLDivElement>(null);
const { entities, isLoading } = useContext(AssistantDirectiveEntitiesContext);
const categories = useMemo(
() =>
(["app", "integration", "board", "widget"] as const).map((type) => ({
id: type,
label: t(`mentions.${type}`),
items: entities
.filter((entity) => entity.type === type)
.map((entity) => ({
id: entity.id,
type: entity.type,
label: entity.label,
description: entity.description,
icon: entity.type,
})),
})),
[entities, t],
);
const mention = unstable_useMentionAdapter({
categories,
includeModelContextTools: {
category: { id: "tools", label: t("mentions.tools") },
formatLabel: (name) => name.replaceAll("_", " "),
icon: "tools",
},
iconMap: contextIcons,
fallbackIcon: IconAt,
});
const slash = unstable_useSlashCommandAdapter({
removeOnExecute: true,
commands: [
{
id: "health",
label: "/health",
description: t("commands.health"),
execute: () => aui.composer().setText(t("suggestions.health.prompt")),
},
{
id: "explore",
label: "/explore",
description: t("commands.explore"),
execute: () => aui.composer().setText(t("suggestions.explore.prompt")),
},
{
id: "media",
label: "/media",
description: t("commands.media"),
execute: () => aui.composer().setText(t("suggestions.media.prompt")),
},
{
id: "style",
label: "/style",
description: t("commands.style"),
execute: () => aui.composer().setText(t("suggestions.style.prompt")),
},
],
});
return (
<>
<ComposerPrimitive.Unstable_TriggerPopover
ref={mentionViewportRef}
className={classes.triggerPopover}
char="@"
adapter={mention.adapter}
isLoading={isLoading}
aria-label={t("mentions.menu")}
>
<TriggerPopoverAutoScroll viewportRef={mentionViewportRef} />
<ComposerPrimitive.Unstable_TriggerPopover.Directive {...mention.directive} />
<ComposerPrimitive.Unstable_TriggerPopoverCategories className={classes.triggerList}>
{(items) =>
items.map((category) => {
const Icon = contextIcons[category.id as keyof typeof contextIcons] ?? IconAt;
return (
<ComposerPrimitive.Unstable_TriggerPopoverCategoryItem
key={category.id}
categoryId={category.id}
className={classes.triggerItem}
>
<ThemeIcon size="sm" variant="light" color="gray">
<Icon size={13} />
</ThemeIcon>
<Text size="sm" fw={600} flex={1}>
{category.label}
</Text>
<IconChevronRight size={14} />
</ComposerPrimitive.Unstable_TriggerPopoverCategoryItem>
);
})
}
</ComposerPrimitive.Unstable_TriggerPopoverCategories>
<ComposerPrimitive.Unstable_TriggerPopoverItems className={classes.triggerList}>
{(items) =>
items.map((item, index) => <TriggerItem key={`${item.type}:${item.id}`} item={item} index={index} />)
}
</ComposerPrimitive.Unstable_TriggerPopoverItems>
</ComposerPrimitive.Unstable_TriggerPopover>
<ComposerPrimitive.Unstable_TriggerPopover
ref={slashViewportRef}
className={classes.triggerPopover}
char="/"
adapter={slash.adapter}
aria-label={t("commands.menu")}
>
<TriggerPopoverAutoScroll viewportRef={slashViewportRef} />
<ComposerPrimitive.Unstable_TriggerPopover.Action {...slash.action} />
<ComposerPrimitive.Unstable_TriggerPopoverItems className={classes.triggerList}>
{(items) => items.map((item, index) => <TriggerItem key={item.id} item={item} index={index} />)}
</ComposerPrimitive.Unstable_TriggerPopoverItems>
</ComposerPrimitive.Unstable_TriggerPopover>
</>
);
};
@@ -0,0 +1,180 @@
"use client";
import { useLayoutEffect, useRef } from "react";
import { ComposerPrimitive, ThreadPrimitive, useAuiState } from "@assistant-ui/react";
import { LexicalComposerInput } from "@assistant-ui/react-lexical";
import { ActionIcon, Box, Button, Group, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core";
import { IconArrowUp, IconMessage, IconPaperclip, IconPlayerStop, IconQuote, IconX } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
import classes from "./assistant-panel.module.css";
import { ComposerTriggers } from "./assistant-composer-triggers";
import type { AssistantConversationControls } from "./assistant-conversation-controls";
import { ConversationContext } from "./assistant-conversation-context";
import { useAssistantPreferences } from "./assistant-context";
import { Attachment, ContextDirectiveChip } from "./assistant-message-content";
import type { AssistantPendingAction } from "./assistant-pending-action";
import { isAssistantProviderUnavailable } from "./assistant-provider-quota";
import { HomarrProviderQuota, RuntimeControls } from "./assistant-runtime-controls";
type ComposerProps = AssistantConversationControls & { pendingAction: AssistantPendingAction | undefined };
export const Composer = (props: ComposerProps) => {
const t = useI18n("assistant");
const preferences = useAssistantPreferences();
const running = useAuiState((state) => state.thread.isRunning);
const hasPendingAction = props.pendingAction !== undefined;
const providerUnavailable = isAssistantProviderUnavailable({
provider: preferences.provider,
signedIn: preferences.providerUser !== null,
remaining: preferences.quota?.remaining,
});
const sendBlocked = hasPendingAction || providerUnavailable;
let sendLabel = t("send");
if (providerUnavailable) sendLabel = t("providerQuota.unavailableDescription");
if (hasPendingAction) sendLabel = t("pendingAction.sendBlocked");
const composerInputRef = useRef<HTMLDivElement>(null);
const composerLabel = t("composerPlaceholder");
useLayoutEffect(() => {
const editor = composerInputRef.current?.querySelector<HTMLElement>(".aui-lexical-input");
if (!editor) return;
editor.setAttribute("aria-label", composerLabel);
return () => {
if (editor.getAttribute("aria-label") === composerLabel) editor.removeAttribute("aria-label");
};
}, [composerLabel]);
return (
<Box className={classes.composerWrap}>
<ComposerPrimitive.Unstable_TriggerPopoverRoot>
<ComposerPrimitive.AttachmentDropzone className={classes.composerDropzone}>
<ComposerPrimitive.Root
className={classes.composer}
data-pending-action={hasPendingAction || undefined}
onSubmit={(event) => {
if (sendBlocked) event.preventDefault();
}}
>
<ComposerTriggers />
<ComposerPrimitive.Quote className={classes.composerQuote}>
<IconQuote size={15} />
<ComposerPrimitive.QuoteText className={classes.composerQuoteText} />
<ComposerPrimitive.QuoteDismiss asChild>
<ActionIcon variant="subtle" color="gray" size="xs" aria-label={t("dismissQuote")}>
<IconX size={12} />
</ActionIcon>
</ComposerPrimitive.QuoteDismiss>
</ComposerPrimitive.Quote>
<ComposerPrimitive.Attachments>{() => <Attachment removable />}</ComposerPrimitive.Attachments>
<Group className={classes.composerRow} gap="xs" wrap="nowrap" align="flex-end">
<Group gap={2} wrap="nowrap">
<Tooltip label={t("attachments.add")}>
<ComposerPrimitive.AddAttachment asChild>
<ActionIcon variant="subtle" color="gray" size="lg" aria-label={t("attachments.add")}>
<IconPaperclip size={17} />
</ActionIcon>
</ComposerPrimitive.AddAttachment>
</Tooltip>
</Group>
<LexicalComposerInput
ref={composerInputRef}
className={classes.composerInput}
data-assistant-composer-input
placeholder={composerLabel}
directiveChip={ContextDirectiveChip}
// oxlint-disable-next-line jsx-a11y/no-autofocus -- opening the assistant is an explicit intent to compose
autoFocus={props.autoFocusComposer}
data-autofocus={props.autoFocusComposer ? true : undefined}
/>
{running ? (
<ComposerPrimitive.Cancel asChild>
<ActionIcon color="red" variant="light" size="lg" aria-label={t("stop")}>
<IconPlayerStop size={18} />
</ActionIcon>
</ComposerPrimitive.Cancel>
) : (
<ComposerPrimitive.Send asChild>
<ActionIcon variant="filled" size="lg" aria-label={sendLabel} disabled={sendBlocked}>
<IconArrowUp size={18} />
</ActionIcon>
</ComposerPrimitive.Send>
)}
</Group>
<Group className={classes.composerFooter} justify="space-between" gap="xs" wrap="nowrap">
<Group className={classes.composerControls} gap={5} wrap="nowrap">
<RuntimeControls {...props} />
<HomarrProviderQuota />
<ConversationContext />
</Group>
</Group>
</ComposerPrimitive.Root>
</ComposerPrimitive.AttachmentDropzone>
</ComposerPrimitive.Unstable_TriggerPopoverRoot>
</Box>
);
};
export const usePendingActionCopy = (action: AssistantPendingAction | undefined) => {
const t = useI18n("assistant");
if (!action) return undefined;
if (action.kind === "question") {
return { title: t("pendingAction.answerTitle"), detail: action.detail ?? t("pendingAction.answerFallback") };
}
if (action.kind === "form") {
const isBoardSettings = action.toolName === "configure_board_settings";
return {
title: isBoardSettings ? t("pendingAction.boardFormTitle") : t("pendingAction.formTitle"),
detail: action.detail ?? (isBoardSettings ? t("configureBoardSettings.title") : t("configureApp.title")),
};
}
const tool = action.toolName.replaceAll("_", " ");
return { title: t("activity.approval"), detail: action.detail ? `${tool} · ${action.detail}` : tool };
};
export const PendingActionBanner = ({ pendingAction }: { pendingAction: AssistantPendingAction | undefined }) => {
const t = useI18n("assistant");
const copy = usePendingActionCopy(pendingAction);
if (!copy || pendingAction?.kind === "question") return null;
return (
<Box component="output" className={classes.pendingActionBanner}>
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" color="yellow" radius="xl">
<IconMessage size={17} />
</ThemeIcon>
<Stack gap={0} flex={1} miw={0}>
<Text size="sm" fw={700}>
{copy.title}
</Text>
<Text size="xs" c="dimmed" lineClamp={1}>
{copy.detail}
</Text>
</Stack>
<ThreadPrimitive.ScrollToBottom asChild>
<Button variant="light" color="yellow" size="compact-sm">
{t("pendingAction.review")}
</Button>
</ThreadPrimitive.ScrollToBottom>
</Group>
</Box>
);
};
export const PendingQuestionDock = ({
pendingAction,
setTarget,
}: {
pendingAction: AssistantPendingAction | undefined;
setTarget: (target: HTMLDivElement | null) => void;
}) => {
const t = useI18n("assistant");
if (pendingAction?.kind !== "question") return null;
return (
<Box component="section" className={classes.pendingQuestionDock} aria-label={t("pendingAction.answerTitle")}>
<div ref={setTarget} className={classes.pendingQuestionTarget} />
</Box>
);
};
@@ -0,0 +1,610 @@
"use client";
import { useId, useMemo, useState } from "react";
import { useAuiState } from "@assistant-ui/react";
import {
Badge,
Box,
Collapse,
Divider,
Group,
Loader,
Popover,
RingProgress,
ScrollArea,
Stack,
Text,
Tooltip,
UnstyledButton,
useComputedColorScheme,
} from "@mantine/core";
import { IconChevronDown, IconRobot } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { assistantProviderIds, assistantProviderPresets } from "@homarr/definitions";
import type { AssistantProvider } from "@homarr/definitions";
import { useI18n } from "@homarr/translation/client";
import classes from "./assistant-panel.module.css";
import {
getAssistantContextBreakdown,
getAssistantConversationUsage,
getAssistantTelemetry,
} from "./assistant-message-metadata";
import type { AssistantContextBreakdown, AssistantConversationTurnUsage } from "./assistant-message-metadata";
const formatDuration = (milliseconds: number) =>
milliseconds < 1000 ? `${Math.round(milliseconds)} ms` : `${(milliseconds / 1000).toFixed(1)} s`;
const getCostPrecision = (cost: number) => {
if (cost < 0.001) return 5;
if (cost < 0.01) return 4;
return 3;
};
const formatCost = (cost: number) => {
if (cost === 0) return "$0";
if (cost < 0.00001) return `<$0.00001`;
return `$${cost.toFixed(getCostPrecision(cost))}`;
};
const formatStepThroughput = (providerThroughput: number | undefined, fallbackThroughput: number | undefined) => {
const throughput = providerThroughput ?? fallbackThroughput;
if (throughput === undefined) return "";
return ` · ${throughput.toFixed(1)} tok/s`;
};
const getContextColor = (percentage: number) => {
if (percentage >= 90) return "red";
if (percentage >= 75) return "orange";
return "blue";
};
const getAssistantProviderPreset = (provider: string) => {
if (!assistantProviderIds.includes(provider as AssistantProvider)) return null;
return assistantProviderPresets[provider as AssistantProvider];
};
const ProviderIcon = ({ provider, size = 16 }: { provider: string; size?: number }) => {
const colorScheme = useComputedColorScheme("light");
const preset = getAssistantProviderPreset(provider);
const source = colorScheme === "dark" && preset && "darkIconUrl" in preset ? preset.darkIconUrl : preset?.iconUrl;
return source ? (
<Box component="img" className={classes.providerIcon} src={source} alt="" aria-hidden w={size} h={size} />
) : (
<IconRobot size={size} aria-hidden />
);
};
export const ProviderMessageInfo = () => {
const metadata = useAuiState((state) => state.message.metadata);
const telemetry = getAssistantTelemetry(metadata);
if (!telemetry) return null;
const details = [
telemetry.modelId,
telemetry.durationMs !== undefined ? formatDuration(telemetry.durationMs) : undefined,
telemetry.outputTokensPerSecond !== undefined ? `${telemetry.outputTokensPerSecond.toFixed(1)} tok/s` : undefined,
telemetry.cost !== undefined ? formatCost(telemetry.cost) : undefined,
].filter(Boolean);
return (
<Tooltip
label={
<Stack gap={2}>
<Text size="xs" fw={700} tt="capitalize">
{telemetry.provider}
</Text>
<Text size="xs">{details.join(" · ")}</Text>
</Stack>
}
multiline
maw="min(24rem, calc(100vw - 2rem))"
>
<Box
component="span"
className={classes.providerAction}
// oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- the logo and its focused tooltip form one labelled image
role="img"
tabIndex={0}
aria-label={`${telemetry.provider}: ${telemetry.modelId}`}
>
<ProviderIcon provider={telemetry.provider} />
</Box>
</Tooltip>
);
};
const ConversationTurn = ({
index,
messageId,
threadId,
turn,
}: {
index: number;
messageId?: string;
threadId?: string;
turn: AssistantConversationTurnUsage;
}) => {
const t = useI18n("assistant");
const detailsId = useId();
const [opened, setOpened] = useState(false);
const generationQuery = clientApi.assistant.getGenerationTelemetry.useQuery(
{ threadId: threadId ?? "", messageId: messageId ?? "" },
{
enabled: opened && turn.provider === "openrouter" && Boolean(threadId) && Boolean(messageId),
refetchInterval: (query) =>
query.state.data?.complete === false && query.state.dataUpdateCount < 6 ? 1500 : false,
retry: 2,
staleTime: (query) => (query.state.data?.complete ? 5 * 60_000 : 0),
},
);
const generationById = new Map(
generationQuery.data?.generations.flatMap((generation) =>
generation.generationId ? [[generation.generationId, generation] as const] : [],
) ?? [],
);
const steps = turn.telemetry.steps.map((step) => ({
...step,
...(step.generationId ? generationById.get(step.generationId) : undefined),
}));
const sumCompleteMetric = (getValue: (step: (typeof steps)[number]) => number | undefined) => {
if (steps.length === 0) return undefined;
const values = steps.map(getValue);
return values.some((value) => value === undefined)
? undefined
: (values as number[]).reduce((sum, value) => sum + value, 0);
};
const providerInputTokens = sumCompleteMetric((step) => step.inputTokens);
const providerOutputTokens = sumCompleteMetric((step) => step.outputTokens);
const providerGenerationTimeMs = sumCompleteMetric((step) => step.generationTimeMs);
const providerOutputTokensPerSecond =
providerOutputTokens !== undefined && providerGenerationTimeMs !== undefined && providerGenerationTimeMs > 0
? providerOutputTokens / (providerGenerationTimeMs / 1000)
: undefined;
const latestStep = steps.at(-1);
const providerContextUsed =
latestStep?.inputTokens !== undefined && latestStep.outputTokens !== undefined
? latestStep.inputTokens + latestStep.outputTokens
: undefined;
const usage = {
...turn.usage,
...(generationQuery.data?.complete && providerInputTokens !== undefined
? { inputTokens: providerInputTokens }
: {}),
...(generationQuery.data?.complete && providerOutputTokens !== undefined
? { outputTokens: providerOutputTokens }
: {}),
...(generationQuery.data?.complete && providerInputTokens !== undefined && providerOutputTokens !== undefined
? { totalTokens: providerInputTokens + providerOutputTokens }
: {}),
...(generationQuery.data?.complete && sumCompleteMetric((step) => step.cachedInputTokens) !== undefined
? { cachedInputTokens: sumCompleteMetric((step) => step.cachedInputTokens) }
: {}),
...(generationQuery.data?.complete && sumCompleteMetric((step) => step.reasoningTokens) !== undefined
? { reasoningTokens: sumCompleteMetric((step) => step.reasoningTokens) }
: {}),
};
const telemetry = {
...turn.telemetry,
steps,
...(generationQuery.data?.complete && providerGenerationTimeMs !== undefined
? { generationTimeMs: providerGenerationTimeMs }
: {}),
...(generationQuery.data?.complete && providerOutputTokensPerSecond !== undefined
? { providerOutputTokensPerSecond }
: {}),
...(generationQuery.data?.complete && providerContextUsed !== undefined
? {
contextUsed: providerContextUsed,
...(turn.telemetry.contextLength
? { contextUtilization: Math.min(providerContextUsed / turn.telemetry.contextLength, 1) }
: {}),
}
: {}),
...(generationQuery.data?.complete && sumCompleteMetric((step) => step.cost) !== undefined
? { cost: sumCompleteMetric((step) => step.cost), costType: "reported" as const }
: {}),
...(generationQuery.data?.complete && sumCompleteMetric((step) => step.upstreamCost) !== undefined
? { upstreamCost: sumCompleteMetric((step) => step.upstreamCost) }
: {}),
...(generationQuery.data?.complete && sumCompleteMetric((step) => step.cacheDiscount) !== undefined
? { cacheDiscount: sumCompleteMetric((step) => step.cacheDiscount) }
: {}),
...(generationQuery.data?.complete && sumCompleteMetric((step) => step.fallbackCount) !== undefined
? { fallbackCount: sumCompleteMetric((step) => step.fallbackCount) }
: {}),
...(generationQuery.data?.complete && sumCompleteMetric((step) => step.fallbackLatencyMs) !== undefined
? { fallbackLatencyMs: sumCompleteMetric((step) => step.fallbackLatencyMs) }
: {}),
};
const displayedThroughput = telemetry.providerOutputTokensPerSecond ?? telemetry.outputTokensPerSecond;
let reportedCost: string | undefined;
if (telemetry.cost !== undefined) {
reportedCost = formatCost(telemetry.cost);
if (telemetry.costType) reportedCost += ` · ${t(`usage.${telemetry.costType}`)}`;
}
return (
<Box className={classes.conversationTurn} data-opened={opened || undefined}>
<UnstyledButton
className={classes.conversationTurnTrigger}
aria-expanded={opened}
aria-controls={detailsId}
onClick={() => setOpened((value) => !value)}
>
<Group justify="space-between" align="flex-start" gap="xs" wrap="nowrap">
<Group gap="xs" wrap="nowrap" miw={0}>
<Box className={classes.conversationTurnProvider}>
<ProviderIcon provider={turn.provider} size={15} />
</Box>
<Box miw={0}>
<Text size="xs" fw={650} truncate>
{t("usage.step", { number: index + 1 })} · {turn.modelId}
</Text>
<Text size="xs" c="dimmed">
{[
telemetry.durationMs !== undefined ? formatDuration(telemetry.durationMs) : undefined,
displayedThroughput !== undefined ? `${displayedThroughput.toFixed(1)} tok/s` : undefined,
usage.totalTokens !== undefined
? `${usage.totalTokens.toLocaleString()} ${t("usage.tokens")}`
: undefined,
telemetry.cost !== undefined ? formatCost(telemetry.cost) : undefined,
]
.filter(Boolean)
.join(" · ") || t("usage.notReported")}
</Text>
</Box>
</Group>
<IconChevronDown size={14} className={classes.disclosureIcon} data-opened={opened || undefined} />
</Group>
</UnstyledButton>
<Collapse id={detailsId} expanded={opened}>
<Stack gap="sm" pt="sm">
{generationQuery.isFetching && generationQuery.data?.complete !== true && (
<Group gap="xs">
<Loader size="xs" type="bars" />
<Text size="xs" c="dimmed">
{t("usage.loadingProviderDetails")}
</Text>
</Group>
)}
{!generationQuery.isFetching && generationQuery.data?.complete === false && (
<Text size="xs" c="dimmed">
{t("usage.providerDetailsPending")}
</Text>
)}
<Box className={classes.usageGrid}>
{[
[t("usage.input"), usage.inputTokens?.toLocaleString()],
[t("usage.output"), usage.outputTokens?.toLocaleString()],
[t("usage.cached"), usage.cachedInputTokens?.toLocaleString()],
[t("usage.reasoning"), usage.reasoningTokens?.toLocaleString()],
[t("usage.cacheWrite"), usage.cacheWriteTokens?.toLocaleString()],
[t("usage.tokens"), usage.totalTokens?.toLocaleString()],
[
t("usage.firstOutput"),
telemetry.timeToFirstOutputMs !== undefined ? formatDuration(telemetry.timeToFirstOutputMs) : undefined,
],
[
t("usage.endToEnd"),
telemetry.durationMs !== undefined ? formatDuration(telemetry.durationMs) : undefined,
],
[
t("usage.generationTime"),
telemetry.generationTimeMs !== undefined ? formatDuration(telemetry.generationTimeMs) : undefined,
],
[
t("usage.providerThroughput"),
displayedThroughput !== undefined ? `${displayedThroughput.toFixed(1)} tok/s` : undefined,
],
[t("usage.cost"), reportedCost],
[
t("usage.upstreamCost"),
telemetry.upstreamCost !== undefined ? formatCost(telemetry.upstreamCost) : undefined,
],
[t("usage.fallbacks"), telemetry.fallbackCount?.toLocaleString()],
[
t("usage.fallbackLatency"),
telemetry.fallbackLatencyMs !== undefined ? formatDuration(telemetry.fallbackLatencyMs) : undefined,
],
[
t("usage.cacheDiscount"),
telemetry.cacheDiscount !== undefined ? formatCost(telemetry.cacheDiscount) : undefined,
],
[t("usage.webSearches"), telemetry.webSearchRequests?.toLocaleString()],
[t("usage.finishReason"), telemetry.finishReason],
].map(([label, value]) => (
<div key={label}>
<Text size="xs" c="dimmed">
{label}
</Text>
<Text size="sm" fw={600}>
{value ?? t("usage.notReported")}
</Text>
</div>
))}
</Box>
{(telemetry.contextUsed !== undefined || telemetry.contextLength !== undefined) && (
<Text size="xs" c="dimmed">
{t("usage.contextWindow")}: {telemetry.contextUsed?.toLocaleString() ?? t("usage.notReported")} /{" "}
{telemetry.contextLength?.toLocaleString() ?? t("usage.notReported")}
</Text>
)}
{steps.length > 0 && (
<Stack gap={4}>
<Text size="xs" fw={650}>
{t("usage.agentSteps")}
</Text>
{steps.map((step) => (
<Box key={step.index} className={classes.stepRow}>
<Group justify="space-between" gap="xs" wrap="nowrap">
<Text size="xs" fw={600}>
{t("usage.step", { number: step.index })}
{step.routedProvider ? ` · ${step.routedProvider}` : ""}
</Text>
<Text size="xs" c="dimmed">
{formatDuration(step.durationMs)}
{formatStepThroughput(step.providerOutputTokensPerSecond, step.outputTokensPerSecond)}
{step.cost !== undefined ? ` · ${formatCost(step.cost)}` : ""}
</Text>
</Group>
<Text size="xs" c="dimmed">
{[
step.providerLatencyMs !== undefined
? `${t("usage.providerLatency")} ${formatDuration(step.providerLatencyMs)}`
: undefined,
step.generationTimeMs !== undefined
? `${t("usage.generationTime")} ${formatDuration(step.generationTimeMs)}`
: undefined,
step.moderationLatencyMs !== undefined
? `${t("usage.moderationLatency")} ${formatDuration(step.moderationLatencyMs)}`
: undefined,
step.inputTokens !== undefined
? `${step.inputTokens.toLocaleString()} ${t("usage.inputShort")}`
: undefined,
step.outputTokens !== undefined
? `${step.outputTokens.toLocaleString()} ${t("usage.outputShort")}`
: undefined,
step.cachedInputTokens !== undefined
? `${step.cachedInputTokens.toLocaleString()} ${t("usage.cachedShort")}`
: undefined,
step.reasoningTokens !== undefined
? `${step.reasoningTokens.toLocaleString()} ${t("usage.reasoningShort")}`
: undefined,
step.fallbackCount !== undefined
? `${t("usage.fallbacks")} ${step.fallbackCount.toLocaleString()}`
: undefined,
step.fallbackLatencyMs !== undefined
? `${t("usage.fallbackLatency")} ${formatDuration(step.fallbackLatencyMs)}`
: undefined,
step.nativeFinishReason,
]
.filter(Boolean)
.join(" · ") || t("usage.notReported")}
</Text>
{(step.normalizedInputTokens !== undefined || step.normalizedOutputTokens !== undefined) && (
<Text size="xs" c="dimmed">
{t("usage.normalizedTokens")}: {step.normalizedInputTokens?.toLocaleString() ?? "–"} /{" "}
{step.normalizedOutputTokens?.toLocaleString() ?? "–"}
</Text>
)}
{(step.routerStrategy || step.routerRegion || step.serviceTier || step.dataRegion || step.isByok) && (
<Text size="xs" c="dimmed">
{[
step.routerStrategy,
step.routerRegion,
step.serviceTier,
step.dataRegion,
step.isByok ? t("usage.byok") : undefined,
]
.filter(Boolean)
.join(" · ")}
</Text>
)}
{step.generationId && (
<Text size="xs" c="dimmed" className={classes.generationId} title={step.generationId}>
{t("usage.generation")}: {step.generationId}
</Text>
)}
</Box>
))}
</Stack>
)}
</Stack>
</Collapse>
</Box>
);
};
const ConversationContextBreakdown = ({ breakdown }: { breakdown: AssistantContextBreakdown }) => {
const t = useI18n("assistant");
const hasContext = breakdown.percentage !== undefined;
const categories = [
{ label: t("usage.input"), value: breakdown.inputTokens },
{ label: t("usage.cached"), value: breakdown.cachedInputTokens },
{ label: t("usage.output"), value: breakdown.outputTokens },
{ label: t("usage.reasoning"), value: breakdown.reasoningTokens },
];
return (
<Box className={classes.contextBreakdown}>
<RingProgress
className={classes.contextBreakdownRing}
size={76}
thickness={7}
roundCaps
sections={
hasContext ? [{ value: breakdown.percentage ?? 0, color: getContextColor(breakdown.percentage ?? 0) }] : []
}
label={
<Text ta="center" size="xs" fw={700}>
{hasContext ? `${breakdown.percentage}%` : "–"}
</Text>
}
/>
<Box className={classes.contextBreakdownWindow}>
{[
{ label: t("usage.used"), value: breakdown.contextUsed },
{ label: t("usage.remaining"), value: breakdown.remaining },
{ label: t("usage.capacity"), value: breakdown.contextLength },
].map((item) => (
<Box key={item.label}>
<Text size="xs" c="dimmed">
{item.label}
</Text>
<Text size="sm" fw={650}>
{item.value?.toLocaleString() ?? t("usage.notReported")}
</Text>
</Box>
))}
</Box>
<Box className={classes.contextBreakdownCategories}>
{categories.map((category) => (
<Box key={category.label}>
<Text size="xs" c="dimmed">
{category.label}
</Text>
<Text size="xs" fw={650}>
{category.value?.toLocaleString() ?? t("usage.notReported")}
</Text>
</Box>
))}
</Box>
</Box>
);
};
export const ConversationContext = () => {
const t = useI18n("assistant");
const [opened, setOpened] = useState(false);
const messages = useAuiState((state) => state.thread.messages);
const threadId = useAuiState((state) => state.threadListItem.remoteId);
const metadata = useMemo(() => messages.map((message) => message.metadata), [messages]);
const messageIdByRequestId = useMemo(
() =>
new Map(
messages.flatMap((message) => {
const telemetry = getAssistantTelemetry(message.metadata);
return telemetry ? [[telemetry.requestId, message.id] as const] : [];
}),
),
[messages],
);
const usage = useMemo(() => getAssistantConversationUsage(metadata), [metadata]);
const breakdown = useMemo(() => getAssistantContextBreakdown(usage), [usage]);
const hasTokenUsage = usage.turns.some((turn) => turn.totalTokens !== undefined);
const hasCost = usage.turns.some((turn) => turn.cost !== undefined);
const hasContext = breakdown.percentage !== undefined;
const contextPercentage = breakdown.percentage ?? 0;
const quickLabel = [
hasTokenUsage ? `${usage.totalTokens.toLocaleString()} ${t("usage.tokens")}` : undefined,
hasCost ? formatCost(usage.cost) : undefined,
]
.filter(Boolean)
.join(" · ");
return (
<Popover
opened={opened}
onChange={setOpened}
width="min(28rem, calc(100vw - 1rem))"
position="top-end"
shadow="md"
withinPortal
trapFocus
returnFocus
>
<Popover.Target>
<UnstyledButton
className={classes.composerContext}
type="button"
aria-label={quickLabel ? `${t("usage.contextWindow")}: ${quickLabel}` : t("usage.contextWindow")}
title={quickLabel || t("usage.contextWindow")}
aria-expanded={opened}
aria-haspopup="dialog"
onClick={() => setOpened((value) => !value)}
>
<Group gap={5} wrap="nowrap">
<RingProgress
size={22}
thickness={3}
roundCaps
sections={hasContext ? [{ value: contextPercentage, color: getContextColor(contextPercentage) }] : []}
/>
<Text className={classes.composerContextLabel} component="span" size="xs" fw={650}>
{hasContext ? `${contextPercentage}%` : t("mentions.context")}
</Text>
</Group>
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown className={classes.conversationContextPopover}>
<Stack gap="sm">
<Group justify="space-between" gap="xs" wrap="nowrap">
<Box miw={0}>
<Text size="sm" fw={700}>
{t("usage.contextWindow")}
</Text>
<Text size="xs" c="dimmed">
{t("usage.requestDetails")}
</Text>
</Box>
{hasContext && (
<Badge size="sm" variant="light" color={getContextColor(contextPercentage)}>
{contextPercentage}%
</Badge>
)}
</Group>
<Box className={classes.conversationUsageSummary}>
<div>
<Text size="xs" c="dimmed">
{t("usage.tokens")}
</Text>
<Text size="sm" fw={650}>
{hasTokenUsage ? usage.totalTokens.toLocaleString() : t("usage.notReported")}
</Text>
</div>
<div>
<Text size="xs" c="dimmed">
{t("usage.cost")}
</Text>
<Text size="sm" fw={650}>
{hasCost ? formatCost(usage.cost) : t("usage.notReported")}
</Text>
</div>
<div>
<Text size="xs" c="dimmed">
{t("usage.agentSteps")}
</Text>
<Text size="sm" fw={650}>
{usage.turns.reduce((total, turn) => total + turn.telemetry.steps.length, 0).toLocaleString()}
</Text>
</div>
</Box>
<ConversationContextBreakdown breakdown={breakdown} />
<Divider />
<ScrollArea.Autosize mah="min(14rem, 30dvh)" type="auto" offsetScrollbars>
<Stack gap={5}>
{usage.turns.length === 0 ? (
<Text size="sm" c="dimmed" py="xs">
{t("usage.notReported")}
</Text>
) : (
usage.turns.map((turn, index) => (
<ConversationTurn
key={turn.requestId}
index={index}
turn={turn}
threadId={threadId ?? undefined}
messageId={messageIdByRequestId.get(turn.requestId)}
/>
))
)}
</Stack>
</ScrollArea.Autosize>
</Stack>
</Popover.Dropdown>
</Popover>
);
};
@@ -0,0 +1,13 @@
import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assistant-preferences";
export interface AssistantConversationControls {
modelId: string | null;
models: AssistantRuntimeModelOption[];
modelOptionsLoading: boolean;
reasoning: AssistantReasoningMode;
isRefreshing: boolean;
autoFocusComposer?: boolean;
onRefresh: () => Promise<void>;
onModelChange: (modelId: string) => void;
onReasoningChange: (reasoning: AssistantReasoningMode) => void;
}
@@ -0,0 +1,311 @@
"use client";
import { createContext, useContext, useEffect, useRef, useState } from "react";
import { ThreadListItemPrimitive, ThreadListPrimitive, useAui, useAuiState } from "@assistant-ui/react";
import { ActionIcon, Button, Group, Popover, ScrollArea, Stack, Text, TextInput, UnstyledButton } from "@mantine/core";
import {
IconCheck,
IconDotsVertical,
IconFileExport,
IconHistory,
IconPencil,
IconPlus,
IconSearch,
IconTrash,
IconX,
} from "@tabler/icons-react";
import { fetchApi } from "@homarr/api/client";
import { showErrorNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmActionIcon } from "@homarr/ui";
import classes from "./assistant-panel.module.css";
import {
buildAssistantConversationMarkdown,
getAssistantConversationExportFilename,
} from "./assistant-conversation-export";
export const downloadAssistantMarkdown = (markdown: string, filename: string) => {
const blobUrl = URL.createObjectURL(new Blob([markdown], { type: "text/markdown;charset=utf-8" }));
const link = document.createElement("a");
link.href = blobUrl;
link.download = filename;
link.hidden = true;
document.body.append(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(blobUrl), 0);
};
const HistorySelectContext = createContext<() => void>(() => undefined);
const ThreadListItem = () => {
const t = useI18n("assistant");
const actionT = useI18n("common.action");
const aui = useAui();
const onSelect = useContext(HistorySelectContext);
const remoteId = useAuiState((state) => state.threadListItem.remoteId);
const title = useAuiState((state) => state.threadListItem.title);
const [exporting, setExporting] = useState(false);
const [actionsOpened, setActionsOpened] = useState(false);
const [renameOpened, setRenameOpened] = useState(false);
const [renaming, setRenaming] = useState(false);
const [nextTitle, setNextTitle] = useState(title ?? "");
const renameInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (renameOpened) renameInputRef.current?.focus();
}, [renameOpened]);
const exportConversation = async () => {
if (!remoteId || exporting) return;
setExporting(true);
try {
const conversation = await fetchApi.assistant.getThread.query({ threadId: remoteId });
const markdown = buildAssistantConversationMarkdown(conversation);
downloadAssistantMarkdown(
markdown,
getAssistantConversationExportFilename(conversation.thread.title ?? title, remoteId),
);
} catch {
showErrorNotification({
title: t("exportConversation.failedTitle"),
message: t("exportConversation.failedDescription"),
});
} finally {
setExporting(false);
}
};
const renameConversation = async () => {
const trimmedTitle = nextTitle.trim();
if (!trimmedTitle || renaming) return;
setRenaming(true);
try {
await aui.threadListItem().rename(trimmedTitle);
setRenameOpened(false);
setActionsOpened(false);
} catch {
showErrorNotification({
title: t("renameConversation.failedTitle"),
message: t("renameConversation.failedDescription"),
});
} finally {
setRenaming(false);
}
};
const deleteConversation = async () => {
try {
await aui.threadListItem().delete();
} catch {
showErrorNotification({
title: t("deleteConversation.failedTitle"),
message: t("deleteConversation.failedDescription"),
});
}
};
if (renameOpened) {
return (
<ThreadListItemPrimitive.Root className={classes.historyItem}>
<TextInput
ref={renameInputRef}
className={classes.historyRenameInput}
value={nextTitle}
onChange={(event) => setNextTitle(event.currentTarget.value)}
aria-label={t("renameConversation.label")}
maxLength={72}
size="xs"
onKeyDown={(event) => {
if (event.key === "Enter") void renameConversation();
if (event.key === "Escape" && !renaming) setRenameOpened(false);
}}
/>
<ActionIcon
variant="subtle"
color="green"
size="sm"
aria-label={t("renameConversation.save")}
disabled={!nextTitle.trim()}
loading={renaming}
loaderProps={{ type: "bars" }}
onClick={() => void renameConversation()}
>
<IconCheck size={15} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
aria-label={actionT("cancel")}
disabled={renaming}
onClick={() => setRenameOpened(false)}
>
<IconX size={15} />
</ActionIcon>
</ThreadListItemPrimitive.Root>
);
}
return (
<ThreadListItemPrimitive.Root className={classes.historyItem}>
<ThreadListItemPrimitive.Trigger asChild>
<UnstyledButton className={classes.historyItemTrigger} title={title} onClick={onSelect}>
<Text component="span" size="sm" fw={500} truncate>
<ThreadListItemPrimitive.Title fallback={t("newConversation")} />
</Text>
</UnstyledButton>
</ThreadListItemPrimitive.Trigger>
{actionsOpened && (
<Group className={classes.historyItemActions} gap={1} wrap="nowrap">
<ActionIcon
variant="subtle"
color="gray"
size="sm"
title={t("renameConversation.action")}
aria-label={t("renameConversation.action")}
onClick={() => {
setNextTitle(title ?? "");
setRenameOpened(true);
}}
>
<IconPencil size={14} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
loading={exporting}
loaderProps={{ type: "bars" }}
disabled={!remoteId}
title={t("exportConversation.action")}
aria-label={t("exportConversation.action")}
onClick={() => void exportConversation()}
>
<IconFileExport size={14} />
</ActionIcon>
<InlineConfirmActionIcon
variant="subtle"
color="red"
size="sm"
title={actionT("delete")}
aria-label={actionT("delete")}
confirmLabel={t("deleteConversation.description", { title: title ?? t("newConversation") })}
confirmationAriaLabel={t("deleteConversation.title")}
confirmationChildren={<IconCheck size={14} />}
onConfirm={deleteConversation}
>
<IconTrash size={14} />
</InlineConfirmActionIcon>
</Group>
)}
<ActionIcon
className={classes.historyItemMenu}
variant="subtle"
color="gray"
size="sm"
title={t("conversationActions")}
aria-label={t("conversationActions")}
aria-expanded={actionsOpened}
onClick={() => setActionsOpened((current) => !current)}
>
{actionsOpened ? <IconX size={15} /> : <IconDotsVertical size={15} />}
</ActionIcon>
</ThreadListItemPrimitive.Root>
);
};
const ThreadHistory = ({ onSelect }: { onSelect: () => void }) => {
const t = useI18n("assistant");
const [query, setQuery] = useState("");
const normalizedQuery = query.trim().toLocaleLowerCase();
const threadItems = useAuiState((state) => state.threads.threadItems);
const hasMatchingConversation = threadItems.some((item) => {
const itemTitle = item.title ?? t("newConversation");
return itemTitle.toLocaleLowerCase().includes(normalizedQuery);
});
return (
<HistorySelectContext.Provider value={onSelect}>
<Stack className={classes.historyMenu} gap="xs" p="xs">
<Group gap="xs" wrap="nowrap" px={4} pt={4}>
<ThreadListPrimitive.New asChild>
<Button variant="light" leftSection={<IconPlus size={16} />} fullWidth onClick={onSelect}>
{t("newConversation")}
</Button>
</ThreadListPrimitive.New>
</Group>
<Group gap="xs" px="xs" mt="xs">
<IconHistory size={14} />
<Text size="sm" fw={600} c="dimmed">
{t("conversations")}
</Text>
</Group>
<TextInput
value={query}
onChange={(event) => setQuery(event.currentTarget.value)}
leftSection={<IconSearch size={15} />}
placeholder={t("searchConversations")}
aria-label={t("searchConversations")}
size="xs"
/>
<ScrollArea.Autosize mah="min(24rem, 55dvh)" type="auto" scrollbars="y" offsetScrollbars>
<Stack gap={3}>
<ThreadListPrimitive.Items>
{({ threadListItem }) => {
const itemTitle = threadListItem.title ?? t("newConversation");
if (normalizedQuery && !itemTitle.toLocaleLowerCase().includes(normalizedQuery)) return null;
return <ThreadListItem />;
}}
</ThreadListPrimitive.Items>
{normalizedQuery && !hasMatchingConversation && (
<Text size="xs" c="dimmed" ta="center" py="md">
{t("noMatchingConversations")}
</Text>
)}
</Stack>
</ScrollArea.Autosize>
</Stack>
</HistorySelectContext.Provider>
);
};
export const ConversationHistory = () => {
const t = useI18n("assistant");
const [opened, setOpened] = useState(false);
return (
<Popover
opened={opened}
onChange={setOpened}
position="bottom-end"
width="min(22rem, calc(100vw - 1rem))"
shadow="md"
withinPortal
>
<Popover.Target>
<Button
className={classes.historyButton}
variant="subtle"
color="gray"
size="compact-sm"
leftSection={<IconHistory size={16} />}
classNames={{ section: classes.historyButtonSection, label: classes.historyButtonLabel }}
onClick={() => setOpened((current) => !current)}
onKeyDown={(event) => {
if (event.key === "Escape" && opened) {
event.stopPropagation();
setOpened(false);
}
}}
aria-label={opened ? t("closeHistory") : t("openHistory")}
>
{t("conversations")}
</Button>
</Popover.Target>
<Popover.Dropdown p={0}>
<ThreadHistory onSelect={() => setOpened(false)} />
</Popover.Dropdown>
</Popover>
);
};
@@ -0,0 +1,523 @@
"use client";
import type { ComponentPropsWithoutRef, MouseEvent as ReactMouseEvent, ReactNode } from "react";
import { createContext, lazy, Suspense, useContext, useEffect, useId, useState } from "react";
import type {
EmptyMessagePartProps,
FileMessagePartProps,
ImageMessagePartProps,
ReasoningMessagePartProps,
SourceMessagePartProps,
} from "@assistant-ui/react";
import { AttachmentPrimitive, useAuiState, useMessagePartText } from "@assistant-ui/react";
import type { DirectiveChipProps } from "@assistant-ui/react-lexical";
import { MarkdownTextPrimitive, unstable_memoizeMarkdownComponents } from "@assistant-ui/react-markdown";
import type { SyntaxHighlighterProps } from "@assistant-ui/react-markdown";
import { ActionIcon, Anchor, Box, Group, Loader, Text, useComputedColorScheme } from "@mantine/core";
import {
IconAlertTriangle,
IconApps,
IconAt,
IconDownload,
IconFile,
IconLink,
IconMessage,
IconPhoto,
IconTool,
IconX,
} from "@tabler/icons-react";
import remarkBreaks from "remark-breaks";
import remarkDirective from "remark-directive";
import remarkGfm from "remark-gfm";
import { useRouter } from "next/navigation";
import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import classes from "./assistant-panel.module.css";
import { getAssistantDirectiveTranslationKey, parseAssistantDirectives } from "./assistant-directives";
import { AssistantDotMatrix } from "./assistant-dot-matrix";
import { AssistantImage } from "./assistant-image";
import { remarkAssistantDirectives, resolveAssistantDirectiveEntity } from "./assistant-markdown-directives";
import type { AssistantDirectiveEntity } from "./assistant-markdown-directives";
import { normalizeAssistantMarkdown } from "./assistant-markdown";
import { getSafeAssistantAttachmentImageSource, getSafeAssistantMarkdownImageSource } from "./assistant-markdown-image";
import { getSafeAssistantHttpUrl } from "./assistant-url";
// Load Shiki only when a settled response contains a code block.
const ShikiHighlighter = lazy(() => import("react-shiki/web"));
const markdownRemarkPlugins = [remarkDirective, remarkAssistantDirectives, remarkGfm, remarkBreaks];
export const contextIcons = {
app: IconApps,
integration: IconLink,
board: IconMessage,
widget: IconTool,
tool: IconTool,
tools: IconTool,
};
export const ContextDirectiveChip = ({
directiveId,
directiveType,
label,
iconUrl,
}: DirectiveChipProps & { iconUrl?: string }) => {
const t = useI18n("assistant");
const Icon = contextIcons[directiveType as keyof typeof contextIcons] ?? IconAt;
const safeIconUrl = getSafeAssistantMarkdownImageSource(iconUrl);
const translationKey = getAssistantDirectiveTranslationKey(directiveType);
const typeLabel = t(`mentions.${translationKey}`);
return (
<span
className={classes.directiveChip}
data-directive-type={directiveType}
data-directive-id={directiveId}
title={`${typeLabel}: ${label}`}
>
{safeIconUrl ? (
<img className={classes.directiveChipImage} src={safeIconUrl} alt="" aria-hidden referrerPolicy="no-referrer" />
) : (
<Icon size={12} aria-hidden />
)}
<span className={classes.directiveChipLabel}>{label}</span>
</span>
);
};
const getInternalAssistantHref = (href: string) => {
if (href.startsWith("/") && !href.startsWith("//")) return href;
if (typeof window === "undefined" || !URL.canParse(href)) return null;
const url = new URL(href);
return url.origin === window.location.origin ? `${url.pathname}${url.search}${url.hash}` : null;
};
export const hasModifiedLinkClick = (event: ReactMouseEvent<HTMLAnchorElement>) =>
event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
const MarkdownLink = ({ href, children, onClick, onMouseEnter, ...props }: ComponentPropsWithoutRef<"a">) => {
const router = useRouter();
const httpLink = href !== undefined && /^https?:\/\//iu.test(href);
const relativeLink = href?.startsWith("/") && !href.startsWith("//");
const safeLink = href?.startsWith("#") || href?.startsWith("mailto:") || httpLink || relativeLink;
if (!href || !safeLink) return <span>{children}</span>;
return (
<a
href={href}
target={httpLink ? "_blank" : undefined}
rel={httpLink ? "noreferrer" : undefined}
{...props}
onMouseEnter={(event) => {
onMouseEnter?.(event);
const internalHref = getInternalAssistantHref(href);
if (internalHref) router.prefetch(internalHref);
}}
onClick={(event) => {
onClick?.(event);
if (event.defaultPrevented || hasModifiedLinkClick(event)) return;
const internalHref = getInternalAssistantHref(href);
if (!internalHref) return;
event.preventDefault();
router.push(internalHref);
}}
>
{children}
</a>
);
};
const MarkdownTable = ({ children, ...props }: ComponentPropsWithoutRef<"table">) => (
<div className={classes.markdownTableWrap}>
<table {...props}>{children}</table>
</div>
);
const MarkdownImage = ({ src, alt = "", ...props }: ComponentPropsWithoutRef<"img">) => {
const t = useI18n("assistant.image");
const safeSource = getSafeAssistantMarkdownImageSource(src);
if (!safeSource) return null;
return (
<AssistantImage
{...props}
source={safeSource}
alt={alt}
className={classes.markdownImage}
loadingLabel={t("loading")}
failedLabel={t("failed")}
retryLabel={t("retry")}
/>
);
};
const AssistantSyntaxHighlighter = ({ code, components, language }: SyntaxHighlighterProps) => {
const colorScheme = useComputedColorScheme("light");
const streaming = useAuiState((state) => state.part.status?.type === "running");
const Pre = components.Pre;
const Code = components.Code;
if (streaming) {
return (
<Pre>
<Code>{code}</Code>
</Pre>
);
}
return (
<Suspense
fallback={
<Pre>
<Code>{code}</Code>
</Pre>
}
>
<ShikiHighlighter
className={classes.syntaxHighlighter}
language={language === "unknown" ? "text" : language}
theme={colorScheme === "dark" ? "github-dark" : "github-light"}
showLanguage={false}
addDefaultStyles={false}
>
{code}
</ShikiHighlighter>
</Suspense>
);
};
const MermaidDiagram = ({ code, components }: SyntaxHighlighterProps) => {
const t = useI18n("assistant");
const colorScheme = useComputedColorScheme("light");
const diagramId = `assistant-mermaid-${useId().replaceAll(/[^A-Za-z0-9_-]/gu, "")}`;
const [rendered, setRendered] = useState<{ code: string; svg?: string; failed?: boolean }>({ code });
useEffect(() => {
let active = true;
setRendered({ code });
void import("mermaid")
.then(async ({ default: mermaid }) => {
mermaid.initialize({
startOnLoad: false,
securityLevel: "strict",
suppressErrorRendering: true,
theme: colorScheme === "dark" ? "dark" : "neutral",
fontFamily: "Inter, system-ui, sans-serif",
});
return await mermaid.render(diagramId, code);
})
.then(({ svg }) => {
if (active) setRendered({ code, svg });
})
.catch(() => {
document.getElementById(`d${diagramId}`)?.remove();
if (active) setRendered({ code, failed: true });
});
return () => {
active = false;
};
}, [code, colorScheme, diagramId]);
if (rendered.code !== code || (!rendered.svg && !rendered.failed)) {
return (
<Group component="output" className={classes.mermaidStatus} gap="xs">
<Loader type="bars" size="xs" />
<Text size="xs" c="dimmed">
{t("markdown.renderingDiagram")}
</Text>
</Group>
);
}
if (rendered.failed || !rendered.svg) {
const Pre = components.Pre;
const Code = components.Code;
return (
<Box className={classes.mermaidFallback}>
<Text size="xs" c="dimmed" mb="xs">
{t("markdown.diagramUnavailable")}
</Text>
<Pre>
<Code>{code}</Code>
</Pre>
</Box>
);
}
return (
<Box
component="figure"
className={classes.mermaidDiagram}
aria-label={t("markdown.diagram")}
dangerouslySetInnerHTML={{ __html: rendered.svg }}
/>
);
};
type MarkdownSpanProps = ComponentPropsWithoutRef<"span"> & {
"data-assistant-directive"?: string;
"data-directive-id"?: string;
"data-directive-label"?: string;
"data-directive-type"?: string;
};
interface AssistantDirectiveEntitiesState {
entities: AssistantDirectiveEntity[];
isLoading: boolean;
}
export const AssistantDirectiveEntitiesContext = createContext<AssistantDirectiveEntitiesState>({
entities: [],
isLoading: false,
});
export const AssistantDirectiveEntitiesProvider = ({ children }: { children: ReactNode }) => {
const { data: entities = [], isLoading } = clientApi.assistant.getContextEntities.useQuery(undefined, {
staleTime: 60_000,
});
return (
<AssistantDirectiveEntitiesContext.Provider value={{ entities, isLoading }}>
{children}
</AssistantDirectiveEntitiesContext.Provider>
);
};
const AssistantMarkdownSpan = ({
children,
"data-assistant-directive": assistantDirective,
"data-directive-id": directiveId,
"data-directive-label": directiveLabel,
"data-directive-type": directiveType,
...props
}: MarkdownSpanProps) => {
const { entities } = useContext(AssistantDirectiveEntitiesContext);
if (assistantDirective === "true" && directiveId && directiveLabel && directiveType) {
const entity = resolveAssistantDirectiveEntity(entities, {
id: directiveId,
label: directiveLabel,
type: directiveType,
});
return (
<ContextDirectiveChip
directiveId={entity?.id ?? directiveId}
directiveType={directiveType}
label={entity?.label ?? directiveLabel}
iconUrl={entity?.iconUrl}
/>
);
}
return <span {...props}>{children}</span>;
};
const assistantMarkdownComponents = unstable_memoizeMarkdownComponents({
a: MarkdownLink,
img: MarkdownImage,
span: AssistantMarkdownSpan,
table: MarkdownTable,
SyntaxHighlighter: AssistantSyntaxHighlighter,
});
export const AssistantTextPart = () => (
<MarkdownTextPrimitive
className={classes.messageMarkdown}
preprocess={normalizeAssistantMarkdown}
remarkPlugins={markdownRemarkPlugins}
components={assistantMarkdownComponents}
componentsByLanguage={{ mermaid: { SyntaxHighlighter: MermaidDiagram } }}
defer
/>
);
export const UserTextPart = () => {
const { text } = useMessagePartText();
const { entities } = useContext(AssistantDirectiveEntitiesContext);
const segments = parseAssistantDirectives(text);
return (
<span className={classes.messageText}>
{segments.map((segment, index) => {
if (segment.kind === "text") return <span key={`${index}:${segment.text}`}>{segment.text}</span>;
const entity = resolveAssistantDirectiveEntity(entities, segment);
return (
<ContextDirectiveChip
key={`${index}:${segment.type}:${segment.id}`}
directiveId={entity?.id ?? segment.id}
directiveType={segment.type}
label={entity?.label ?? segment.label}
iconUrl={entity?.iconUrl}
/>
);
})}
</span>
);
};
const AttachmentPreview = () => {
const attachment = useAuiState((state) => state.attachment);
const [filePreview, setFilePreview] = useState<string | null>(null);
const contentImage = attachment.content?.find((part) => part.type === "image");
const persistedPreview =
contentImage?.type === "image"
? getSafeAssistantAttachmentImageSource(
contentImage.image,
typeof window === "undefined" ? undefined : window.location.origin,
)
: null;
useEffect(() => {
if (persistedPreview || attachment.type !== "image" || !attachment.file) {
setFilePreview(null);
return;
}
const objectUrl = URL.createObjectURL(attachment.file);
setFilePreview(objectUrl);
return () => URL.revokeObjectURL(objectUrl);
}, [attachment.file, attachment.type, persistedPreview]);
const source = persistedPreview ?? filePreview;
if (source) {
return <Box component="img" className={classes.attachmentImage} src={source} alt="" aria-hidden />;
}
return (
<Box className={classes.attachmentFileIcon}>
{attachment.type === "image" ? <IconPhoto size={18} /> : <IconFile size={18} />}
</Box>
);
};
export const Attachment = ({ removable = false }: { removable?: boolean }) => {
const t = useI18n("assistant");
const attachment = useAuiState((state) => state.attachment);
return (
<AttachmentPrimitive.Root className={classes.attachment}>
<AttachmentPreview />
<Box className={classes.attachmentCopy}>
<Text size="xs" fw={600} lineClamp={1} className={classes.attachmentName}>
<AttachmentPrimitive.Name />
</Text>
{attachment.contentType && (
<Text size="xs" truncate className={classes.attachmentType}>
{attachment.contentType}
</Text>
)}
</Box>
{attachment.status.type === "running" && <Loader type="bars" size="xs" />}
{removable && (
<AttachmentPrimitive.Remove asChild>
<ActionIcon variant="subtle" color="gray" size="xs" aria-label={t("removeAttachment")}>
<IconX size={12} />
</ActionIcon>
</AttachmentPrimitive.Remove>
)}
</AttachmentPrimitive.Root>
);
};
export const SentAttachment = () => <Attachment />;
export const HiddenMessagePart = () => null;
export const ReasoningPart = (_props: ReasoningMessagePartProps) => (
<AssistantDirectiveEntitiesContext.Provider value={{ entities: [], isLoading: false }}>
<MarkdownTextPrimitive
className={`${classes.messageMarkdown} ${classes.reasoningText}`}
preprocess={normalizeAssistantMarkdown}
remarkPlugins={markdownRemarkPlugins}
components={assistantMarkdownComponents}
componentsByLanguage={{ mermaid: { SyntaxHighlighter: MermaidDiagram } }}
/>
</AssistantDirectiveEntitiesContext.Provider>
);
export const AssistantMessagePending = ({ status }: EmptyMessagePartProps) => {
const t = useI18n("assistant");
if (status.type !== "running") return null;
return (
<Group component="output" className={classes.messagePending} gap="xs" wrap="nowrap" aria-live="polite">
<AssistantDotMatrix state="thinking" role="presentation" aria-hidden />
<Text size="xs" fw={600} c="dimmed">
{t("activity.thinking")}
</Text>
</Group>
);
};
export const ReasoningVisibilityContext = createContext<{
collapsed: boolean;
setCollapsed: (collapsed: boolean) => void;
}>({ collapsed: false, setCollapsed: () => undefined });
export const SourcePart = (source: SourceMessagePartProps) => {
const safeUrl = getSafeAssistantHttpUrl(source.url);
const content = (
<>
<IconLink size={13} />
{source.title}
</>
);
return safeUrl ? (
<Anchor className={classes.source} href={safeUrl} target="_blank" rel="noreferrer" size="xs">
{content}
</Anchor>
) : (
<Text component="span" className={classes.source} c="dimmed" size="xs">
{content}
</Text>
);
};
export const FilePart = ({ data, filename, mimeType }: FileMessagePartProps) => {
const t = useI18n("assistant");
const downloadable = data.startsWith("data:");
const displayName = filename ?? mimeType;
return (
<Group className={classes.messageFile} gap="xs" wrap="nowrap">
<IconFile size={16} />
<Text size="xs" lineClamp={1} flex={1}>
{displayName}
</Text>
{downloadable && (
<ActionIcon
component="a"
href={data}
download={filename ?? "assistant-file"}
variant="subtle"
color="gray"
size="xs"
aria-label={t("downloadFile", { name: displayName })}
>
<IconDownload size={13} />
</ActionIcon>
)}
</Group>
);
};
export const ImagePart = ({ image, filename }: ImageMessagePartProps) => {
const t = useI18n("assistant");
const source = getSafeAssistantAttachmentImageSource(
image,
typeof window === "undefined" ? undefined : window.location.origin,
);
if (!source) {
return (
<Group className={classes.messageFile} gap="xs" wrap="nowrap">
<IconAlertTriangle size={16} />
<Text size="xs">{t("externalImageBlocked")}</Text>
</Group>
);
}
return (
<AssistantImage
messagePart
source={source}
alt={filename ?? t("attachedImage")}
caption={filename}
className={classes.messageImage}
loadingLabel={t("image.loading")}
failedLabel={t("image.failed")}
retryLabel={t("image.retry")}
/>
);
};
@@ -0,0 +1,158 @@
"use client";
import { useState } from "react";
import { ThreadPrimitive } from "@assistant-ui/react";
import { ActionIcon, Box, Button, Popover, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core";
import {
IconActivityHeartbeat,
IconApps,
IconPalette,
IconRefresh,
IconRobot,
IconSearch,
IconShieldCheck,
} from "@tabler/icons-react";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import classes from "./assistant-panel.module.css";
import { useAssistantAutoApproval } from "./assistant-auto-approval";
import type { AssistantConversationControls } from "./assistant-conversation-controls";
export const AutoApprovalControl = () => {
const t = useI18n("assistant.autoApproval");
const [opened, setOpened] = useState(false);
const { enabled, setEnabled } = useAssistantAutoApproval();
return (
<Popover
opened={opened}
onChange={setOpened}
onDismiss={() => setOpened(false)}
position="bottom-end"
width="min(19rem, calc(100vw - 1rem))"
shadow="md"
withinPortal
>
<Popover.Target>
<ActionIcon
className={classes.panelAction}
variant={enabled ? "light" : "subtle"}
color={enabled ? "green" : "gray"}
onClick={() => setOpened((current) => !current)}
aria-label={t("label")}
aria-pressed={enabled}
title={enabled ? t("enabled") : t("label")}
>
<IconShieldCheck size={17} />
</ActionIcon>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="sm">
<Box>
<Text size="sm" fw={700}>
{t("title")}
</Text>
<Text size="xs" c="dimmed" mt={3}>
{t("description")}
</Text>
</Box>
<Button
fullWidth
variant={enabled ? "default" : "light"}
color={enabled ? "gray" : "green"}
leftSection={<IconShieldCheck size={16} />}
onClick={() => {
setEnabled(!enabled);
setOpened(false);
}}
>
{enabled ? t("disable") : t("enable")}
</Button>
</Stack>
</Popover.Dropdown>
</Popover>
);
};
export const ViewRefreshAction = ({
isRefreshing,
onRefresh,
}: Pick<AssistantConversationControls, "isRefreshing" | "onRefresh">) => {
const t = useI18n("assistant.refresh");
const refresh = async () => {
try {
await onRefresh();
showSuccessNotification({ title: t("completeTitle"), message: t("completeDescription") });
} catch {
showErrorNotification({ title: t("failedTitle"), message: t("failedDescription") });
}
};
return (
<Tooltip label={isRefreshing ? t("working") : t("action")}>
<ActionIcon
className={classes.panelAction}
variant="subtle"
color="gray"
loading={isRefreshing}
loaderProps={{ type: "bars" }}
onClick={() => void refresh()}
aria-label={isRefreshing ? t("working") : t("action")}
>
<IconRefresh size={17} />
</ActionIcon>
</Tooltip>
);
};
export const EmptyThread = () => {
const t = useI18n("assistant");
return (
<ThreadPrimitive.Empty>
<Box className={classes.empty}>
<Stack align="center" gap="lg" maw={560} w="100%">
<Stack align="center" gap="xs" maw={430}>
<ThemeIcon size={52} radius="xl" variant="light">
<IconRobot size={27} />
</ThemeIcon>
<Text size="xl" fw={700}>
{t("emptyTitle")}
</Text>
<Text size="sm" className={classes.emptyDescription}>
{t("emptyDescription")}
</Text>
</Stack>
<Box className={classes.suggestions}>
<ThreadPrimitive.Suggestion prompt={t("suggestions.health.prompt")} send={false} clearComposer asChild>
<Button
variant="default"
className={classes.suggestion}
leftSection={<IconActivityHeartbeat size={18} />}
>
{t("suggestions.health.label")}
</Button>
</ThreadPrimitive.Suggestion>
<ThreadPrimitive.Suggestion prompt={t("suggestions.explore.prompt")} send={false} clearComposer asChild>
<Button variant="default" className={classes.suggestion} leftSection={<IconApps size={18} />}>
{t("suggestions.explore.label")}
</Button>
</ThreadPrimitive.Suggestion>
<ThreadPrimitive.Suggestion prompt={t("suggestions.media.prompt")} send={false} clearComposer asChild>
<Button variant="default" className={classes.suggestion} leftSection={<IconSearch size={18} />}>
{t("suggestions.media.label")}
</Button>
</ThreadPrimitive.Suggestion>
<ThreadPrimitive.Suggestion prompt={t("suggestions.style.prompt")} send={false} clearComposer asChild>
<Button variant="default" className={classes.suggestion} leftSection={<IconPalette size={18} />}>
{t("suggestions.style.label")}
</Button>
</ThreadPrimitive.Suggestion>
</Box>
</Stack>
</Box>
</ThreadPrimitive.Empty>
);
};
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,350 @@
"use client";
import type { ReactNode } from "react";
import { useMemo, useState } from "react";
import {
Badge,
Box,
Button,
Combobox,
Divider,
Group,
Loader,
Popover,
Progress,
SegmentedControl,
Stack,
Text,
ThemeIcon,
Tooltip,
UnstyledButton,
useCombobox,
} from "@mantine/core";
import {
IconBattery1,
IconBattery3,
IconBatteryFilled,
IconBatteryOff,
IconCheck,
IconPhoto,
} from "@tabler/icons-react";
import { useTimeAgo } from "@homarr/common";
import { assistantReasoningModes } from "@homarr/definitions";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import classes from "./assistant-panel.module.css";
import type { AssistantConversationControls } from "./assistant-conversation-controls";
import { useAssistantPreferences } from "./assistant-context";
import type { AssistantPendingAction } from "./assistant-pending-action";
import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assistant-preferences";
import { getAssistantProviderQuotaLevel } from "./assistant-provider-quota";
type ComposerProps = AssistantConversationControls & { pendingAction: AssistantPendingAction | undefined };
const getModelProviderLabel = (modelId: string, fallback: string) => {
const separator = modelId.indexOf("/");
if (separator <= 0) return fallback;
const provider = modelId
.slice(0, separator)
.replace(/^[^\p{L}\p{N}]+/u, "")
.replaceAll(/[-_]/gu, " ");
if (!provider) return fallback;
return `${provider.charAt(0).toLocaleUpperCase()}${provider.slice(1)}`;
};
const formatCompactModelNumber = (value: number) =>
new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(value);
export const RuntimeControls = ({
modelId,
models,
modelOptionsLoading,
reasoning,
onModelChange,
onReasoningChange,
}: ComposerProps) => {
const t = useI18n("assistant");
const [modelSearch, setModelSearch] = useState("");
const selectedModel = models.find((model) => model.id === modelId);
const normalizedModelSearch = modelSearch.trim().toLocaleLowerCase();
const visibleModels =
normalizedModelSearch.length === 0
? models
: models.filter(
(model) =>
model.name.toLocaleLowerCase().includes(normalizedModelSearch) ||
model.id.toLocaleLowerCase().includes(normalizedModelSearch),
);
const groupedModels = visibleModels.reduce((groups, model) => {
const provider = getModelProviderLabel(model.id, t("runtime.otherModels"));
const providerModels = groups.get(provider);
if (providerModels) providerModels.push(model);
else groups.set(provider, [model]);
return groups;
}, new Map<string, AssistantRuntimeModelOption[]>());
const modelCombobox = useCombobox({
onDropdownOpen: () => {
modelCombobox.selectActiveOption();
requestAnimationFrame(() => modelCombobox.focusSearchInput());
},
onDropdownClose: () => {
modelCombobox.resetSelectedOption();
setModelSearch("");
},
});
const selectModel = (value: string) => {
if (!models.some((model) => model.id === value)) return;
onModelChange(value);
modelCombobox.closeDropdown();
};
return (
<Combobox store={modelCombobox} onOptionSubmit={selectModel} withinPortal position="top-start" width={340}>
<Combobox.Target>
<UnstyledButton
className={classes.runtimeSelectorTrigger}
type="button"
disabled={modelOptionsLoading || models.length === 0}
onClick={() => modelCombobox.toggleDropdown()}
aria-label={`${t("runtime.model")}: ${selectedModel?.name ?? t("runtime.noModels")}. ${t("runtime.thinking")}: ${t(`runtime.reasoning.${reasoning}`)}`}
aria-expanded={modelCombobox.dropdownOpened}
>
<Group gap="xs" wrap="nowrap">
{modelOptionsLoading && <Loader size={12} />}
<Text className={classes.runtimeSelectorName} size="xs" fw={650} lineClamp={1}>
{selectedModel?.name ?? t("runtime.model")}
</Text>
<Badge className={classes.runtimeSelectorEffort} size="xs" variant="light" color="gray">
{t(`runtime.reasoning.${reasoning}`)}
</Badge>
<Combobox.Chevron size="xs" />
</Group>
</UnstyledButton>
</Combobox.Target>
<Combobox.Dropdown className={classes.modelDropdown}>
<Combobox.Search
value={modelSearch}
onChange={(event) => {
setModelSearch(event.currentTarget.value);
modelCombobox.updateSelectedOptionIndex();
}}
placeholder={t("runtime.searchModels")}
aria-label={t("runtime.searchModels")}
size="xs"
/>
<Combobox.Options className={classes.modelOptions}>
{[...groupedModels.entries()].map(([provider, providerModels]) => (
<Combobox.Group key={provider} label={provider}>
{providerModels.map((model) => (
<Combobox.Option
className={classes.modelOption}
key={model.id}
value={model.id}
active={model.id === modelId}
>
<Group gap="xs" wrap="nowrap">
<Divider
className={classes.modelOptionDivider}
orientation="vertical"
data-active={model.id === modelId || undefined}
/>
<Stack gap={1} className={classes.modelOptionText}>
<Text size="sm" fw={model.id === modelId ? 650 : 500} lineClamp={1}>
{model.name}
</Text>
<Text size="xs" className={classes.modelOptionDescription} lineClamp={1}>
{model.description?.trim() || model.id}
</Text>
</Stack>
<Group className={classes.modelOptionMeta} gap={4} wrap="nowrap">
{model.inputModalities.includes("image") && (
<Tooltip label={t("runtime.imageInput")}>
<IconPhoto size={14} aria-label={t("runtime.imageInput")} />
</Tooltip>
)}
{model.contextLength && (
<Badge size="xs" variant="light" color="gray">
{formatCompactModelNumber(model.contextLength)}
</Badge>
)}
{model.id === modelId && <IconCheck size={15} className={classes.runtimeOptionCheck} />}
</Group>
</Group>
</Combobox.Option>
))}
</Combobox.Group>
))}
{visibleModels.length === 0 && <Combobox.Empty>{t("runtime.noModels")}</Combobox.Empty>}
</Combobox.Options>
<Divider />
<Box className={classes.reasoningSelector}>
<Text size="xs" fw={650} mb={5}>
{t("runtime.thinking")}
</Text>
<SegmentedControl
className={classes.reasoningSegmentedControl}
value={reasoning}
onChange={(value) => {
if (assistantReasoningModes.includes(value as AssistantReasoningMode)) {
onReasoningChange(value as AssistantReasoningMode);
}
}}
size="xs"
fullWidth
data={assistantReasoningModes.map((mode) => ({
value: mode,
label: t(`runtime.reasoning.${mode}`),
}))}
/>
</Box>
</Combobox.Dropdown>
</Combobox>
);
};
const providerQuotaIcons = {
ok: IconBatteryFilled,
warning: IconBattery3,
bad: IconBattery1,
dead: IconBatteryOff,
};
const providerQuotaColors = {
ok: "green",
warning: "yellow",
bad: "orange",
dead: "red",
} as const;
export const HomarrProviderQuota = () => {
const t = useI18n("assistant");
const locale = useCurrentIntlLocale();
const preferences = useAssistantPreferences();
const quota = preferences.quota;
const resetAt = useMemo(() => new Date(quota?.resetsAt ?? Date.now()), [quota?.resetsAt]);
const resetRelative = useTimeAgo(resetAt, 30_000);
if (preferences.provider !== "homarr") return null;
const level = preferences.quota ? getAssistantProviderQuotaLevel(preferences.quota) : "dead";
const Icon = providerQuotaIcons[level];
const color = providerQuotaColors[level];
const percentage = preferences.quota
? Math.min(100, Math.max(0, (preferences.quota.remaining / Math.max(preferences.quota.limit, 1)) * 100))
: 0;
let label = t("providerQuota.signInRequired");
if (preferences.providerUser && !quota) label = t("providerQuota.loading");
if (preferences.providerUser && quota) {
label = t("providerQuota.remainingLabel", {
remaining: quota.remaining,
limit: quota.limit,
});
}
const resetTime = quota ? (
<time dateTime={quota.resetsAt} title={new Date(quota.resetsAt).toLocaleString(locale)}>
{new Date(quota.resetsAt).toLocaleTimeString(locale, {
hour: "2-digit",
minute: "2-digit",
timeZoneName: "short",
})}
</time>
) : null;
let quotaContent: ReactNode;
if (!preferences.providerUser) {
quotaContent = (
<>
<Text size="sm" c="dimmed">
{t("providerQuota.signInDescription")}
</Text>
<Button
size="compact-sm"
loading={preferences.quotaLoading}
onClick={() => void preferences.signInToProvider().catch(() => undefined)}
>
{t("providerQuota.signIn")}
</Button>
</>
);
} else if (quota) {
quotaContent = (
<>
<Box>
<Group justify="space-between" gap="xs" mb={5}>
<Text size="xs" c="dimmed">
{t("providerQuota.dailyAllowance")}
</Text>
<Text size="xs" fw={650}>
{quota.remaining} / {quota.limit}
</Text>
</Group>
<Progress value={percentage} color={color} size="sm" aria-label={label} />
</Box>
<Text size="xs" c="dimmed">
{t.rich("providerQuota.reset", {
relative: resetRelative,
time: () => resetTime,
})}
</Text>
<Text size="xs" c="dimmed">
{t("providerQuota.toolCalls")}
</Text>
</>
);
} else {
quotaContent = (
<Button
variant="light"
size="compact-sm"
loading={preferences.quotaLoading}
onClick={() => void preferences.refreshQuota()}
>
{t("providerQuota.retry")}
</Button>
);
}
return (
<Popover position="top" width={290} shadow="md" withinPortal>
<Popover.Target>
<UnstyledButton className={classes.providerQuotaTrigger} data-level={level} type="button" aria-label={label}>
{preferences.quotaLoading && !preferences.quota ? <Loader size={14} /> : <Icon size={16} aria-hidden />}
<Text component="span" size="xs" fw={650}>
{preferences.quota?.remaining ?? "–"}
</Text>
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="sm">
<Group justify="space-between" wrap="nowrap">
<Group gap="xs" wrap="nowrap">
<ThemeIcon color={color} variant="light" size="sm" radius="xl">
<Icon size={15} aria-hidden />
</ThemeIcon>
<Box>
<Text size="sm" fw={700}>
{t("providerQuota.title")}
</Text>
<Text size="xs" c="dimmed">
{preferences.providerUser?.name || t("providerQuota.communityWorkshop")}
</Text>
</Box>
</Group>
{preferences.quota && (
<Badge color={color} variant="light" size="sm">
{t(`providerQuota.level.${level}`)}
</Badge>
)}
</Group>
{quotaContent}
{preferences.quotaError && (
<Text size="xs" c="red">
{preferences.quotaError}
</Text>
)}
</Stack>
</Popover.Dropdown>
</Popover>
);
};
@@ -0,0 +1,438 @@
"use client";
import type { ReactNode } from "react";
import { createContext, useContext, useId, useState } from "react";
import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { ChainOfThoughtPrimitive } from "@assistant-ui/react";
import {
Badge,
Box,
Button,
Collapse,
Group,
Image,
Loader,
Stack,
Text,
ThemeIcon,
UnstyledButton,
} from "@mantine/core";
import { IconCheck, IconChevronDown, IconLink, IconRobot, IconX } from "@tabler/icons-react";
import { useRouter } from "next/navigation";
import { useI18n } from "@homarr/translation/client";
import classes from "./assistant-panel.module.css";
import { useAssistantAutomaticAction } from "./assistant-auto-approval";
import { AssistantDotMatrix } from "./assistant-dot-matrix";
import { hasModifiedLinkClick, ReasoningVisibilityContext } from "./assistant-message-content";
import { useAssistantReasoningState } from "./assistant-reasoning-state";
import { getAssistantIconSearchQuery } from "./assistant-tool-label";
import { getToolResultPresentation } from "./assistant-tool-result";
import { getAssistantToolTraceTarget } from "./assistant-tool-trace";
const formatToolResultValue = (value: string | number | boolean) =>
typeof value === "number" ? value.toLocaleString() : String(value);
const ToolResultPresentation = ({ result, toolName }: { result: unknown; toolName: string }) => {
const presentation = getToolResultPresentation(result, { toolName });
if (!presentation) return null;
if (presentation.type === "text") {
return (
<Text size="sm" className={classes.toolResultText}>
{presentation.text}
</Text>
);
}
if (presentation.type === "properties") {
return (
<Box className={classes.toolResultProperties}>
{presentation.fields.map((field) => (
<Box key={field.label} className={classes.toolResultProperty}>
<Text size="xs" c="dimmed" lineClamp={1}>
{field.label}
</Text>
<Text size="sm" fw={600} lineClamp={2}>
{formatToolResultValue(field.value)}
</Text>
</Box>
))}
</Box>
);
}
if (presentation.type === "icons") {
if (presentation.items.length === 0) return null;
const hiddenCount = Math.max(0, presentation.totalCount - presentation.items.length);
return (
<Box className={classes.toolResultIconCollection}>
{presentation.items.map((item) => (
<Box key={`${item.repository ?? "icon"}-${item.url}`} className={classes.toolResultIconItem}>
<Box className={classes.toolResultIconPreview}>
<Image
src={item.url}
alt={item.name}
className={classes.toolResultIconImage}
fit="contain"
loading="lazy"
/>
</Box>
<Group gap={4} mt="xs" wrap="nowrap" justify="space-between">
<Text size="xs" fw={650} lineClamp={1} title={item.name}>
{item.name}
</Text>
<Badge size="xs" variant="light" color="gray" flex="0 0 auto">
{item.variant}
</Badge>
</Group>
{item.repository && (
<Text size="xs" c="dimmed" lineClamp={1} title={item.repository}>
{item.repository}
</Text>
)}
</Box>
))}
{hiddenCount > 0 && (
<Badge className={classes.toolResultMore} size="sm" variant="outline" color="gray">
+{hiddenCount}
</Badge>
)}
</Box>
);
}
const hiddenCount = Math.max(0, presentation.totalCount - presentation.items.length);
return (
<Box className={classes.toolResultCollection}>
{presentation.items.map((item, index) => (
<Box key={`${item.title}-${index}`} className={classes.toolResultItem}>
<Group gap="xs" justify="space-between" wrap="nowrap">
<Box className={classes.toolResultIdentity}>
<Text size="sm" fw={650} lineClamp={1}>
{item.title}
</Text>
{item.description && (
<Text size="xs" c="dimmed" lineClamp={1}>
{item.description}
</Text>
)}
</Box>
{item.badges.length > 0 && (
<Group gap={4} wrap="nowrap">
{item.badges.map((badge) => (
<Badge key={badge} size="xs" variant="light" color="gray">
{badge}
</Badge>
))}
</Group>
)}
</Group>
{item.fields.length > 0 && (
<Group gap="xs" mt={6} wrap="wrap">
{item.fields.map((field) => (
<Text key={field.label} size="xs" c="dimmed">
{field.label}:{" "}
<Text component="span" inherit c="var(--mantine-color-text)" fw={600}>
{formatToolResultValue(field.value)}
</Text>
</Text>
))}
</Group>
)}
</Box>
))}
{hiddenCount > 0 && (
<Badge className={classes.toolResultMore} size="sm" variant="outline" color="gray">
+{hiddenCount}
</Badge>
)}
</Box>
);
};
const getToolResultNavigation = (result: unknown) => {
if (typeof result !== "object" || result === null || Array.isArray(result)) return [];
const record = result as Record<string, unknown>;
const candidates = [
{ href: record.previewPath, translationKey: "toolNavigation.openPreview" as const },
{ href: record.managementPath, translationKey: "toolNavigation.openWidget" as const },
];
return candidates.filter(
(candidate): candidate is { href: string; translationKey: (typeof candidate)["translationKey"] } =>
typeof candidate.href === "string" && candidate.href.startsWith("/") && !candidate.href.startsWith("//"),
);
};
const ToolResultNavigation = ({ result }: { result: unknown }) => {
const t = useI18n("assistant");
const router = useRouter();
const links = getToolResultNavigation(result);
if (links.length === 0) return null;
return (
<Group className={classes.toolResultNavigation} gap="xs" wrap="wrap">
{links.map((link) => (
<Button
key={`${link.translationKey}-${link.href}`}
component="a"
href={link.href}
size="compact-xs"
variant="light"
leftSection={<IconLink size={13} aria-hidden />}
onMouseEnter={() => router.prefetch(link.href)}
onFocus={() => router.prefetch(link.href)}
onClick={(event) => {
if (hasModifiedLinkClick(event)) return;
event.preventDefault();
router.push(link.href);
}}
>
{t(link.translationKey)}
</Button>
))}
</Group>
);
};
const ToolResultPreview = ({ result, toolName }: { result: unknown; toolName: string }) => {
const navigation = getToolResultNavigation(result);
if (navigation.length === 0) return <ToolResultPresentation result={result} toolName={toolName} />;
return (
<Stack gap="xs">
<ToolResultPresentation result={result} toolName={toolName} />
<ToolResultNavigation result={result} />
</Stack>
);
};
const AgentTraceToolContext = createContext(false);
const getToolStatusPresentation = (denied: boolean, failed: boolean, successful: boolean) => {
if (denied || failed) return { color: "red", Icon: IconX } as const;
if (successful) return { color: "green", Icon: IconCheck } as const;
return { color: "gray", Icon: IconRobot } as const;
};
export const ToolPart = ({
toolCallId,
toolName,
args,
result,
isError,
status,
approval,
respondToApproval,
timing,
}: ToolCallMessagePartProps) => {
const t = useI18n("assistant");
const compact = useContext(AgentTraceToolContext);
const [opened, setOpened] = useState(false);
const [approvalResponse, setApprovalResponse] = useState<"approve" | "deny" | null>(null);
const completed = status?.type === "complete";
const awaitingApproval = approval !== undefined && approval.approved === undefined && !approval.resolution;
const denied = approval?.approved === false;
const failed =
!denied &&
(isError === true ||
status?.type === "incomplete" ||
(typeof result === "object" && result !== null && "error" in result));
const successful = completed && !denied && !failed;
const compactPresentation = compact && !awaitingApproval;
const traceTarget = getAssistantToolTraceTarget(args);
const iconSearchQuery = getAssistantIconSearchQuery(toolName, args);
let displayName = toolName.replaceAll("_", " ");
if (iconSearchQuery !== null) {
displayName =
iconSearchQuery.length > 0
? t("toolActivity.iconSearch", { query: iconSearchQuery })
: t("toolActivity.iconBrowse");
}
const duration = timing?.completedAt !== undefined ? Math.max(0, timing.completedAt - timing.startedAt) : undefined;
const { color: statusColor, Icon: StatusIcon } = getToolStatusPresentation(denied, failed, successful);
let statusLabel = t("working");
if (successful) statusLabel = t("complete");
if (failed) statusLabel = t("failed");
if (denied) statusLabel = t("denied");
if (awaitingApproval) statusLabel = t("approvalRequired");
const autoApprovalInProgress = useAssistantAutomaticAction({
toolCallId,
ready: awaitingApproval,
completed: !awaitingApproval,
confirm: () => {
respondToApproval({ approved: true, reason: "Approved automatically by the user." });
},
});
return (
<Box className={classes.tool} data-compact={compactPresentation || undefined}>
<UnstyledButton
className={classes.toolHeader}
onClick={() => setOpened((current) => !current)}
aria-expanded={opened}
>
<Group justify="space-between" wrap="nowrap" gap="xs">
<Group gap="xs" wrap="nowrap">
<ThemeIcon size="sm" radius="xl" variant="light" color={statusColor}>
<StatusIcon size={13} />
</ThemeIcon>
<Box miw={0}>
<Text size={compactPresentation ? "xs" : "sm"} fw={650} lineClamp={1}>
{displayName}
</Text>
{compactPresentation && traceTarget && (
<Text size="xs" c="dimmed" lineClamp={1}>
{traceTarget}
</Text>
)}
</Box>
</Group>
<Group gap={6} wrap="nowrap">
{duration !== undefined && (
<Text size="xs" c="dimmed">
{duration < 1000 ? `${duration} ms` : `${(duration / 1000).toFixed(1)} s`}
</Text>
)}
<Text size="xs" c="dimmed">
{statusLabel}
</Text>
<IconChevronDown
size={14}
className={classes.disclosureIcon}
data-opened={opened || awaitingApproval || undefined}
/>
</Group>
</Group>
</UnstyledButton>
{awaitingApproval && (
<Box className={classes.approvalPanel}>
<Text size="sm" fw={600}>
{t("approvalDescription")}
</Text>
<ToolResultPreview result={args} toolName={toolName} />
{autoApprovalInProgress ? (
<Group className={classes.autoApprovalProgress} gap="sm" wrap="nowrap">
<Loader type="bars" size="sm" color="green" />
<Text size="sm" fw={600}>
{t("autoApproval.approving")}
</Text>
</Group>
) : (
<Group className={classes.approvalActions} gap="sm" grow wrap="nowrap">
<Button
size="md"
fullWidth
leftSection={<IconCheck size={18} />}
loading={approvalResponse === "approve"}
disabled={approvalResponse !== null}
onClick={() => {
setApprovalResponse("approve");
respondToApproval({ approved: true });
}}
>
{t("approveAndRun")}
</Button>
<Button
size="md"
fullWidth
variant="default"
leftSection={<IconX size={18} />}
loading={approvalResponse === "deny"}
disabled={approvalResponse !== null}
onClick={() => {
setApprovalResponse("deny");
respondToApproval({ approved: false });
}}
>
{t("deny")}
</Button>
</Group>
)}
</Box>
)}
{!compactPresentation && successful && result !== undefined && (
<ToolResultPreview result={result} toolName={toolName} />
)}
<Collapse expanded={opened}>
<Stack gap="xs" mt="sm">
{compactPresentation && successful && result !== undefined && (
<ToolResultPreview result={result} toolName={toolName} />
)}
<Box>
<Text size="xs" fw={600} c="dimmed">
{t("toolInput")}
</Text>
<Text className={classes.codeBlock} size="xs" component="pre">
{JSON.stringify(args, null, 2)}
</Text>
</Box>
{!denied && result !== undefined && (
<Box>
<Text size="xs" fw={600} c="dimmed">
{t("toolOutput")}
</Text>
<Text className={classes.codeBlock} size="xs" component="pre">
{typeof result === "string" ? result : JSON.stringify(result, null, 2)}
</Text>
</Box>
)}
</Stack>
</Collapse>
</Box>
);
};
export const AgentTraceToolGroup = ({ children }: { children?: ReactNode }) => (
<AgentTraceToolContext.Provider value>
<Stack className={classes.agentTraceTools} gap={4}>
{children}
</Stack>
</AgentTraceToolContext.Provider>
);
const ChainOfThoughtLayout = ({ children }: { children?: ReactNode }) => (
<Stack className={classes.reasoningParts} gap={6}>
{children}
</Stack>
);
export const AssistantChainOfThought = ({ children }: { children?: ReactNode }) => {
const t = useI18n("assistant");
const contentId = useId();
const { collapsed: preferredCollapsed, setCollapsed: setPreferredCollapsed } = useContext(ReasoningVisibilityContext);
const { chainStatus, collapsed } = useAssistantReasoningState(preferredCollapsed);
const running = chainStatus.type === "running";
return (
<ChainOfThoughtPrimitive.Root className={classes.reasoning} data-opened={!collapsed || undefined}>
<ChainOfThoughtPrimitive.AccordionTrigger asChild>
<UnstyledButton
className={classes.reasoningToggle}
type="button"
aria-expanded={!collapsed}
aria-controls={contentId}
onClick={() => setPreferredCollapsed(!collapsed)}
>
<Group gap="xs" wrap="nowrap">
{(running || chainStatus.type === "requires-action") && (
<AssistantDotMatrix state={running ? "thinking" : "waiting"} role="presentation" aria-hidden />
)}
<Text size="xs" fw={650} c="dimmed">
{t("reasoning")}
</Text>
</Group>
<IconChevronDown size={14} className={classes.reasoningChevron} />
</UnstyledButton>
</ChainOfThoughtPrimitive.AccordionTrigger>
<Box id={contentId} className={classes.reasoningContent} aria-busy={running} hidden={collapsed}>
<ChainOfThoughtLayout>{children}</ChainOfThoughtLayout>
</Box>
</ChainOfThoughtPrimitive.Root>
);
};