On latest `dev`, the chat that is currently open is indicated **only** by a background tint: `bg-black/[0.035]` in light mode and `dark:bg-white/[0.045]` in dark.
Against the page background that is **1.07:1** in light and **1.05:1** in dark. It is close to imperceptible for sighted users, and it carries no programmatic state at all, so assistive technology has no way to tell which entry in the list is the one being viewed.
Breaks WCAG 1.4.1 Use of Color (Level A), since the state is conveyed by colour alone, and 4.1.2 Name, Role, Value (Level A), since the state is not exposed.
Fix: set `aria-current="page"` on the chat link when it is the open chat, using the same `id === $chatId` condition that already drives the visual highlight, so the two cannot drift apart. `'page'` is the correct token because the trigger is a real navigation to `/c/{id}`.
This matches the existing pattern in `routes/(app)/workspace/+layout.svelte` and `chat/Placeholder/ChatList.svelte`, which already set `aria-current` for their active entries.
Verified that the bits-ui `LinkPreview.Trigger` forwards unknown attributes to the rendered anchor and does not set `aria-current` itself, so the attribute reaches the DOM.
This does not change the visual contrast of the highlight, which is worth addressing separately.
Severity: Serious. In a long chat list there is no reliable way to tell which chat is open.
### Contributor License Agreement
<!--
🚨 DO NOT DELETE THE TEXT BELOW 🚨
Keep the "Contributor License Agreement" confirmation text intact.
Deleting it will trigger the CLA-Bot to INVALIDATE your PR.
Your PR will NOT be reviewed or merged until you check the box below confirming that you have read and agree to the terms of the CLA.
-->
- [x] By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](https://github.com/open-webui/open-webui/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.
> [!NOTE]
> Deleting the CLA section will lead to immediate closure of your PR and it will not be merged in.
On latest `dev`, the sidebar folder row is a bare `<div>` carrying `on:click` (navigate into the folder) and `on:dblclick` (rename). It has **no `role`, no `tabindex` and no key handler**, so opening a folder is impossible from the keyboard.
The nested chevron `<button>` is focusable, but it only expands the folder in place, it does not navigate to it, so there is no keyboard route to the folder page at all.
Breaks WCAG 2.1.1 Keyboard (Level A) and 4.1.2 Name, Role, Value (Level A). The Svelte compiler already flags this file with `a11y_click_events_have_key_events`; after this change the component compiles with zero a11y warnings.
Fix: apply the row pattern already used elsewhere in this codebase (`workspace/Prompts.svelte`, `workspace/Knowledge.svelte`, `admin/Functions.svelte`), namely `role="button"`, `tabindex="0"` and a keydown handler for Enter and Space, with the same `e.currentTarget !== e.target` guard and the same `shouldIgnoreRowClick` helper those files use.
That guard matters more here than in the files it was copied from: the rename `<input>` is rendered **inside** this row, so without it typing a space in the rename field would be swallowed and navigate away, and Enter would both save the rename and navigate.
The navigation body is extracted to `openFolderHandler` because it now has two callers. The keyboard path calls it directly rather than through the 100ms `clickTimer`, which exists only to disambiguate single from double click and has no keyboard equivalent.
A dead `(e) => e.stopPropagation();` expression statement in the click handler is removed. It allocated an arrow function and discarded it without ever calling it.
The `…` folder menu is still `invisible group-hover:visible` and therefore unreachable, so rename, share, delete, export and new subfolder remain keyboard-inaccessible until that is addressed. That is fixed repo wide in a separate PR that replaces the `invisible group-hover:visible` pattern, so it is deliberately not touched here to avoid conflicting on the same line.
Folder reparenting by drag still has no keyboard alternative, which is a separate WCAG 2.5.7 issue needing a "Move" menu action.
Severity: Critical. Folders cannot be opened without a pointing device.
### Contributor License Agreement
<!--
🚨 DO NOT DELETE THE TEXT BELOW 🚨
Keep the "Contributor License Agreement" confirmation text intact.
Deleting it will trigger the CLA-Bot to INVALIDATE your PR.
Your PR will NOT be reviewed or merged until you check the box below confirming that you have read and agree to the terms of the CLA.
-->
- [x] By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](https://github.com/open-webui/open-webui/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.
> [!NOTE]
> Deleting the CLA section will lead to immediate closure of your PR and it will not be merged in.
Co-authored-by: Tim Baek <tim@openwebui.com>
* fix: block external resource loading in Vega chart rendering to prevent client-side SSRF
renderVegaVisualization renders vega/vega-lite chart specs that appear in untrusted chat content (shared chats, channel messages, assistant/RAG/tool output) by constructing a Vega View with no restricted loader, so a crafted spec could make a viewer's browser issue arbitrary outbound requests. There are two paths: data.url (and topojson/geo data) is fetched via loader.load at view construction, and image-mark urls are resolved via loader.sanitize and emitted as <image href> into the output SVG, fetched by the browser when the SVG is displayed. Both are client-side SSRF, and against same-origin or CORS-permissive targets allow reading the response back into the page. Pass a loader that rejects external resource loads on both paths, load throws and sanitize rejects http(s)/protocol-relative URIs, so rendered charts can only use inline data. Inline data.values charts are unaffected.
Co-authored-by: Zureno <Zureno@users.noreply.github.com>
* fix: resolve Vega image urls with the URL parser before blocking external loads
The previous scheme regex could be bypassed with encodings the browser URL parser
normalizes away, such as a leading tab or newline before the scheme and backslash
variants of protocol-relative urls like /\evil.com, which would still be emitted
into the rendered SVG and fetched externally on display. Resolve the uri against
document.baseURI with the browser's own URL parser and only allow data: uris and
same-origin results, so the check cannot diverge from what the browser would
actually fetch. Also shortens the explanatory comments.
---------
Co-authored-by: Zureno <Zureno@users.noreply.github.com>
KatexRenderer rendered the raw math source through {@html} whenever renderToString threw. throwOnError only suppresses KaTeX ParseError, so a RangeError (maximum call stack size exceeded, reachable with deeply-nested brace input) escaped into the catch and re-exposed the unescaped source. Because the math tokenizer captures everything between the delimiters verbatim, that source can carry an HTML/JS payload which then executed in the viewer's browser on the application origin, a stored, cross-user XSS reachable through normal chat/channel/shared-chat rendering. Escape the fallback so the source is shown as text and is never injected as HTML. Valid math is unaffected, it still renders through the success path.
Co-authored-by: maxntv <maxntv@users.noreply.github.com>
ConfirmDialog trapped focus and closed on Escape but its container was a plain
div, so screen readers did not announce it as a modal dialog. Its text input
also had only a placeholder, giving no persistent accessible name. Add
role=dialog / aria-modal / aria-label / tabindex to the dialog surface and an
aria-label to the textarea.
Relates to #2790
Co-authored-by: Tim Baek <tim@openwebui.com>
Adds a multiselect input type for Valves and UserValves so plugin authors can let users pick multiple values from static or runtime-resolved options instead of maintaining comma-separated text fields with hardcoded allowed-value lists in the description.
ENABLED_ITEMS: list[str] = Field(
default=["foo"],
json_schema_extra={"input": {"type": "multiselect", "options": "get_item_options"}},
)
@classmethod
def get_item_options(cls):
return [{"value": "foo", "label": "Foo"}, {"value": "bar", "label": "Bar"}]
Options accept the same shapes as the existing select input: either a static list (strings or {value, label} dicts) or a classmethod name resolved at request time (including __user__ context for UserValves). No backend changes are needed because resolve_valves_schema_options already resolves options independently of the input type.
The new MultiSelect component follows the existing Select portal dropdown pattern and renders checkbox rows that stay open while toggling, with the selected labels shown in the trigger. Values bind as a real string array end to end: the array-to-comma-string conversions in the chat controls valves panel and the valves modal are skipped for multiselect fields, so the stored valve is a native list[str] validated by Pydantic.
Requested in #26848.
* chore: drop redundant background repaints so surfaces inherit their parent
Four spots repaint the exact color their parent surface already provides
(bg-white / dark:bg-gray-900 rows inside same-colored pages and modals,
and the selectClass dark repaint inside the connection modals — the
sibling input const is already fully transparent). Visually identical in
stock light and dark; removing them lets instance theming show through
instead of leaving opaque boxes:
- .tiptap tr (app.css) — table rows in notes/editors
- Edit User Group Users tab body rows (common Modal surface)
- AddToolServerModal + AddTerminalServerModal selectClass dark repaint
The matching repaints inside the ModelUsage/UserUsage components are
not part of this change — those files were dead code and were removed
entirely in #27574.
* chore: catch remaining redundant surface repaints missed in the first pass
Same rule as the previous commit — every one of these repaints the exact
color its parent surface already provides, so removal is stock-identical
in light and dark while letting instance theming show through:
- Analytics Dashboard's inline Model Usage / User Activity row markup
(the Analytics tab renders these tables from Dashboard.svelte itself;
the unreferenced ModelUsage/UserUsage component files were removed
in #27574)
- Evaluations Feedbacks + Leaderboard body rows (settings modal surface)
- admin UserList body rows (app page surface)
- chat markdown tables (MarkdownTokens): thead and body rows — unlike
the tiptap header (gray-850 contrast, untouched), this thead painted
the page's own color
- CitationsModal source rows (common Modal surface)
- AddConnectionModal selectClass dark repaint — third copy of the same
const already fixed in AddToolServerModal / AddTerminalServerModal
Nothing in the tree imports either component; the admin Analytics tab
renders its own inline copies of both tables directly from
Dashboard.svelte. Both files landed with the dashboard in a4ad34841
(feat: analytics frontend dashboard) but were never wired into it.
The remaining name matches elsewhere (the getUserUsage API and
UserUsage* types in src/lib/apis/users/index.ts, consumed by
chat/Settings/Usage.svelte, plus the backend usage endpoints) belong to
the unrelated per-user usage feature and are untouched.
* feat: add default upload mode setting
Add user setting to configure the default upload mode for files, allowing users to choose between "Using Entire Document" (full context) and "Using Focused Retrieval" (RAG processing) as the default behavior.
* i18n: sync locale catalogs for the new upload mode strings
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* chore: re-trigger CI (previous run hit the pre-existing Node heap OOM, see #27254)
* fix: apply the default upload mode at upload time so the payload carries it
The previous approach only pre-set the modal toggle's visual state on
mount; item.context is written solely by the Switch's on:change, so the
sent files kept context: undefined and the backend never saw 'full'. It
also showed a misleading ON state for legacy context-less files, since
FileItemModal mounts with every FileItem chip render.
Stamp context on the fileItem in uploadFileHandler instead (before
...itemData, so callers passing an explicit context still win) and
revert the FileItemModal hunk — the modal already renders from
item.context alone.
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args: free_disk:false name:main suffix:]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_CUDA=true
USE_CUDA_VER=cu126
free_disk:true name:cuda126 suffix:-cuda126]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_CUDA=true free_disk:true name:cuda suffix:-cuda]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_OLLAMA=true free_disk:false name:ollama suffix:-ollama]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/amd64 runner:ubuntu-latest], map[build_args:USE_SLIM=true free_disk:false name:slim suffix:-slim]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args: free_disk:false name:main suffix:]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_CUDA=true
USE_CUDA_VER=cu126
free_disk:true name:cuda126 suffix:-cuda126]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_CUDA=true free_disk:true name:cuda suffix:-cuda]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_OLLAMA=true free_disk:false name:ollama suffix:-ollama]) (push) Waiting to run
Create and publish Docker images with specific build args / build (map[arch:linux/arm64 runner:ubuntu-24.04-arm], map[build_args:USE_SLIM=true free_disk:false name:slim suffix:-slim]) (push) Waiting to run
On latest `dev`, the `Select` trigger sets `aria-label={placeholder}`. In the accessible name computation `aria-label` is evaluated before the element's contents, so on a button that renders visible text it **replaces** that text instead of adding to it.
The trigger's content is `selectedLabel`, which resolves to the selected item's label and only falls back to `placeholder` when nothing is selected. So a control visually reading "Week" is exposed to assistive technology as "Select view", permanently, no matter what is selected. The dropdown items expose no `aria-selected` either, the current one is marked with a check icon only, so there is no path by which a screen reader user can find out what the control is set to.
Breaks WCAG 2.5.3 Label in Name (Level A), because the accessible name does not contain the visible label, so voice control cannot target the control by what it says on screen. Also 4.1.2 Name, Role, Value (Level A), because the value is never exposed.
Fix: drop the overriding `aria-label` so the name is computed from the visible text, and expose `aria-expanded` so the open state is conveyed. All 7 call sites plus the `DropdownOptions` wrapper override `slot="trigger"` and every one of them renders `selectedLabel` (or `placeholder`) as text, so no trigger is left unnamed. The two call sites that pass no `placeholder` were already emitting an empty `aria-label`, which is skipped by the name computation, so they are unaffected.
`aria-haspopup` is deliberately not added: the popup is a plain `DropdownMenu` of buttons with no `listbox` role, so claiming one would misdescribe it.
`placeholder` is still used, it drives the `selectedLabel` fallback and `TagSelector` renders it directly.
Severity: Serious. Affects every custom select in Admin Settings, Workspace and the calendar and automations pages.
### Contributor License Agreement
<!--
🚨 DO NOT DELETE THE TEXT BELOW 🚨
Keep the "Contributor License Agreement" confirmation text intact.
Deleting it will trigger the CLA-Bot to INVALIDATE your PR.
Your PR will NOT be reviewed or merged until you check the box below confirming that you have read and agree to the terms of the CLA.
-->
- [x] By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](https://github.com/open-webui/open-webui/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.
> [!NOTE]
> Deleting the CLA section will lead to immediate closure of your PR and it will not be merged in.
On latest `dev`, the five sortable column headers in the admin Users table are click handling `<th>` elements:
```svelte
<th scope="col" class="px-2.5 py-1.5 font-normal cursor-pointer select-none" on:click={() => setSortKey('name')}>
```
A `<th>` is not interactive. There is no `<button>`, no `tabindex`, no `role` and no key handler, so **sorting the user list is impossible without a mouse**. The sort direction is also conveyed only by an 8×8 pixel chevron, with no programmatic state, so assistive technology cannot report which column is sorted or in which direction.
Breaks WCAG 2.1.1 Keyboard (Level A) and 4.1.2 Name, Role, Value (Level A).
Fix: move the click handler onto a real `<button>` inside the header, which brings native focus, Enter and Space activation and the correct role, and add `aria-sort` to the `<th>`, which already carries `scope="col"` and therefore the implicit `columnheader` role. Only the active column reports a direction, since `orderBy` is a single value; the non sortable actions column deliberately gets no `aria-sort` at all rather than `none`, so it is not advertised as sortable.
The cell padding moves from the `<th>` onto the button so the whole header stays clickable. Left on the `<th>`, the padding ring would have become a dead zone, shrinking the hit target and flipping the cursor at an invisible boundary inside the header.
`cursor-pointer` is dropped from the `<th>` because `src/tailwind.css` already applies it to every `button`.
The repeated `aria-sort` ternary is extracted to a small `sortState` helper rather than pasted five times.
The same mouse only `<th on:click>` pattern still exists in the Analytics, Evaluations and Groups tables and is not touched here.
Severity: Serious. A core admin function is unreachable without a pointing device.
### Contributor License Agreement
<!--
🚨 DO NOT DELETE THE TEXT BELOW 🚨
Keep the "Contributor License Agreement" confirmation text intact.
Deleting it will trigger the CLA-Bot to INVALIDATE your PR.
Your PR will NOT be reviewed or merged until you check the box below confirming that you have read and agree to the terms of the CLA.
-->
- [x] By submitting this pull request, I confirm that I have read and fully agree to the [Contributor License Agreement (CLA)](https://github.com/open-webui/open-webui/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT), and I am providing my contributions under its terms.
> [!NOTE]
> Deleting the CLA section will lead to immediate closure of your PR and it will not be merged in.
* fix: show download preparation toast and prevent duplicate zip jobs
Downloading a file or folder from the file navigator gave no feedback while the server prepared the response, which can take 30 seconds or more for large folders that are zipped server-side. Users assumed the click did nothing and pressed Download again, starting additional zip jobs on the server. Both downloadFile and bulkDownload, the two functions every download control funnels through, now show a persistent "Preparing download..." loading toast while the request is in flight and dismiss it once the download starts or fails. A shared downloading flag makes repeated clicks no-ops until the current download finishes, so a single click starts exactly one server-side job. Fixes#27055
* fix: report terminal download failures instead of dismissing the toast
A failed download dismissed the preparation toast without saying anything, which reads as the download silently disappearing. Both download paths now report the failure. The two helpers also declare a nullable return but could still reject once the response body started streaming, so an interrupted transfer escaped as an unhandled rejection and left the same silent dismissal. They now return null in that case, which also stops an interrupted preview from leaving its spinner running.
Every other navigation entry in the user menu (Settings, Admin Panel,
Archived Chats, Workspace, Notes, Automations, Playground, Sign Out)
collapses the sidebar on mobile after navigating, but the Calendar entry
was missing this handling, leaving the sidebar open over the Calendar
page on mobile. Add the same mobile guard used by the sibling entries.
When the same model is selected multiple times in a side-by-side chat,
each response is created with a distinct modelIdx (0,1,2,3) that
identifies its column. The backend now owns message persistence, but it
built the assistant placeholders without modelIdx, so the field was
never saved. On reload MultiResponseMessages groups responses by
modelIdx and falls back to grouping by model id when modelIdx is
missing; with duplicate models that fallback lumps every response into
each column, so all columns render the first response (and show a bogus
'1/N' pager).
Send modelIdx with each message_ids entry from the frontend and persist
it on the assistant placeholders in both the new-chat and existing-chat
paths. The message_ids list is now forwarded for every send (not just
multi-model ones) so single-column regenerations in a duplicate-model
chat also keep their column identity across reloads.