Compare commits

..

8 Commits
edge ... main

37 changed files with 312 additions and 106 deletions

View File

@@ -24,13 +24,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& install -m 0755 -d /etc/apt/keyrings \
&& curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" > /etc/apt/sources.list.d/docker.list \
&& curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg -o /etc/apt/keyrings/githubcli-archive-keyring.gpg \
&& chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" > /etc/apt/sources.list.d/github-cli.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin gh \
&& apt-get install -y --no-install-recommends gh \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# fd-find installs as fdfind on Debian/Ubuntu — symlink to fd

View File

@@ -6,7 +6,7 @@ A containerized development environment for working on Onyx.
- Ubuntu 26.04 base image
- Node.js 20, uv, Claude Code
- Docker CLI, GitHub CLI (`gh`)
- GitHub CLI (`gh`)
- Neovim, ripgrep, fd, fzf, jq, make, wget, unzip
- Zsh as default shell (sources host `~/.zshrc` if available)
- Python venv auto-activation
@@ -73,19 +73,6 @@ user has read/write access to the bind-mounted workspace:
To override the auto-detection, set `DEVCONTAINER_REMOTE_USER` before running
`ods dev up`.
## Docker socket
The container mounts the host's Docker socket so you can run `docker` commands
from inside. `ods dev` auto-detects the socket path and sets `DOCKER_SOCK`:
| Environment | Socket path |
| ----------------------- | ------------------------------ |
| Linux (rootless Docker) | `$XDG_RUNTIME_DIR/docker.sock` |
| macOS (Docker Desktop) | `~/.docker/run/docker.sock` |
| Linux (standard Docker) | `/var/run/docker.sock` |
To override, set `DOCKER_SOCK` before running `ods dev up`.
## Firewall
The container starts with a default-deny firewall (`init-firewall.sh`) that only allows outbound traffic to:

View File

