mirror of
https://github.com/ollama/ollama.git
synced 2026-09-21 13:38:14 -05:00
app: fix desktop interaction regressions (#17970)
* app: fix desktop interaction regressions * app: serialize settings reset updates
This commit is contained in:
@@ -613,12 +613,7 @@ static NSImage *ollamaApplicationIcon(void) {
|
||||
}
|
||||
|
||||
- (BOOL)applicationShouldHandleReopen:(NSApplication *)sender hasVisibleWindows:(BOOL)hasVisibleWindows {
|
||||
if (IsOnboardingActive()) {
|
||||
ShowUI();
|
||||
return YES;
|
||||
}
|
||||
|
||||
[self appsUI];
|
||||
ShowUI();
|
||||
return YES;
|
||||
}
|
||||
|
||||
|
||||
@@ -238,111 +238,100 @@ export function ChatSidebar({ currentChatId }: ChatSidebarProps) {
|
||||
[startEditing, handleDeleteChat],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<nav className="flex min-h-0 flex-col">
|
||||
<div className="flex flex-1 flex-col p-4">
|
||||
<div className="p-4">Loading...</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<nav className="flex min-h-0 flex-col">
|
||||
<div className="flex flex-1 flex-col p-4">
|
||||
<div className="p-4 text-red-500">Error loading chats</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<nav className="flex flex-1 flex-col min-h-0 select-none">
|
||||
<nav
|
||||
aria-busy={isLoading || undefined}
|
||||
className="flex flex-1 flex-col min-h-0 select-none"
|
||||
>
|
||||
<header className="flex flex-col gap-0.5 px-4 pb-2">
|
||||
<AppNavigation current="chat" />
|
||||
</header>
|
||||
<div className="flex flex-1 flex-col px-4 py-1 overflow-y-auto overscroll-auto scrollbar-gutter">
|
||||
<div className="flex flex-col gap-3 pt-4">
|
||||
{chatGroups.map((group) => (
|
||||
<div key={group.name} className="flex flex-col gap-0.5">
|
||||
<h3 className="text-xs font-medium text-neutral-400 dark:text-neutral-500 px-2 py-1 select-none">
|
||||
{group.name}
|
||||
</h3>
|
||||
{group.chats.map((chat) => (
|
||||
<div
|
||||
key={chat.id}
|
||||
className={`allow-context-menu flex items-center relative text-sm text-neutral-800 dark:text-neutral-400 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800 ${
|
||||
chat.id === currentChatId
|
||||
? "bg-neutral-100 text-black dark:bg-neutral-800"
|
||||
: ""
|
||||
}`}
|
||||
onMouseEnter={() => handleMouseEnter(chat.id)}
|
||||
onContextMenu={(e) =>
|
||||
handleContextMenu(
|
||||
e,
|
||||
chat.id,
|
||||
chat.title ||
|
||||
chat.userExcerpt ||
|
||||
chat.createdAt.toLocaleString(),
|
||||
)
|
||||
}
|
||||
>
|
||||
{editingChatId === chat.id ? (
|
||||
<div className="flex-1 flex items-center min-w-0 px-2 py-2 bg-neutral-100 text-black dark:bg-neutral-800 rounded-lg">
|
||||
<span className="truncate font-sans text-sm w-full">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
saveRename();
|
||||
} else if (e.key === "Escape") {
|
||||
setEditingChatId(null);
|
||||
setEditValue("");
|
||||
}
|
||||
}}
|
||||
className="bg-transparent border-0 focus:outline-none w-full dark:text-white"
|
||||
style={{
|
||||
font: "inherit",
|
||||
lineHeight: "inherit",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
to="/c/$chatId"
|
||||
params={{ chatId: chat.id }}
|
||||
className="flex-1 flex items-center min-w-0 px-2 py-2 select-none"
|
||||
onClick={(e) => {
|
||||
handleShiftClick(e, chat.id);
|
||||
}}
|
||||
draggable={false}
|
||||
>
|
||||
<span className="truncate font-sans text-sm">
|
||||
{chat.title ||
|
||||
{error ? (
|
||||
<div className="px-2 pt-4 text-sm text-red-500">
|
||||
Error loading chats
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 pt-4">
|
||||
{chatGroups.map((group) => (
|
||||
<div key={group.name} className="flex flex-col gap-0.5">
|
||||
<h3 className="text-xs font-medium text-neutral-400 dark:text-neutral-500 px-2 py-1 select-none">
|
||||
{group.name}
|
||||
</h3>
|
||||
{group.chats.map((chat) => (
|
||||
<div
|
||||
key={chat.id}
|
||||
className={`allow-context-menu flex items-center relative text-sm text-neutral-800 dark:text-neutral-400 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800 ${
|
||||
chat.id === currentChatId
|
||||
? "bg-neutral-100 text-black dark:bg-neutral-800"
|
||||
: ""
|
||||
}`}
|
||||
onMouseEnter={() => handleMouseEnter(chat.id)}
|
||||
onContextMenu={(e) =>
|
||||
handleContextMenu(
|
||||
e,
|
||||
chat.id,
|
||||
chat.title ||
|
||||
chat.userExcerpt ||
|
||||
chat.createdAt.toLocaleString()}
|
||||
</span>
|
||||
{copiedChatId === chat.id && (
|
||||
<span className="ml-2 text-xs text-green-600 dark:text-green-400">
|
||||
Copied!
|
||||
chat.createdAt.toLocaleString(),
|
||||
)
|
||||
}
|
||||
>
|
||||
{editingChatId === chat.id ? (
|
||||
<div className="flex-1 flex items-center min-w-0 px-2 py-2 bg-neutral-100 text-black dark:bg-neutral-800 rounded-lg">
|
||||
<span className="truncate font-sans text-sm w-full">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
saveRename();
|
||||
} else if (e.key === "Escape") {
|
||||
setEditingChatId(null);
|
||||
setEditValue("");
|
||||
}
|
||||
}}
|
||||
className="bg-transparent border-0 focus:outline-none w-full dark:text-white"
|
||||
style={{
|
||||
font: "inherit",
|
||||
lineHeight: "inherit",
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
to="/c/$chatId"
|
||||
params={{ chatId: chat.id }}
|
||||
className="flex-1 flex items-center min-w-0 px-2 py-2 select-none"
|
||||
onClick={(e) => {
|
||||
handleShiftClick(e, chat.id);
|
||||
}}
|
||||
draggable={false}
|
||||
>
|
||||
<span className="truncate font-sans text-sm">
|
||||
{chat.title ||
|
||||
chat.userExcerpt ||
|
||||
chat.createdAt.toLocaleString()}
|
||||
</span>
|
||||
{copiedChatId === chat.id && (
|
||||
<span className="ml-2 text-xs text-green-600 dark:text-green-400">
|
||||
Copied!
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
ClaudeConnectedIntro,
|
||||
FIRST_MODEL_COMMAND,
|
||||
ConnectAppsScreen,
|
||||
IntroScreen,
|
||||
default as Onboarding,
|
||||
RunOllamaScreen,
|
||||
shouldShowClaudeConnectedIntro,
|
||||
terminalRowsForWindowHeight,
|
||||
WelcomeScreen,
|
||||
} from "./Onboarding";
|
||||
@@ -90,7 +92,7 @@ describe("Onboarding", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).not.toContain('id="applications-heading"');
|
||||
expect(html).not.toContain('id="desktop-heading"');
|
||||
expect(html).not.toContain("Use Ollama models in Claude Desktop");
|
||||
expect(html).toContain('id="terminal-heading"');
|
||||
expect(html).toContain("ollama launch claude");
|
||||
@@ -160,6 +162,45 @@ describe("Onboarding", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("shows the Claude intro only before the integration has been used", () => {
|
||||
const firstConnection = {
|
||||
supported: true,
|
||||
used: false,
|
||||
installed: true,
|
||||
configured: true,
|
||||
connected: true,
|
||||
running: false,
|
||||
startFailed: false,
|
||||
portConflict: false,
|
||||
};
|
||||
|
||||
expect(shouldShowClaudeConnectedIntro(firstConnection)).toBe(true);
|
||||
expect(
|
||||
shouldShowClaudeConnectedIntro({ ...firstConnection, used: true }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldShowClaudeConnectedIntro({
|
||||
...firstConnection,
|
||||
connected: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldShowClaudeConnectedIntro({
|
||||
...firstConnection,
|
||||
startFailed: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("uses Continue as the only Claude intro action", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ClaudeConnectedIntro onDone={vi.fn()} />,
|
||||
);
|
||||
|
||||
expect(html).toContain(">Continue</button>");
|
||||
expect(html).not.toContain('aria-label="Close"');
|
||||
});
|
||||
|
||||
it("opens the device connection flow without relaunching the app", () => {
|
||||
expect(
|
||||
onboardingConnectUrl(
|
||||
@@ -270,12 +311,12 @@ describe("Onboarding", () => {
|
||||
expect(html).toContain("Claude Code");
|
||||
expect(html).not.toContain("Search apps");
|
||||
expect(html).not.toContain('type="search"');
|
||||
expect(html).toContain("Application");
|
||||
expect(html).toContain('id="applications-heading"');
|
||||
expect(html).toContain("Desktop");
|
||||
expect(html).toContain('id="desktop-heading"');
|
||||
expect(html).toContain('id="terminal-heading"');
|
||||
expect(html).not.toContain("Ready to launch");
|
||||
expect(html).not.toContain('id="claude-apps-heading"');
|
||||
expect(html.indexOf("Application")).toBeLessThan(
|
||||
expect(html.indexOf("Desktop")).toBeLessThan(
|
||||
html.indexOf("Use Ollama models in Claude Desktop"),
|
||||
);
|
||||
expect(html).not.toContain(">Command</th>");
|
||||
@@ -312,7 +353,7 @@ describe("Onboarding", () => {
|
||||
expect(html).not.toContain('viewBox="0 0 3400 3400"');
|
||||
});
|
||||
|
||||
it("keeps connected Claude in Application without an idle status", () => {
|
||||
it("keeps connected Claude in Desktop without an idle status", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<ConnectAppsScreen
|
||||
completionError={null}
|
||||
@@ -346,7 +387,7 @@ describe("Onboarding", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('id="applications-heading"');
|
||||
expect(html).toContain('id="desktop-heading"');
|
||||
expect(html).not.toContain('id="claude-apps-heading"');
|
||||
expect(html).not.toContain("Ready to launch");
|
||||
expect(html).not.toContain("Active");
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
CommandLineIcon,
|
||||
ShieldCheckIcon,
|
||||
Square2StackIcon,
|
||||
XMarkIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import {
|
||||
CheckIcon,
|
||||
@@ -49,13 +48,12 @@ type ClaudeConnectPhase =
|
||||
|
||||
const CLAUDE_CONNECTION_POLL_INTERVAL_MS = 500;
|
||||
const CLAUDE_CONNECTION_TIMEOUT_MS = 45_000;
|
||||
const CLAUDE_CONNECTED_INTRO_KEY = "ollama.claude-connected-intro-seen";
|
||||
const MINIMUM_APP_WINDOW_HEIGHT = 660;
|
||||
const TERMINAL_ROW_HEIGHT_WITH_GAP = 80;
|
||||
const TERMINAL_LIST_RESERVED_HEIGHT = 296;
|
||||
|
||||
function hasSeenClaudeConnectedIntro() {
|
||||
return window.localStorage.getItem(CLAUDE_CONNECTED_INTRO_KEY) === "true";
|
||||
export function shouldShowClaudeConnectedIntro(status: ClaudeDesktopStatus) {
|
||||
return status.connected && !status.startFailed && !status.used;
|
||||
}
|
||||
|
||||
function setClaudeConnection(enabled: boolean, deferLaunch = false) {
|
||||
@@ -375,6 +373,54 @@ function LaunchCommandIcon({ item }: { item: IntegrationStatus }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function ClaudeConnectedIntro({ onDone }: { onDone: () => void }) {
|
||||
return (
|
||||
<div className="claude-connected-backdrop fixed inset-0 z-50 flex items-center justify-center bg-black/20 p-6">
|
||||
<section
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="claude-connected-title"
|
||||
aria-describedby="claude-connected-description"
|
||||
className="claude-connected-dialog relative w-full max-w-md overflow-hidden rounded-2xl bg-white font-sans shadow-2xl ring-1 ring-black/10"
|
||||
>
|
||||
<img
|
||||
src="/claude-connected.png"
|
||||
alt="Ollama models in the Claude model picker"
|
||||
width={900}
|
||||
height={761}
|
||||
className="h-auto w-full object-contain"
|
||||
draggable={false}
|
||||
/>
|
||||
<div className="p-6">
|
||||
<h2
|
||||
id="claude-connected-title"
|
||||
className="font-rounded text-lg font-medium leading-6 text-neutral-950"
|
||||
>
|
||||
Easily access Ollama models in your Claude
|
||||
</h2>
|
||||
<p
|
||||
id="claude-connected-description"
|
||||
className="mt-2 text-[13px] leading-5 text-neutral-500"
|
||||
>
|
||||
Ollama models now show up in Claude so you can pick the right model
|
||||
for the task.
|
||||
</p>
|
||||
<div className="mt-5 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
autoFocus
|
||||
className="rounded-full bg-neutral-100 px-6 py-2 text-sm font-normal text-neutral-950 transition-colors hover:bg-neutral-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-500"
|
||||
onClick={onDone}
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConnectAppsScreen({
|
||||
initialIntegrations,
|
||||
initialClaudeStatus,
|
||||
@@ -488,11 +534,7 @@ export function ConnectAppsScreen({
|
||||
const finishClaudeConnection = useCallback(
|
||||
async (status: ClaudeDesktopStatus) => {
|
||||
if (claudeConnectedIntroPending.current) return null;
|
||||
if (
|
||||
status.connected &&
|
||||
!status.startFailed &&
|
||||
!hasSeenClaudeConnectedIntro()
|
||||
) {
|
||||
if (shouldShowClaudeConnectedIntro(status)) {
|
||||
claudeConnectedIntroPending.current = true;
|
||||
setShowClaudeConnectedIntro(true);
|
||||
window.activateOllama?.();
|
||||
@@ -506,7 +548,6 @@ export function ConnectAppsScreen({
|
||||
|
||||
const dismissClaudeConnectedIntro = async () => {
|
||||
claudeConnectedIntroPending.current = false;
|
||||
window.localStorage.setItem(CLAUDE_CONNECTED_INTRO_KEY, "true");
|
||||
setShowClaudeConnectedIntro(false);
|
||||
if (!window.setClaudeDesktopConnected) return;
|
||||
setClaudePhase("launching");
|
||||
@@ -606,10 +647,7 @@ export function ConnectAppsScreen({
|
||||
}
|
||||
|
||||
setClaudePhase("connecting");
|
||||
const result = await setClaudeConnection(
|
||||
true,
|
||||
!hasSeenClaudeConnectedIntro(),
|
||||
);
|
||||
const result = await setClaudeConnection(true, !status.used);
|
||||
if (!active) return;
|
||||
setClaudeStatus(result.status);
|
||||
let actionError = result.error || null;
|
||||
@@ -698,7 +736,7 @@ export function ConnectAppsScreen({
|
||||
try {
|
||||
const result = await setClaudeConnection(
|
||||
enabling,
|
||||
enabling && !hasSeenClaudeConnectedIntro(),
|
||||
enabling && !status.used,
|
||||
);
|
||||
setClaudeStatus(result.status);
|
||||
let actionError = result.error || null;
|
||||
@@ -883,12 +921,12 @@ export function ConnectAppsScreen({
|
||||
{integrationStatuses ? (
|
||||
<div className="space-y-7 pb-4 pt-2">
|
||||
{claudeIntegration && (
|
||||
<section aria-labelledby="applications-heading">
|
||||
<section aria-labelledby="desktop-heading">
|
||||
<h2
|
||||
id="applications-heading"
|
||||
id="desktop-heading"
|
||||
className="px-4 text-xs font-medium uppercase tracking-wider text-neutral-400"
|
||||
>
|
||||
Application
|
||||
Desktop
|
||||
</h2>
|
||||
<div className="mt-2 bg-white">{claudeRow}</div>
|
||||
</section>
|
||||
@@ -979,57 +1017,9 @@ export function ConnectAppsScreen({
|
||||
</section>
|
||||
</div>
|
||||
{showClaudeConnectedIntro && (
|
||||
<div className="claude-connected-backdrop fixed inset-0 z-50 flex items-center justify-center bg-black/20 p-6">
|
||||
<section
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="claude-connected-title"
|
||||
aria-describedby="claude-connected-description"
|
||||
className="claude-connected-dialog relative w-full max-w-md overflow-hidden rounded-2xl bg-white font-sans shadow-2xl ring-1 ring-black/10"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
className="absolute right-3 top-3 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/90 text-neutral-500 transition-colors hover:text-neutral-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-500"
|
||||
onClick={() => void dismissClaudeConnectedIntro()}
|
||||
>
|
||||
<XMarkIcon aria-hidden="true" className="h-5 w-5" />
|
||||
</button>
|
||||
<img
|
||||
src="/claude-connected.png"
|
||||
alt="Ollama models in the Claude model picker"
|
||||
width={900}
|
||||
height={761}
|
||||
className="h-auto w-full object-contain"
|
||||
draggable={false}
|
||||
/>
|
||||
<div className="p-6">
|
||||
<h2
|
||||
id="claude-connected-title"
|
||||
className="font-rounded text-lg font-medium leading-6 text-neutral-950"
|
||||
>
|
||||
Easily access Ollama models in your Claude
|
||||
</h2>
|
||||
<p
|
||||
id="claude-connected-description"
|
||||
className="mt-2 text-[13px] leading-5 text-neutral-500"
|
||||
>
|
||||
Ollama models now show up in Claude so you can pick the right
|
||||
model for the task.
|
||||
</p>
|
||||
<div className="mt-5 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
autoFocus
|
||||
className="min-w-24 rounded-full bg-neutral-100 px-6 py-2 text-sm font-normal text-neutral-950 transition-colors hover:bg-neutral-200 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-500"
|
||||
onClick={() => void dismissClaudeConnectedIntro()}
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<ClaudeConnectedIntro
|
||||
onDone={() => void dismissClaudeConnectedIntro()}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Settings as SettingsType } from "@/gotypes";
|
||||
import { applySettingsDefaults } from "./Settings";
|
||||
|
||||
function currentSettings(overrides: Partial<SettingsType> = {}) {
|
||||
return new SettingsType({
|
||||
ContextLength: 65_536,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe("Settings defaults", () => {
|
||||
it("serializes a full reset before showing Saved", async () => {
|
||||
const settingsUpdate = deferred();
|
||||
const updateSettings = vi.fn(() => settingsUpdate.promise);
|
||||
const updateCloud = vi.fn().mockResolvedValue(undefined);
|
||||
const updateShowAppsInMenu = vi.fn().mockResolvedValue(undefined);
|
||||
const onSaved = vi.fn();
|
||||
|
||||
const reset = applySettingsDefaults({
|
||||
updateSettings,
|
||||
updateCloud,
|
||||
updateShowAppsInMenu,
|
||||
currentSettings: currentSettings({
|
||||
Expose: true,
|
||||
Models: "/custom/models",
|
||||
}),
|
||||
cloudSource: "config",
|
||||
onSaved,
|
||||
});
|
||||
|
||||
expect(updateSettings).toHaveBeenCalledOnce();
|
||||
expect(updateSettings.mock.calls[0][0]).toMatchObject({
|
||||
Expose: false,
|
||||
Models: "",
|
||||
ContextLength: 65_536,
|
||||
AutoUpdateEnabled: true,
|
||||
});
|
||||
expect(updateCloud).not.toHaveBeenCalled();
|
||||
expect(updateShowAppsInMenu).not.toHaveBeenCalled();
|
||||
expect(onSaved).not.toHaveBeenCalled();
|
||||
|
||||
settingsUpdate.resolve();
|
||||
await reset;
|
||||
|
||||
expect(updateCloud).toHaveBeenCalledWith(true);
|
||||
expect(updateShowAppsInMenu).toHaveBeenCalledWith(true);
|
||||
expect(onSaved).toHaveBeenCalledOnce();
|
||||
expect(updateSettings.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
updateCloud.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(updateCloud.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
updateShowAppsInMenu.mock.invocationCallOrder[0],
|
||||
);
|
||||
expect(updateShowAppsInMenu.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
onSaved.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves an environment-only Cloud override", async () => {
|
||||
const updateCloud = vi.fn().mockResolvedValue(undefined);
|
||||
const updateShowAppsInMenu = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await applySettingsDefaults({
|
||||
updateSettings: vi.fn().mockResolvedValue(undefined),
|
||||
updateCloud,
|
||||
updateShowAppsInMenu,
|
||||
currentSettings: currentSettings(),
|
||||
cloudSource: "env",
|
||||
onSaved: vi.fn(),
|
||||
});
|
||||
|
||||
expect(updateCloud).not.toHaveBeenCalled();
|
||||
expect(updateShowAppsInMenu).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("clears the persisted Cloud override when the source is both", async () => {
|
||||
let environmentDisabled = true;
|
||||
let configDisabled = true;
|
||||
const updateCloud = vi.fn(async (enabled: boolean) => {
|
||||
configDisabled = !enabled;
|
||||
});
|
||||
|
||||
await applySettingsDefaults({
|
||||
updateSettings: vi.fn().mockResolvedValue(undefined),
|
||||
updateCloud,
|
||||
updateShowAppsInMenu: vi.fn().mockResolvedValue(undefined),
|
||||
currentSettings: currentSettings(),
|
||||
cloudSource: "both",
|
||||
onSaved: vi.fn(),
|
||||
});
|
||||
|
||||
expect(environmentDisabled || configDisabled).toBe(true);
|
||||
expect(configDisabled).toBe(false);
|
||||
|
||||
environmentDisabled = false;
|
||||
expect(environmentDisabled || configDisabled).toBe(false);
|
||||
});
|
||||
|
||||
it("does not issue a redundant Cloud update when Cloud is already on", async () => {
|
||||
const updateCloud = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await applySettingsDefaults({
|
||||
updateSettings: vi.fn().mockResolvedValue(undefined),
|
||||
updateCloud,
|
||||
updateShowAppsInMenu: vi.fn().mockResolvedValue(undefined),
|
||||
currentSettings: currentSettings(),
|
||||
cloudSource: "none",
|
||||
onSaved: vi.fn(),
|
||||
});
|
||||
|
||||
expect(updateCloud).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not show Saved or continue after settings fail", async () => {
|
||||
const updateCloud = vi.fn().mockResolvedValue(undefined);
|
||||
const updateShowAppsInMenu = vi.fn().mockResolvedValue(undefined);
|
||||
const onSaved = vi.fn();
|
||||
|
||||
await expect(
|
||||
applySettingsDefaults({
|
||||
updateSettings: vi.fn().mockRejectedValue(new Error("restart failed")),
|
||||
updateCloud,
|
||||
updateShowAppsInMenu,
|
||||
currentSettings: currentSettings({
|
||||
Expose: true,
|
||||
Models: "/custom/models",
|
||||
}),
|
||||
cloudSource: "config",
|
||||
onSaved,
|
||||
}),
|
||||
).rejects.toThrow("restart failed");
|
||||
|
||||
expect(updateCloud).not.toHaveBeenCalled();
|
||||
expect(updateShowAppsInMenu).not.toHaveBeenCalled();
|
||||
expect(onSaved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not show Saved when a later reset update fails", async () => {
|
||||
const updateShowAppsInMenu = vi.fn().mockResolvedValue(undefined);
|
||||
const onSaved = vi.fn();
|
||||
|
||||
await expect(
|
||||
applySettingsDefaults({
|
||||
updateSettings: vi.fn().mockResolvedValue(undefined),
|
||||
updateCloud: vi.fn().mockRejectedValue(new Error("cloud failed")),
|
||||
updateShowAppsInMenu,
|
||||
currentSettings: currentSettings(),
|
||||
cloudSource: "config",
|
||||
onSaved,
|
||||
}),
|
||||
).rejects.toThrow("cloud failed");
|
||||
|
||||
expect(updateShowAppsInMenu).not.toHaveBeenCalled();
|
||||
expect(onSaved).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -19,11 +19,13 @@ import {
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { Settings as SettingsType } from "@/gotypes";
|
||||
import { isWindowsPlatform } from "@/lib/platform";
|
||||
import { settingsMutationScope } from "@/lib/settingsMutationScope";
|
||||
import { useUser } from "@/hooks/useUser";
|
||||
import { useCloudStatus } from "@/hooks/useCloudStatus";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
getSettings,
|
||||
type CloudStatusSource,
|
||||
type CloudStatusResponse,
|
||||
updateCloudSetting,
|
||||
updateSettings,
|
||||
@@ -44,12 +46,55 @@ function AnimatedDots() {
|
||||
);
|
||||
}
|
||||
|
||||
interface SettingsDefaultsActions {
|
||||
updateSettings: (settings: SettingsType) => Promise<unknown>;
|
||||
updateCloud: (enabled: boolean) => Promise<unknown>;
|
||||
updateShowAppsInMenu: (visible: boolean) => Promise<unknown>;
|
||||
currentSettings: SettingsType;
|
||||
cloudSource?: CloudStatusSource;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
interface CloudUpdateRequest {
|
||||
enabled: boolean;
|
||||
requestId: number;
|
||||
}
|
||||
|
||||
let latestCloudRequestId = 0;
|
||||
|
||||
export async function applySettingsDefaults({
|
||||
updateSettings,
|
||||
updateCloud,
|
||||
updateShowAppsInMenu,
|
||||
currentSettings,
|
||||
cloudSource,
|
||||
onSaved,
|
||||
}: SettingsDefaultsActions): Promise<void> {
|
||||
await updateSettings(
|
||||
new SettingsType({
|
||||
Expose: false,
|
||||
Browser: false,
|
||||
Models: "",
|
||||
Agent: false,
|
||||
Tools: false,
|
||||
ContextLength: currentSettings.ContextLength,
|
||||
AutoUpdateEnabled: true,
|
||||
}),
|
||||
);
|
||||
if (cloudSource === "config" || cloudSource === "both") {
|
||||
await updateCloud(true);
|
||||
}
|
||||
await updateShowAppsInMenu(true);
|
||||
onSaved();
|
||||
}
|
||||
|
||||
export default function Settings() {
|
||||
const queryClient = useQueryClient();
|
||||
const [showSaved, setShowSaved] = useState(false);
|
||||
const [restartMessage, setRestartMessage] = useState(false);
|
||||
const [showAppsInMenu, setShowAppsInMenuState] = useState(true);
|
||||
const [showAppsInMenuPending, setShowAppsInMenuPending] = useState(false);
|
||||
const [resettingToDefaults, setResettingToDefaults] = useState(false);
|
||||
const {
|
||||
user,
|
||||
isAuthenticated,
|
||||
@@ -63,11 +108,12 @@ export default function Settings() {
|
||||
const [isAwaitingConnection, setIsAwaitingConnection] = useState(false);
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||
const [pollingInterval, setPollingInterval] = useState<number | null>(null);
|
||||
const {
|
||||
cloudDisabled,
|
||||
cloudStatus,
|
||||
isLoading: cloudStatusLoading,
|
||||
} = useCloudStatus();
|
||||
const { cloudDisabled, cloudStatus } = useCloudStatus();
|
||||
|
||||
const showSavedConfirmation = useCallback(() => {
|
||||
setShowSaved(true);
|
||||
setTimeout(() => setShowSaved(false), 1500);
|
||||
}, []);
|
||||
|
||||
const {
|
||||
data: settingsData,
|
||||
@@ -88,22 +134,24 @@ export default function Settings() {
|
||||
const defaultContextLength = inferenceComputeResponse?.defaultContextLength;
|
||||
|
||||
const updateSettingsMutation = useMutation({
|
||||
scope: settingsMutationScope,
|
||||
mutationFn: updateSettings,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["settings"] });
|
||||
setShowSaved(true);
|
||||
setTimeout(() => setShowSaved(false), 1500);
|
||||
},
|
||||
});
|
||||
|
||||
const updateCloudMutation = useMutation({
|
||||
mutationFn: (enabled: boolean) => updateCloudSetting(enabled),
|
||||
onMutate: async (enabled: boolean) => {
|
||||
scope: settingsMutationScope,
|
||||
mutationFn: ({ enabled }: CloudUpdateRequest) =>
|
||||
updateCloudSetting(enabled),
|
||||
onMutate: async ({ enabled, requestId }: CloudUpdateRequest) => {
|
||||
await queryClient.cancelQueries({ queryKey: ["cloudStatus"] });
|
||||
|
||||
const previous = queryClient.getQueryData<CloudStatusResponse | null>([
|
||||
"cloudStatus",
|
||||
]);
|
||||
if (requestId !== latestCloudRequestId) return { previous };
|
||||
const envForcesDisabled =
|
||||
previous?.source === "env" || previous?.source === "both";
|
||||
|
||||
@@ -122,24 +170,31 @@ export default function Settings() {
|
||||
|
||||
return { previous };
|
||||
},
|
||||
onError: (_error, _enabled, context) => {
|
||||
onError: (_error, request, context) => {
|
||||
if (request.requestId !== latestCloudRequestId) return;
|
||||
if (context?.previous !== undefined) {
|
||||
queryClient.setQueryData(["cloudStatus"], context.previous);
|
||||
}
|
||||
},
|
||||
onSuccess: (status) => {
|
||||
onSuccess: (status, request) => {
|
||||
if (request.requestId !== latestCloudRequestId) return;
|
||||
queryClient.setQueryData<CloudStatusResponse | null>(
|
||||
["cloudStatus"],
|
||||
status,
|
||||
);
|
||||
},
|
||||
onSettled: (_status, _error, request) => {
|
||||
if (request.requestId !== latestCloudRequestId) return;
|
||||
queryClient.invalidateQueries({ queryKey: ["models"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["cloudStatus"] });
|
||||
|
||||
setShowSaved(true);
|
||||
setTimeout(() => setShowSaved(false), 1500);
|
||||
},
|
||||
});
|
||||
|
||||
const requestCloudUpdate = (enabled: boolean) => {
|
||||
const requestId = ++latestCloudRequestId;
|
||||
return updateCloudMutation.mutateAsync({ enabled, requestId });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refetchUser();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
@@ -209,47 +264,69 @@ export default function Settings() {
|
||||
setTimeout(() => setRestartMessage(false), 3000);
|
||||
}
|
||||
|
||||
updateSettingsMutation.mutate(updatedSettings);
|
||||
updateSettingsMutation.mutate(updatedSettings, {
|
||||
onSuccess: showSavedConfirmation,
|
||||
});
|
||||
}
|
||||
},
|
||||
[settings, updateSettingsMutation],
|
||||
[settings, showSavedConfirmation, updateSettingsMutation],
|
||||
);
|
||||
|
||||
const handleResetToDefaults = () => {
|
||||
if (settings) {
|
||||
const defaultSettings = new SettingsType({
|
||||
Expose: false,
|
||||
Browser: false,
|
||||
Models: "",
|
||||
Agent: false,
|
||||
Tools: false,
|
||||
ContextLength: 0,
|
||||
AutoUpdateEnabled: true,
|
||||
});
|
||||
updateSettingsMutation.mutate(defaultSettings);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowAppsInMenu = async (checked: boolean) => {
|
||||
const updateShowAppsInMenuVisibility = async (checked: boolean) => {
|
||||
const previous = showAppsInMenu;
|
||||
setShowAppsInMenuState(checked);
|
||||
setShowAppsInMenuPending(true);
|
||||
try {
|
||||
await window.setShowAppsInMenu?.(checked);
|
||||
setShowSaved(true);
|
||||
setTimeout(() => setShowSaved(false), 1500);
|
||||
} catch (error) {
|
||||
setShowAppsInMenuState(previous);
|
||||
console.error("Failed to update menu app visibility:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
setShowAppsInMenuPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowAppsInMenu = (checked: boolean) => {
|
||||
void updateShowAppsInMenuVisibility(checked)
|
||||
.then(showSavedConfirmation)
|
||||
.catch((error) =>
|
||||
console.error("Failed to update menu app visibility:", error),
|
||||
);
|
||||
};
|
||||
|
||||
const handleCloudUpdate = (enabled: boolean) => {
|
||||
void requestCloudUpdate(enabled)
|
||||
.then(showSavedConfirmation)
|
||||
.catch((error) =>
|
||||
console.error("Failed to update cloud setting:", error),
|
||||
);
|
||||
};
|
||||
|
||||
const cloudOverriddenByEnv =
|
||||
cloudStatus?.source === "env" || cloudStatus?.source === "both";
|
||||
const cloudToggleDisabled =
|
||||
cloudStatusLoading || updateCloudMutation.isPending || cloudOverriddenByEnv;
|
||||
const cloudToggleDisabled = cloudOverriddenByEnv;
|
||||
|
||||
const handleResetToDefaults = async () => {
|
||||
if (!settings || resettingToDefaults) return;
|
||||
|
||||
setResettingToDefaults(true);
|
||||
setShowSaved(false);
|
||||
try {
|
||||
await applySettingsDefaults({
|
||||
updateSettings: (defaultSettings) =>
|
||||
updateSettingsMutation.mutateAsync(defaultSettings),
|
||||
updateCloud: requestCloudUpdate,
|
||||
updateShowAppsInMenu: updateShowAppsInMenuVisibility,
|
||||
currentSettings: settings,
|
||||
cloudSource: cloudStatus?.source,
|
||||
onSaved: showSavedConfirmation,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to reset settings:", error);
|
||||
} finally {
|
||||
setResettingToDefaults(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnectOllamaAccount = async () => {
|
||||
setConnectionError(null);
|
||||
@@ -434,7 +511,7 @@ export default function Settings() {
|
||||
if (cloudOverriddenByEnv) {
|
||||
return;
|
||||
}
|
||||
updateCloudMutation.mutate(checked);
|
||||
handleCloudUpdate(checked);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -640,7 +717,8 @@ export default function Settings() {
|
||||
type="button"
|
||||
color="white"
|
||||
className="px-3"
|
||||
onClick={handleResetToDefaults}
|
||||
disabled={resettingToDefaults}
|
||||
onClick={() => void handleResetToDefaults()}
|
||||
>
|
||||
Reset to defaults
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { preloadChatData } from "./chatPreload";
|
||||
|
||||
describe("preloadChatData", () => {
|
||||
it("warms Chat data once without installing polling options", async () => {
|
||||
const prefetchQuery = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
await preloadChatData({ prefetchQuery } as unknown as Pick<
|
||||
QueryClient,
|
||||
"prefetchQuery"
|
||||
>);
|
||||
|
||||
expect(prefetchQuery).toHaveBeenCalledTimes(4);
|
||||
expect(
|
||||
prefetchQuery.mock.calls.map(([options]) => options.queryKey),
|
||||
).toEqual([
|
||||
["chats"],
|
||||
["health"],
|
||||
["models", ""],
|
||||
["modelRecommendations"],
|
||||
]);
|
||||
for (const [options] of prefetchQuery.mock.calls) {
|
||||
expect(options).not.toHaveProperty("refetchInterval");
|
||||
expect(options).not.toHaveProperty("refetchIntervalInBackground");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
fetchHealth,
|
||||
getChats,
|
||||
getModelRecommendations,
|
||||
getModels,
|
||||
} from "@/api";
|
||||
|
||||
type PrefetchClient = Pick<QueryClient, "prefetchQuery">;
|
||||
|
||||
export function preloadChatData(queryClient: PrefetchClient) {
|
||||
return Promise.all([
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["chats"],
|
||||
queryFn: getChats,
|
||||
}),
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["health"],
|
||||
queryFn: fetchHealth,
|
||||
}),
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["models", ""],
|
||||
queryFn: () => getModels(""),
|
||||
gcTime: 10 * 60 * 1000,
|
||||
}),
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ["modelRecommendations"],
|
||||
queryFn: getModelRecommendations,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 30 * 60 * 1000,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { preventPageSelectAll } from "./keyboard";
|
||||
|
||||
function keyboardEvent(overrides: Partial<KeyboardEvent> = {}): KeyboardEvent {
|
||||
return {
|
||||
key: "a",
|
||||
metaKey: true,
|
||||
ctrlKey: false,
|
||||
target: { tagName: "BODY" } as EventTarget,
|
||||
preventDefault: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as KeyboardEvent;
|
||||
}
|
||||
|
||||
describe("preventPageSelectAll", () => {
|
||||
it("prevents Command+A from selecting the page", () => {
|
||||
const event = keyboardEvent();
|
||||
|
||||
preventPageSelectAll(event);
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("prevents Ctrl+A from selecting the page", () => {
|
||||
const event = keyboardEvent({ metaKey: false, ctrlKey: true });
|
||||
|
||||
preventPageSelectAll(event);
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ tagName: "INPUT" },
|
||||
{ tagName: "TEXTAREA" },
|
||||
{ tagName: "DIV", isContentEditable: true },
|
||||
])("keeps Select All working in editable targets", (target) => {
|
||||
const event = keyboardEvent({ target: target as EventTarget });
|
||||
|
||||
preventPageSelectAll(event);
|
||||
|
||||
expect(event.preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves unmodified A keypresses alone", () => {
|
||||
const event = keyboardEvent({ metaKey: false });
|
||||
|
||||
preventPageSelectAll(event);
|
||||
|
||||
expect(event.preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
type KeyboardTarget = EventTarget & {
|
||||
tagName?: string;
|
||||
isContentEditable?: boolean;
|
||||
};
|
||||
|
||||
function isEditableTarget(target: EventTarget | null) {
|
||||
if (!target || typeof target !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { tagName, isContentEditable } = target as KeyboardTarget;
|
||||
return (
|
||||
isContentEditable === true || tagName === "INPUT" || tagName === "TEXTAREA"
|
||||
);
|
||||
}
|
||||
|
||||
export function preventPageSelectAll(event: KeyboardEvent) {
|
||||
const isSelectAll =
|
||||
(event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "a";
|
||||
|
||||
if (isSelectAll && !isEditableTarget(event.target)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { settingsMutationScope } from "./settingsMutationScope";
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((resolvePromise) => {
|
||||
resolve = resolvePromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
describe("settingsMutationScope", () => {
|
||||
it("serializes settings and Cloud updates in request order", async () => {
|
||||
const first = deferred<string>();
|
||||
const second = deferred<string>();
|
||||
const updateSettings = vi
|
||||
.fn<() => Promise<string>>()
|
||||
.mockReturnValue(first.promise);
|
||||
const updateCloud = vi
|
||||
.fn<() => Promise<string>>()
|
||||
.mockReturnValue(second.promise);
|
||||
const queryClient = new QueryClient();
|
||||
const mutationCache = queryClient.getMutationCache();
|
||||
const settingsMutation = mutationCache.build(queryClient, {
|
||||
mutationFn: updateSettings,
|
||||
scope: settingsMutationScope,
|
||||
});
|
||||
const cloudMutation = mutationCache.build(queryClient, {
|
||||
mutationFn: updateCloud,
|
||||
scope: settingsMutationScope,
|
||||
});
|
||||
|
||||
const firstResult = settingsMutation.execute(undefined);
|
||||
const secondResult = cloudMutation.execute(undefined);
|
||||
await vi.waitFor(() => expect(updateSettings).toHaveBeenCalledOnce());
|
||||
expect(updateCloud).not.toHaveBeenCalled();
|
||||
|
||||
first.resolve("settings updated");
|
||||
await firstResult;
|
||||
await vi.waitFor(() => expect(updateCloud).toHaveBeenCalledOnce());
|
||||
|
||||
second.resolve("cloud updated");
|
||||
await expect(secondResult).resolves.toBe("cloud updated");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export const settingsMutationScope = { id: "settings-update" } as const;
|
||||
@@ -1,10 +1,24 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { createRootRouteWithContext, Outlet } from "@tanstack/react-router";
|
||||
import { getSettings } from "@/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCloudStatus } from "@/hooks/useCloudStatus";
|
||||
import { preloadChatData } from "@/lib/chatPreload";
|
||||
import { preventPageSelectAll } from "@/lib/keyboard";
|
||||
import { useEffect } from "react";
|
||||
|
||||
function RootComponent() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("keydown", preventPageSelectAll);
|
||||
return () => document.removeEventListener("keydown", preventPageSelectAll);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void preloadChatData(queryClient);
|
||||
}, [queryClient]);
|
||||
|
||||
// This hook ensures settings are fetched on app startup
|
||||
useQuery({
|
||||
queryKey: ["settings"],
|
||||
|
||||
@@ -9,10 +9,7 @@ export const Route = createFileRoute("/connect")({
|
||||
|
||||
function ConnectRoute() {
|
||||
return (
|
||||
<SidebarLayout
|
||||
title="Connect your apps"
|
||||
sidebar={<AppSidebar current="apps" />}
|
||||
>
|
||||
<SidebarLayout title="Apps" sidebar={<AppSidebar current="apps" />}>
|
||||
<ConnectAppsScreen />
|
||||
</SidebarLayout>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user