Compare commits

..

2 Commits

Author SHA1 Message Date
Dane Urban
c59dd36d55 yoooo 2026-02-25 00:23:28 -08:00
SubashMohan
0558e687d9 fix: persist onboarding dismissal in localStorage with user-specific keys (#8674) 2026-02-25 06:22:17 +00:00
10 changed files with 276 additions and 161 deletions

View File

@@ -12,7 +12,6 @@ from onyx.configs.app_configs import CODE_INTERPRETER_BASE_URL
from onyx.configs.app_configs import CODE_INTERPRETER_DEFAULT_TIMEOUT_MS
from onyx.configs.app_configs import CODE_INTERPRETER_MAX_OUTPUT_LENGTH
from onyx.configs.constants import FileOrigin
from onyx.db.code_interpreter import fetch_code_interpreter_server
from onyx.file_store.utils import build_full_frontend_file_url
from onyx.file_store.utils import get_default_file_store
from onyx.server.query_and_chat.placement import Placement
@@ -104,10 +103,8 @@ class PythonTool(Tool[PythonToolOverrideKwargs]):
@override
@classmethod
def is_available(cls, db_session: Session) -> bool:
if not CODE_INTERPRETER_BASE_URL:
return False
server = fetch_code_interpreter_server(db_session)
return server.server_enabled
is_available = bool(CODE_INTERPRETER_BASE_URL)
return is_available
def tool_definition(self) -> dict:
return {

View File

@@ -1,12 +1,10 @@
import requests
from tests.integration.common_utils.constants import API_SERVER_URL
from tests.integration.common_utils.managers.tool import ToolManager
from tests.integration.common_utils.test_models import DATestUser
CODE_INTERPRETER_URL = f"{API_SERVER_URL}/admin/code-interpreter"
CODE_INTERPRETER_HEALTH_URL = f"{CODE_INTERPRETER_URL}/health"
PYTHON_TOOL_NAME = "python"
def test_get_code_interpreter_health_as_admin(
@@ -96,35 +94,3 @@ def test_code_interpreter_endpoints_require_admin(
headers=basic_user.headers,
)
assert put_response.status_code == 403
def test_python_tool_hidden_from_tool_list_when_disabled(
admin_user: DATestUser,
) -> None:
"""When code interpreter is disabled, the Python tool should not appear
in the GET /tool response (i.e. the frontend tool list)."""
# Disable
response = requests.put(
CODE_INTERPRETER_URL,
json={"enabled": False},
headers=admin_user.headers,
)
assert response.status_code == 200
# Python tool should not be in the tool list
tools = ToolManager.list_tools(user_performing_action=admin_user)
tool_names = [t.name for t in tools]
assert PYTHON_TOOL_NAME not in tool_names
# Re-enable
response = requests.put(
CODE_INTERPRETER_URL,
json={"enabled": True},
headers=admin_user.headers,
)
assert response.status_code == 200
# Python tool should reappear
tools = ToolManager.list_tools(user_performing_action=admin_user)
tool_names = [t.name for t in tools]
assert PYTHON_TOOL_NAME in tool_names

View File

@@ -1,88 +0,0 @@
"""Tests for PythonTool availability based on server_enabled flag.
Verifies that PythonTool reports itself as unavailable when either:
- CODE_INTERPRETER_BASE_URL is not set, or
- CodeInterpreterServer.server_enabled is False in the database.
"""
from unittest.mock import MagicMock
from unittest.mock import patch
from sqlalchemy.orm import Session
# ------------------------------------------------------------------
# Unavailable when CODE_INTERPRETER_BASE_URL is not set
# ------------------------------------------------------------------
@patch(
"onyx.tools.tool_implementations.python.python_tool.CODE_INTERPRETER_BASE_URL",
None,
)
def test_python_tool_unavailable_without_base_url() -> None:
from onyx.tools.tool_implementations.python.python_tool import PythonTool
db_session = MagicMock(spec=Session)
assert PythonTool.is_available(db_session) is False
@patch(
"onyx.tools.tool_implementations.python.python_tool.CODE_INTERPRETER_BASE_URL",
"",
)
def test_python_tool_unavailable_with_empty_base_url() -> None:
from onyx.tools.tool_implementations.python.python_tool import PythonTool
db_session = MagicMock(spec=Session)
assert PythonTool.is_available(db_session) is False
# ------------------------------------------------------------------
# Unavailable when server_enabled is False
# ------------------------------------------------------------------
@patch(
"onyx.tools.tool_implementations.python.python_tool.CODE_INTERPRETER_BASE_URL",
"http://localhost:8000",
)
@patch(
"onyx.tools.tool_implementations.python.python_tool.fetch_code_interpreter_server",
)
def test_python_tool_unavailable_when_server_disabled(
mock_fetch: MagicMock,
) -> None:
from onyx.tools.tool_implementations.python.python_tool import PythonTool
mock_server = MagicMock()
mock_server.server_enabled = False
mock_fetch.return_value = mock_server
db_session = MagicMock(spec=Session)
assert PythonTool.is_available(db_session) is False
# ------------------------------------------------------------------
# Available when both conditions are met
# ------------------------------------------------------------------
@patch(
"onyx.tools.tool_implementations.python.python_tool.CODE_INTERPRETER_BASE_URL",
"http://localhost:8000",
)
@patch(
"onyx.tools.tool_implementations.python.python_tool.fetch_code_interpreter_server",
)
def test_python_tool_available_when_server_enabled(
mock_fetch: MagicMock,
) -> None:
from onyx.tools.tool_implementations.python.python_tool import PythonTool
mock_server = MagicMock()
mock_server.server_enabled = True
mock_fetch.return_value = mock_server
db_session = MagicMock(spec=Session)
assert PythonTool.is_available(db_session) is True

View File

@@ -1,8 +1,13 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { MinimalPersonaSnapshot } from "@/app/admin/assistants/interfaces";
import { useOnboardingState } from "@/refresh-components/onboarding/useOnboardingState";
function getOnboardingCompletedKey(userId: string): string {
return `onyx:onboardingCompleted:${userId}`;
}
interface UseShowOnboardingParams {
liveAssistant: MinimalPersonaSnapshot | undefined;
isLoadingProviders: boolean;
@@ -21,6 +26,15 @@ export function useShowOnboarding({
userId,
}: UseShowOnboardingParams) {
const [showOnboarding, setShowOnboarding] = useState(false);
const [onboardingDismissed, setOnboardingDismissed] = useState(false);
// Read localStorage once userId is available to check if onboarding was dismissed
useEffect(() => {
if (userId === undefined) return;
const dismissed =
localStorage.getItem(getOnboardingCompletedKey(userId)) === "true";
setOnboardingDismissed(dismissed);
}, [userId]);
// Initialize onboarding state
const {
@@ -38,13 +52,23 @@ export function useShowOnboarding({
// Show onboarding only if no LLM providers are configured.
// Skip entirely if user has existing chat sessions.
useEffect(() => {
// If onboarding was previously dismissed, never show it again
if (onboardingDismissed) {
setShowOnboarding(false);
return;
}
// Wait for data to load
if (isLoadingProviders || isLoadingChatSessions || userId === undefined) {
return;
}
// Only check once per user
// Only check once per user — but allow self-correction from true→false
// when provider data arrives (e.g. after a transient fetch error).
if (hasCheckedOnboardingForUserId.current === userId) {
if (showOnboarding && hasAnyProvider && onboardingState.stepIndex === 0) {
setShowOnboarding(false);
}
return;
}
hasCheckedOnboardingForUserId.current = userId;
@@ -63,18 +87,24 @@ export function useShowOnboarding({
hasAnyProvider,
chatSessionsCount,
userId,
showOnboarding,
onboardingDismissed,
onboardingState.stepIndex,
]);
const hideOnboarding = () => {
const dismissOnboarding = useCallback(() => {
if (userId === undefined) return;
setShowOnboarding(false);
};
setOnboardingDismissed(true);
localStorage.setItem(getOnboardingCompletedKey(userId), "true");
}, [userId]);
const finishOnboarding = () => {
setShowOnboarding(false);
};
const hideOnboarding = dismissOnboarding;
const finishOnboarding = dismissOnboarding;
return {
showOnboarding,
onboardingDismissed,
onboardingState,
onboardingActions,
llmDescriptors,

View File

@@ -7,6 +7,11 @@ interface LinguistLanguage {
filenames?: string[];
}
interface LanguageMaps {
extensions: Map<string, string>;
filenames: Map<string, string>;
}
const allLanguages = Object.values(languages) as LinguistLanguage[];
// Collect extensions that linguist-languages assigns to "Markdown" so we can
@@ -17,37 +22,58 @@ const markdownExtensions = new Set(
?.extensions?.map((ext) => ext.toLowerCase()) ?? []
);
// Build extension → language name and filename → language name maps at module load
const extensionMap = new Map<string, string>();
const filenameMap = new Map<string, string>();
function buildLanguageMaps(
type: string,
excludedExtensions?: Set<string>
): LanguageMaps {
const extensions = new Map<string, string>();
const filenames = new Map<string, string>();
for (const lang of allLanguages) {
if (lang.type !== "programming") continue;
for (const lang of allLanguages) {
if (lang.type !== type) continue;
const name = lang.name.toLowerCase();
for (const ext of lang.extensions ?? []) {
if (markdownExtensions.has(ext.toLowerCase())) continue;
// First language to claim an extension wins
if (!extensionMap.has(ext)) {
extensionMap.set(ext, name);
}
}
for (const filename of lang.filenames ?? []) {
if (!filenameMap.has(filename.toLowerCase())) {
filenameMap.set(filename.toLowerCase(), name);
const name = lang.name.toLowerCase();
for (const ext of lang.extensions ?? []) {
if (excludedExtensions?.has(ext.toLowerCase())) continue;
if (!extensions.has(ext)) {
extensions.set(ext, name);
}
}
for (const filename of lang.filenames ?? []) {
if (!filenames.has(filename.toLowerCase())) {
filenames.set(filename.toLowerCase(), name);
}
}
}
return { extensions, filenames };
}
function lookupLanguage(name: string, maps: LanguageMaps): string | null {
const lower = name.toLowerCase();
const ext = lower.match(/\.[^.]+$/)?.[0];
return (ext && maps.extensions.get(ext)) ?? maps.filenames.get(lower) ?? null;
}
const codeMaps = buildLanguageMaps("programming", markdownExtensions);
const dataMaps = buildLanguageMaps("data");
/**
* Returns the language name for a given file name, or null if it's not a
* recognised code file. Looks up by extension first, then by exact filename
* (e.g. "Dockerfile", "Makefile"). Runs in O(1).
*/
export function getCodeLanguage(name: string): string | null {
const lower = name.toLowerCase();
const ext = lower.match(/\.[^.]+$/)?.[0];
return (ext && extensionMap.get(ext)) ?? filenameMap.get(lower) ?? null;
return lookupLanguage(name, codeMaps);
}
/**
* Returns the language name for a given file name if it's a recognised
* "data" type in linguist-languages (e.g. JSON, YAML, TOML, XML).
* Returns null otherwise. Runs in O(1).
*/
export function getDataLanguage(name: string): string | null {
return lookupLanguage(name, dataMaps);
}
/**

View File

@@ -17,11 +17,13 @@ const mockActions = {
reset: jest.fn(),
};
let mockStepIndex = 0;
jest.mock("@/refresh-components/onboarding/useOnboardingState", () => ({
useOnboardingState: () => ({
state: {
currentStep: OnboardingStep.Welcome,
stepIndex: 0,
stepIndex: mockStepIndex,
totalSteps: 3,
data: {},
isButtonActive: true,
@@ -60,6 +62,8 @@ function renderUseShowOnboarding(
describe("useShowOnboarding", () => {
beforeEach(() => {
jest.clearAllMocks();
localStorage.clear();
mockStepIndex = 0;
});
it("returns showOnboarding=false while providers are loading", () => {
@@ -107,7 +111,7 @@ describe("useShowOnboarding", () => {
expect(result.current.showOnboarding).toBe(false);
});
it("only evaluates once per userId", () => {
it("self-corrects showOnboarding to false when providers arrive late", () => {
const { result, rerender } = renderUseShowOnboarding({
hasAnyProvider: false,
chatSessionsCount: 0,
@@ -115,7 +119,7 @@ describe("useShowOnboarding", () => {
});
expect(result.current.showOnboarding).toBe(true);
// Re-render with same userId but different provider state
// Re-render with same userId but provider data now available
rerender({
liveAssistant: undefined,
isLoadingProviders: false,
@@ -125,7 +129,32 @@ describe("useShowOnboarding", () => {
userId: "user-1",
});
// Should still be true because it was already evaluated for this userId
// Should correct to false — providers exist, no need for LLM setup flow
expect(result.current.showOnboarding).toBe(false);
});
it("does not self-correct when user has advanced past Welcome step", () => {
const { result, rerender } = renderUseShowOnboarding({
hasAnyProvider: false,
chatSessionsCount: 0,
userId: "user-1",
});
expect(result.current.showOnboarding).toBe(true);
// Simulate user advancing past Welcome (e.g. they configured an LLM provider)
mockStepIndex = 1;
// Re-render with same userId but provider data now available
rerender({
liveAssistant: undefined,
isLoadingProviders: false,
hasAnyProvider: true,
isLoadingChatSessions: false,
chatSessionsCount: 0,
userId: "user-1",
});
// Should stay true — user is actively using onboarding
expect(result.current.showOnboarding).toBe(true);
});
@@ -186,4 +215,83 @@ describe("useShowOnboarding", () => {
expect(result.current.onboardingActions).toBeDefined();
expect(result.current.llmDescriptors).toEqual([]);
});
describe("localStorage persistence", () => {
it("finishOnboarding sets localStorage flag and onboardingDismissed", () => {
const { result } = renderUseShowOnboarding({
hasAnyProvider: false,
chatSessionsCount: 0,
});
expect(result.current.showOnboarding).toBe(true);
expect(result.current.onboardingDismissed).toBe(false);
act(() => {
result.current.finishOnboarding();
});
expect(result.current.showOnboarding).toBe(false);
expect(result.current.onboardingDismissed).toBe(true);
expect(localStorage.getItem("onyx:onboardingCompleted:user-1")).toBe(
"true"
);
});
it("hideOnboarding sets localStorage flag and onboardingDismissed", () => {
const { result } = renderUseShowOnboarding({
hasAnyProvider: false,
chatSessionsCount: 0,
});
act(() => {
result.current.hideOnboarding();
});
expect(result.current.onboardingDismissed).toBe(true);
expect(localStorage.getItem("onyx:onboardingCompleted:user-1")).toBe(
"true"
);
});
it("showOnboarding stays false when localStorage flag is set", () => {
localStorage.setItem("onyx:onboardingCompleted:user-1", "true");
const { result } = renderUseShowOnboarding({
hasAnyProvider: false,
chatSessionsCount: 0,
});
expect(result.current.showOnboarding).toBe(false);
expect(result.current.onboardingDismissed).toBe(true);
});
it("onboardingDismissed is false when localStorage flag is not set", () => {
const { result } = renderUseShowOnboarding();
expect(result.current.onboardingDismissed).toBe(false);
});
it("dismissal for user-1 does not suppress onboarding for user-2", () => {
const { result: result1 } = renderUseShowOnboarding({
hasAnyProvider: false,
chatSessionsCount: 0,
userId: "1",
});
expect(result1.current.showOnboarding).toBe(true);
act(() => {
result1.current.finishOnboarding();
});
expect(result1.current.onboardingDismissed).toBe(true);
expect(localStorage.getItem("onyx:onboardingCompleted:1")).toBe("true");
// user-2 should still see onboarding
const { result: result2 } = renderUseShowOnboarding({
hasAnyProvider: false,
chatSessionsCount: 0,
userId: "2",
});
expect(result2.current.showOnboarding).toBe(true);
expect(result2.current.onboardingDismissed).toBe(false);
expect(localStorage.getItem("onyx:onboardingCompleted:2")).toBeNull();
});
});
});

View File

@@ -227,6 +227,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
const {
showOnboarding,
onboardingDismissed,
onboardingState,
onboardingActions,
llmDescriptors,
@@ -462,7 +463,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
currentMessageFiles,
deepResearch: deepResearchEnabled,
});
if (showOnboarding) {
if (showOnboarding || !onboardingDismissed) {
finishOnboarding();
}
},
@@ -472,6 +473,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
currentMessageFiles,
deepResearchEnabled,
showOnboarding,
onboardingDismissed,
finishOnboarding,
]
);
@@ -505,7 +507,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
currentMessageFiles,
deepResearch: deepResearchEnabled,
});
if (showOnboarding) {
if (showOnboarding || !onboardingDismissed) {
finishOnboarding();
}
return;
@@ -526,6 +528,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
currentMessageFiles,
deepResearchEnabled,
showOnboarding,
onboardingDismissed,
finishOnboarding,
]
);
@@ -797,7 +800,8 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
{/* OnboardingUI */}
{(appFocus.isNewSession() || appFocus.isAgent()) &&
!classification &&
(showOnboarding || !user?.personalization?.name) && (
(showOnboarding || !user?.personalization?.name) &&
!onboardingDismissed && (
<OnboardingFlow
showOnboarding={showOnboarding}
handleHideOnboarding={hideOnboarding}

View File

@@ -7,7 +7,7 @@ import Text from "@/refresh-components/texts/Text";
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
import { cn } from "@/lib/utils";
import { Section } from "@/layouts/general-layouts";
import { getCodeLanguage } from "@/lib/languages";
import { getCodeLanguage, getDataLanguage } from "@/lib/languages";
import { fetchChatFile } from "@/lib/chat/svc";
import { PreviewContext } from "@/sections/modals/PreviewModal/interfaces";
import { resolveVariant } from "@/sections/modals/PreviewModal/variants";
@@ -47,6 +47,7 @@ export default function PreviewModal({
const language = useMemo(
() =>
getCodeLanguage(presentingDocument.semantic_identifier || "") ||
getDataLanguage(presentingDocument.semantic_identifier || "") ||
"plaintext",
[presentingDocument.semantic_identifier]
);

View File

@@ -0,0 +1,69 @@
import MinimalMarkdown from "@/components/chat/MinimalMarkdown";
import Text from "@/refresh-components/texts/Text";
import { Section } from "@/layouts/general-layouts";
import { getDataLanguage } from "@/lib/languages";
import { CodeBlock } from "@/app/app/message/CodeBlock";
import { extractCodeText } from "@/app/app/message/codeUtils";
import { PreviewVariant } from "@/sections/modals/PreviewModal/interfaces";
import {
CopyButton,
DownloadButton,
} from "@/sections/modals/PreviewModal/variants/shared";
function formatContent(language: string, content: string): string {
if (language === "json") {
try {
return JSON.stringify(JSON.parse(content), null, 2);
} catch {
return content;
}
}
return content;
}
export const dataVariant: PreviewVariant = {
matches: (name) => !!getDataLanguage(name || ""),
width: "md",
height: "lg",
needsTextContent: true,
headerDescription: (ctx) =>
ctx.fileContent
? `${ctx.language} - ${ctx.lineCount} ${
ctx.lineCount === 1 ? "line" : "lines"
} · ${ctx.fileSize}`
: "",
renderContent: (ctx) => {
const formatted = formatContent(ctx.language, ctx.fileContent);
return (
<MinimalMarkdown
content={`\`\`\`${ctx.language}\n${formatted}\n\n\`\`\``}
className="w-full break-words h-full"
components={{
code: ({ node, children }: any) => {
const codeText = extractCodeText(node, formatted, children);
return (
<CodeBlock className="" codeText={codeText}>
{children}
</CodeBlock>
);
},
}}
/>
);
},
renderFooterLeft: (ctx) => (
<Text text03 mainUiBody className="select-none">
{ctx.lineCount} {ctx.lineCount === 1 ? "line" : "lines"}
</Text>
),
renderFooterRight: (ctx) => (
<Section flexDirection="row" width="fit">
<CopyButton getText={() => ctx.fileContent} />
<DownloadButton fileUrl={ctx.fileUrl} fileName={ctx.fileName} />
</Section>
),
};

View File

@@ -4,6 +4,7 @@ import { imageVariant } from "@/sections/modals/PreviewModal/variants/imageVaria
import { pdfVariant } from "@/sections/modals/PreviewModal/variants/pdfVariant";
import { csvVariant } from "@/sections/modals/PreviewModal/variants/csvVariant";
import { markdownVariant } from "@/sections/modals/PreviewModal/variants/markdownVariant";
import { dataVariant } from "@/sections/modals/PreviewModal/variants/dataVariant";
import { unsupportedVariant } from "@/sections/modals/PreviewModal/variants/unsupportedVariant";
const PREVIEW_VARIANTS: PreviewVariant[] = [
@@ -12,6 +13,7 @@ const PREVIEW_VARIANTS: PreviewVariant[] = [
pdfVariant,
csvVariant,
markdownVariant,
dataVariant,
];
export function resolveVariant(