Compare commits

...

7 Commits

21 changed files with 1094 additions and 382 deletions

View File

@@ -1,4 +1,5 @@
import json
from typing import Any
from typing import cast
from urllib.parse import parse_qs
from urllib.parse import ParseResult
@@ -53,6 +54,21 @@ from onyx.utils.logger import setup_logger
logger = setup_logger()
def _load_google_json(raw: object) -> dict[str, Any]:
"""Accept both the current (dict) and legacy (JSON string) KV payload shapes.
Payloads written before the fix for serializing Google credentials into
``EncryptedJson`` columns are stored as JSON strings; new writes store dicts.
Once every install has re-uploaded their Google credentials the legacy
``str`` branch can be removed.
"""
if isinstance(raw, dict):
return raw
if isinstance(raw, str):
return json.loads(raw)
raise ValueError(f"Unexpected Google credential payload type: {type(raw)!r}")
def _build_frontend_google_drive_redirect(source: DocumentSource) -> str:
if source == DocumentSource.GOOGLE_DRIVE:
return f"{WEB_DOMAIN}/admin/connectors/google-drive/auth/callback"
@@ -162,12 +178,13 @@ def build_service_account_creds(
def get_auth_url(credential_id: int, source: DocumentSource) -> str:
if source == DocumentSource.GOOGLE_DRIVE:
creds_str = str(get_kv_store().load(KV_GOOGLE_DRIVE_CRED_KEY))
credential_json = _load_google_json(
get_kv_store().load(KV_GOOGLE_DRIVE_CRED_KEY)
)
elif source == DocumentSource.GMAIL:
creds_str = str(get_kv_store().load(KV_GMAIL_CRED_KEY))
credential_json = _load_google_json(get_kv_store().load(KV_GMAIL_CRED_KEY))
else:
raise ValueError(f"Unsupported source: {source}")
credential_json = json.loads(creds_str)
flow = InstalledAppFlow.from_client_config(
credential_json,
scopes=GOOGLE_SCOPES[source],
@@ -188,12 +205,12 @@ def get_auth_url(credential_id: int, source: DocumentSource) -> str:
def get_google_app_cred(source: DocumentSource) -> GoogleAppCredentials:
if source == DocumentSource.GOOGLE_DRIVE:
creds_str = str(get_kv_store().load(KV_GOOGLE_DRIVE_CRED_KEY))
creds = _load_google_json(get_kv_store().load(KV_GOOGLE_DRIVE_CRED_KEY))
elif source == DocumentSource.GMAIL:
creds_str = str(get_kv_store().load(KV_GMAIL_CRED_KEY))
creds = _load_google_json(get_kv_store().load(KV_GMAIL_CRED_KEY))
else:
raise ValueError(f"Unsupported source: {source}")
return GoogleAppCredentials(**json.loads(creds_str))
return GoogleAppCredentials(**creds)
def upsert_google_app_cred(
@@ -201,10 +218,14 @@ def upsert_google_app_cred(
) -> None:
if source == DocumentSource.GOOGLE_DRIVE:
get_kv_store().store(
KV_GOOGLE_DRIVE_CRED_KEY, app_credentials.json(), encrypt=True
KV_GOOGLE_DRIVE_CRED_KEY,
app_credentials.model_dump(mode="json"),
encrypt=True,
)
elif source == DocumentSource.GMAIL:
get_kv_store().store(KV_GMAIL_CRED_KEY, app_credentials.json(), encrypt=True)
get_kv_store().store(
KV_GMAIL_CRED_KEY, app_credentials.model_dump(mode="json"), encrypt=True
)
else:
raise ValueError(f"Unsupported source: {source}")
@@ -220,12 +241,14 @@ def delete_google_app_cred(source: DocumentSource) -> None:
def get_service_account_key(source: DocumentSource) -> GoogleServiceAccountKey:
if source == DocumentSource.GOOGLE_DRIVE:
creds_str = str(get_kv_store().load(KV_GOOGLE_DRIVE_SERVICE_ACCOUNT_KEY))
creds = _load_google_json(
get_kv_store().load(KV_GOOGLE_DRIVE_SERVICE_ACCOUNT_KEY)
)
elif source == DocumentSource.GMAIL:
creds_str = str(get_kv_store().load(KV_GMAIL_SERVICE_ACCOUNT_KEY))
creds = _load_google_json(get_kv_store().load(KV_GMAIL_SERVICE_ACCOUNT_KEY))
else:
raise ValueError(f"Unsupported source: {source}")
return GoogleServiceAccountKey(**json.loads(creds_str))
return GoogleServiceAccountKey(**creds)
def upsert_service_account_key(
@@ -234,12 +257,14 @@ def upsert_service_account_key(
if source == DocumentSource.GOOGLE_DRIVE:
get_kv_store().store(
KV_GOOGLE_DRIVE_SERVICE_ACCOUNT_KEY,
service_account_key.json(),
service_account_key.model_dump(mode="json"),
encrypt=True,
)
elif source == DocumentSource.GMAIL:
get_kv_store().store(
KV_GMAIL_SERVICE_ACCOUNT_KEY, service_account_key.json(), encrypt=True
KV_GMAIL_SERVICE_ACCOUNT_KEY,
service_account_key.model_dump(mode="json"),
encrypt=True,
)
else:
raise ValueError(f"Unsupported source: {source}")

View File

@@ -65,6 +65,7 @@ class Settings(BaseModel):
anonymous_user_enabled: bool | None = None
invite_only_enabled: bool = False
deep_research_enabled: bool | None = None
multi_model_chat_enabled: bool | None = None
search_ui_enabled: bool | None = None
# Whether EE features are unlocked for use.
@@ -89,7 +90,8 @@ class Settings(BaseModel):
default=DEFAULT_USER_FILE_MAX_UPLOAD_SIZE_MB, ge=0
)
file_token_count_threshold_k: int | None = Field(
default=None, ge=0 # thousands of tokens; None = context-aware default
default=None,
ge=0, # thousands of tokens; None = context-aware default
)
# Connector settings

View File

@@ -0,0 +1,182 @@
from typing import Any
import pytest
from onyx.configs.constants import DocumentSource
from onyx.configs.constants import KV_GOOGLE_DRIVE_CRED_KEY
from onyx.configs.constants import KV_GOOGLE_DRIVE_SERVICE_ACCOUNT_KEY
from onyx.connectors.google_utils.google_kv import get_auth_url
from onyx.connectors.google_utils.google_kv import get_google_app_cred
from onyx.connectors.google_utils.google_kv import get_service_account_key
from onyx.connectors.google_utils.google_kv import upsert_google_app_cred
from onyx.connectors.google_utils.google_kv import upsert_service_account_key
from onyx.server.documents.models import GoogleAppCredentials
from onyx.server.documents.models import GoogleAppWebCredentials
from onyx.server.documents.models import GoogleServiceAccountKey
def _make_app_creds() -> GoogleAppCredentials:
return GoogleAppCredentials(
web=GoogleAppWebCredentials(
client_id="client-id.apps.googleusercontent.com",
project_id="test-project",
auth_uri="https://accounts.google.com/o/oauth2/auth",
token_uri="https://oauth2.googleapis.com/token",
auth_provider_x509_cert_url="https://www.googleapis.com/oauth2/v1/certs",
client_secret="secret",
redirect_uris=["https://example.com/callback"],
javascript_origins=["https://example.com"],
)
)
def _make_service_account_key() -> GoogleServiceAccountKey:
return GoogleServiceAccountKey(
type="service_account",
project_id="test-project",
private_key_id="private-key-id",
private_key="-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----\n",
client_email="test@test-project.iam.gserviceaccount.com",
client_id="123",
auth_uri="https://accounts.google.com/o/oauth2/auth",
token_uri="https://oauth2.googleapis.com/token",
auth_provider_x509_cert_url="https://www.googleapis.com/oauth2/v1/certs",
client_x509_cert_url="https://www.googleapis.com/robot/v1/metadata/x509/test",
universe_domain="googleapis.com",
)
def test_upsert_google_app_cred_stores_dict(monkeypatch: Any) -> None:
stored: dict[str, Any] = {}
class _StubKvStore:
def store(self, key: str, value: object, encrypt: bool) -> None:
stored["key"] = key
stored["value"] = value
stored["encrypt"] = encrypt
monkeypatch.setattr(
"onyx.connectors.google_utils.google_kv.get_kv_store", lambda: _StubKvStore()
)
upsert_google_app_cred(_make_app_creds(), DocumentSource.GOOGLE_DRIVE)
assert stored["key"] == KV_GOOGLE_DRIVE_CRED_KEY
assert stored["encrypt"] is True
assert isinstance(stored["value"], dict)
assert stored["value"]["web"]["client_id"] == "client-id.apps.googleusercontent.com"
def test_upsert_service_account_key_stores_dict(monkeypatch: Any) -> None:
stored: dict[str, Any] = {}
class _StubKvStore:
def store(self, key: str, value: object, encrypt: bool) -> None:
stored["key"] = key
stored["value"] = value
stored["encrypt"] = encrypt
monkeypatch.setattr(
"onyx.connectors.google_utils.google_kv.get_kv_store", lambda: _StubKvStore()
)
upsert_service_account_key(_make_service_account_key(), DocumentSource.GOOGLE_DRIVE)
assert stored["key"] == KV_GOOGLE_DRIVE_SERVICE_ACCOUNT_KEY
assert stored["encrypt"] is True
assert isinstance(stored["value"], dict)
assert stored["value"]["project_id"] == "test-project"
@pytest.mark.parametrize("legacy_string", [False, True])
def test_get_google_app_cred_accepts_dict_and_legacy_string(
monkeypatch: Any, legacy_string: bool
) -> None:
payload: dict[str, Any] = _make_app_creds().model_dump(mode="json")
stored_value: object = (
payload if not legacy_string else _make_app_creds().model_dump_json()
)
class _StubKvStore:
def load(self, key: str) -> object:
assert key == KV_GOOGLE_DRIVE_CRED_KEY
return stored_value
monkeypatch.setattr(
"onyx.connectors.google_utils.google_kv.get_kv_store", lambda: _StubKvStore()
)
creds = get_google_app_cred(DocumentSource.GOOGLE_DRIVE)
assert creds.web.client_id == "client-id.apps.googleusercontent.com"
@pytest.mark.parametrize("legacy_string", [False, True])
def test_get_service_account_key_accepts_dict_and_legacy_string(
monkeypatch: Any, legacy_string: bool
) -> None:
stored_value: object = (
_make_service_account_key().model_dump(mode="json")
if not legacy_string
else _make_service_account_key().model_dump_json()
)
class _StubKvStore:
def load(self, key: str) -> object:
assert key == KV_GOOGLE_DRIVE_SERVICE_ACCOUNT_KEY
return stored_value
monkeypatch.setattr(
"onyx.connectors.google_utils.google_kv.get_kv_store", lambda: _StubKvStore()
)
key = get_service_account_key(DocumentSource.GOOGLE_DRIVE)
assert key.client_email == "test@test-project.iam.gserviceaccount.com"
@pytest.mark.parametrize("legacy_string", [False, True])
def test_get_auth_url_accepts_dict_and_legacy_string(
monkeypatch: Any, legacy_string: bool
) -> None:
payload = _make_app_creds().model_dump(mode="json")
stored_value: object = (
payload if not legacy_string else _make_app_creds().model_dump_json()
)
stored_state: dict[str, object] = {}
class _StubKvStore:
def load(self, key: str) -> object:
assert key == KV_GOOGLE_DRIVE_CRED_KEY
return stored_value
def store(self, key: str, value: object, encrypt: bool) -> None:
stored_state["key"] = key
stored_state["value"] = value
stored_state["encrypt"] = encrypt
class _StubFlow:
def authorization_url(self, prompt: str) -> tuple[str, None]:
assert prompt == "consent"
return "https://accounts.google.com/o/oauth2/auth?state=test-state", None
monkeypatch.setattr(
"onyx.connectors.google_utils.google_kv.get_kv_store", lambda: _StubKvStore()
)
def _from_client_config(
_app_config: object, *, scopes: object, redirect_uri: object
) -> _StubFlow:
del scopes, redirect_uri
return _StubFlow()
monkeypatch.setattr(
"onyx.connectors.google_utils.google_kv.InstalledAppFlow.from_client_config",
_from_client_config,
)
auth_url = get_auth_url(42, DocumentSource.GOOGLE_DRIVE)
assert auth_url.startswith("https://accounts.google.com")
assert stored_state["value"] == {"value": "test-state"}
assert stored_state["encrypt"] is True

View File

@@ -163,6 +163,7 @@ export default function MultiModelPanel({
<AgentMessage
{...agentMessageProps}
hideFooter={isNonPreferredInSelection}
disableTTS
/>
</div>
)}

View File

@@ -1,3 +1,25 @@
/* Map Tailwind Typography prose variables to the project's color tokens.
These auto-switch for dark mode via colors.css — no dark: modifier needed.
Note: text-05 = highest contrast, text-01 = lowest. */
.prose-onyx {
--tw-prose-body: var(--text-05);
--tw-prose-headings: var(--text-05);
--tw-prose-lead: var(--text-04);
--tw-prose-links: var(--action-link-05);
--tw-prose-bold: var(--text-05);
--tw-prose-counters: var(--text-03);
--tw-prose-bullets: var(--text-03);
--tw-prose-hr: var(--border-02);
--tw-prose-quotes: var(--text-04);
--tw-prose-quote-borders: var(--border-02);
--tw-prose-captions: var(--text-03);
--tw-prose-code: var(--text-05);
--tw-prose-pre-code: var(--text-04);
--tw-prose-pre-bg: var(--background-code-01);
--tw-prose-th-borders: var(--border-02);
--tw-prose-td-borders: var(--border-01);
}
/* Light mode syntax highlighting (Atom One Light) */
.hljs {
color: #383a42 !important;
@@ -236,23 +258,102 @@ pre[class*="language-"] {
scrollbar-color: #4b5563 #1f2937;
}
/* Card wrapper — holds the background, border-radius, padding, and fade overlay.
Does NOT scroll — the inner .markdown-table-breakout handles that. */
.markdown-table-card {
position: relative;
background: var(--background-neutral-01);
border-radius: 0.5rem;
padding: 0.5rem 0;
}
/*
* Table breakout container - allows tables to extend beyond their parent's
* constrained width to use the full container query width (100cqw).
*
* Requires an ancestor element with `container-type: inline-size` (@container in Tailwind).
*
* How the math works:
* - width: 100cqw → expand to full container query width
* - marginLeft: calc((100% - 100cqw) / 2) → negative margin pulls element left
* (100% is parent width, 100cqw is larger, so result is negative)
* - paddingLeft/Right: calc((100cqw - 100%) / 2) → padding keeps content aligned
* with original position while allowing scroll area to extend
* Scrollable table container — sits inside the card.
*/
.markdown-table-breakout {
overflow-x: auto;
width: 100cqw;
margin-left: calc((100% - 100cqw) / 2);
padding-left: calc((100cqw - 100%) / 2);
padding-right: calc((100cqw - 100%) / 2);
/* Always reserve scrollbar height so hover doesn't shift content.
Thumb is transparent by default, revealed on hover. */
scrollbar-width: thin; /* Firefox — always shows track */
scrollbar-color: transparent transparent; /* invisible thumb + track */
}
.markdown-table-breakout::-webkit-scrollbar {
height: 6px;
}
.markdown-table-breakout::-webkit-scrollbar-track {
background: transparent;
}
.markdown-table-breakout::-webkit-scrollbar-thumb {
background: transparent;
border-radius: 3px;
}
.markdown-table-breakout:hover {
scrollbar-color: var(--border-03) transparent; /* Firefox — reveal thumb */
}
.markdown-table-breakout:hover::-webkit-scrollbar-thumb {
background: var(--border-03);
}
/* Fade the right edge via an ::after overlay on the non-scrolling card.
Stays pinned while table scrolls; doesn't affect the sticky column. */
.markdown-table-card::after {
content: "";
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 2rem;
pointer-events: none;
z-index: 2;
background: linear-gradient(
to right,
transparent,
var(--background-neutral-01)
);
border-radius: 0 0.5rem 0.5rem 0;
opacity: 0;
transition: opacity 0.15s;
}
.markdown-table-card[data-overflows="true"]::after {
opacity: 1;
}
/* Sticky first column — inherits the container's background so it
matches regardless of theme or custom wallpaper. */
.markdown-table-breakout th:first-child,
.markdown-table-breakout td:first-child {
position: sticky;
left: 0;
z-index: 1;
padding-left: 0.75rem;
background: var(--background-neutral-01);
}
.markdown-table-breakout th:last-child,
.markdown-table-breakout td:last-child {
padding-right: 0.75rem;
}
/* Shadow on sticky column when scrolled. Uses an ::after pseudo-element
so it isn't clipped by the overflow container or the mask-image fade. */
.markdown-table-breakout th:first-child::after,
.markdown-table-breakout td:first-child::after {
content: "";
position: absolute;
top: 0;
right: -6px;
bottom: 0;
width: 6px;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s;
box-shadow: inset 6px 0 8px -4px var(--alpha-grey-100-25);
}
.dark .markdown-table-breakout th:first-child::after,
.dark .markdown-table-breakout td:first-child::after {
box-shadow: inset 6px 0 8px -4px var(--alpha-grey-100-60);
}
.markdown-table-breakout[data-scrolled="true"] th:first-child::after,
.markdown-table-breakout[data-scrolled="true"] td:first-child::after {
opacity: 1;
}

View File

@@ -51,6 +51,8 @@ export interface AgentMessageProps {
processingDurationSeconds?: number;
/** Hide the feedback/toolbar footer (used in multi-model non-preferred panels) */
hideFooter?: boolean;
/** Skip TTS streaming (used in multi-model where voice doesn't apply) */
disableTTS?: boolean;
}
// TODO: Consider more robust comparisons:
@@ -99,6 +101,7 @@ const AgentMessage = React.memo(function AgentMessage({
parentMessage,
processingDurationSeconds,
hideFooter,
disableTTS,
}: AgentMessageProps) {
const markdownRef = useRef<HTMLDivElement>(null);
const finalAnswerRef = useRef<HTMLDivElement>(null);
@@ -202,6 +205,9 @@ const AgentMessage = React.memo(function AgentMessage({
// Skip if we've already finished TTS for this message
if (ttsCompletedRef.current) return;
// Multi-model: skip TTS entirely
if (disableTTS) return;
// If user cancelled generation, do not send more text to TTS.
if (stopPacketSeen && stopReason === StopReason.USER_CANCELLED) {
ttsCompletedRef.current = true;
@@ -305,7 +311,7 @@ const AgentMessage = React.memo(function AgentMessage({
onRenderComplete();
}
}}
animate={false}
animate={!stopPacketSeen}
stopPacketSeen={stopPacketSeen}
stopReason={stopReason}
>

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useMemo, JSX } from "react";
import React, { useCallback, useEffect, useRef, useMemo, JSX } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
@@ -17,6 +17,66 @@ import { transformLinkUri, cn } from "@/lib/utils";
import { InMessageImage } from "@/app/app/components/files/images/InMessageImage";
import { extractChatImageFileId } from "@/app/app/components/files/images/utils";
/** Table wrapper that detects horizontal overflow and shows a fade + scrollbar. */
interface ScrollableTableProps
extends React.TableHTMLAttributes<HTMLTableElement> {
children: React.ReactNode;
}
export function ScrollableTable({
className,
children,
...props
}: ScrollableTableProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const wrapRef = useRef<HTMLDivElement>(null);
const tableRef = useRef<HTMLTableElement>(null);
useEffect(() => {
const el = scrollRef.current;
const wrap = wrapRef.current;
const table = tableRef.current;
if (!el || !wrap) return;
const check = () => {
const overflows = el.scrollWidth > el.clientWidth;
const atEnd = el.scrollLeft + el.clientWidth >= el.scrollWidth - 2;
wrap.dataset.overflows = overflows && !atEnd ? "true" : "false";
el.dataset.scrolled = el.scrollLeft > 0 ? "true" : "false";
};
check();
el.addEventListener("scroll", check, { passive: true });
// Observe both the scroll container (parent resize) and the table
// itself (content growth during streaming).
const ro = new ResizeObserver(check);
ro.observe(el);
if (table) ro.observe(table);
return () => {
el.removeEventListener("scroll", check);
ro.disconnect();
};
}, []);
return (
<div ref={wrapRef} className="markdown-table-card">
<div ref={scrollRef} className="markdown-table-breakout">
<table
ref={tableRef}
className={cn(
className,
"min-w-full !my-0 [&_th]:whitespace-nowrap [&_td]:whitespace-nowrap"
)}
{...props}
>
{children}
</table>
</div>
</div>
);
}
/**
* Processes content for markdown rendering by handling code blocks and LaTeX
*/
@@ -127,11 +187,9 @@ export const useMarkdownComponents = (
},
table: ({ node, className, children, ...props }: any) => {
return (
<div className="markdown-table-breakout">
<table className={cn(className, "min-w-full")} {...props}>
{children}
</table>
</div>
<ScrollableTable className={className} {...props}>
{children}
</ScrollableTable>
);
},
code: ({ node, className, children }: any) => {

View File

@@ -1,6 +1,14 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import Text from "@/refresh-components/texts/Text";
import ReactMarkdown, { Components } from "react-markdown";
import type { PluggableList } from "unified";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import rehypeHighlight from "rehype-highlight";
import rehypeKatex from "rehype-katex";
import "katex/dist/katex.min.css";
import { useTypewriter } from "@/hooks/useTypewriter";
import Text from "@/refresh-components/texts/Text";
import {
ChatPacket,
PacketType,
@@ -8,16 +16,22 @@ import {
} from "../../../services/streamingModels";
import { MessageRenderer, FullChatState } from "../interfaces";
import { isFinalAnswerComplete } from "../../../services/packetUtils";
import { useMarkdownRenderer } from "../markdownUtils";
import { processContent } from "../markdownUtils";
import { BlinkingBar } from "../../BlinkingBar";
import { useVoiceMode } from "@/providers/VoiceModeProvider";
import {
MemoizedAnchor,
MemoizedParagraph,
} from "@/app/app/message/MemoizedTextComponents";
import { extractCodeText } from "@/app/app/message/codeUtils";
import { CodeBlock } from "@/app/app/message/CodeBlock";
import { InMessageImage } from "@/app/app/components/files/images/InMessageImage";
import { extractChatImageFileId } from "@/app/app/components/files/images/utils";
import { cn, transformLinkUri } from "@/lib/utils";
/**
* Maps a cleaned character position to the corresponding position in markdown text.
* This allows progressive reveal to work with markdown formatting.
*/
/** Maps a visible-char count to a markdown index (skips formatting chars,
* extends to word boundary). Used by the voice-sync reveal path only. */
function getRevealPosition(markdown: string, cleanChars: number): number {
// Skip patterns that don't contribute to visible character count
const skipChars = new Set(["*", "`", "#"]);
let cleanIndex = 0;
let mdIndex = 0;
@@ -25,13 +39,11 @@ function getRevealPosition(markdown: string, cleanChars: number): number {
while (cleanIndex < cleanChars && mdIndex < markdown.length) {
const char = markdown[mdIndex];
// Skip markdown formatting characters
if (char !== undefined && skipChars.has(char)) {
mdIndex++;
continue;
}
// Handle link syntax [text](url) - skip the (url) part but count the text
if (
char === "]" &&
mdIndex + 1 < markdown.length &&
@@ -48,7 +60,6 @@ function getRevealPosition(markdown: string, cleanChars: number): number {
mdIndex++;
}
// Extend to word boundary to avoid cutting mid-word
while (
mdIndex < markdown.length &&
markdown[mdIndex] !== " " &&
@@ -60,8 +71,15 @@ function getRevealPosition(markdown: string, cleanChars: number): number {
return mdIndex;
}
// Control the rate of packet streaming (packets per second)
const PACKET_DELAY_MS = 10;
// Cheap streaming plugins (gfm only) → cheap per-frame parse. Full
// pipeline flips in once, at the end, for syntax highlighting + math.
const STREAMING_REMARK_PLUGINS: PluggableList = [remarkGfm];
const STREAMING_REHYPE_PLUGINS: PluggableList = [];
const FULL_REMARK_PLUGINS: PluggableList = [
remarkGfm,
[remarkMath, { singleDollarTextMath: true }],
];
const FULL_REHYPE_PLUGINS: PluggableList = [rehypeHighlight, rehypeKatex];
export const MessageTextRenderer: MessageRenderer<
ChatPacket,
@@ -78,19 +96,17 @@ export const MessageTextRenderer: MessageRenderer<
stopReason,
children,
}) => {
// If we're animating and the final answer is already complete, show more packets initially
const initialPacketCount = animate
? packets.length > 0
? 1 // Otherwise start with 1 packet
: 0
: -1; // Show all if not animating
const [displayedPacketCount, setDisplayedPacketCount] =
useState(initialPacketCount);
const lastStableSyncedContentRef = useRef("");
const lastVisibleContentRef = useRef("");
// Get voice mode context for progressive text reveal synced with audio
// Timeout guard: if TTS doesn't start within 5s of voice sync
// activating, fall back to normal streaming. Prevents permanent
// content suppression when the voice WebSocket fails to connect.
const [voiceSyncTimedOut, setVoiceSyncTimedOut] = useState(false);
const voiceSyncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null
);
const {
revealedCharCount,
autoPlayback,
@@ -99,7 +115,6 @@ export const MessageTextRenderer: MessageRenderer<
isAwaitingAutoPlaybackStart,
} = useVoiceMode();
// Get the full content from all packets
const fullContent = packets
.map((packet) => {
if (
@@ -114,117 +129,74 @@ export const MessageTextRenderer: MessageRenderer<
const shouldUseAutoPlaybackSync =
autoPlayback &&
!voiceSyncTimedOut &&
typeof messageNodeId === "number" &&
activeMessageNodeId === messageNodeId;
// Animation effect - gradually increase displayed packets at controlled rate
// Start/clear the timeout when voice sync activates/deactivates.
useEffect(() => {
if (!animate) {
setDisplayedPacketCount(-1); // Show all packets
return;
}
if (displayedPacketCount >= 0 && displayedPacketCount < packets.length) {
const timer = setTimeout(() => {
setDisplayedPacketCount((prev) => Math.min(prev + 1, packets.length));
}, PACKET_DELAY_MS);
return () => clearTimeout(timer);
}
}, [animate, displayedPacketCount, packets.length]);
// Reset displayed count when packet array changes significantly (e.g., new message)
useEffect(() => {
if (animate && packets.length < displayedPacketCount) {
const resetCount = isFinalAnswerComplete(packets)
? Math.min(10, packets.length)
: packets.length > 0
? 1
: 0;
setDisplayedPacketCount(resetCount);
}
}, [animate, packets.length, displayedPacketCount]);
// Only mark as complete when all packets are received AND displayed
useEffect(() => {
if (isFinalAnswerComplete(packets)) {
// If animating, wait until all packets are displayed
if (
animate &&
displayedPacketCount >= 0 &&
displayedPacketCount < packets.length
) {
return;
if (shouldUseAutoPlaybackSync && isAwaitingAutoPlaybackStart) {
if (!voiceSyncTimeoutRef.current) {
voiceSyncTimeoutRef.current = setTimeout(() => {
setVoiceSyncTimedOut(true);
}, 5000);
}
onComplete();
} else {
// TTS started or sync deactivated — clear timeout
if (voiceSyncTimeoutRef.current) {
clearTimeout(voiceSyncTimeoutRef.current);
voiceSyncTimeoutRef.current = null;
}
if (voiceSyncTimedOut && !autoPlayback) setVoiceSyncTimedOut(false);
}
}, [packets, onComplete, animate, displayedPacketCount]);
return () => {
if (voiceSyncTimeoutRef.current) {
clearTimeout(voiceSyncTimeoutRef.current);
voiceSyncTimeoutRef.current = null;
}
};
}, [
shouldUseAutoPlaybackSync,
isAwaitingAutoPlaybackStart,
isAudioSyncActive,
voiceSyncTimedOut,
]);
// Get content based on displayed packet count or audio progress
// Normal streaming hands full text to the typewriter. Voice-sync
// paths pre-slice and bypass. If shouldUseAutoPlaybackSync is false
// (including after the 5s timeout), all paths fall through to fullContent.
const computedContent = useMemo(() => {
// Hold response in "thinking" state only while autoplay startup is pending.
if (shouldUseAutoPlaybackSync && isAwaitingAutoPlaybackStart) {
return "";
}
// Sync text with audio only for the message currently being spoken.
if (shouldUseAutoPlaybackSync && isAudioSyncActive) {
const MIN_REVEAL_CHARS = 12;
if (revealedCharCount < MIN_REVEAL_CHARS) {
return "";
}
// Reveal text progressively based on audio progress
const revealPos = getRevealPosition(fullContent, revealedCharCount);
return fullContent.slice(0, Math.max(revealPos, 0));
}
// During an active synced turn, if sync temporarily drops, keep current reveal
// instead of jumping to full content or blanking.
if (shouldUseAutoPlaybackSync && !stopPacketSeen) {
return lastStableSyncedContentRef.current;
}
// Standard behavior when auto-playback is off
if (!animate || displayedPacketCount === -1) {
return fullContent; // Show all content
}
// Packet-based reveal (when auto-playback is disabled)
return packets
.slice(0, displayedPacketCount)
.map((packet) => {
if (
packet.obj.type === PacketType.MESSAGE_DELTA ||
packet.obj.type === PacketType.MESSAGE_START
) {
return packet.obj.content;
}
return "";
})
.join("");
return fullContent;
}, [
animate,
displayedPacketCount,
fullContent,
packets,
revealedCharCount,
autoPlayback,
isAudioSyncActive,
activeMessageNodeId,
isAwaitingAutoPlaybackStart,
messageNodeId,
shouldUseAutoPlaybackSync,
isAwaitingAutoPlaybackStart,
isAudioSyncActive,
revealedCharCount,
fullContent,
stopPacketSeen,
]);
// Keep synced text monotonic: once visible, never regress or disappear between chunks.
// Monotonic guard for voice sync + freeze on user cancel.
const content = useMemo(() => {
const wasUserCancelled = stopReason === StopReason.USER_CANCELLED;
// On user cancel during live streaming, freeze at exactly what was already
// visible to prevent flicker. On history reload (animate=false), the ref
// starts empty so we must use computedContent directly.
if (wasUserCancelled && animate) {
return lastVisibleContentRef.current;
}
@@ -242,13 +214,10 @@ export const MessageTextRenderer: MessageRenderer<
return computedContent;
}
// If content shape changed unexpectedly mid-stream, prefer the stable version
// to avoid flicker/dumps.
if (!stopPacketSeen || wasUserCancelled) {
return last;
}
// For normal completed responses, allow final full content.
return computedContent;
}, [
computedContent,
@@ -258,7 +227,6 @@ export const MessageTextRenderer: MessageRenderer<
animate,
]);
// Sync the stable ref outside of useMemo to avoid side effects during render.
useEffect(() => {
if (stopReason === StopReason.USER_CANCELLED) {
return;
@@ -270,13 +238,128 @@ export const MessageTextRenderer: MessageRenderer<
}
}, [content, shouldUseAutoPlaybackSync, stopReason]);
// Track last actually rendered content so cancel can freeze without dumping buffered text.
useEffect(() => {
if (content.length > 0) {
lastVisibleContentRef.current = content;
}
}, [content]);
const isStreamingAnimationEnabled =
animate &&
!shouldUseAutoPlaybackSync &&
stopReason !== StopReason.USER_CANCELLED;
const isStreamFinished = isFinalAnswerComplete(packets);
const displayedContent = useTypewriter(content, isStreamingAnimationEnabled);
// One-way signal: stream done AND typewriter caught up. Do NOT derive
// this from "typewriter currently behind" — it oscillates mid-stream
// between packet bursts and would thrash the plugin pipeline.
const streamFullyDisplayed =
isStreamFinished && displayedContent.length >= content.length;
// Fire onComplete exactly once per mount. `onComplete` is an inline
// arrow in AgentMessage so its identity changes on every parent render;
// without this guard, each new identity would re-fire the effect once
// `streamFullyDisplayed` is true.
const onCompleteFiredRef = useRef(false);
useEffect(() => {
if (streamFullyDisplayed && !onCompleteFiredRef.current) {
onCompleteFiredRef.current = true;
onComplete();
}
}, [streamFullyDisplayed, onComplete]);
const processedContent = useMemo(
() => processContent(displayedContent),
[displayedContent]
);
// Stable-identity components for ReactMarkdown. Dynamic data (`state`,
// `processedContent`) flows through refs so the callback identities
// never change — otherwise every typewriter tick would invalidate
// React reconciliation on the markdown subtree.
const stateRef = useRef(state);
stateRef.current = state;
const processedContentRef = useRef(processedContent);
processedContentRef.current = processedContent;
const markdownComponents = useMemo<Components>(
() => ({
a: ({ href, children }) => {
const s = stateRef.current;
const imageFileId = extractChatImageFileId(
href,
String(children ?? "")
);
if (imageFileId) {
return (
<InMessageImage
fileId={imageFileId}
fileName={String(children ?? "")}
/>
);
}
return (
<MemoizedAnchor
updatePresentingDocument={s?.setPresentingDocument || (() => {})}
docs={s?.docs || []}
userFiles={s?.userFiles || []}
citations={s?.citations}
href={href}
>
{children}
</MemoizedAnchor>
);
},
p: ({ children }) => (
<MemoizedParagraph className="font-main-content-body">
{children}
</MemoizedParagraph>
),
pre: ({ children }) => <>{children}</>,
b: ({ className, children }) => (
<span className={className}>{children}</span>
),
ul: ({ className, children, ...rest }) => (
<ul className={className} {...rest}>
{children}
</ul>
),
ol: ({ className, children, ...rest }) => (
<ol className={className} {...rest}>
{children}
</ol>
),
li: ({ className, children, ...rest }) => (
<li className={className} {...rest}>
{children}
</li>
),
table: ({ className, children, ...rest }) => (
<div className="markdown-table-breakout">
<table className={cn(className, "min-w-full")} {...rest}>
{children}
</table>
</div>
),
code: ({ node, className, children }) => {
const codeText = extractCodeText(
node,
processedContentRef.current,
children
);
return (
<CodeBlock className={className} codeText={codeText}>
{children}
</CodeBlock>
);
},
}),
[]
);
const shouldShowThinkingPlaceholder =
shouldUseAutoPlaybackSync &&
isAwaitingAutoPlaybackStart &&
@@ -292,16 +375,16 @@ export const MessageTextRenderer: MessageRenderer<
!stopPacketSeen;
const shouldShowCursor =
content.length > 0 &&
(!stopPacketSeen ||
displayedContent.length > 0 &&
((isStreamingAnimationEnabled && !streamFullyDisplayed) ||
(!isStreamingAnimationEnabled && !stopPacketSeen) ||
(shouldUseAutoPlaybackSync && content.length < fullContent.length));
const { renderedContent } = useMarkdownRenderer(
// the [*]() is a hack to show a blinking dot when the packet is not complete
shouldShowCursor ? content + " [*]() " : content,
state,
"font-main-content-body"
);
// `[*]() ` is rendered by the anchor component as an inline blinking
// caret, keeping it flush with the trailing character.
const markdownInput = shouldShowCursor
? processedContent + " [*]() "
: processedContent;
return children([
{
@@ -312,8 +395,26 @@ export const MessageTextRenderer: MessageRenderer<
<Text as="span" secondaryBody text04 className="italic">
Thinking
</Text>
) : content.length > 0 ? (
<>{renderedContent}</>
) : displayedContent.length > 0 ? (
<div dir="auto">
<ReactMarkdown
className="prose prose-onyx font-main-content-body max-w-full"
components={markdownComponents}
remarkPlugins={
streamFullyDisplayed
? FULL_REMARK_PLUGINS
: STREAMING_REMARK_PLUGINS
}
rehypePlugins={
streamFullyDisplayed
? FULL_REHYPE_PLUGINS
: STREAMING_REHYPE_PLUGINS
}
urlTransform={transformLinkUri}
>
{markdownInput}
</ReactMarkdown>
</div>
) : (
<BlinkingBar addMargin />
),

View File

@@ -320,7 +320,7 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
onSubmit({
message: submittedMessage,
currentMessageFiles: currentMessageFiles,
deepResearch: deepResearchEnabled,
deepResearch: deepResearchEnabled && !multiModel.isMultiModelActive,
additionalContext,
selectedModels,
});
@@ -332,7 +332,7 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
onSubmit({
message: chatMessage,
currentMessageFiles: currentMessageFiles,
deepResearch: deepResearchEnabled,
deepResearch: deepResearchEnabled && !multiModel.isMultiModelActive,
additionalContext,
selectedModels,
});
@@ -370,10 +370,16 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
onSubmit({
message: lastUserMsg.message,
currentMessageFiles: currentMessageFiles,
deepResearch: deepResearchEnabled,
deepResearch: deepResearchEnabled && !multiModel.isMultiModelActive,
messageIdToResend: lastUserMsg.messageId,
});
}, [messageHistory, onSubmit, currentMessageFiles, deepResearchEnabled]);
}, [
messageHistory,
onSubmit,
currentMessageFiles,
deepResearchEnabled,
multiModel.isMultiModelActive,
]);
// Start a new chat session in the side panel
const handleNewChat = useCallback(() => {
@@ -516,8 +522,7 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
ref={inputRef}
className={cn(
"w-full flex flex-col",
!isSidePanel &&
"max-w-[var(--app-page-main-content-width)] px-4"
!isSidePanel && "max-w-[var(--app-page-main-content-width)]"
)}
>
{hasMessages && liveAgent && !llmManager.isLoadingProviders && (
@@ -535,6 +540,7 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
ref={chatInputBarRef}
deepResearchEnabled={deepResearchEnabled}
toggleDeepResearch={toggleDeepResearch}
isMultiModelActive={multiModel.isMultiModelActive}
filterManager={filterManager}
llmManager={llmManager}
initialMessage={message}

View File

@@ -644,6 +644,7 @@ export default function useChatController({
});
node.modelDisplayName = model.displayName;
node.overridden_model = model.modelName;
node.is_generating = true;
return node;
});
}
@@ -711,6 +712,13 @@ export default function useChatController({
? selectedModels?.map((m) => m.displayName) ?? []
: [];
// rAF-batched flush state. One Zustand write per frame instead of
// one per packet.
const dirtyModelIndices = new Set<number>();
let singleModelDirty = false;
let userNodeDirty = false;
let pendingFlush = false;
/** Build a non-errored multi-model assistant node for upsert. */
function buildAssistantNodeUpdate(
idx: number,
@@ -740,16 +748,124 @@ export default function useChatController({
};
}
/** Build updated nodes for all non-errored models. */
function buildNonErroredNodes(overrides?: Partial<Message>): Message[] {
/** With `onlyDirty`, rebuilds only those model nodes — unchanged
* siblings keep their stable Message ref so React memo short-circuits. */
function buildNonErroredNodes(
overrides?: Partial<Message>,
onlyDirty?: Set<number> | null
): Message[] {
const nodes: Message[] = [];
for (let idx = 0; idx < initialAssistantNodes.length; idx++) {
if (erroredModelIndices.has(idx)) continue;
if (onlyDirty && !onlyDirty.has(idx)) continue;
nodes.push(buildAssistantNodeUpdate(idx, overrides));
}
return nodes;
}
/** Flush accumulated packet state into the tree as one Zustand
* update. No-op when nothing is pending. */
function flushPendingUpdates() {
if (!pendingFlush) return;
pendingFlush = false;
parentMessage =
parentMessage || currentMessageTreeLocal?.get(SYSTEM_NODE_ID)!;
let messagesToUpsert: Message[];
if (isMultiModel) {
if (dirtyModelIndices.size === 0 && !userNodeDirty) return;
const dirtySnapshot = new Set(dirtyModelIndices);
dirtyModelIndices.clear();
const dirtyNodes = buildNonErroredNodes(undefined, dirtySnapshot);
if (userNodeDirty) {
userNodeDirty = false;
// Read current user node to preserve childrenNodeIds
// (initialUserNode's are stale from creation time).
const currentUserNode =
currentMessageTreeLocal.get(initialUserNode.nodeId) ||
initialUserNode;
const updatedUserNode: Message = {
...currentUserNode,
messageId: newUserMessageId ?? undefined,
files: files,
};
messagesToUpsert = [updatedUserNode, ...dirtyNodes];
} else {
messagesToUpsert = dirtyNodes;
}
if (messagesToUpsert.length === 0) return;
} else {
if (!singleModelDirty) return;
singleModelDirty = false;
messagesToUpsert = [
{
...initialUserNode,
messageId: newUserMessageId ?? undefined,
files: files,
},
{
...initialAgentNode,
messageId: newAgentMessageId ?? undefined,
message: error || answer,
type: error ? "error" : "assistant",
retrievalType,
query: finalMessage?.rephrased_query || query,
documents: documents,
citations: finalMessage?.citations || citations || {},
files: finalMessage?.files || aiMessageImages || [],
toolCall: finalMessage?.tool_call || toolCall,
stackTrace: stackTrace,
overridden_model: finalMessage?.overridden_model,
stopReason: stopReason,
packets: packets,
packetCount: packets.length,
processingDurationSeconds:
finalMessage?.processing_duration_seconds ??
(() => {
const startTime = useChatSessionStore
.getState()
.getStreamingStartTime(frozenSessionId);
return startTime
? Math.floor((Date.now() - startTime) / 1000)
: undefined;
})(),
},
];
}
currentMessageTreeLocal = upsertToCompleteMessageTree({
messages: messagesToUpsert,
completeMessageTreeOverride: currentMessageTreeLocal,
chatSessionId: frozenSessionId!,
});
}
/** Awaits next animation frame (or a setTimeout fallback when the
* tab is hidden — rAF is paused in background tabs, which would
* otherwise hang the stream loop here), then flushes. Aligns
* React updates with the paint cycle when visible. */
function flushViaRAF(): Promise<void> {
return new Promise<void>((resolve) => {
let done = false;
const flush = () => {
if (done) return;
done = true;
flushPendingUpdates();
resolve();
};
requestAnimationFrame(flush);
// Fallback for hidden tabs where rAF is paused. Throttled to
// ~1s by browsers, matching the previous setTimeout(500) cadence.
setTimeout(flush, 100);
});
}
let streamSucceeded = false;
try {
@@ -836,7 +952,12 @@ export default function useChatController({
await delay(50);
while (!stack.isComplete || !stack.isEmpty()) {
if (stack.isEmpty()) {
await delay(0.5);
// Flush the burst on the next paint, or idle briefly.
if (pendingFlush) {
await flushViaRAF();
} else {
await delay(0.5);
}
}
if (!stack.isEmpty() && !controller.signal.aborted) {
@@ -860,6 +981,7 @@ export default function useChatController({
if ((packet as MessageResponseIDInfo).user_message_id) {
newUserMessageId = (packet as MessageResponseIDInfo)
.user_message_id;
userNodeDirty = true;
// Track extension queries in PostHog (reuses isExtension/extensionContext from above)
if (isExtension) {
@@ -898,6 +1020,8 @@ export default function useChatController({
modelDisplayNames[mi] = slot.model_name;
}
}
userNodeDirty = true;
pendingFlush = true;
continue;
}
@@ -909,6 +1033,7 @@ export default function useChatController({
!files.some((existingFile) => existingFile.id === newFile.id)
);
files = files.concat(newUserFiles);
if (newUserFiles.length > 0) userNodeDirty = true;
}
if (Object.hasOwn(packet, "file_ids")) {
@@ -928,15 +1053,20 @@ export default function useChatController({
// In multi-model mode, route per-model errors to the specific model's
// node instead of killing the entire stream. Other models keep streaming.
if (isMultiModel && streamingError.details?.model_index != null) {
const errorModelIndex = streamingError.details
.model_index as number;
if (isMultiModel) {
// Multi-model: isolate the error to its panel. Never throw
// or set global error state — other models keep streaming.
const errorModelIndex = streamingError.details?.model_index as
| number
| undefined;
if (
errorModelIndex != null &&
errorModelIndex >= 0 &&
errorModelIndex < initialAssistantNodes.length
) {
const errorNode = initialAssistantNodes[errorModelIndex]!;
erroredModelIndices.add(errorModelIndex);
dirtyModelIndices.delete(errorModelIndex);
currentMessageTreeLocal = upsertToCompleteMessageTree({
messages: [
{
@@ -963,8 +1093,15 @@ export default function useChatController({
completeMessageTreeOverride: currentMessageTreeLocal,
chatSessionId: frozenSessionId!,
});
} else {
// Error without model_index in multi-model — can't route
// to a specific panel. Log and continue; the stream loop
// stays alive for other models.
console.warn(
"Multi-model error without model_index:",
streamingError.error
);
}
// Skip the normal per-packet upsert — we already upserted the error node
continue;
} else {
// Single-model: kill the stream
@@ -993,19 +1130,21 @@ export default function useChatController({
if (isMultiModel) {
// Multi-model: route packet by placement.model_index.
// OverallStop (type "stop") has model_index=null — it's a global
// terminal packet that must be delivered to ALL models so each
// panel's AgentMessage sees the stop and exits "Thinking..." state.
// OverallStop (type "stop") has model_index=null — it's a
// global terminal packet that must be delivered to ALL
// models so each panel's AgentMessage sees the stop and
// exits "Thinking..." state.
const isGlobalStop =
packetObj.type === "stop" &&
typedPacket.placement?.model_index == null;
if (isGlobalStop) {
for (let mi = 0; mi < packetsPerModel.length; mi++) {
packetsPerModel[mi] = [
...packetsPerModel[mi]!,
typedPacket,
];
// Mutated in place — change detection uses packetCount, not array identity.
packetsPerModel[mi]!.push(typedPacket);
if (!erroredModelIndices.has(mi)) {
dirtyModelIndices.add(mi);
}
}
}
@@ -1015,10 +1154,10 @@ export default function useChatController({
modelIndex >= 0 &&
modelIndex < packetsPerModel.length
) {
packetsPerModel[modelIndex] = [
...packetsPerModel[modelIndex]!,
typedPacket,
];
packetsPerModel[modelIndex]!.push(typedPacket);
if (!erroredModelIndices.has(modelIndex)) {
dirtyModelIndices.add(modelIndex);
}
if (packetObj.type === "citation_info") {
const citationInfo = packetObj as {
@@ -1048,6 +1187,7 @@ export default function useChatController({
// Single-model
packets.push(typedPacket);
packetsVersion++;
singleModelDirty = true;
if (packetObj.type === "citation_info") {
const citationInfo = packetObj as {
@@ -1074,73 +1214,16 @@ export default function useChatController({
console.warn("Unknown packet:", JSON.stringify(packet));
}
// on initial message send, we insert a dummy system message
// set this as the parent here if no parent is set
parentMessage =
parentMessage || currentMessageTreeLocal?.get(SYSTEM_NODE_ID)!;
// Build the messages to upsert based on single vs multi-model mode
let messagesToUpsertInLoop: Message[];
if (isMultiModel) {
// Read the current user node from the tree to preserve childrenNodeIds
// (initialUserNode has stale/empty children from creation time).
const currentUserNode =
currentMessageTreeLocal.get(initialUserNode.nodeId) ||
initialUserNode;
const updatedUserNode: Message = {
...currentUserNode,
messageId: newUserMessageId ?? undefined,
files: files,
};
messagesToUpsertInLoop = [
updatedUserNode,
...buildNonErroredNodes(),
];
} else {
messagesToUpsertInLoop = [
{
...initialUserNode,
messageId: newUserMessageId ?? undefined,
files: files,
},
{
...initialAgentNode,
messageId: newAgentMessageId ?? undefined,
message: error || answer,
type: error ? "error" : "assistant",
retrievalType,
query: finalMessage?.rephrased_query || query,
documents: documents,
citations: finalMessage?.citations || citations || {},
files: finalMessage?.files || aiMessageImages || [],
toolCall: finalMessage?.tool_call || toolCall,
stackTrace: stackTrace,
overridden_model: finalMessage?.overridden_model,
stopReason: stopReason,
packets: packets,
packetCount: packets.length,
processingDurationSeconds:
finalMessage?.processing_duration_seconds ??
(() => {
const startTime = useChatSessionStore
.getState()
.getStreamingStartTime(frozenSessionId);
return startTime
? Math.floor((Date.now() - startTime) / 1000)
: undefined;
})(),
},
];
}
currentMessageTreeLocal = upsertToCompleteMessageTree({
messages: messagesToUpsertInLoop,
completeMessageTreeOverride: currentMessageTreeLocal,
chatSessionId: frozenSessionId!,
});
// Mark dirty — flushViaRAF coalesces bursts into one React update per frame.
if (!isMultiModel) singleModelDirty = true;
pendingFlush = true;
}
}
// Flush any tail state from the final packet(s) before declaring
// the stream complete. Without this, the last ≤1 frame of packets
// could get stranded in local state.
flushPendingUpdates();
// Surface FIFO errors (e.g. 429 before any packets arrive) so the
// catch block replaces the thinking placeholder with an error message.
if (stack.error) {
@@ -1174,6 +1257,7 @@ export default function useChatController({
errorCode,
isRetryable,
errorDetails,
is_generating: false,
})
: [
{

View File

@@ -48,6 +48,7 @@ describe("useSettings", () => {
anonymous_user_enabled: false,
invite_only_enabled: false,
deep_research_enabled: true,
multi_model_chat_enabled: true,
temperature_override_enabled: true,
query_history_type: QueryHistoryType.NORMAL,
});
@@ -65,6 +66,7 @@ describe("useSettings", () => {
anonymous_user_enabled: false,
invite_only_enabled: false,
deep_research_enabled: true,
multi_model_chat_enabled: true,
temperature_override_enabled: true,
query_history_type: QueryHistoryType.NORMAL,
};

View File

@@ -23,6 +23,7 @@ const DEFAULT_SETTINGS = {
anonymous_user_enabled: false,
invite_only_enabled: false,
deep_research_enabled: true,
multi_model_chat_enabled: true,
temperature_override_enabled: true,
query_history_type: QueryHistoryType.NORMAL,
} satisfies Settings;

View File

@@ -0,0 +1,117 @@
import { useEffect, useMemo, useRef, useState } from "react";
// Fixed reveal rate — NOT adaptive. Any ceil(delta/N) formula produces
// visible chunks on burst packet arrivals. 1 = 60 cps, 2 = 120 cps.
const CHARS_PER_FRAME = 2;
/**
* Reveals `target` one character at a time on each animation frame.
* When `enabled` is false (historical messages), snaps to full on mount.
* The rAF loop pauses once caught up and resumes when `target` grows.
*/
export function useTypewriter(target: string, enabled: boolean): string {
// Ref so the rAF loop reads latest length without restarting.
const targetRef = useRef(target);
targetRef.current = target;
// Mirror `enabled` so the restart effect can short-circuit when the
// caller has turned animation off (e.g. voice-mode, where display is
// driven by audio position — the typewriter must stay idle and not
// animate a jump after audio ends).
const enabledRef = useRef(enabled);
enabledRef.current = enabled;
// `enabled` controls initial state: animate from 0 vs snap to full for
// history/voice. Transitions mid-stream are handled via enabledRef in
// the restart effect so a flip to false doesn't dump the buffered tail
// *and* doesn't spin up the rAF loop on later growth.
const [displayedLength, setDisplayedLength] = useState<number>(
enabled ? 0 : target.length
);
// Mirror displayedLength in a ref so the rAF loop can read the latest
// value without stale-closure issues AND without needing a functional
// state updater (which must be pure — no ref mutations inside).
const displayedLengthRef = useRef(displayedLength);
// Clamp (not reset) on target shrink — preserves already-revealed chars
// across user-cancel freeze and regeneration.
const prevTargetLengthRef = useRef(target.length);
useEffect(() => {
if (target.length < prevTargetLengthRef.current) {
const clamped = Math.min(displayedLengthRef.current, target.length);
displayedLengthRef.current = clamped;
setDisplayedLength(clamped);
}
prevTargetLengthRef.current = target.length;
}, [target.length]);
// Self-scheduling rAF loop. Pauses when caught up so idle/historical
// messages don't run a 60fps no-op updater for their entire lifetime.
const rafIdRef = useRef<number | null>(null);
const runningRef = useRef(false);
const startLoopRef = useRef<(() => void) | null>(null);
useEffect(() => {
const tick = () => {
const targetLen = targetRef.current.length;
const prev = displayedLengthRef.current;
if (prev >= targetLen) {
// Caught up — pause the loop. The sibling effect below will
// restart it when `target` grows.
runningRef.current = false;
rafIdRef.current = null;
return;
}
const next = Math.min(prev + CHARS_PER_FRAME, targetLen);
displayedLengthRef.current = next;
setDisplayedLength(next);
rafIdRef.current = requestAnimationFrame(tick);
};
const start = () => {
if (runningRef.current) return;
// Animation disabled — snap to full and stay idle. This is the
// voice-mode path where content is driven by audio position, and
// any "gap" (e.g. user stops audio early) must jump instantly
// instead of animating a 1500-char typewriter burst.
if (!enabledRef.current) {
const targetLen = targetRef.current.length;
if (displayedLengthRef.current !== targetLen) {
displayedLengthRef.current = targetLen;
setDisplayedLength(targetLen);
}
return;
}
runningRef.current = true;
rafIdRef.current = requestAnimationFrame(tick);
};
startLoopRef.current = start;
if (targetRef.current.length > displayedLengthRef.current) {
start();
}
return () => {
runningRef.current = false;
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null;
}
startLoopRef.current = null;
};
}, []);
// Restart the loop when target grows past what's currently displayed.
useEffect(() => {
if (target.length > displayedLength && startLoopRef.current) {
startLoopRef.current();
}
}, [target.length, displayedLength]);
return useMemo(
() => target.slice(0, Math.min(displayedLength, target.length)),
[target, displayedLength]
);
}

View File

@@ -27,6 +27,7 @@ export interface Settings {
query_history_type: QueryHistoryType;
deep_research_enabled?: boolean;
multi_model_chat_enabled?: boolean;
search_ui_enabled?: boolean;
// Image processing settings

View File

@@ -9,6 +9,7 @@ import { useField, useFormikContext } from "formik";
import { Section } from "@/layouts/general-layouts";
import { Content } from "@opal/layouts";
import Label from "@/refresh-components/form/Label";
import type { TagProps } from "@opal/components/tag/components";
interface OrientationLayoutProps {
name?: string;
@@ -16,6 +17,8 @@ interface OrientationLayoutProps {
nonInteractive?: boolean;
children?: React.ReactNode;
title: string | RichStr;
/** Tag rendered inline beside the title (passed through to Content). */
tag?: TagProps;
description?: string | RichStr;
suffix?: "optional" | (string & {});
sizePreset?: "main-content" | "main-ui";
@@ -128,6 +131,7 @@ function HorizontalInputLayout({
children,
center,
title,
tag,
description,
suffix,
sizePreset = "main-content",
@@ -144,6 +148,7 @@ function HorizontalInputLayout({
title={title}
description={description}
suffix={suffix}
tag={tag}
sizePreset={sizePreset}
variant="section"
widthVariant="full"

View File

@@ -6,6 +6,7 @@ import { LlmManager } from "@/lib/hooks";
import { getModelIcon } from "@/lib/llmConfig";
import { Button, SelectButton, OpenButton } from "@opal/components";
import { SvgPlusCircle, SvgX } from "@opal/icons";
import { useSettingsContext } from "@/providers/SettingsProvider";
import { LLMOption } from "@/refresh-components/popovers/interfaces";
import ModelListContent from "@/refresh-components/popovers/ModelListContent";
import Separator from "@/refresh-components/Separator";
@@ -44,8 +45,12 @@ export default function ModelSelector({
// Virtual anchor ref — points to the clicked pill so the popover positions above it
const anchorRef = useRef<HTMLElement | null>(null);
const settings = useSettingsContext();
const multiModelAllowed =
settings?.settings?.multi_model_chat_enabled ?? true;
const isMultiModel = selectedModels.length > 1;
const atMax = selectedModels.length >= MAX_MODELS;
const atMax = selectedModels.length >= MAX_MODELS || !multiModelAllowed;
const selectedKeys = useMemo(
() => new Set(selectedModels.map((m) => modelKey(m.provider, m.modelName))),

View File

@@ -522,7 +522,8 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
onSubmit({
message: lastUserMsg.message,
currentMessageFiles: currentMessageFiles,
deepResearch: deepResearchEnabledForCurrentWorkflow,
deepResearch:
deepResearchEnabledForCurrentWorkflow && !multiModel.isMultiModelActive,
messageIdToResend: lastUserMsg.messageId,
});
}, [
@@ -530,6 +531,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
onSubmit,
currentMessageFiles,
deepResearchEnabledForCurrentWorkflow,
multiModel.isMultiModelActive,
]);
const toggleDocumentSidebar = useCallback(() => {
@@ -553,7 +555,9 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
onSubmit({
message,
currentMessageFiles,
deepResearch: deepResearchEnabledForCurrentWorkflow,
deepResearch:
deepResearchEnabledForCurrentWorkflow &&
!multiModel.isMultiModelActive,
selectedModels: multiModel.isMultiModelActive
? multiModel.selectedModels
: undefined,
@@ -607,7 +611,9 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
onSubmit({
message,
currentMessageFiles,
deepResearch: deepResearchEnabledForCurrentWorkflow,
deepResearch:
deepResearchEnabledForCurrentWorkflow &&
!multiModel.isMultiModelActive,
selectedModels: multiModel.isMultiModelActive
? multiModel.selectedModels
: undefined,
@@ -980,6 +986,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
deepResearchEnabledForCurrentWorkflow
}
toggleDeepResearch={toggleDeepResearch}
isMultiModelActive={multiModel.isMultiModelActive}
filterManager={filterManager}
llmManager={llmManager}
initialMessage={

View File

@@ -1,9 +1,9 @@
"use client";
import { markdown } from "@opal/utils";
import React, { useCallback, useRef, useState } from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Formik, Form, useFormikContext } from "formik";
import { Formik, Form } from "formik";
import useSWR, { mutate } from "swr";
import { SWR_KEYS } from "@/lib/swr-keys";
import { errorHandlingFetcher } from "@/lib/fetcher";
@@ -14,10 +14,9 @@ import Card from "@/refresh-components/cards/Card";
import Separator from "@/refresh-components/Separator";
import SimpleCollapsible from "@/refresh-components/SimpleCollapsible";
import SimpleTooltip from "@/refresh-components/SimpleTooltip";
import SwitchField from "@/refresh-components/form/SwitchField";
import InputTypeInField from "@/refresh-components/form/InputTypeInField";
import InputTextAreaField from "@/refresh-components/form/InputTextAreaField";
import InputSelectField from "@/refresh-components/form/InputSelectField";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import InputTextArea from "@/refresh-components/inputs/InputTextArea";
import InputSelect from "@/refresh-components/inputs/InputSelect";
import {
SvgAddLines,
@@ -57,7 +56,6 @@ import * as ActionsLayouts from "@/layouts/actions-layouts";
import { getActionIcon } from "@/lib/tools/mcpUtils";
import { Disabled, Hoverable } from "@opal/core";
import IconButton from "@/refresh-components/buttons/IconButton";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import useFilter from "@/hooks/useFilter";
import { MCPServer } from "@/lib/tools/interfaces";
import type { IconProps } from "@opal/types";
@@ -70,26 +68,6 @@ interface DefaultAgentConfiguration {
default_system_prompt: string;
}
interface ChatPreferencesFormValues {
// Features
search_ui_enabled: boolean;
deep_research_enabled: boolean;
auto_scroll: boolean;
// Team context
company_name: string;
company_description: string;
// Advanced
maximum_chat_retention_days: string;
anonymous_user_enabled: boolean;
disable_default_assistant: boolean;
// File limits
user_file_max_upload_size_mb: string;
file_token_count_threshold_k: string;
}
interface MCPServerCardTool {
id: number;
icon: React.FunctionComponent<IconProps>;
@@ -198,6 +176,7 @@ type FileLimitFieldName =
interface NumericLimitFieldProps {
name: FileLimitFieldName;
initialValue: string;
defaultValue: string;
saveSettings: (updates: Partial<Settings>) => Promise<void>;
maxValue?: number;
@@ -206,16 +185,15 @@ interface NumericLimitFieldProps {
function NumericLimitField({
name,
initialValue: initialValueProp,
defaultValue,
saveSettings,
maxValue,
allowZero = false,
}: NumericLimitFieldProps) {
const { values, setFieldValue } =
useFormikContext<ChatPreferencesFormValues>();
const initialValue = useRef(values[name]);
const [value, setValue] = useState(initialValueProp);
const savedValue = useRef(initialValueProp);
const restoringRef = useRef(false);
const value = values[name];
const parsed = parseInt(value, 10);
const isOverMax =
@@ -223,8 +201,8 @@ function NumericLimitField({
const handleRestore = () => {
restoringRef.current = true;
initialValue.current = defaultValue;
void setFieldValue(name, defaultValue);
savedValue.current = defaultValue;
setValue(defaultValue);
void saveSettings({ [name]: parseInt(defaultValue, 10) });
};
@@ -242,11 +220,11 @@ function NumericLimitField({
if (!isValid) {
if (allowZero) {
// Empty/invalid means "no limit" — persist 0 and clear the field.
void setFieldValue(name, "");
setValue("");
void saveSettings({ [name]: 0 });
initialValue.current = "";
savedValue.current = "";
} else {
void setFieldValue(name, initialValue.current);
setValue(savedValue.current);
}
return;
}
@@ -259,10 +237,10 @@ function NumericLimitField({
// For allowZero fields, 0 means "no limit" — clear the display
// so the "No limit" placeholder is visible, but still persist 0.
if (allowZero && parsed === 0) {
void setFieldValue(name, "");
if (initialValue.current !== "") {
setValue("");
if (savedValue.current !== "") {
void saveSettings({ [name]: 0 });
initialValue.current = "";
savedValue.current = "";
}
return;
}
@@ -271,23 +249,24 @@ function NumericLimitField({
// Update the display to the canonical form (e.g. strip leading zeros).
if (value !== normalizedDisplay) {
void setFieldValue(name, normalizedDisplay);
setValue(normalizedDisplay);
}
// Persist only when the value actually changed.
if (normalizedDisplay !== initialValue.current) {
if (normalizedDisplay !== savedValue.current) {
void saveSettings({ [name]: parsed });
initialValue.current = normalizedDisplay;
savedValue.current = normalizedDisplay;
}
};
return (
<Hoverable.Root group="numericLimit" widthVariant="full">
<InputTypeInField
name={name}
<InputTypeIn
inputMode="numeric"
showClearButton={false}
pattern="[0-9]*"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={allowZero ? "No limit" : `Default: ${defaultValue}`}
variant={isOverMax ? "error" : undefined}
rightSection={
@@ -311,14 +290,18 @@ function NumericLimitField({
interface FileSizeLimitFieldsProps {
saveSettings: (updates: Partial<Settings>) => Promise<void>;
initialUploadSizeMb: string;
defaultUploadSizeMb: string;
initialTokenThresholdK: string;
defaultTokenThresholdK: string;
maxAllowedUploadSizeMb?: number;
}
function FileSizeLimitFields({
saveSettings,
initialUploadSizeMb,
defaultUploadSizeMb,
initialTokenThresholdK,
defaultTokenThresholdK,
maxAllowedUploadSizeMb,
}: FileSizeLimitFieldsProps) {
@@ -336,6 +319,7 @@ function FileSizeLimitFields({
>
<NumericLimitField
name="user_file_max_upload_size_mb"
initialValue={initialUploadSizeMb}
defaultValue={defaultUploadSizeMb}
saveSettings={saveSettings}
maxValue={maxAllowedUploadSizeMb}
@@ -349,6 +333,7 @@ function FileSizeLimitFields({
>
<NumericLimitField
name="file_token_count_threshold_k"
initialValue={initialTokenThresholdK}
defaultValue={defaultTokenThresholdK}
saveSettings={saveSettings}
allowZero
@@ -359,18 +344,39 @@ function FileSizeLimitFields({
);
}
/**
* Inner form component that uses useFormikContext to access values
* and create save handlers for settings fields.
*/
function ChatPreferencesForm() {
const router = useRouter();
const settings = useSettingsContext();
const { values } = useFormikContext<ChatPreferencesFormValues>();
const s = settings.settings;
// Track initial text values to avoid unnecessary saves on blur
const initialCompanyName = useRef(values.company_name);
const initialCompanyDescription = useRef(values.company_description);
// Local state for text fields (save-on-blur)
const [companyName, setCompanyName] = useState(s.company_name ?? "");
const [companyDescription, setCompanyDescription] = useState(
s.company_description ?? ""
);
const savedCompanyName = useRef(companyName);
const savedCompanyDescription = useRef(companyDescription);
// Re-sync local state when settings change externally (e.g. another admin),
// but only when there's no in-progress edit (local matches last-saved value).
useEffect(() => {
const incoming = s.company_name ?? "";
if (companyName === savedCompanyName.current && incoming !== companyName) {
setCompanyName(incoming);
savedCompanyName.current = incoming;
}
}, [s.company_name]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
const incoming = s.company_description ?? "";
if (
companyDescription === savedCompanyDescription.current &&
incoming !== companyDescription
) {
setCompanyDescription(incoming);
savedCompanyDescription.current = incoming;
}
}, [s.company_description]); // eslint-disable-line react-hooks/exhaustive-deps
// Tools availability
const { tools: availableTools } = useAvailableTools();
@@ -526,16 +532,18 @@ function ChatPreferencesForm() {
<InputLayouts.Vertical
title="Team Name"
subDescription="This is added to all chat sessions as additional context to provide a richer/customized experience."
nonInteractive
>
<InputTypeInField
name="company_name"
<InputTypeIn
placeholder="Enter team name"
value={companyName}
onChange={(e) => setCompanyName(e.target.value)}
onBlur={() => {
if (values.company_name !== initialCompanyName.current) {
if (companyName !== savedCompanyName.current) {
void saveSettings({
company_name: values.company_name || null,
company_name: companyName || null,
});
initialCompanyName.current = values.company_name;
savedCompanyName.current = companyName;
}
}}
/>
@@ -544,23 +552,21 @@ function ChatPreferencesForm() {
<InputLayouts.Vertical
title="Team Context"
subDescription="Users can also provide additional individual context in their personal settings."
nonInteractive
>
<InputTextAreaField
name="company_description"
<InputTextArea
placeholder="Describe your team and how Onyx should behave."
rows={4}
maxRows={10}
autoResize
value={companyDescription}
onChange={(e) => setCompanyDescription(e.target.value)}
onBlur={() => {
if (
values.company_description !==
initialCompanyDescription.current
) {
if (companyDescription !== savedCompanyDescription.current) {
void saveSettings({
company_description: values.company_description || null,
company_description: companyDescription || null,
});
initialCompanyDescription.current =
values.company_description;
savedCompanyDescription.current = companyDescription;
}
}}
/>
@@ -604,9 +610,10 @@ function ChatPreferencesForm() {
title="Search Mode"
description="UI mode for quick document search across your organization."
disabled={uniqueSources.length === 0}
nonInteractive
>
<SwitchField
name="search_ui_enabled"
<Switch
checked={s.search_ui_enabled ?? false}
onCheckedChange={(checked) => {
void saveSettings({ search_ui_enabled: checked });
}}
@@ -616,12 +623,26 @@ function ChatPreferencesForm() {
</div>
</Disabled>
</SimpleTooltip>
<InputLayouts.Horizontal
title="Multi-Model Generation"
tag={{ title: "beta", color: "blue" }}
description="Allow multiple models to generate responses in parallel in chat."
nonInteractive
>
<Switch
checked={s.multi_model_chat_enabled ?? true}
onCheckedChange={(checked) => {
void saveSettings({ multi_model_chat_enabled: checked });
}}
/>
</InputLayouts.Horizontal>
<InputLayouts.Horizontal
title="Deep Research"
description="Agentic research system that works across the web and connected sources. Uses significantly more tokens per query."
nonInteractive
>
<SwitchField
name="deep_research_enabled"
<Switch
checked={s.deep_research_enabled ?? true}
onCheckedChange={(checked) => {
void saveSettings({ deep_research_enabled: checked });
}}
@@ -630,9 +651,10 @@ function ChatPreferencesForm() {
<InputLayouts.Horizontal
title="Chat Auto-Scroll"
description="Automatically scroll to new content as chat generates response. Users can override this in their personal settings."
nonInteractive
>
<SwitchField
name="auto_scroll"
<Switch
checked={s.auto_scroll ?? false}
onCheckedChange={(checked) => {
void saveSettings({ auto_scroll: checked });
}}
@@ -643,7 +665,7 @@ function ChatPreferencesForm() {
<Separator noPadding />
<Disabled disabled={values.disable_default_assistant}>
<Disabled disabled={s.disable_default_assistant ?? false}>
<div>
<Section gap={1.5}>
{/* Connectors */}
@@ -873,9 +895,12 @@ function ChatPreferencesForm() {
<InputLayouts.Horizontal
title="Keep Chat History"
description="Specify how long Onyx should retain chats in your organization."
nonInteractive
>
<InputSelectField
name="maximum_chat_retention_days"
<InputSelect
value={
s.maximum_chat_retention_days?.toString() ?? "forever"
}
onValueChange={(value) => {
void saveSettings({
maximum_chat_retention_days:
@@ -895,7 +920,7 @@ function ChatPreferencesForm() {
365 days
</InputSelect.Item>
</InputSelect.Content>
</InputSelectField>
</InputSelect>
</InputLayouts.Horizontal>
</Card>
@@ -906,17 +931,29 @@ function ChatPreferencesForm() {
>
<FileSizeLimitFields
saveSettings={saveSettings}
initialUploadSizeMb={
(s.user_file_max_upload_size_mb ?? 0) <= 0
? s.default_user_file_max_upload_size_mb?.toString() ??
"100"
: s.user_file_max_upload_size_mb!.toString()
}
defaultUploadSizeMb={
settings?.settings.default_user_file_max_upload_size_mb?.toString() ??
s.default_user_file_max_upload_size_mb?.toString() ??
"100"
}
initialTokenThresholdK={
s.file_token_count_threshold_k == null
? s.default_file_token_count_threshold_k?.toString() ??
"200"
: s.file_token_count_threshold_k === 0
? ""
: s.file_token_count_threshold_k.toString()
}
defaultTokenThresholdK={
settings?.settings.default_file_token_count_threshold_k?.toString() ??
s.default_file_token_count_threshold_k?.toString() ??
"200"
}
maxAllowedUploadSizeMb={
settings?.settings.max_allowed_upload_size_mb
}
maxAllowedUploadSizeMb={s.max_allowed_upload_size_mb}
/>
</InputLayouts.Vertical>
</Card>
@@ -925,9 +962,10 @@ function ChatPreferencesForm() {
<InputLayouts.Horizontal
title="Allow Anonymous Users"
description="Allow anyone to start chats without logging in. They do not see any other chats and cannot create agents or update settings."
nonInteractive
>
<SwitchField
name="anonymous_user_enabled"
<Switch
checked={s.anonymous_user_enabled ?? false}
onCheckedChange={(checked) => {
void saveSettings({ anonymous_user_enabled: checked });
}}
@@ -937,9 +975,11 @@ function ChatPreferencesForm() {
<InputLayouts.Horizontal
title="Always Start with an Agent"
description="This removes the default chat. Users will always start in an agent, and new chats will be created in their last active agent. Set featured agents to help new users get started."
nonInteractive
>
<SwitchField
name="disable_default_assistant"
<Switch
id="disable_default_assistant"
checked={s.disable_default_assistant ?? false}
onCheckedChange={(checked) => {
void saveSettings({
disable_default_assistant: checked,
@@ -1042,50 +1082,5 @@ function ChatPreferencesForm() {
}
export default function ChatPreferencesPage() {
const settings = useSettingsContext();
const initialValues: ChatPreferencesFormValues = {
// Features
search_ui_enabled: settings.settings.search_ui_enabled ?? false,
deep_research_enabled: settings.settings.deep_research_enabled ?? true,
auto_scroll: settings.settings.auto_scroll ?? false,
// Team context
company_name: settings.settings.company_name ?? "",
company_description: settings.settings.company_description ?? "",
// Advanced
maximum_chat_retention_days:
settings.settings.maximum_chat_retention_days?.toString() ?? "forever",
anonymous_user_enabled: settings.settings.anonymous_user_enabled ?? false,
disable_default_assistant:
settings.settings.disable_default_assistant ?? false,
// File limits — for upload size: 0/null means "use default";
// for token threshold: null means "use default", 0 means "no limit".
user_file_max_upload_size_mb:
(settings.settings.user_file_max_upload_size_mb ?? 0) <= 0
? settings.settings.default_user_file_max_upload_size_mb?.toString() ??
"100"
: settings.settings.user_file_max_upload_size_mb!.toString(),
file_token_count_threshold_k:
settings.settings.file_token_count_threshold_k == null
? settings.settings.default_file_token_count_threshold_k?.toString() ??
"200"
: settings.settings.file_token_count_threshold_k === 0
? ""
: settings.settings.file_token_count_threshold_k.toString(),
};
return (
<Formik
initialValues={initialValues}
onSubmit={() => {}}
enableReinitialize
>
<Form className="h-full w-full">
<ChatPreferencesForm />
</Form>
</Formik>
);
return <ChatPreferencesForm />;
}

View File

@@ -213,9 +213,12 @@ const ChatScrollContainer = React.memo(
}
}, [updateScrollState, getScrollState]);
// Watch for content changes (MutationObserver + ResizeObserver)
// MutationObserver (structural) + ResizeObserver (height growth).
// NOT characterData — typewriter reveals don't change scrollHeight
// and firing per-char thrashed auto-scroll.
useEffect(() => {
const container = scrollContainerRef.current;
const contentWrapper = contentWrapperRef.current;
if (!container) return;
let rafId: number | null = null;
@@ -244,17 +247,17 @@ const ChatScrollContainer = React.memo(
});
};
// MutationObserver for content changes
const mutationObserver = new MutationObserver(onContentChange);
mutationObserver.observe(container, {
childList: true,
subtree: true,
characterData: true,
});
// ResizeObserver for container size changes
const resizeObserver = new ResizeObserver(onContentChange);
resizeObserver.observe(container);
if (contentWrapper) {
resizeObserver.observe(contentWrapper);
}
return () => {
mutationObserver.disconnect();

View File

@@ -331,10 +331,13 @@ const ChatUI = React.memo(
return null;
})}
{/* Error banner when last message is user message or error type */}
{/* Error banner when last message is user message or error type.
Skip for multi-model per-panel errors — those are shown in
their own panel, not as a global banner. */}
{(((error !== null || loadError !== null) &&
messages[messages.length - 1]?.type === "user") ||
messages[messages.length - 1]?.type === "error") && (
(messages[messages.length - 1]?.type === "error" &&
!messages[messages.length - 1]?.modelDisplayName)) && (
<div className={`p-4 w-full ${MSG_MAX_W} self-center`}>
<ErrorBanner
resubmit={onResubmit}

View File

@@ -86,6 +86,7 @@ export interface AppInputBarProps {
deepResearchEnabled: boolean;
setPresentingDocument?: (document: MinimalOnyxDocument) => void;
toggleDeepResearch: () => void;
isMultiModelActive?: boolean;
disabled: boolean;
ref?: React.Ref<AppInputBarHandle>;
// Side panel tab reading
@@ -109,6 +110,7 @@ const AppInputBar = React.memo(
llmManager,
deepResearchEnabled,
toggleDeepResearch,
isMultiModelActive,
setPresentingDocument,
disabled,
ref,
@@ -554,12 +556,17 @@ const AppInputBar = React.memo(
) : (
showDeepResearch && (
<SelectButton
disabled={disabled}
disabled={disabled || isMultiModelActive}
variant="select-light"
icon={SvgHourglass}
onClick={toggleDeepResearch}
state={deepResearchEnabled ? "selected" : "empty"}
foldable={!deepResearchEnabled}
tooltip={
isMultiModelActive
? "Deep Research is disabled in multi-model mode"
: undefined
}
>
Deep Research
</SelectButton>