diff --git a/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx b/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx index bc971bc4a..66a0f2f35 100644 --- a/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx +++ b/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx @@ -6,21 +6,21 @@ tags: Homarr has the following keyboard shortcuts: -| Shortcut | Description | -| ---------------------------------------------- | ------------------------------------------ | -| `MOD` + `K` | Opens the search bar | -| `MOD` + `J` | Toggles Dark Mode | -| `MOD` + `E` | Toggles Edit Mode | -| Hold `Shift` while hovering a supported widget | Opens its temporary advanced view | -| `Shift` + `Enter` on a supported widget | Opens its advanced view until it is closed | -| `Esc` | Closes an open advanced view or board menu | +| Shortcut | Description | +| ----------------------------------------------- | ------------------------------------------ | +| `MOD` + `K` | Opens the search bar | +| `MOD` + `J` | Toggles Dark Mode | +| `MOD` + `E` | Toggles Edit Mode | +| Hold `Shift` for 500 ms over a supported widget | Opens its temporary advanced view | +| `Shift` + `Enter` on a supported widget | Opens its advanced view until it is closed | +| `Esc` | Closes an open advanced view or board menu | :::tip Here `MOD` refers to the modifier key, which is `Ctrl` on Windows and `Cmd` on macOS. Advanced view is available outside edit mode. Widgets without a distinct advanced experience adapt to their normal -dimensions instead. +dimensions instead. A progress ring follows the pointer while the temporary advanced view is preparing to open. ::: diff --git a/apps/docs/docs/management/api/index.mdx b/apps/docs/docs/management/api/index.mdx index 359d189e6..35e9f49e4 100644 --- a/apps/docs/docs/management/api/index.mdx +++ b/apps/docs/docs/management/api/index.mdx @@ -82,6 +82,8 @@ The OpenAPI and MCP APIs include board management endpoints for common automatio - Set desktop and mobile home boards for the current user - Configure global board defaults such as public home boards and status behavior +Board layout responses identify each layout with a `mobile`, `base`, or `custom` role. Mobile and Base are protected. Layout-saving clients must preserve existing roles, keep Mobile at breakpoint `0`, keep Base as the highest breakpoint, and use unique custom breakpoints between them. The layout save operation returns the canonical saved layouts, including server-generated IDs for new custom layouts. A non-Base layout can be regenerated from Base with the protected reset operation. + Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags. ## Partial integration failures diff --git a/apps/docs/docs/management/assistant.mdx b/apps/docs/docs/management/assistant.mdx new file mode 100644 index 000000000..ff0d83af5 --- /dev/null +++ b/apps/docs/docs/management/assistant.mdx @@ -0,0 +1,275 @@ +--- +tags: + - Assistant + - AI + - Administration + - Security +--- + +# Assistant + +Administrators can configure the instance-wide AI assistant from **Management → Assistant**. +It is available to signed-in users from the user menu, with `Shift + A`, and from the +`Cmd/Ctrl + K` command menu. **Ask Homarr Assistant** is always available as a local result in the default +search. Selecting it with an existing query sends that query in the background; selecting it without a +query turns the command-menu search field into a prompt composer. Press +`Enter` to send the prompt in the background without opening the conversation. The search field receives +focus whenever the command menu opens, including when it is opened by an assistant tool. Sending from the +command menu preserves any draft in the expanded conversation. The local result is temporarily disabled while +the assistant is responding or waiting for an approval. + +While a response is running, a compact activity bar appears at the bottom of the page and the user-menu +avatar shows a thinking indicator. The activity bar can be opened to follow the response. Completed +responses add an unread count to the user menu until they are opened or dismissed. The expanded +conversation has a minimize action that returns it to the activity bar without interrupting a response. +The transcript follows streamed output while it is already at the bottom, including fast responses. Scrolling +up releases that follow state until **Scroll to latest** is selected or the user returns to the bottom. + +`Shift + A` opens the compact conversation panel directly. The shortcut is ignored while typing in text +fields, selectors, and editable content. + +The **Assistant** board widget embeds the same persisted conversation and full interaction surface directly +on a dashboard. By default it follows the current conversation. Widget settings can instead pin a specific +conversation; the conversation picker stays hidden until **Pinned conversation** is selected. +The Shift+A panel is non-modal: opening it focuses its composer, but it does not capture focus from the board. +Users can continue typing in an Assistant widget or interacting with other dashboard controls while the panel +is open. Every visible assistant surface keeps its own draft, attachment list, selection, and focus while sharing +the same persisted conversation. Switching conversations restores the draft that belongs to that surface and +conversation instead of copying text into another widget or the floating panel. + +When the assistant is unavailable, its command-menu result remains visible but disabled and explains that +an administrator must configure and enable it. + +## Configure a model provider + +Homarr includes presets for OpenRouter, OpenAI, Anthropic Claude, Google Gemini, xAI, Groq, Mistral AI, +DeepSeek, Together AI, Ollama, and LM Studio. Provider icons make the presets easy to identify. A custom +preset connects any other service that implements the OpenAI Chat Completions API. + +The provider list also shows **Homarr — Coming soon** as a disabled option. + +The management page presents setup as three compact sections: provider and endpoint, credentials, then +model and availability. A status summary at the top shows whether the connection is ready, whether a key +is protected, and which model is selected. Redundant active-state badges are omitted when the setup is ready. + +1. Choose a provider preset or **Custom endpoint**, then confirm the API base URL. It usually ends in `/v1`. +2. Add an API key if the service requires bearer authentication. Additional authentication or routing + headers are available in the collapsed advanced section. +3. Save the provider connection. +4. Refresh the model list and select a discovered model. Homarr displays the friendly model name but + stores and sends the provider's exact model ID. Context-window sizes use compact labels such as `1M`. +5. Enable the assistant and save the configuration. + +The model discovery path defaults to `/models` and is appended to the API base URL. Leave it blank when +the service does not expose model discovery. A manual model ID can still be used when discovery is +disabled, unavailable, or returns no models. + +API keys and additional header values are encrypted in the Homarr database. They are decrypted only on +the server for discovery and model requests, and they are never returned to the browser. Changing the +provider or base URL clears existing credentials unless replacements are submitted with the change. This +prevents credentials for one service from being sent to another endpoint. + +When a key is already stored, the page shows its protected state instead of an ambiguous empty password +field. **Replace key** starts a two-step flow: enter the new key, then review the provider, destination, +and impact before confirming. The existing key remains active until confirmation, and its value cannot be +restored after replacement. Removing only the API key also requires explicit confirmation. **Remove all +credentials** uses a separate destructive confirmation because it also clears the selected model and +disables the assistant. + +:::info Container networking + +For a containerized Homarr installation, `localhost` refers to the Homarr container. Use a resolvable +container name, internal DNS name, or host gateway address to reach Ollama, LM Studio, or another local +model server. + +::: + +The configured endpoint must support streaming Chat Completions and structured tool calls for the full +assistant experience. Model discovery alone does not guarantee tool support, except when OpenRouter +explicitly advertises the model's `tools` capability. + +OpenRouter web search is an opt-in option in the model section. It registers OpenRouter's model-callable +[`openrouter:web_search` server tool](https://openrouter.ai/docs/guides/features/server-tools/web-search) +alongside Homarr's tools, so OpenRouter performs each search without +exposing the stored credential to the browser. The model decides when live research is needed and can use the +grounded results and source links in responses or dashboard notes. Homarr limits each assistant request to +three searches with five results per search. OpenRouter charges web-search usage separately from model tokens. +Completed responses show a compact web-search activity card when OpenRouter reports search usage. OpenRouter +does not expose these server-side searches as ordinary client-executed tool calls, so Homarr shows the reported +search count and the safe title and URL from each returned `url_citation` annotation instead. Citation excerpts +stay in the provider response and are not persisted in the activity card. + +The option is also available for custom endpoints that forward OpenRouter's server-tools request format. This +supports OpenRouter proxies, including the future Homarr Router provider. Leave it disabled for ordinary +OpenAI-compatible endpoints because the `openrouter:*` tool type is not part of the standard OpenAI API. + +The Claude preset uses Anthropic's OpenAI SDK compatibility layer. Anthropic describes that layer as +suited to testing and comparison rather than as its full native API; unsupported OpenAI fields may be +ignored. Streaming responses and tool calls are supported, which are the capabilities Homarr Assistant +uses. Choose OpenRouter or another provider when you prefer a provider-neutral production endpoint. + +## Conversations and tools + +Conversations and messages are stored per user in the Homarr database. The assistant stays out of the +page layout until it is opened. Users can start, revisit, and delete conversations from the +conversation menu in the floating panel. + +Messages support copy, edit-and-resend, response regeneration and branch navigation, Markdown export, +and helpful/not-helpful feedback. +Selecting text in a message exposes a quote action that carries the selected passage into the next prompt. +Files returned by a compatible model can be downloaded directly from the message. +Assistant responses render GitHub-flavored Markdown, including headings, links, lists, task lists, tables, +quotes, inline code, and fenced code blocks. Line breaks are preserved, and Homarr repairs common streamed +responses where a provider returns escaped line breaks or places a bold-label list on one line. +Completed fenced code blocks use language-aware syntax highlighting; a streaming block stays plain until its +content is stable. Safe same-origin links are prefetched and use client-side navigation, so opening a returned +preview, editor, board, or other Homarr item does not unload the assistant. External links keep their normal +external-browser behavior. +Fenced `mermaid` blocks render as diagrams in the current color scheme using Mermaid's strict security mode; +invalid diagrams fall back to their source instead of breaking the response. +When Homarr's icon search returns an image URL, visual result tables render that actual icon in a compact +image column instead of substituting an emoji. Assistant-provided Markdown images accept safe relative, +HTTP, and HTTPS sources and load without forwarding the Homarr page as the referrer. +Images up to 1 MB and text, Markdown, CSV, HTML, XML, or JSON documents up to 350 KB can be attached by +using the paperclip, dropping files onto the composer, or pasting an image with `Cmd/Ctrl + V`. Attachment +previews stay local to the composer where they were added and show upload state, file type, and a removable image +thumbnail or document tile. A request can contain up to five attachments. +Image understanding depends on the selected model; document text is extracted locally by Homarr and sent +as part of the prompt. + +Type `@` in the composer to add permission-checked context. The nested menu contains apps, integrations, +boards, widgets, and tools available to the current user. Homarr validates every referenced ID again on +the server before adding it to the model context. Selected references appear as compact, typed pills in the +composer and sent message, while preserving assistant-ui's underlying directive syntax for the model. When the +assistant cites one of those references in its own response, Homarr renders the same typed pill and uses the +stored app or integration icon when one is available. Type `/` +for common prompt shortcuts. Both menus support keyboard navigation and keep the active result visible while +moving through long lists. The selector below the composer combines searchable, provider-grouped models with +their description, context size and image-input capability, plus the thinking-level control. Model selection is +stored on the conversation after a completed request. Thinking mode can be left on **Auto**, disabled, or set +from minimal through high; explicit levels are sent using the AI SDK's provider-neutral reasoning setting and +still depend on support from the chosen model and endpoint. Reasoning text is shown live when the provider +streams it through assistant-ui's compact chain-of-thought disclosure. The **Thinking** disclosure renders +Markdown, can be opened or closed without interrupting the response, and keeps the user's collapsed state when +another message starts. A compact dot matrix distinguishes an initial thinking pass from response streaming and +settles into waiting, success, or error states in the minimized activity bar. The state always keeps its text label, +and the animation is disabled when the operating system requests reduced motion. + +A circular context ring beside the model selector gives a quick glance at cumulative conversation usage without +expanding the composer toolbar. Its accessible label includes cumulative tokens and cost when available, while +its popover keeps the latest complete context reading as a new response starts and lists every request +turn with its model, provider, input, output, cached-input and reasoning tokens, context use, duration, speed, +agent steps, routing details, and cost when reported. Before usage is available, the control remains a compact +ring instead of showing a redundant missing-data label. Closing and reopening the popover reads persisted +conversation metadata instead of resetting the display to zero. + +Each assistant response places a small provider logo in the same stable action toolbar as copy, export, +feedback, and regenerate. Hovering the logo shows the provider, exact model, timing, throughput, and cost without +adding another metadata row to the transcript. Branch navigation occupies its own adjacent group, so switching +between response versions never pushes the response actions off-screen. + +OpenRouter responses request inline usage and routing metadata. Opening detailed turn data also loads the +server-side generation record for its model steps and briefly polls while OpenRouter finishes processing it. +The details separate Homarr's end-to-end timing from OpenRouter's provider latency, generation time, and provider +throughput, and can include normalized and native token counts, moderation latency, cache discount, fallback count, +service tier, data region, BYOK status, routed provider, generation identifier, exact cost, and upstream inference +cost. Provider credentials remain server-side; the browser can request only generation identifiers already stored +on a conversation owned by the signed-in user and signed by the Homarr server for that user and conversation. +Successful generation lookups are cached briefly so polling and multi-step requests do not repeatedly query +OpenRouter for the same record. +When a provider omits cost but model discovery includes prompt and completion pricing, Homarr labels the +calculated value as estimated. Missing provider data is shown as not reported rather than inferred. + +The assistant can use the same internal tool definitions as the [Homarr MCP server](./mcp). +Each tool call runs as the signed-in user, so normal Homarr permission checks still apply. Read-only +procedures can run during the agent loop. Mutating procedures require an explicit approval in the +conversation before they execute. The approval appears as a large **Approve and run** or **Deny** action; +the assistant does not require a second typed confirmation first. Tool cards expose their status, timing, +validated input, result, and error state without flooding the conversation with raw JSON by default. +Common collection and widget results are summarized inline as compact responsive cards, while the full +structured result remains available in the tool card details. Icon searches show the returned image variants, +file formats, and repositories instead of the size of the complete icon catalog. + +Large tool-driven requests can use up to 32 agent steps and a larger response budget within one request, so +multi-item dashboard changes normally finish without requiring a follow-up “Continue” message. Independent +read-only lookups may be performed together, while every mutation still pauses for its normal approval. + +The assistant can create a formatted dashboard note by adding a **Notebook** widget. Ask it for a note with +headings, lists, tasks, links, emphasis, tables, or images; it generates compatible rich-text content, shows +the native widget configuration form and standard mutation approval, then places the widget in the next +available dashboard position. The generated content is retained even though the Notebook content field is +normally hidden from widget settings. + +Every assistant-created dashboard widget now passes through Homarr's native widget editor before it is added. +The form fills widget defaults, validates widget-specific options, and only offers compatible integrations that +the current user can use. Required integrations cannot be left empty, single-integration widgets cannot receive +multiple integrations, and the reviewed values are passed unchanged into the final approval-gated mutation. + +Custom-widget tools let an administrator list, inspect, create, and update definitions. The custom-widget +form's **Design with AI** action sends a focused request into the current conversation, where the assistant +can propose the matching mutation and use the same approval controls. Existing stored secret values are +reported only as configured/not configured and are preserved on update unless replacements are explicitly +provided. For Custom Widget requests, Homarr lazily adds the complete installed skill, all bundled schema, +runtime and security references, and the current JSON Schema to the trusted authoring context. This avoids an +extra provider round trip for the large `customWidget_getSkill` and `customWidget_schema` results. If the model +discovers the Custom Widget workflow mid-response, Homarr promotes the same resources before its next step and +removes the now-redundant resource calls from provider history. Multiline JSX uses `templateLines` to avoid fragile +streamed JSON escaping. The assistant validates the candidate, creates a preview, executes every returned preview query, +checks the actual HTTP status and response shape, and iterates before saving. Preview and saved-definition results +include safe Homarr paths that open in the assistant without a page reload. When placement is still a choice, the +assistant uses the structured question UI instead of ending with a prose question. + +The shield control in the conversation header can enable automatic approvals for the current conversation. +While enabled, Homarr accepts every affirmative approval step as it arrives: protected mutations are approved, +presented app details and board changes are used, and structured questions with an explicitly categorized +**Confirm** answer choose that answer. Neutral choices such as selecting a board still wait for input because +they do not have a safe affirmative default. Automatic approvals turn off when you switch conversations, and +the shared approval tracker prevents the same call from being handled twice when both a board widget and the +floating assistant are visible. + +When the assistant genuinely needs missing information or a choice, it can pause with a structured +question. Each proposed answer is visibly categorized as **Confirm**, **Decline**, or **Option**, so the effect +of automatic approval is unambiguous. Users can select one of the proposed answers or choose **Other** and +enter their own response. While a question is unanswered, its controls stay in a dedicated area immediately +above the composer instead of requiring the user to find the tool call in the transcript. The question temporarily +uses the available conversation body, with its own scrolling when necessary, rather than squeezing a long option +list into a short dock beside part of the transcript. The answered choice then remains in the conversation history. +Submitting the answer resumes the same agent run automatically. The minimized activity bar shows the +question or approval that needs attention and opens directly back to the request. While an answer, app +form, or mutation approval is pending, the composer preserves its draft but does not replace the pending +tool call with a new message. If the provider stops before an interactive tool supplies complete arguments, +Homarr replaces the loading state with an explicit error and confirms that no change was made. + +App creation uses Homarr's native app form inside the conversation. The form reuses the local icon picker, +including automatic matching and searches through the `icon.findIcons` procedure, before the final +`app.create` approval is requested. App fields are filled only after the complete tool input has arrived, so +streaming partial arguments cannot replace the proposed values with empty fields. The model is instructed not +to invent external icon CDN URLs. + +Board appearance changes use a similar review flow. The assistant first reads the board's current settings, +including its complete custom stylesheet, and then opens an editable form with Custom CSS, Appearance, +Background, and General tabs. The form highlights how many fields were proposed, preserves settings the +assistant did not change, and returns only values that differ from the current board. Custom CSS can be +copied or restored before continuing, and Homarr warns when it contains an imported stylesheet or remote +resource URL. Continuing from the form opens the normal mutation approval before anything is saved. + +Ask for changes such as “make the cards denser on my Home board,” “add a mobile CSS rule without removing +my existing styles,” or “change the board colors and disable app status checks.” The current user must have +modify permission on the selected board. The empty conversation includes an **Improve a dashboard** starter, +and `/style` prepares the same guided workflow from the composer. + +Homarr also exposes a small set of browser-only actions to the assistant. These actions can navigate to +safe internal routes, refresh the current view, and open Homarr interfaces such as the command menu and media request search. +They never receive provider credentials and cannot navigate to arbitrary external URLs. + +After an approved mutation completes, Homarr automatically refetches active page and dashboard data so +board items, settings, custom CSS, apps, and other visible results update without a full browser reload. +The refresh action in the conversation header performs the same safe refetch manually. It keeps the +conversation open while refreshing both client queries and server-rendered route data. + +:::warning + +Assistant prompts and selected tool results are sent to the configured model endpoint. Review that +service's privacy and retention terms before enabling this feature for an instance. + +::: diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index 83918e06b..cbd4bf483 100644 --- a/apps/docs/docs/management/boards/index.mdx +++ b/apps/docs/docs/management/boards/index.mdx @@ -38,7 +38,8 @@ Underscores and hyphen-minus are allowed. The column count defines how many fixed-width cells are available in each row. One cell contains a `200 × 200` logical-pixel tile. -Homarr scales the whole board uniformly instead of changing widget proportions, with horizontal scrolling when further shrinking would make controls too small. +Homarr scales the whole board uniformly instead of changing widget proportions, and always fits it to the available width without horizontal scrolling. +The main canvas and enabled rails stay at least one viewport tall, and the canvas grows automatically while you drag or resize items near its lower edge. ### Public @@ -89,37 +90,41 @@ Homarr will fall back to the Homarr logo if nothing is set. ### Layout -In this section you can configure the different responsive layouts. -Homarr selects a layout from the screen width, then scales its complete fixed-size canvas uniformly. +In this section you can configure the different responsive layouts. Homarr selects the layout with the highest breakpoint that is not wider than the current screen, then scales its complete fixed-size canvas uniformly. ![](./img/layout-settings.png) +Every board has two protected layouts: + Every `1 × 1` tile has a `200 × 200` logical-pixel content area. The `24` logical-pixel gap between tiles is separate from that size. A `2 × 1` tile therefore contains two fixed cell widths plus the gap between them. Widgets see their logical size, while the board's visual scale makes the complete layout larger or smaller. When fitting below 100%, Homarr keeps widget typography and control density at their normal readable size. On larger canvases, the complete board grows together like browser zoom. -The scale never drops below 75%; on narrower screens the canvas scrolls horizontally instead of shrinking controls below an accessible size. +The complete canvas always fits the available width and never adds horizontal board scrolling. -The position and size of every item are independent in each responsive layout. -Moving or resizing an item in one layout does not change any other layout. -When adding a new layout, its initial positions and sizes are generated from the column count. -New items use their widget's default grid size, while a new Container starts at a useful `4 × 3` size when the layout has enough columns. -Both are placed in the first available position. +- **Mobile** starts at `0px` and uses three columns on new boards. +- **Base** is the widest layout and starts at `768px` on new boards. -The current board edge is a hard drag boundary, so moving a tile outside the canvas cancels that placement instead of creating empty rows. -Adding content can still create the rows required for the new tile. +Mobile and Base can be renamed and their column counts and rails can be changed, but they cannot be removed. You can add removable custom layouts between their breakpoints. + +Each layout stores independent positions and sizes. Moving or resizing a widget while editing Mobile does not move it in Base or any custom layout. New custom layouts are generated from Base and scaled to their selected column count. + +Use **Reset from Base** on Mobile or a custom layout to replace all of its positions and sizes with a newly generated version of Base. The layout's name, breakpoint, and column count are kept. + +The canvas is at least one window tall, even when it is empty. +Its left and right edges are hard drag boundaries, while its bottom edge extends automatically as a tile is dragged or resized downward. Nested Containers keep their own resizable bounds. +New items use their widget's default size and new Containers use a useful `4 × 3` size when space allows. #### Name -The name of the layout can be chosen freely. It is only used for management and will not be visible on the board. +The name of the layout can be chosen freely. It is only used for management and is not displayed on the board. #### Column count -Total number of columns for this layout. -When changed, Homarr moves items into the next available positions without overlap. +The total number of columns in the layout. Changing it projects the saved positions and sizes into the new grid width without overlap. #### Fixed rails @@ -133,13 +138,12 @@ For example, a 12-column layout with a two-column left rail leaves ten columns f Enabling or widening a rail reflows canvas items into the remaining columns. Disabling a rail moves its contents back into the next available canvas positions. -Rails remain visible while the page scrolls. -Apps, widgets, and Containers inside them still use the same fixed `200 × 200` cells and can be rearranged in edit mode. +Rails fill the available window height and remain fixed while the page scrolls. +Apps, widgets, and Containers inside them still use the same fixed `200 × 200` cells and can be rearranged in edit mode, where each rail shows its name and boundary. #### Breakpoint -Minimum width of the screen where the layout will be used. -Once the minimum of the next layout is reached, the layout will change. +The minimum screen width where the layout is used. Mobile is fixed at `0px`. Custom breakpoints must be unique and sit between Mobile and Base. Base must remain the highest breakpoint. #### View and edit modes @@ -164,6 +168,10 @@ The **Move / resize item** action in the edit menu remains available for precise #### Preview +Layout Settings shows a lightweight structural preview for every layout, including saved spans, Containers, widget labels, and app icons. The canvas width represents the screen range where that layout is active. + +Choose **Edit layout** to open the real board editor at that representative width on desktop. Save returns to Layout Settings. Cancel discards the local changes. On phones and tablets, the normal board editor continues to use the layout selected by the device width. + @@ -239,6 +247,10 @@ Change the radius of your items and Containers. This is the field where you can enter custom CSS. The styling will only be applied on the respective board page. +When Homarr Assistant is enabled, users with modify permission can also ask it to edit the board's Custom +CSS and other visual or behavior settings. The assistant reads the current stylesheet first and presents an +editable review form before requesting approval to save the change. + [See this guide on how to work with custom CSS](/docs/advanced/styling/). ### Behaviour diff --git a/apps/docs/docs/management/custom-widgets/agent-authoring.mdx b/apps/docs/docs/management/custom-widgets/agent-authoring.mdx index 25afa3724..413a9c48c 100644 --- a/apps/docs/docs/management/custom-widgets/agent-authoring.mdx +++ b/apps/docs/docs/management/custom-widgets/agent-authoring.mdx @@ -17,7 +17,9 @@ Install the Agent Skill from the verified Homarr repository: npx skills add https://github.com/homarr-labs/homarr --skill homarr-custom-widget ``` -The portable skill gives concise authoring guidance. The Homarr MCP prompt gives connected agents a retrieval workflow: read the live schema, then fetch only the component documentation needed for the widget. +The portable skill gives concise authoring guidance. For connected agents, Homarr returns the complete `SKILL.md` and +all bundled skill references once in one lazy tool response. The Homarr MCP prompt then gives the agent a +retrieval workflow: read the live schema and fetch only the component documentation needed for the widget. The workbench's **Copy AI prompt** action is separate. It creates a self-contained prompt below 12,000 characters for an ordinary ChatGPT or Claude conversation. It includes the lean contract, bindings, recommended components, and two polished examples. It never tells an offline model to call MCP tools. @@ -37,38 +39,55 @@ Repository code cannot give this attribution. The authoring-prompt tool description contains one sentence. The prompt contains the workflow instructions and resource links. -`GET /api/custom-widgets/skill` and the MCP tool return the concise skill discovery payload: +`GET /api/custom-widgets/skill` and `customWidget_getSkill` return a self-contained structured skill payload: ```ts { name: "homarr-custom-widget"; version: string; - skillMd: string; + skillMd: string; // Complete SKILL.md + references: Record; skillsShUrl: string; sourceUrl: string; installCommand: string; } ``` -The full installed component catalog stays available through `homarr://custom-widgets/components` and per-component resources. It is deliberately not embedded in the skill or clipboard prompt. +The `homarr://custom-widgets/skill` MCP resource returns the same `SKILL.md` and references once as bundled Markdown. + +The component catalog is a compact index of names, categories, safety levels, shared prop names, policies, and example +IDs. Per-component results contain only component-specific props and a reference to shared prop documentation. Use +`customWidget_getSharedProps` once with only the shared prop names needed across the selected components. This avoids +repeating hundreds of global props in every tool result. The catalog is deliberately not embedded in the skill or +clipboard prompt; the skill response still contains the complete authoring, schema, runtime, and security references. These interfaces do not return an archive URL or archive hash. ## Create a Custom Widget with MCP -1. Get the prompt, live schema, and component catalog. -2. Read the external API documentation. -3. Build one definition. -4. Validate the definition without saving it. -5. Create a preview session. -6. Test the queries and actions. -7. Open the preview URL with browser tools. -8. Inspect the redacted journal and revise the local candidate until it is valid. -9. Create the widget, including any source URLs and credentials the user already supplied. -10. Otherwise, ask the user to configure the saved definition with the ten-minute Homarr source-setup URL. -11. Create a final preview with the saved definition ID so Homarr loads its stored credentials, then test it again. -12. Update the saved definition if the final verification finds a problem. -13. Repeat the procedure for the next widget. +1. Load `customWidget_getSkill`, then get the prompt and live schema. +2. Call `customWidget_getComponentCatalog`, followed by `customWidget_getComponent` for only the relevant installed + components and `customWidget_getExample` when one catalog example matches the interaction pattern. If shared props + are needed, collect their catalog names and call `customWidget_getSharedProps` once for that set. +3. Read the external API documentation. +4. Build one definition, using `templateLines` for multiline JSX tool input. +5. Validate the definition without saving it and repair every reported issue. +6. Create a preview session. +7. Run `customWidget_previewQuery` for every query returned by the preview and inspect its actual status and data shape. +8. Test relevant actions in simulation mode. +9. Open the returned preview path with browser tools. +10. Inspect the redacted journal and revise the local candidate until validation, data, and rendering all work. +11. Create the widget, including any source URLs and credentials the user already supplied. +12. Open its returned management path, and use a structured question when the user must choose whether to place it on a board. +13. Otherwise, ask the user to configure the saved definition with the ten-minute Homarr source-setup URL. +14. Create a final preview with the saved definition ID so Homarr loads its stored credentials, then test it again. +15. Update the saved definition if the final verification finds a problem. `customWidget_update` also accepts + `templateLines` for multiline JSX without resetting omitted options. +16. Repeat the procedure for the next widget. + +Successful validation returns a compact summary instead of echoing the full widget and template. Preview creation +returns its session, links, and query checklist without repeating the submitted definition. Keep the candidate locally +and reuse it for the next lifecycle step. Each source-configuration URL applies to one definition or preview and one source. It is short-lived, single-use, and can only be opened and completed by an authenticated Homarr administrator. The administrator confirms the server URL, diff --git a/apps/docs/docs/management/custom-widgets/index.mdx b/apps/docs/docs/management/custom-widgets/index.mdx index 508dde70c..5d054fe89 100644 --- a/apps/docs/docs/management/custom-widgets/index.mdx +++ b/apps/docs/docs/management/custom-widgets/index.mdx @@ -40,6 +40,12 @@ interface HomarrCustomWidgetV2 { } ``` +### Design with AI + +The **Design with AI** action is available while creating or editing a custom widget. Describe what the widget should show and, when [Homarr Assistant](../assistant) is enabled, send the request directly from the form. The assistant receives the widget schema, current non-secret configuration, and latest test response, then proposes the matching create or update tool for your approval. + +Run **Test** first when possible so the prompt includes the endpoint's real response shape. Saved secrets are never added to the prompt. **Copy to clipboard** remains available when the Assistant is disabled or when you prefer another AI service. + " } @@ -73,7 +82,7 @@ Replace `` with your actual API key. Never commit API keys to vers "mcpServers": { "homarr": { "command": "npx", - "args": ["-y", "mcp-remote", "https://your-homarr-instance.com/api/mcp/mcp", "--header", "ApiKey:"] + "args": ["-y", "mcp-remote", "https://your-homarr-instance.com/api/mcp", "--header", "ApiKey:"] } } } @@ -96,6 +105,10 @@ Custom Widget and Workshop authoring are different: their tools, prompts, and re key owner is a Homarr administrator. Non-administrator keys cannot discover or invoke those capabilities. Creating an API key never elevates the owner. +OAuth authorization and token requests bind issued credentials to the canonical `/api/mcp` resource. Dynamic client +registration remains available for compatibility with current MCP clients while the ecosystem transitions toward +pre-registered clients and Client ID Metadata Documents. + ## Testing the Connection Verify your MCP endpoint is working: @@ -103,13 +116,17 @@ Verify your MCP endpoint is working: ```bash curl -s -X POST \ -H "Content-Type: application/json" \ - -H "Accept: application/json, text/event-stream" \ + -H "Accept: application/json" \ -H "ApiKey: " \ - -d '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}},"id":1}' \ - https://your-homarr-instance.com/api/mcp/mcp + -H "Mcp-Method: server/discover" \ + -H "Mcp-Name: homarr" \ + -d '{"jsonrpc":"2.0","method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"test","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}},"id":1}' \ + https://your-homarr-instance.com/api/mcp ``` -A successful response includes the server capabilities and available tools. Use `tools/list` to see all available tools: +A successful response includes the supported protocol versions and server capabilities. Use `tools/list` to see all +available tools. MCP v2 clients add the per-request metadata and `Mcp-Method` / `Mcp-Name` headers automatically; the +abbreviated request below uses the stateless 2025 compatibility path for readability: ```bash curl -s -X POST \ @@ -117,33 +134,36 @@ curl -s -X POST \ -H "Accept: application/json, text/event-stream" \ -H "ApiKey: " \ -d '{"jsonrpc":"2.0","method":"tools/list","id":1}' \ - https://your-homarr-instance.com/api/mcp/mcp + https://your-homarr-instance.com/api/mcp ``` +Tool catalogs are returned in deterministic order and may be cached privately by the client for five minutes. The +removed HTTP+SSE `/sse` and `/message` transports return `410 Gone`; use the single Streamable HTTP endpoint instead. + ## Available Tools Homarr exposes 50+ tools organized by namespace. Each tool is either a **query** (GET, read-only) or **mutation** (POST, write action). -| Namespace | Example Tools | Type | -| ------------------ | --------------------------------------------------------- | ---------- | -| `app` | List all apps, get app by ID, create/update/delete app | GET + POST | -| `apiKeys` | List API keys | GET | -| `beszel` | Systems status, alerts, historical system metrics | GET | -| `board` | List boards, get board by ID, create/update/delete board | GET + POST | -| `docker` | List containers, inspect container, start/stop/restart | GET + POST | -| `icon` | Search icons | GET | -| `info` | Server health, version info | GET | -| `integration` | List integrations, test connection, delete integration | GET + POST | -| `invite` | List invitations, delete invite | GET + POST | -| `calendar` | Calendar events from media servers | GET | -| `customWidget` | Validate, preview, create, and iteratively update widgets | GET + POST | -| `dnsHole` | DNS hole summary, toggle blocking | GET + POST | -| `downloads` | Active downloads, pause/resume/delete | GET + POST | -| `healthMonitoring` | Resource usage stats | GET | -| `patchmon` | Host patch and security update statistics | GET | -| `mediaRequests` | Approve/decline media requests | GET + POST | -| `mediaServer` | Media server sessions and stats | GET | -| `smartHome` | Execute automations, device states | GET + POST | +| Namespace | Example Tools | Type | +| ------------------ | ---------------------------------------------------------- | ---------- | +| `app` | List all apps, get app by ID, create/update/delete app | GET + POST | +| `apiKeys` | List API keys | GET | +| `beszel` | Systems status, alerts, historical system metrics | GET | +| `board` | List boards, read settings/CSS, create/update/delete board | GET + POST | +| `docker` | List containers, inspect container, start/stop/restart | GET + POST | +| `icon` | Search icons | GET | +| `info` | Server health, version info | GET | +| `integration` | List integrations, test connection, delete integration | GET + POST | +| `invite` | List invitations, delete invite | GET + POST | +| `calendar` | Calendar events from media servers | GET | +| `customWidget` | Validate, preview, create, and iteratively update widgets | GET + POST | +| `dnsHole` | DNS hole summary, toggle blocking | GET + POST | +| `downloads` | Active downloads, pause/resume/delete | GET + POST | +| `healthMonitoring` | Resource usage stats | GET | +| `patchmon` | Host patch and security update statistics | GET | +| `mediaRequests` | Approve/decline media requests | GET + POST | +| `mediaServer` | Media server sessions and stats | GET | +| `smartHome` | Execute automations, device states | GET + POST | The tools table on the MCP tab is dynamically generated - it always reflects the latest available procedures from your Homarr instance. @@ -175,21 +195,36 @@ An agent creates one `homarr-custom-widget-v2` definition at a time. Start with The API key owner must be a Homarr administrator. The same rule applies to management, import, migration, preview, secrets, publication, Workshop installation, and the HTTP authoring resources. -Then, read the live schema and component resources. These resources describe the installed Mantine components. +Load the installed skill before authoring. `customWidget_getSkill` returns the complete `SKILL.md` entrypoint and every +bundled reference in one response, so an MCP client does not need filesystem access or a second documentation fetch. +Then read the live schema and only the component resources required by the design. These resources describe the +installed Mantine components. -1. Call `customWidget_getAuthoringPrompt` and `customWidget_schema`. -2. Build one Custom Widget definition. -3. Call `customWidget_validate` without saving the definition. -4. Call `customWidget_previewCreate`. -5. Test named requests with `customWidget_previewQuery` and `customWidget_previewAction`. -6. Open the returned preview URL. -7. Examine `customWidget_previewJournal`. -8. Revise the local candidate and create another preview session until it is ready. -9. Call `customWidget_create`, supplying any credentials and deployment-specific source URLs the user already provided. -10. If the user must enter the source URL or credentials, call `customWidget_configurationRequestUser` with the new definition ID and wait for completion. -11. Create a final preview with that definition ID so Homarr loads its stored credentials, then test it again. -12. Use `customWidget_update` or `customWidget_templatePatch` for any final saved-definition corrections. -13. Repeat this procedure for the next Custom Widget. +1. Call `customWidget_getSkill`, `customWidget_getAuthoringPrompt`, `customWidget_schema`, and + `customWidget_getComponentCatalog`. +2. Fetch only the relevant installed component documentation with `customWidget_getComponent` and, when useful, + one interaction example with `customWidget_getExample`. Component results contain component-specific props; collect + any needed shared prop names from the compact catalog and fetch them together once with `customWidget_getSharedProps`. +3. Build one Custom Widget definition. Use `templateLines` for generated multiline JSX to avoid a fragile JSON-escaped + template string. +4. Call `customWidget_validate` without saving the definition and repair every returned issue path. +5. Call `customWidget_previewCreate`. +6. Call `customWidget_previewQuery` for every entry in the returned `queries` list. Supply the listed invocation + parameters for manual queries and inspect the real HTTP status and response shape before binding template fields. +7. Test relevant actions with `customWidget_previewAction` in simulation mode. +8. Open the returned `previewPath` or `previewUrl`. +9. Examine `customWidget_previewJournal`. +10. Revise the local candidate and create another preview session until validation, query data, actions, and rendering + are ready. +11. Call `customWidget_create`, supplying any credentials and deployment-specific source URLs the user already provided. +12. Open the returned `managementPath` to review the exact saved definition. +13. If the user must enter the source URL or credentials, call `customWidget_configurationRequestUser` with the new definition ID and wait for completion. +14. Create a final preview with that definition ID so Homarr loads its stored credentials, then test it again. +15. Use `customWidget_update` or `customWidget_templatePatch` for any final saved-definition corrections. +16. Repeat this procedure for the next Custom Widget. + +Validation and preview creation return compact lifecycle metadata. They do not echo the submitted widget or duplicate +its template as both a string and line array; the agent should retain and reuse its local candidate. The agent receives the configuration-request status. The agent does not receive plaintext credentials. A preview-scoped setup URL configures only that temporary preview; use a definition-scoped URL when the values must persist. @@ -225,6 +260,10 @@ The Custom Widget tools are: - `customWidget_workshopGet` - `customWidget_workshopInstall` - `customWidget_getAuthoringPrompt` +- `customWidget_getComponentCatalog` +- `customWidget_getComponent` +- `customWidget_getSharedProps` +- `customWidget_getExample` - `customWidget_getSkill` Custom Widget permissions are not assignable group capabilities. Administrator status is required for the complete diff --git a/apps/docs/docs/widgets/assistant/index.mdx b/apps/docs/docs/widgets/assistant/index.mdx new file mode 100644 index 000000000..2a4e22645 --- /dev/null +++ b/apps/docs/docs/widgets/assistant/index.mdx @@ -0,0 +1,59 @@ +--- +title: "Assistant" +description: "Have a complete assistant conversation directly on a board." +hide_title: true +--- + +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { assistantWidget } from "."; + + + +The Assistant widget brings the same conversation available from the user menu and command menu onto a +board. Messages, conversations, tool calls, approvals, files, mentions, model controls, reasoning controls, +usage details, and response actions remain synchronized with the app-wide assistant. + +The assistant must be enabled by an administrator in **Management → Assistant**. Signed-out users and users +on an instance without an enabled model see an unavailable state instead of a broken composer. + +### Adding the widget + + + +The default widget size is ten columns by four rows and automatically fits boards with fewer columns. Controls +and labels condense as the widget becomes narrower, while the conversation remains scrollable and usable on +touch devices. When a board layout does not provide enough height for conversation controls, the widget shows +a compact **Open assistant** action instead of squeezing an unusable composer into the card. Increase its height +in that layout to keep the complete conversation inline. + +### Configuration + + + +**Current conversation** follows whichever conversation is active in the user-menu panel, `Shift + A`, or +the command menu. This is the default and keeps every assistant entry point synchronized. + +**Pinned conversation** reveals a searchable conversation selector. A pinned widget never changes the +active conversation silently: select **Open conversation** inside the widget before continuing that thread. + +### Conversation features + +- Create, switch, rename, and delete conversations. +- Send text and supported file or image attachments by picker, drag and drop, or image paste. +- Mention permitted apps, integrations, boards, widgets, and tools with `@`. +- Use `/` shortcuts for common dashboard tasks. +- Review tool calls, approve mutations, and answer structured assistant questions inline. +- Answer pending structured questions from the dock above the composer without scrolling through the conversation. +- Enable conversation-scoped automatic approvals from the shield control to approve protected mutations, use presented form details, and choose answers categorized as affirmative without individual clicks. +- Edit user messages and copy, regenerate, branch, quote, export, or rate responses. +- Inspect syntax-highlighted Markdown, sources, generated files, tool results, streaming chain-of-thought, + cumulative conversation usage, per-turn token and context use, timing, speed, provider routing, and cost. +- Change the model and reasoning effort for the active conversation. +- Refresh the current board from the conversation header. Approved mutations also refresh visible board data automatically. + +The expanded assistant and board widget use the same persisted thread runtime. A response continues in the +background when the widget leaves the viewport or the expanded panel is minimized. Each visible surface keeps an +independent draft, attachments, selection, and focus, so typing in a board widget cannot move or overwrite the +floating panel composer. diff --git a/apps/docs/docs/widgets/assistant/index.ts b/apps/docs/docs/widgets/assistant/index.ts new file mode 100644 index 000000000..d465261bf --- /dev/null +++ b/apps/docs/docs/widgets/assistant/index.ts @@ -0,0 +1,25 @@ +import type { WidgetDefinition } from "@site/src/types"; +import { IconRobot } from "@tabler/icons-react"; + +export const assistantWidget: WidgetDefinition = { + icon: IconRobot, + name: "Assistant", + description: "Have a complete assistant conversation directly on a board.", + path: "../../widgets/assistant", + configuration: { + items: [ + { + name: "Conversation", + description: "Follow the active conversation or attach the widget to one persisted conversation.", + values: { type: "select", options: ["Current conversation", "Pinned conversation"] }, + defaultValue: "Current conversation", + }, + { + name: "Pinned conversation", + description: "Search for a conversation. This field is hidden when Current conversation is selected.", + values: { type: "string" }, + defaultValue: "", + }, + ], + }, +}; diff --git a/apps/docs/docs/widgets/calendar/index.mdx b/apps/docs/docs/widgets/calendar/index.mdx index b3ff3ec87..c6b7397a6 100644 --- a/apps/docs/docs/widgets/calendar/index.mdx +++ b/apps/docs/docs/widgets/calendar/index.mdx @@ -26,6 +26,8 @@ import { icalIntegration } from "@site/docs/integrations/ical"; The calendar widget shows a month view of events from different integrations. It allows you to view calendar events or upcoming releases from your media managers. +Calendar requests use normal human month numbers (`1` for January through `12` for December), including when +the same data is requested through Homarr's MCP or Assistant tools. ### Screenshots diff --git a/apps/docs/docs/widgets/docker-containers/index.mdx b/apps/docs/docs/widgets/docker-containers/index.mdx index 397520a7e..1959ca959 100644 --- a/apps/docs/docs/widgets/docker-containers/index.mdx +++ b/apps/docs/docs/widgets/docker-containers/index.mdx @@ -4,8 +4,6 @@ description: "Stats of your containers" hide_title: true --- -import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; - import { WidgetHeader } from "@site/src/components/widgets/header"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; @@ -44,8 +42,6 @@ import dockerTable from "./img/table.png"; Hover a column header to reveal its grab handle, then drag it to reorder the table or drag the header's edge to resize it. The layout is saved automatically for each widget instance and restored after a reload. These controls are disabled while the board is in edit mode. -Advanced mode exposes every available column regardless of the compact-mode column selection. - Each container row has an action menu, accessible via the three-dot icon or by right-clicking the row, with the following options: - **View logs** - opens a dedicated terminal page streaming the container's logs @@ -53,8 +49,6 @@ Each container row has an action menu, accessible via the three-dot icon or by r - **Remove** - permanently remove the container after a second confirmation click - **Add to Homarr** - create a bookmark app from the container - - ### Adding the widget diff --git a/apps/docs/static/api/open-api-schema.json b/apps/docs/static/api/open-api-schema.json index 20da7a300..ba82a95e0 100644 --- a/apps/docs/static/api/open-api-schema.json +++ b/apps/docs/static/api/open-api-schema.json @@ -2151,7 +2151,8 @@ "beszelAlerts", "beszelSystemStats", "traefik", - "customApi" + "customApi", + "assistant" ] }, "options": { @@ -2164,6 +2165,7 @@ }, "integrationIds": { "default": [], + "maxItems": 32, "type": "array", "items": { "type": "string" diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index 30afbf34e..d58933e42 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -14,6 +14,11 @@ "with-env": "dotenv -e ../../.env --" }, "dependencies": { + "@ai-sdk/openai-compatible": "catalog:", + "@assistant-ui/react": "catalog:", + "@assistant-ui/react-ai-sdk": "catalog:", + "@assistant-ui/react-lexical": "catalog:", + "@assistant-ui/react-markdown": "catalog:", "@dnd-kit/abstract": "catalog:", "@dnd-kit/collision": "catalog:", "@dnd-kit/core": "catalog:", @@ -62,7 +67,7 @@ "@mantine/hooks": "catalog:", "@mantine/modals": "catalog:", "@mantine/tiptap": "catalog:", - "@modelcontextprotocol/sdk": "catalog:", + "@modelcontextprotocol/server": "catalog:", "@scalar/api-reference-react": "catalog:", "@tabler/icons-react": "catalog:", "@tanstack/react-devtools": "catalog:", @@ -77,6 +82,8 @@ "@xterm/addon-fit": "catalog:", "@xterm/xterm": "catalog:", "adm-zip": "catalog:", + "ai": "catalog:", + "assistant-stream": "catalog:", "babel-plugin-react-compiler": "catalog:", "better-sqlite3": "catalog:", "chroma-js": "catalog:", @@ -92,7 +99,7 @@ "lucide-react": "catalog:", "mantine-datatable": "catalog:", "mantine-react-table": "catalog:", - "mcp-handler": "catalog:", + "mermaid": "catalog:", "next": "catalog:", "postcss-preset-mantine": "catalog:", "posthog-js": "catalog:", @@ -100,11 +107,14 @@ "react": "catalog:", "react-dom": "catalog:", "react-error-boundary": "catalog:", + "react-shiki": "catalog:", "react-simple-code-editor": "catalog:", + "remark-breaks": "catalog:", + "remark-directive": "catalog:", + "remark-gfm": "catalog:", "sass": "catalog:", "sql.js": "catalog:", "superjson": "catalog:", - "trpc-to-mcp": "catalog:", "use-deep-compare-effect": "catalog:", "yaml": "catalog:", "zod": "catalog:" diff --git a/apps/nextjs/src/app/.well-known/oauth-authorization-server/route.ts b/apps/nextjs/src/app/.well-known/oauth-authorization-server/route.ts index c400b4020..43b107e2c 100644 --- a/apps/nextjs/src/app/.well-known/oauth-authorization-server/route.ts +++ b/apps/nextjs/src/app/.well-known/oauth-authorization-server/route.ts @@ -22,6 +22,7 @@ export async function GET() { code_challenge_methods_supported: ["S256"], token_endpoint_auth_methods_supported: ["none"], scopes_supported: ["mcp:tools"], + resource_indicators_supported: true, }, { headers: corsHeaders }, ); diff --git a/apps/nextjs/src/app/.well-known/oauth-protected-resource/route.ts b/apps/nextjs/src/app/.well-known/oauth-protected-resource/route.ts index ca66ee1a3..eb5f22e5a 100644 --- a/apps/nextjs/src/app/.well-known/oauth-protected-resource/route.ts +++ b/apps/nextjs/src/app/.well-known/oauth-protected-resource/route.ts @@ -13,7 +13,7 @@ export async function GET() { return Response.json( { - resource: baseUrl, + resource: `${baseUrl}/api/mcp`, authorization_servers: [baseUrl], }, { headers: corsHeaders }, diff --git a/apps/nextjs/src/app/[locale]/auth/login/_login-form.tsx b/apps/nextjs/src/app/[locale]/auth/login/_login-form.tsx index 8d36d1e79..47b8a7f7f 100644 --- a/apps/nextjs/src/app/[locale]/auth/login/_login-form.tsx +++ b/apps/nextjs/src/app/[locale]/auth/login/_login-form.tsx @@ -4,7 +4,7 @@ import type { PropsWithChildren } from "react"; import { useCallback, useEffect, useRef, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { Anchor, Button, Card, Code, Collapse, Divider, PasswordInput, Stack, Text, TextInput } from "@mantine/core"; -import { useDisclosure } from "@mantine/hooks"; +import { useDisclosure, useMounted } from "@mantine/hooks"; import { z } from "zod/v4"; import { signIn } from "@homarr/auth/client"; @@ -32,6 +32,7 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c const searchParams = useSearchParams(); const isError = searchParams.has("error"); const router = useRouter(); + const isMounted = useMounted(); const [isPending, setIsPending] = useState(false); const form = useZodForm(extendedValidation, { initialValues: { @@ -132,7 +133,7 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c {providers.includes("credentials") && ( - + {t("action.login.label")} @@ -140,7 +141,7 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c )} {providers.includes("ldap") && ( - + {t("action.login.labelWith", { provider: "LDAP" })} )} @@ -151,7 +152,7 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c )} {providers.includes("oidc") && ( - )} @@ -161,12 +162,13 @@ export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, c }; interface SubmitButtonProps { + isMounted: boolean; isPending: boolean; form: UseFormReturnType; provider: "credentials" | "ldap"; } -const SubmitButton = ({ isPending, form, provider, children }: PropsWithChildren) => { +const SubmitButton = ({ isMounted, isPending, form, provider, children }: PropsWithChildren) => { const isCurrentProviderActive = form.getValues().provider === provider; return ( @@ -176,7 +178,7 @@ const SubmitButton = ({ isPending, form, provider, children }: PropsWithChildren fullWidth onClick={() => form.setFieldValue("provider", provider)} loading={isPending && isCurrentProviderActive} - disabled={isPending && !isCurrentProviderActive} + disabled={!isMounted || (isPending && !isCurrentProviderActive)} > {children} diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_client.module.css b/apps/nextjs/src/app/[locale]/boards/(content)/_client.module.css index 158ad16ae..8514e86d9 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_client.module.css +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.module.css @@ -9,14 +9,48 @@ } .gutter { - position: relative; - top: 0; + --board-gutter-sticky-top: calc((var(--app-shell-header-offset, 0px) + 1rem) * var(--board-canvas-inverse-scale, 1)); + --board-gutter-viewport-height: calc( + (100dvh - var(--app-shell-header-offset, 0px) - 1rem) * var(--board-canvas-inverse-scale, 1) + ); + + position: sticky; + top: var(--board-gutter-sticky-top); align-self: start; + box-sizing: border-box; + height: var(--board-gutter-viewport-height); padding-top: 24px; margin-top: -24px; - max-height: calc(100dvh - var(--app-shell-header-offset, 0px) - var(--mantine-spacing-md)); + overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; - transform: translateY(var(--board-gutter-sticky-offset, 0px)); +} + +.gutter[data-board-editing="true"] { + outline: 1px dashed var(--mantine-color-default-border); + outline-offset: -1px; + border-radius: var(--mantine-radius-md); +} + +.gutter[data-board-editing="true"]::before { + position: sticky; + top: 0; + inset-inline-start: 0; + z-index: 2; + display: block; + width: fit-content; + height: 24px; + padding-inline: var(--mantine-spacing-xs); + margin-top: -24px; + overflow: hidden; + font-size: var(--mantine-font-size-xs); + line-height: 24px; + color: var(--mantine-color-dimmed); + text-overflow: ellipsis; + white-space: nowrap; + pointer-events: none; + content: attr(aria-label); + background: color-mix(in srgb, var(--mantine-color-body) 88%, transparent); + border-radius: var(--mantine-radius-sm); } diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx index e77a9fa6b..7c3276d9b 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx @@ -1,11 +1,12 @@ "use client"; -import { useEffect, useState } from "react"; -import { Box } from "@mantine/core"; +import { Box, Paper, Stack, Text } from "@mantine/core"; -import { useCurrentLayout, useInitialViewportWidth, useRequiredBoard } from "@homarr/boards/context"; +import { useCurrentLayout, useInitialViewportWidth, useLayoutOverride, useRequiredBoard } from "@homarr/boards/context"; +import { useEditMode } from "@homarr/boards/edit-mode"; import { useScopedI18n } from "@homarr/translation/client"; +import { getRepresentativeLayoutWidth } from "../_layout-utils"; import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context"; import { getBoardLaneColumnCount, @@ -24,66 +25,14 @@ import classes from "./_client.module.css"; const APP_SHELL_INLINE_PADDING = 32; -const useFixedBoardGutters = () => { - const [columns, setColumns] = useState(null); - - useEffect(() => { - if (!columns) return; - - let frame = 0; - const update = () => { - frame = 0; - const columnsRect = columns.getBoundingClientRect(); - const headerBottom = document.querySelector("[data-app-shell-header]")?.getBoundingClientRect().bottom ?? 0; - const stickyTop = Math.max(0, headerBottom + 16); - - for (const gutter of columns.querySelectorAll("[data-board-gutter]")) { - const rect = gutter.getBoundingClientRect(); - if (gutter.offsetWidth <= 0) continue; - - const effectiveScale = rect.width / gutter.offsetWidth; - if (!Number.isFinite(effectiveScale) || effectiveScale <= 0) continue; - - const previousOffset = Number(gutter.dataset.stickyOffset ?? 0); - const uncorrectedTop = rect.top - previousOffset * effectiveScale; - const maximumTop = columnsRect.bottom - rect.height; - const targetTop = Math.min(stickyTop, maximumTop); - const nextOffset = Math.max(0, targetTop - uncorrectedTop) / effectiveScale; - - gutter.dataset.stickyOffset = String(nextOffset); - gutter.style.setProperty("--board-gutter-sticky-offset", `${nextOffset}px`); - } - }; - const scheduleUpdate = () => { - if (frame !== 0) return; - frame = window.requestAnimationFrame(update); - }; - - const resizeObserver = new ResizeObserver(scheduleUpdate); - resizeObserver.observe(columns); - document.addEventListener("scroll", scheduleUpdate, { capture: true, passive: true }); - window.addEventListener("scroll", scheduleUpdate, { passive: true }); - window.addEventListener("resize", scheduleUpdate, { passive: true }); - scheduleUpdate(); - - return () => { - window.cancelAnimationFrame(frame); - resizeObserver.disconnect(); - document.removeEventListener("scroll", scheduleUpdate, { capture: true }); - window.removeEventListener("scroll", scheduleUpdate); - window.removeEventListener("resize", scheduleUpdate); - }; - }, [columns]); - - return setColumns; -}; - export const ClientBoard = () => { const board = useRequiredBoard(); const t = useScopedI18n("board.landmark"); + const tPreview = useScopedI18n("board.setting.section.layout.preview"); + const [isEditMode] = useEditMode(); const currentLayoutId = useCurrentLayout(); const initialViewportWidth = useInitialViewportWidth(); - const columnsRef = useFixedBoardGutters(); + const layoutOverrideId = useLayoutOverride(); const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId) ?? board.layouts.at(0); if (!currentLayout) throw new Error("Expected the board to contain a layout"); @@ -101,7 +50,8 @@ export const ClientBoard = () => { const logicalWidth = laneWidths.reduce((total, width) => total + width, 0) + (laneWidths.length - 1) * LOGICAL_GRID_GAP; const initialLogicalHeight = getInitialBoardLogicalHeight(board, currentLayoutId) + LOGICAL_GRID_GAP; - const initialAvailableWidth = Math.max(1, initialViewportWidth - APP_SHELL_INLINE_PADDING); + const representativeWidth = layoutOverrideId ? getRepresentativeLayoutWidth(currentLayout, board.layouts) : null; + const initialAvailableWidth = Math.max(1, (representativeWidth ?? initialViewportWidth) - APP_SHELL_INLINE_PADDING); const gridTemplateColumns = [ leftColumnCount > 0 ? `${getLogicalTrackSize(leftColumnCount)}px` : null, `${getLogicalTrackSize(mainColumnCount)}px`, @@ -110,7 +60,7 @@ export const ClientBoard = () => { .filter((value) => value !== null) .join(" "); - return ( + const content = ( @@ -124,16 +74,13 @@ export const ClientBoard = () => { > -
+
{leftColumnCount > 0 && leftSection && (