@@ -3,7 +3,6 @@
"image": "onyxdotapp/onyx-devcontainer@sha256:12184169c5bcc9cca0388286d5ffe504b569bc9c37bfa631b76ee8eee2064055",
"runArgs": ["--cap-add=NET_ADMIN", "--cap-add=NET_RAW"],
"mounts": [
"source=${localEnv:DOCKER_SOCK},target=/var/run/docker.sock,type=bind",
"source=${localEnv:HOME}/.claude,target=/home/dev/.claude,type=bind",
"source=${localEnv:HOME}/.claude.json,target=/home/dev/.claude.json,type=bind",
"source=${localEnv:HOME}/.zshrc,target=/home/dev/.zshrc.host,type=bind,readonly",

View File

@@ -56,9 +56,10 @@ for domain in "${ALLOWED_DOMAINS[@]}"; do
done
done
# Detect host network
if [[ "${DOCKER_HOST:-}" == "unix://"* ]]; then
DOCKER_GATEWAY=$(ip -4 route show | grep "^default" | awk '{print $3}')
# Allow traffic to the Docker gateway so the container can reach host services
# (e.g. the Onyx stack at localhost:3000, localhost:8080, etc.)
DOCKER_GATEWAY=$(ip -4 route show default | awk '{print $3}')
if [ -n "$DOCKER_GATEWAY" ]; then
if ! ipset add allowed-domains "$DOCKER_GATEWAY/32" -exist 2>&1; then
echo "warning: failed to add Docker gateway $DOCKER_GATEWAY to allowlist" >&2
fi

View File

@@ -5,7 +5,7 @@ home: https://www.onyx.app/
sources:
- "https://github.com/onyx-dot-app/onyx"
type: application
version: 0.4.43
version: 0.4.44
appVersion: latest
annotations:
category: Productivity

View File

@@ -0,0 +1,30 @@
{{- if and .Values.ingress.enabled .Values.mcpServer.enabled -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "onyx.fullname" . }}-ingress-mcp-oauth-callback
annotations:
{{- if not .Values.ingress.className }}
kubernetes.io/ingress.class: nginx
{{- end }}
cert-manager.io/cluster-issuer: {{ include "onyx.fullname" . }}-letsencrypt
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
rules:
- host: {{ .Values.ingress.api.host }}
http:
paths:
- path: /mcp/oauth/callback
pathType: Exact
backend:
service:
name: {{ include "onyx.fullname" . }}-webserver
port:
number: {{ .Values.webserver.service.servicePort }}
tls:
- hosts:
- {{ .Values.ingress.api.host }}
secretName: {{ include "onyx.fullname" . }}-ingress-mcp-oauth-callback-tls
{{- end }}

View File

@@ -63,7 +63,7 @@ func checkDevcontainerCLI() {
}
// ensureDockerSock sets the DOCKER_SOCK environment variable if not already set.
// devcontainer.json references ${localEnv:DOCKER_SOCK} for the socket mount.
// Used by ensureRemoteUser to detect rootless Docker.
func ensureDockerSock() {
if os.Getenv("DOCKER_SOCK") != "" {
return

View File

@@ -1,6 +1,6 @@
import "@opal/components/cards/card/styles.css";
import type { PaddingVariants, RoundingVariants } from "@opal/types";
import { cardPaddingVariants, cardRoundingVariants } from "@opal/shared";
import { paddingVariants, cardRoundingVariants } from "@opal/shared";
import { cn } from "@opal/utils";
// ---------------------------------------------------------------------------
@@ -79,7 +79,7 @@ function Card({
ref,
children,
}: CardProps) {
const padding = cardPaddingVariants[paddingProp];
const padding = paddingVariants[paddingProp];
const rounding = cardRoundingVariants[roundingProp];
return (

View File

@@ -1,6 +1,6 @@
import "@opal/components/cards/select-card/styles.css";
import type { PaddingVariants, RoundingVariants } from "@opal/types";
import { cardPaddingVariants, cardRoundingVariants } from "@opal/shared";
import { paddingVariants, cardRoundingVariants } from "@opal/shared";
import { cn } from "@opal/utils";
import { Interactive, type InteractiveStatefulProps } from "@opal/core";
@@ -78,7 +78,7 @@ function SelectCard({
children,
...statefulProps
}: SelectCardProps) {
const padding = cardPaddingVariants[paddingProp];
const padding = paddingVariants[paddingProp];
const rounding = cardRoundingVariants[roundingProp];
return (

View File

@@ -15,6 +15,42 @@ export const Plain: Story = {
render: () => <Divider />,
};
export const Vertical: Story = {
render: () => (
<div
style={{ display: "flex", alignItems: "stretch", height: 64, gap: 16 }}
>
<span>Left</span>
<Divider orientation="vertical" />
<span>Right</span>
</div>
),
};
export const NoPadding: Story = {
render: () => <Divider paddingParallel="fit" paddingPerpendicular="fit" />,
};
export const CustomPadding: Story = {
render: () => <Divider paddingParallel="lg" paddingPerpendicular="sm" />,
};
export const VerticalNoPadding: Story = {
render: () => (
<div
style={{ display: "flex", alignItems: "stretch", height: 64, gap: 16 }}
>
<span>Left</span>
<Divider
orientation="vertical"
paddingParallel="fit"
paddingPerpendicular="fit"
/>
<span>Right</span>
</div>
),
};
export const WithTitle: Story = {
render: () => <Divider title="Section" />,
};

View File

@@ -10,7 +10,13 @@ The component uses a discriminated union with four variants. `title` and `descri
### Bare divider
No props — renders a plain horizontal line.
A plain line with no title or description.
| Prop | Type | Default | Description |
|---|---|---|---|
| `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | Direction of the line |
| `paddingParallel` | `PaddingVariants` | `"sm"` | Padding along the line direction (0.5rem) |
| `paddingPerpendicular` | `PaddingVariants` | `"xs"` | Padding perpendicular to the line (0.25rem) |
### Titled divider
@@ -40,9 +46,18 @@ No props — renders a plain horizontal line.
```tsx
import { Divider } from "@opal/components";
// Plain line
// Plain horizontal line
<Divider />
// Vertical line
<Divider orientation="vertical" />
// No padding
<Divider paddingParallel="fit" paddingPerpendicular="fit" />
// Custom padding
<Divider paddingParallel="lg" paddingPerpendicular="sm" />
// With title
<Divider title="Advanced" />

View File

@@ -2,16 +2,25 @@
import "@opal/components/divider/styles.css";
import { useState, useCallback } from "react";
import type { RichStr } from "@opal/types";
import type { PaddingVariants, RichStr } from "@opal/types";
import { Button, Text } from "@opal/components";
import { SvgChevronRight } from "@opal/icons";
import { Interactive } from "@opal/core";
import { cn } from "@opal/utils";
import { paddingXVariants, paddingYVariants } from "@opal/shared";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface DividerNeverFields {
interface DividerSharedProps {
ref?: React.Ref<HTMLDivElement>;
title?: never;
description?: never;
foldable?: false;
orientation?: never;
paddingParallel?: never;
paddingPerpendicular?: never;
open?: never;
defaultOpen?: never;
onOpenChange?: never;
@@ -19,36 +28,37 @@ interface DividerNeverFields {
}
/** Plain line — no title, no description. */
interface DividerBareProps extends DividerNeverFields {
title?: never;
description?: never;
foldable?: false;
ref?: React.Ref<HTMLDivElement>;
}
type DividerBareProps = Omit<
DividerSharedProps,
"orientation" | "paddingParallel" | "paddingPerpendicular"
> & {
/** Orientation of the line. Default: `"horizontal"`. */
orientation?: "horizontal" | "vertical";
/** Padding along the line direction. Default: `"sm"` (0.5rem). */
paddingParallel?: PaddingVariants;
/** Padding perpendicular to the line. Default: `"xs"` (0.25rem). */
paddingPerpendicular?: PaddingVariants;
};
/** Line with a title to the left. */
interface DividerTitledProps extends DividerNeverFields {
type DividerTitledProps = Omit<DividerSharedProps, "title"> & {
title: string | RichStr;
description?: never;
foldable?: false;
ref?: React.Ref<HTMLDivElement>;
}
};
/** Line with a description below. */
interface DividerDescribedProps extends DividerNeverFields {
title?: never;
type DividerDescribedProps = Omit<DividerSharedProps, "description"> & {
/** Description rendered below the divider line. */
description: string | RichStr;
foldable?: false;
ref?: React.Ref<HTMLDivElement>;
}
};
/** Foldable — requires title, reveals children. */
interface DividerFoldableProps {
type DividerFoldableProps = Omit<
DividerSharedProps,
"title" | "foldable" | "open" | "defaultOpen" | "onOpenChange" | "children"
> & {
/** Title is required when foldable. */
title: string | RichStr;
foldable: true;
description?: never;
/** Controlled open state. */
open?: boolean;
/** Uncontrolled default open state. */
@@ -57,8 +67,7 @@ interface DividerFoldableProps {
onOpenChange?: (open: boolean) => void;
/** Content revealed when open. */
children?: React.ReactNode;
ref?: React.Ref<HTMLDivElement>;
}
};
type DividerProps =
| DividerBareProps
@@ -75,12 +84,39 @@ function Divider(props: DividerProps) {
return <FoldableDivider {...props} />;
}
const { ref } = props;
const title = "title" in props ? props.title : undefined;
const description = "description" in props ? props.description : undefined;
const {
ref,
title,
description,
orientation = "horizontal",
paddingParallel = "sm",
paddingPerpendicular = "xs",
} = props;
if (orientation === "vertical") {
return (
<div
ref={ref}
className={cn(
"opal-divider-vertical",
paddingXVariants[paddingPerpendicular],
paddingYVariants[paddingParallel]
)}
>
<div className="opal-divider-line-vertical" />
</div>
);
}
return (
<div ref={ref} className="opal-divider">
<div
ref={ref}
className={cn(
"opal-divider",
paddingXVariants[paddingParallel],
paddingYVariants[paddingPerpendicular]
)}
>
<div className="opal-divider-row">
{title && (
<div className="opal-divider-title">

View File

@@ -2,11 +2,13 @@
Divider
A horizontal rule with optional title, foldable chevron, or description.
Padding is controlled via Tailwind classes applied by the component.
--------------------------------------------------------------------------- */
/* ── Horizontal ─────────────────────────────────────────────────────────────── */
.opal-divider {
@apply flex flex-col w-full;
padding: 0.25rem 0.5rem;
gap: 0.75rem;
}
@@ -29,6 +31,18 @@
padding: 0px 2px;
}
/* ── Vertical orientation ───────────────────────────────────────────────────── */
.opal-divider-vertical {
@apply flex flex-row h-full;
}
.opal-divider-line-vertical {
@apply flex-1 w-px bg-border-01;
}
/* ── Foldable chevron ───────────────────────────────────────────────────────── */
.opal-divider-chevron {
@apply transition-transform duration-200 ease-in-out;
}

View File

@@ -100,7 +100,7 @@ const heightVariants: Record<ExtremaSizeVariants, string> = {
// - SelectCard (paddingVariant, roundingVariant)
// ---------------------------------------------------------------------------
const cardPaddingVariants: Record<PaddingVariants, string> = {
const paddingVariants: Record<PaddingVariants, string> = {
lg: "p-6",
md: "p-4",
sm: "p-2",
@@ -109,6 +109,24 @@ const cardPaddingVariants: Record<PaddingVariants, string> = {
fit: "p-0",
};
const paddingXVariants: Record<PaddingVariants, string> = {
lg: "px-6",
md: "px-4",
sm: "px-2",
xs: "px-1",
"2xs": "px-0.5",
fit: "px-0",
};
const paddingYVariants: Record<PaddingVariants, string> = {
lg: "py-6",
md: "py-4",
sm: "py-2",
xs: "py-1",
"2xs": "py-0.5",
fit: "py-0",
};
const cardRoundingVariants: Record<RoundingVariants, string> = {
lg: "rounded-16",
md: "rounded-12",
@@ -122,7 +140,9 @@ export {
type OverridableExtremaSizeVariants,
type SizeVariants,
containerSizeVariants,
cardPaddingVariants,
paddingVariants,
paddingXVariants,
paddingYVariants,
cardRoundingVariants,
widthVariants,
heightVariants,

16
web/package-lock.json generated
View File

@@ -47,6 +47,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"cookies-next": "^5.1.0",
"copy-to-clipboard": "^3.3.3",
"date-fns": "^3.6.0",
"docx-preview": "^0.3.7",
"favicon-fetch": "^1.0.0",
@@ -8843,6 +8844,15 @@
"react": ">= 16.8.0"
}
},
"node_modules/copy-to-clipboard": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz",
"integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==",
"license": "MIT",
"dependencies": {
"toggle-selection": "^1.0.6"
}
},
"node_modules/core-js": {
"version": "3.46.0",
"hasInstallScript": true,
@@ -17426,6 +17436,12 @@
"node": ">=8.0"
}
},
"node_modules/toggle-selection": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz",
"integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==",
"license": "MIT"
},
"node_modules/toposort": {
"version": "2.0.2",
"license": "MIT"

View File

@@ -65,6 +65,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"cookies-next": "^5.1.0",
"copy-to-clipboard": "^3.3.3",
"date-fns": "^3.6.0",
"docx-preview": "^0.3.7",
"favicon-fetch": "^1.0.0",

View File

@@ -1,6 +1,7 @@
import Modal from "@/refresh-components/Modal";
import { Button } from "@opal/components";
import { CloudEmbeddingModel } from "../../../../components/embedding/interfaces";
import { markdown } from "@opal/utils";
import { SvgCheck } from "@opal/icons";
export interface AlreadyPickedModalProps {
@@ -17,7 +18,7 @@ export default function AlreadyPickedModal({
<Modal.Content width="sm" height="sm">
<Modal.Header
icon={SvgCheck}
title={`${model.model_name} already chosen`}
title={markdown(`*${model.model_name}* already chosen`)}
description="You can select a different one if you want!"
onClose={onClose}
/>

View File

@@ -12,6 +12,7 @@ import {
getFormattedProviderName,
} from "@/components/embedding/interfaces";
import { EMBEDDING_PROVIDERS_ADMIN_URL } from "@/lib/llmConfig/constants";
import { markdown } from "@opal/utils";
import { mutate } from "swr";
import { SWR_KEYS } from "@/lib/swr-keys";
import { testEmbedding } from "@/app/admin/embeddings/pages/utils";
@@ -172,9 +173,11 @@ export default function ChangeCredentialsModal({
<Modal.Content>
<Modal.Header
icon={SvgSettings}
title={`Modify your ${getFormattedProviderName(
provider.provider_type
)} ${isProxy ? "Configuration" : "key"}`}
title={markdown(
`Modify your *${getFormattedProviderName(
provider.provider_type
)}* ${isProxy ? "configuration" : "key"}`
)}
onClose={onCancel}
/>
<Modal.Body>

View File

@@ -7,6 +7,7 @@ import {
getFormattedProviderName,
} from "../../../../components/embedding/interfaces";
import { SvgTrash } from "@opal/icons";
import { markdown } from "@opal/utils";
export interface DeleteCredentialsModalProps {
modelProvider: CloudEmbeddingProvider;
@@ -24,9 +25,11 @@ export default function DeleteCredentialsModal({
<Modal.Content width="sm" height="sm">
<Modal.Header
icon={SvgTrash}
title={`Delete ${getFormattedProviderName(
modelProvider.provider_type
)} Credentials?`}
title={markdown(
`Delete *${getFormattedProviderName(
modelProvider.provider_type
)}* credentials?`
)}
onClose={onCancel}
/>
<Modal.Body>

View File

@@ -12,6 +12,7 @@ import {
} from "@/components/embedding/interfaces";
import { EMBEDDING_PROVIDERS_ADMIN_URL } from "@/lib/llmConfig/constants";
import Modal from "@/refresh-components/Modal";
import { markdown } from "@opal/utils";
import { SvgSettings } from "@opal/icons";
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
export interface ProviderCreationModalProps {
@@ -185,9 +186,11 @@ export default function ProviderCreationModal({
<Modal.Content width="sm" height="sm">
<Modal.Header
icon={SvgSettings}
title={`Configure ${getFormattedProviderName(
selectedProvider.provider_type
)}`}
title={markdown(
`Configure *${getFormattedProviderName(
selectedProvider.provider_type
)}*`
)}
onClose={onCancel}
/>
<Modal.Body>

View File

@@ -2,6 +2,7 @@ import Modal from "@/refresh-components/Modal";
import { Button } from "@opal/components";
import Text from "@/refresh-components/texts/Text";
import { CloudEmbeddingModel } from "@/components/embedding/interfaces";
import { markdown } from "@opal/utils";
import { SvgServer } from "@opal/icons";
export interface SelectModelModalProps {
@@ -20,7 +21,7 @@ export default function SelectModelModal({
<Modal.Content width="sm" height="sm">
<Modal.Header
icon={SvgServer}
title={`Select ${model.model_name}`}
title={markdown(`Select *${model.model_name}*`)}
onClose={onCancel}
/>
<Modal.Body>

View File

@@ -1,6 +1,7 @@
"use client";
import { toast } from "@/hooks/useToast";
import { markdown } from "@opal/utils";
import EmbeddingModelSelection from "../EmbeddingModelSelectionForm";
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
@@ -538,7 +539,9 @@ export default function EmbeddingForm() {
<Modal.Content>
<Modal.Header
icon={SvgAlertTriangle}
title={`Are you sure you want to select ${selectedProvider.model_name}?`}
title={markdown(
`Are you sure you want to select *${selectedProvider.model_name}*?`
)}
onClose={() => setShowPoorModel(false)}
/>
<Modal.Body>

View File

@@ -210,8 +210,10 @@ export default function MultiModelResponseView({
const response = responses.find((r) => r.modelIndex === modelIndex);
if (!response) return;
// Persist preferred response to backend + update local tree so the
// input bar unblocks (awaitingPreferredSelection clears).
// Persist preferred response + sync `latestChildNodeId`. Backend's
// `set_preferred_response` updates `latest_child_message_id`; if the
// frontend chain walk disagrees, the next follow-up fails with
// "not on the latest mainline".
if (parentMessage?.messageId && response.messageId && currentSessionId) {
setPreferredResponse(parentMessage.messageId, response.messageId).catch(
(err) => console.error("Failed to persist preferred response:", err)
@@ -227,6 +229,7 @@ export default function MultiModelResponseView({
updated.set(parentMessage.nodeId, {
...userMsg,
preferredResponseId: response.messageId,
latestChildNodeId: response.nodeId,
});
updateSessionMessageTree(currentSessionId, updated);
}

View File

@@ -137,7 +137,7 @@ function DeleteConfirmModal({ hook, onDelete }: DeleteConfirmModalProps) {
<Modal.Header
// TODO(@raunakab): replace the colour of this SVG with red.
icon={SvgTrash}
title={`Delete ${hook.name}`}
title={markdown(`Delete *${hook.name}*`)}
onClose={onClose}
/>
<Modal.Body>

View File

@@ -694,6 +694,25 @@ export function useLlmManager(
prevAgentIdRef.current = liveAgent?.id;
}, [liveAgent?.id]);
// Clear manual override when arriving at a *different* existing session
// from any previously-seen defined session. Tracks only the last
// *defined* session id so a round-trip through new-chat (A → undefined
// → B) still resets, while A → undefined (new-chat) preserves it.
const prevDefinedSessionIdRef = useRef<string | undefined>(undefined);
useEffect(() => {
const nextId = currentChatSession?.id;
if (
nextId !== undefined &&
prevDefinedSessionIdRef.current !== undefined &&
nextId !== prevDefinedSessionIdRef.current
) {
setUserHasManuallyOverriddenLLM(false);
}
if (nextId !== undefined) {
prevDefinedSessionIdRef.current = nextId;
}
}, [currentChatSession?.id]);
function getValidLlmDescriptor(
modelName: string | null | undefined
): LlmDescriptor {
@@ -715,8 +734,9 @@ export function useLlmManager(
if (llmProviders === undefined || llmProviders === null) {
resolved = manualLlm;
} else if (userHasManuallyOverriddenLLM && !currentChatSession) {
// User has overridden in this session and switched to a new session
} else if (userHasManuallyOverriddenLLM) {
// Manual override wins over session's `current_alternate_model`.
// Cleared on cross-session navigation by the effect above.
resolved = manualLlm;
} else if (currentChatSession?.current_alternate_model) {
resolved = getValidLlmDescriptorForProviders(
@@ -728,8 +748,6 @@ export function useLlmManager(
liveAgent.llm_model_version_override,
llmProviders
);
} else if (userHasManuallyOverriddenLLM) {
resolved = manualLlm;
} else if (user?.preferences?.default_model) {
resolved = getValidLlmDescriptorForProviders(
user.preferences.default_model,

View File

@@ -1,6 +1,7 @@
"use client";
import { useEffect, useRef, useState } from "react";
import copy from "copy-to-clipboard";
import { Button, ButtonProps } from "@opal/components";
import { SvgAlertTriangle, SvgCheck, SvgCopy } from "@opal/icons";
@@ -40,26 +41,19 @@ export default function CopyIconButton({
}
try {
// Check if Clipboard API is available
if (!navigator.clipboard) {
throw new Error("Clipboard API not available");
}
// If HTML content getter is provided, copy both HTML and plain text
if (getHtmlContent) {
if (navigator.clipboard && getHtmlContent) {
const htmlContent = getHtmlContent();
const clipboardItem = new ClipboardItem({
"text/html": new Blob([htmlContent], { type: "text/html" }),
"text/plain": new Blob([text], { type: "text/plain" }),
});
await navigator.clipboard.write([clipboardItem]);
}
// Default: plain text only
else {
} else if (navigator.clipboard) {
await navigator.clipboard.writeText(text);
} else if (!copy(text)) {
throw new Error("copy-to-clipboard returned false");
}
// Show "copied" state
setCopyState("copied");
} catch (err) {
console.error("Failed to copy:", err);

View File

@@ -159,9 +159,12 @@ export default function ModelSelector({
);
if (!isMultiModel) {
// Stable key — keying on model would unmount the pill
// on change and leave Radix's anchorRef detached,
// flashing the closing popover at (0,0).
return (
<OpenButton
key={modelKey(model.provider, model.modelName)}
key="single-model-pill"
icon={ProviderIcon}
onClick={(e: React.MouseEvent) =>
handlePillClick(index, e.currentTarget as HTMLElement)

View File

@@ -425,16 +425,27 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [multiModel.isMultiModelActive]);
// Sync single-model selection to llmManager so the submission path
// uses the correct provider/version (replaces the old LLMPopover sync).
// Sync single-model selection to llmManager so the submission path uses
// the correct provider/version. Guard against echoing derived state back
// — only call updateCurrentLlm when the selection actually differs from
// currentLlm, otherwise the initial [] → [currentLlmModel] sync would
// pin `userHasManuallyOverriddenLLM=true` with whatever was resolved
// first (often the default model before the session's alt_model loads).
useEffect(() => {
if (multiModel.selectedModels.length === 1) {
const model = multiModel.selectedModels[0]!;
llmManager.updateCurrentLlm({
name: model.name,
provider: model.provider,
modelName: model.modelName,
});
const current = llmManager.currentLlm;
if (
model.provider !== current.provider ||
model.modelName !== current.modelName ||
model.name !== current.name
) {
llmManager.updateCurrentLlm({
name: model.name,
provider: model.provider,
modelName: model.modelName,
});
}
}
}, [multiModel.selectedModels]);

View File

@@ -5,6 +5,7 @@ import { usePathname, useRouter } from "next/navigation";
import * as InputLayouts from "@/layouts/input-layouts";
import { Section, AttachmentItemLayout } from "@/layouts/general-layouts";
import { Content, ContentAction } from "@opal/layouts";
import { markdown } from "@opal/utils";
import { Formik, Form } from "formik";
import * as Yup from "yup";
import {
@@ -1556,7 +1557,7 @@ function FederatedConnectorCard({
{showDisconnectConfirmation && (
<ConfirmationModalLayout
icon={SvgUnplug}
title={`Disconnect ${sourceMetadata.displayName}`}
title={markdown(`Disconnect *${sourceMetadata.displayName}*`)}
onClose={() => setShowDisconnectConfirmation(false)}
submit={
<Button

View File

@@ -4,7 +4,7 @@ import { useCallback, useState } from "react";
import { Button } from "@opal/components";
// TODO(@raunakab): migrate to Opal LineItemButton once it supports danger variant
import LineItem from "@/refresh-components/buttons/LineItem";
import { cn } from "@opal/utils";
import { cn, markdown } from "@opal/utils";
import {
SvgMoreHorizontal,
SvgEdit,
@@ -341,7 +341,7 @@ export default function AgentRowActions({
{unlistOpen && (
<ConfirmationModalLayout
icon={SvgEyeOff}
title={`Unlist ${agent.name}`}
title={markdown(`Unlist *${agent.name}*`)}
onClose={isSubmitting ? undefined : () => setUnlistOpen(false)}
submit={
<Button

View File

@@ -347,7 +347,7 @@ export default function ImageGenerationContent() {
{disconnectProvider && (
<ConfirmationModalLayout
icon={SvgUnplug}
title={`Disconnect ${disconnectProvider.title}`}
title={markdown(`Disconnect *${disconnectProvider.title}*`)}
description="This will remove the stored credentials for this provider."
onClose={() => {
setDisconnectProvider(null);

View File

@@ -201,7 +201,7 @@ function VoiceDisconnectModal({
return (
<ConfirmationModalLayout
icon={SvgUnplug}
title={`Disconnect ${disconnectTarget.providerLabel}`}
title={markdown(`Disconnect *${disconnectTarget.providerLabel}*`)}
description="Voice models"
onClose={onClose}
submit={

View File

@@ -9,6 +9,7 @@ import Modal from "@/refresh-components/Modal";
import { Button } from "@opal/components";
import { SvgArrowExchange } from "@opal/icons";
import { markdown } from "@opal/utils";
import { SvgOnyxLogo } from "@opal/logos";
import type { IconProps } from "@opal/types";
@@ -81,7 +82,7 @@ export const WebProviderSetupModal = memo(
<Modal.Content width="sm" preventAccidentalClose>
<Modal.Header
icon={LogoArrangement}
title={`Set up ${providerLabel}`}
title={markdown(`Set up *${providerLabel}*`)}
description={description}
onClose={onClose}
/>

View File

@@ -7,6 +7,7 @@ import Text from "@/refresh-components/texts/Text";
import { Section } from "@/layouts/general-layouts";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { Content, Card } from "@opal/layouts";
import { markdown } from "@opal/utils";
import useSWR from "swr";
import { errorHandlingFetcher, FetchError } from "@/lib/fetcher";
import { SWR_KEYS } from "@/lib/swr-keys";
@@ -146,7 +147,7 @@ function WebSearchDisconnectModal({
return (
<ConfirmationModalLayout
icon={SvgUnplug}
title={`Disconnect ${disconnectTarget.label}`}
title={markdown(`Disconnect *${disconnectTarget.label}*`)}
description="This will remove the stored credentials for this provider."
onClose={onClose}
submit={

View File

@@ -5,6 +5,7 @@ import Modal from "@/refresh-components/Modal";
import { Button } from "@opal/components";
import Text from "@/refresh-components/texts/Text";
import { cn } from "@/lib/utils";
import { markdown } from "@opal/utils";
import { SvgUnplug } from "@opal/icons";
interface DisconnectEntityModalProps {
isOpen: boolean;
@@ -51,7 +52,7 @@ export default function DisconnectEntityModal({
icon={({ className }) => (
<SvgUnplug className={cn(className, "stroke-action-danger-05")} />
)}
title={`Disconnect ${name}`}
title={markdown(`Disconnect *${name}*`)}
onClose={onClose}
/>

View File

@@ -10,6 +10,7 @@ import InputSelect from "@/refresh-components/inputs/InputSelect";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import PasswordInputTypeIn from "@/refresh-components/inputs/PasswordInputTypeIn";
import { Button } from "@opal/components";
import { markdown } from "@opal/utils";
import CopyIconButton from "@/refresh-components/buttons/CopyIconButton";
import Text from "@/refresh-components/texts/Text";
import { Formik, Form } from "formik";
@@ -317,7 +318,11 @@ export default function MCPAuthenticationModal({
<Modal.Content width="sm" height="lg" skipOverlay={skipOverlay}>
<Modal.Header
icon={SvgArrowExchange}
title={`Authenticate ${mcpServer?.name || "MCP Server"}`}
title={
mcpServer
? markdown(`Authenticate *${mcpServer.name}*`)
: "Authenticate MCP Server"
}
description="Authenticate your connection to start using the MCP server."
/>

View File

@@ -4,6 +4,7 @@ import React, { useEffect, useRef, useState } from "react";
import { Formik, Form, useFormikContext } from "formik";
import type { FormikConfig } from "formik";
import { cn } from "@/lib/utils";
import { markdown } from "@opal/utils";
import { Interactive } from "@opal/core";
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
import { useAgents } from "@/hooks/useAgents";
@@ -720,7 +721,7 @@ function ModalWrapperInner({
} = getProvider(providerName);
const title = llmProvider
? `Configure "${llmProvider.name}"`
? markdown(`Configure *${llmProvider.name}*`)
: `Set up ${providerProductName}`;
const description =
descriptionOverride ??