app: fix desktop interaction regressions (#17970)

* app: fix desktop interaction regressions

* app: serialize settings reset updates
This commit is contained in:
Eva H
2026-08-24 19:13:48 -04:00
committed by GitHub
parent 02dc3ea4c3
commit 939425152e
14 changed files with 675 additions and 223 deletions
+1 -6
View File
@@ -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;
}
+87 -98
View File
@@ -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>
);
+47 -6
View File
@@ -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");
+59 -69
View File
@@ -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>
);
+165
View File
@@ -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();
});
});
+117 -39
View File
@@ -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>
+28
View File
@@ -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");
}
});
});
+33
View File
@@ -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,
}),
]);
}
+51
View File
@@ -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();
});
});
+24
View File
@@ -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;
+15 -1
View File
@@ -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"],
+1 -4
View File
@@ -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>
);