Merge remote-tracking branch 'origin/main' into merge/main-into-dev

# Conflicts:
#	CLAUDE.md
#	nix/package.nix
#	packages/app/src/components/combined-model-selector.tsx
#	packages/app/src/hooks/use-sidebar-workspaces-list.ts
#	packages/app/src/screens/workspace/workspace-screen.tsx
#	packages/app/src/stores/session-store.test.ts
#	packages/server/src/client/daemon-client.ts
#	packages/server/src/server/agent/agent-manager.ts
#	packages/server/src/server/session.ts
#	packages/server/src/server/session.workspace-git-watch.test.ts
#	packages/server/src/server/session.workspaces.test.ts
#	packages/server/src/shared/messages.ts
This commit is contained in:
Mohamed Boudra
2026-04-08 11:38:17 +07:00
90 changed files with 6284 additions and 923 deletions
+21 -264
View File
@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery, useQueries, useQueryClient } from "@tanstack/react-query";
import {
AGENT_PROVIDER_DEFINITIONS,
type AgentProviderDefinition,
@@ -11,8 +10,7 @@ import type {
ProviderSnapshotEntry,
} from "@server/server/agent/agent-sdk-types";
import { useHosts } from "@/runtime/host-runtime";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useSessionForServer } from "./use-session-directory";
import { useProvidersSnapshot } from "./use-providers-snapshot";
import {
useFormPreferences,
mergeProviderPreferences,
@@ -320,10 +318,6 @@ function combineInitialValues(
return initialValues;
}
function providersSnapshotQueryKey(serverId: string | null, cwd: string | undefined) {
return ["providersSnapshot", serverId, cwd ?? ""] as const;
}
export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAgentFormStateResult {
const {
initialServerId = null,
@@ -341,7 +335,6 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
} = useFormPreferences();
const daemons = useHosts();
const queryClient = useQueryClient();
// Build a set of valid server IDs for preference validation
const validServerIds = useMemo(() => new Set(daemons.map((d) => d.serverId)), [daemons]);
@@ -374,95 +367,19 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
}
}, [isVisible]);
// Session state for provider model listing
const client = useHostRuntimeClient(formState.serverId ?? "");
const isConnected = useHostRuntimeIsConnected(formState.serverId ?? "");
const supportsProvidersSnapshot = useSessionForServer(
formState.serverId,
(session) => session?.serverInfo?.features?.providersSnapshot === true,
);
const {
entries: snapshotEntries,
isLoading: snapshotIsLoading,
isFetching: snapshotIsFetching,
error: snapshotError,
refresh: refreshSnapshot,
} = useProvidersSnapshot(formState.serverId);
const [debouncedCwd, setDebouncedCwd] = useState<string | undefined>(undefined);
useEffect(() => {
const trimmed = formState.workingDir.trim();
const next = trimmed.length > 0 ? trimmed : undefined;
const timer = setTimeout(() => setDebouncedCwd(next), 180);
return () => clearTimeout(timer);
}, [formState.workingDir]);
const snapshotQueryKey = useMemo(
() => providersSnapshotQueryKey(formState.serverId, debouncedCwd),
[debouncedCwd, formState.serverId],
);
const providersSnapshotQuery = useQuery({
queryKey: snapshotQueryKey,
enabled: Boolean(
supportsProvidersSnapshot &&
isVisible &&
isTargetDaemonReady &&
formState.serverId &&
client &&
isConnected,
),
staleTime: 60 * 1000,
queryFn: async () => {
if (!client) {
throw new Error("Host is not connected");
}
return client.getProvidersSnapshot({ cwd: debouncedCwd });
},
});
useEffect(() => {
if (
!supportsProvidersSnapshot ||
!client ||
!isConnected ||
!isVisible ||
!isTargetDaemonReady ||
!formState.serverId
) {
return;
}
return client.on("providers_snapshot_update", (message) => {
if (message.type !== "providers_snapshot_update") {
return;
}
if (message.payload.cwd !== undefined && message.payload.cwd !== debouncedCwd) {
return;
}
queryClient.setQueryData(snapshotQueryKey, {
entries: message.payload.entries,
generatedAt: message.payload.generatedAt,
requestId: "providers_snapshot_update",
});
});
}, [
client,
debouncedCwd,
formState.serverId,
isConnected,
isTargetDaemonReady,
isVisible,
queryClient,
snapshotQueryKey,
supportsProvidersSnapshot,
]);
const snapshotEntries = providersSnapshotQuery.data?.entries ?? undefined;
const allProviderEntries = useMemo(
() => (supportsProvidersSnapshot ? snapshotEntries ?? [] : undefined),
[snapshotEntries, supportsProvidersSnapshot],
);
const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]);
const snapshotProviderDefinitions = useMemo(() => {
if (!supportsProvidersSnapshot) {
return [];
}
const snapshotProviders = new Set((snapshotEntries ?? []).map((entry) => entry.provider));
return allProviderDefinitions.filter((definition) => snapshotProviders.has(definition.id));
}, [snapshotEntries, supportsProvidersSnapshot]);
}, [snapshotEntries]);
const snapshotProviderDefinitionMap = useMemo(
() =>
new Map<AgentProvider, AgentProviderDefinition>(
@@ -498,157 +415,13 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
snapshotSelectedEntry?.modes ??
snapshotProviderDefinitionMap.get(formState.provider)?.modes ??
[];
// COMPAT(providersSnapshot): legacy fallback for daemons without snapshot support — remove when all daemons support snapshots
const availableProvidersQuery = useQuery({
queryKey: ["availableProviders", formState.serverId],
enabled: Boolean(
!supportsProvidersSnapshot &&
isVisible &&
isTargetDaemonReady &&
formState.serverId &&
client &&
isConnected,
),
staleTime: 60 * 1000,
queryFn: async () => {
if (!client) {
throw new Error("Host is not connected");
}
const payload = await client.listAvailableProviders();
if (payload.error) {
throw new Error(payload.error);
}
return payload.providers.filter((entry) => entry.available).map((entry) => entry.provider);
},
});
const legacyProviderDefinitions = useMemo(() => {
const availableProviders = availableProvidersQuery.data;
if (!availableProviders) {
return [];
}
const available = new Set(availableProviders);
return allProviderDefinitions.filter((definition) => available.has(definition.id));
}, [availableProvidersQuery.data]);
const legacyProviderDefinitionMap = useMemo(
() =>
new Map<AgentProvider, AgentProviderDefinition>(
legacyProviderDefinitions.map((definition) => [definition.id, definition]),
),
[legacyProviderDefinitions],
);
// COMPAT(providersSnapshot): legacy fallback for daemons without snapshot support — remove when all daemons support snapshots
const legacySelectedProviderModelsQuery = useQuery({
queryKey: ["providerModels", formState.serverId, formState.provider],
enabled: Boolean(
!supportsProvidersSnapshot &&
isVisible &&
isTargetDaemonReady &&
formState.serverId &&
client &&
isConnected &&
legacyProviderDefinitionMap.has(formState.provider),
),
staleTime: 5 * 60 * 1000,
queryFn: async () => {
if (!client) {
throw new Error("Host is not connected");
}
const payload = await client.listProviderModels(formState.provider, {
cwd: debouncedCwd,
});
if (payload.error) {
throw new Error(payload.error);
}
return payload.models ?? [];
},
});
const legacySelectedProviderModels = legacySelectedProviderModelsQuery.data ?? null;
// COMPAT(providersSnapshot): legacy fallback for daemons without snapshot support — remove when all daemons support snapshots
const providerModesQuery = useQuery({
queryKey: ["providerModes", formState.serverId, formState.provider, debouncedCwd],
enabled: Boolean(
!supportsProvidersSnapshot &&
isVisible &&
isTargetDaemonReady &&
formState.serverId &&
client &&
isConnected &&
legacyProviderDefinitionMap.has(formState.provider),
),
staleTime: 5 * 60 * 1000,
queryFn: async () => {
if (!client) {
throw new Error("Host is not connected");
}
const payload = await client.listProviderModes(formState.provider, {
cwd: debouncedCwd,
});
if (payload.error) {
throw new Error(payload.error);
}
return payload.modes ?? [];
},
});
// COMPAT(providersSnapshot): legacy fallback for daemons without snapshot support — remove when all daemons support snapshots
const allProviderModelQueries = useQueries({
queries: (supportsProvidersSnapshot ? [] : legacyProviderDefinitions).map((def) => ({
queryKey: ["providerModels", formState.serverId, def.id],
enabled: Boolean(
isVisible && isTargetDaemonReady && formState.serverId && client && isConnected,
),
staleTime: 5 * 60 * 1000,
queryFn: async () => {
if (!client) {
throw new Error("Host is not connected");
}
const payload = await client.listProviderModels(def.id as AgentProvider, {
cwd: debouncedCwd,
});
if (payload.error) {
throw new Error(payload.error);
}
return payload.models ?? [];
},
})),
});
const legacyAllProviderModels = useMemo(() => {
const map = new Map<string, AgentModelDefinition[]>();
for (let i = 0; i < legacyProviderDefinitions.length; i++) {
const query = allProviderModelQueries[i];
if (query?.data) {
map.set(legacyProviderDefinitions[i]!.id, query.data);
}
}
return map;
}, [allProviderModelQueries, legacyProviderDefinitions]);
const legacySelectedProviderModes =
providerModesQuery.data ?? legacyProviderDefinitionMap.get(formState.provider)?.modes ?? [];
const providerDefinitions = supportsProvidersSnapshot
? snapshotProviderDefinitions
: legacyProviderDefinitions;
const providerDefinitionMap = supportsProvidersSnapshot
? snapshotProviderDefinitionMap
: legacyProviderDefinitionMap;
const selectableProviderDefinitionMap = supportsProvidersSnapshot
? snapshotSelectableProviderDefinitionMap
: legacyProviderDefinitionMap;
const allProviderModels = supportsProvidersSnapshot
? snapshotAllProviderModels
: legacyAllProviderModels;
const availableModels = supportsProvidersSnapshot
? snapshotSelectedProviderModels
: legacySelectedProviderModels;
const modeOptions = supportsProvidersSnapshot
? snapshotSelectedProviderModes
: legacySelectedProviderModes;
const isAllModelsLoading = supportsProvidersSnapshot
? providersSnapshotQuery.isLoading || providersSnapshotQuery.isFetching
: allProviderModelQueries.some((q) => q.isLoading);
const providerDefinitions = snapshotProviderDefinitions;
const providerDefinitionMap = snapshotProviderDefinitionMap;
const selectableProviderDefinitionMap = snapshotSelectableProviderDefinitionMap;
const allProviderModels = snapshotAllProviderModels;
const availableModels = snapshotSelectedProviderModels;
const modeOptions = snapshotSelectedProviderModes;
const isAllModelsLoading = snapshotIsLoading || snapshotIsFetching;
// Combine initialValues with initialServerId for resolution
const combinedInitialValues = useMemo((): FormInitialValues | undefined => {
@@ -870,15 +643,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
}, []);
const refreshProviderModels = useCallback(() => {
if (supportsProvidersSnapshot) {
if (!client) {
return;
}
void client.refreshProvidersSnapshot({ cwd: debouncedCwd });
return;
}
void legacySelectedProviderModelsQuery.refetch();
}, [client, debouncedCwd, legacySelectedProviderModelsQuery, supportsProvidersSnapshot]);
refreshSnapshot();
}, [refreshSnapshot]);
const persistFormPreferences = useCallback(async () => {
const resolvedModel = resolveEffectiveModel(availableModels, formState.model);
@@ -914,17 +680,8 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
const effectiveModel = resolveEffectiveModel(availableModels, formState.model);
const resolvedModelId = effectiveModel?.id ?? formState.model;
const availableThinkingOptions = effectiveModel?.thinkingOptions ?? [];
const isModelLoading = supportsProvidersSnapshot
? providersSnapshotQuery.isLoading || providersSnapshotQuery.isFetching
: legacySelectedProviderModelsQuery.isLoading || legacySelectedProviderModelsQuery.isFetching;
const modelError =
supportsProvidersSnapshot
? providersSnapshotQuery.error instanceof Error
? providersSnapshotQuery.error.message
: null
: legacySelectedProviderModelsQuery.error instanceof Error
? legacySelectedProviderModelsQuery.error.message
: null;
const isModelLoading = snapshotIsLoading || snapshotIsFetching;
const modelError = snapshotError;
const workingDirIsEmpty = !formState.workingDir.trim();
@@ -0,0 +1,74 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const asyncStorageMock = vi.hoisted(() => ({
getItem: vi.fn<(_: string) => Promise<string | null>>(),
setItem: vi.fn<(_: string, __: string) => Promise<void>>(),
}));
vi.mock("@react-native-async-storage/async-storage", () => ({
default: asyncStorageMock,
}));
describe("use-changes-preferences", () => {
beforeEach(() => {
vi.resetModules();
asyncStorageMock.getItem.mockReset();
asyncStorageMock.setItem.mockReset();
});
it("defaults to unified layout with visible whitespace", async () => {
asyncStorageMock.getItem.mockResolvedValue(null);
asyncStorageMock.setItem.mockResolvedValue();
const mod = await import("./use-changes-preferences");
const result = await mod.loadChangesPreferencesFromStorage();
expect(result).toEqual(mod.DEFAULT_CHANGES_PREFERENCES);
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
"@paseo:changes-preferences",
JSON.stringify(mod.DEFAULT_CHANGES_PREFERENCES),
);
});
it("migrates the legacy wrap-lines toggle into the new preferences object", async () => {
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
if (key === "diff-wrap-lines") {
return "true";
}
return null;
});
asyncStorageMock.setItem.mockResolvedValue();
const mod = await import("./use-changes-preferences");
const result = await mod.loadChangesPreferencesFromStorage();
expect(result).toEqual({
layout: "unified",
wrapLines: true,
hideWhitespace: false,
});
});
it("loads persisted layout and whitespace preferences", async () => {
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
if (key === "@paseo:changes-preferences") {
return JSON.stringify({
layout: "split",
hideWhitespace: true,
wrapLines: false,
});
}
return null;
});
const mod = await import("./use-changes-preferences");
const result = await mod.loadChangesPreferencesFromStorage();
expect(result).toEqual({
layout: "split",
hideWhitespace: true,
wrapLines: false,
});
expect(asyncStorageMock.setItem).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,89 @@
import { useCallback } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { z } from "zod";
const CHANGES_PREFERENCES_STORAGE_KEY = "@paseo:changes-preferences";
const LEGACY_WRAP_LINES_STORAGE_KEY = "diff-wrap-lines";
const CHANGES_PREFERENCES_QUERY_KEY = ["changes-preferences"];
const changesPreferencesSchema = z.object({
layout: z.enum(["unified", "split"]).optional(),
wrapLines: z.boolean().optional(),
hideWhitespace: z.boolean().optional(),
});
export interface ChangesPreferences {
layout: "unified" | "split";
wrapLines: boolean;
hideWhitespace: boolean;
}
export const DEFAULT_CHANGES_PREFERENCES: ChangesPreferences = {
layout: "unified",
wrapLines: false,
hideWhitespace: false,
};
async function loadLegacyWrapLinesPreference(): Promise<boolean | null> {
const legacyValue = await AsyncStorage.getItem(LEGACY_WRAP_LINES_STORAGE_KEY);
if (legacyValue === "true") {
return true;
}
if (legacyValue === "false") {
return false;
}
return null;
}
export async function loadChangesPreferencesFromStorage(): Promise<ChangesPreferences> {
const stored = await AsyncStorage.getItem(CHANGES_PREFERENCES_STORAGE_KEY);
if (stored) {
const parsed = changesPreferencesSchema.safeParse(JSON.parse(stored));
if (parsed.success) {
return { ...DEFAULT_CHANGES_PREFERENCES, ...parsed.data };
}
}
const legacyWrapLines = await loadLegacyWrapLinesPreference();
const next = {
...DEFAULT_CHANGES_PREFERENCES,
...(legacyWrapLines !== null ? { wrapLines: legacyWrapLines } : {}),
} satisfies ChangesPreferences;
await AsyncStorage.setItem(CHANGES_PREFERENCES_STORAGE_KEY, JSON.stringify(next));
return next;
}
export interface UseChangesPreferencesReturn {
preferences: ChangesPreferences;
isLoading: boolean;
updatePreferences: (updates: Partial<ChangesPreferences>) => Promise<void>;
}
export function useChangesPreferences(): UseChangesPreferencesReturn {
const queryClient = useQueryClient();
const { data, isPending } = useQuery({
queryKey: CHANGES_PREFERENCES_QUERY_KEY,
queryFn: loadChangesPreferencesFromStorage,
staleTime: Infinity,
gcTime: Infinity,
});
const updatePreferences = useCallback(
async (updates: Partial<ChangesPreferences>) => {
const prev =
queryClient.getQueryData<ChangesPreferences>(CHANGES_PREFERENCES_QUERY_KEY) ??
DEFAULT_CHANGES_PREFERENCES;
const next = { ...prev, ...updates };
queryClient.setQueryData<ChangesPreferences>(CHANGES_PREFERENCES_QUERY_KEY, next);
await AsyncStorage.setItem(CHANGES_PREFERENCES_STORAGE_KEY, JSON.stringify(next));
},
[queryClient],
);
return {
preferences: data ?? DEFAULT_CHANGES_PREFERENCES,
isLoading: isPending,
updatePreferences,
};
}
@@ -13,8 +13,9 @@ function checkoutDiffQueryKey(
cwd: string,
mode: "uncommitted" | "base",
baseRef?: string,
ignoreWhitespace?: boolean,
) {
return ["checkoutDiff", serverId, cwd, mode, baseRef ?? ""] as const;
return ["checkoutDiff", serverId, cwd, mode, baseRef ?? "", ignoreWhitespace === true] as const;
}
interface UseCheckoutDiffQueryOptions {
@@ -22,6 +23,7 @@ interface UseCheckoutDiffQueryOptions {
cwd: string;
mode: "uncommitted" | "base";
baseRef?: string;
ignoreWhitespace?: boolean;
enabled?: boolean;
}
@@ -35,12 +37,16 @@ export type HighlightToken = NonNullable<DiffLine["tokens"]>[number];
function normalizeCheckoutDiffCompare(compare: {
mode: "uncommitted" | "base";
baseRef?: string;
}): { mode: "uncommitted" | "base"; baseRef?: string } {
ignoreWhitespace?: boolean;
}): { mode: "uncommitted" | "base"; baseRef?: string; ignoreWhitespace?: boolean } {
const ignoreWhitespace = compare.ignoreWhitespace === true;
if (compare.mode === "uncommitted") {
return { mode: "uncommitted" };
return { mode: "uncommitted", ignoreWhitespace };
}
const trimmedBaseRef = compare.baseRef?.trim();
return trimmedBaseRef ? { mode: "base", baseRef: trimmedBaseRef } : { mode: "base" };
return trimmedBaseRef
? { mode: "base", baseRef: trimmedBaseRef, ignoreWhitespace }
: { mode: "base", ignoreWhitespace };
}
export function useCheckoutDiffQuery({
@@ -48,6 +54,7 @@ export function useCheckoutDiffQuery({
cwd,
mode,
baseRef,
ignoreWhitespace,
enabled = true,
}: UseCheckoutDiffQueryOptions) {
const queryClient = useQueryClient();
@@ -60,14 +67,15 @@ export function useCheckoutDiffQuery({
const isOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
const hookInstanceId = useId();
const normalizedCompare = useMemo(
() => normalizeCheckoutDiffCompare({ mode, baseRef }),
[mode, baseRef],
() => normalizeCheckoutDiffCompare({ mode, baseRef, ignoreWhitespace }),
[mode, baseRef, ignoreWhitespace],
);
const compareMode = normalizedCompare.mode;
const compareBaseRef = normalizedCompare.baseRef;
const compareIgnoreWhitespace = normalizedCompare.ignoreWhitespace;
const queryKey = useMemo(
() => checkoutDiffQueryKey(serverId, cwd, mode, baseRef),
[serverId, cwd, mode, baseRef],
() => checkoutDiffQueryKey(serverId, cwd, mode, baseRef, compareIgnoreWhitespace),
[serverId, cwd, mode, baseRef, compareIgnoreWhitespace],
);
const query = useQuery({
@@ -79,6 +87,7 @@ export function useCheckoutDiffQuery({
const payload = await client.getCheckoutDiff(cwd, {
mode: compareMode,
baseRef: compareBaseRef,
ignoreWhitespace: compareIgnoreWhitespace,
});
return {
...payload,
@@ -104,6 +113,7 @@ export function useCheckoutDiffQuery({
cwd,
compareMode,
compareBaseRef ?? "",
compareIgnoreWhitespace ? "ignore-ws" : "keep-ws",
].join(":");
let cancelled = false;
@@ -145,6 +155,7 @@ export function useCheckoutDiffQuery({
{
mode: compareMode,
baseRef: compareBaseRef,
ignoreWhitespace: compareIgnoreWhitespace,
},
{ subscriptionId },
)
@@ -191,6 +202,7 @@ export function useCheckoutDiffQuery({
serverId,
compareMode,
compareBaseRef,
compareIgnoreWhitespace,
queryKey,
queryClient,
]);
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { resolvePreferredEditorId } from "./use-preferred-editor";
describe("resolvePreferredEditorId", () => {
it("keeps the stored editor when it is still available", () => {
expect(resolvePreferredEditorId(["cursor", "vscode"], "vscode")).toBe("vscode");
});
it("falls back to the first available editor when the stored one is missing", () => {
expect(resolvePreferredEditorId(["zed", "finder"], "cursor")).toBe("zed");
});
it("falls back when a platform-specific file manager target is unavailable", () => {
expect(resolvePreferredEditorId(["explorer", "vscode"], "finder")).toBe("explorer");
});
it("returns null when no editors are available", () => {
expect(resolvePreferredEditorId([], "cursor")).toBeNull();
});
});
@@ -0,0 +1,57 @@
import { useCallback } from "react";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { EditorTargetIdSchema, type EditorTargetId } from "@server/shared/messages";
const PREFERRED_EDITOR_STORAGE_KEY = "@paseo:preferred-editor";
const PREFERRED_EDITOR_QUERY_KEY = ["preferred-editor"];
async function loadPreferredEditor(): Promise<EditorTargetId | null> {
const stored = await AsyncStorage.getItem(PREFERRED_EDITOR_STORAGE_KEY);
if (!stored) {
return null;
}
const parsed = EditorTargetIdSchema.safeParse(stored);
return parsed.success ? parsed.data : null;
}
export function resolvePreferredEditorId(
availableEditorIds: readonly EditorTargetId[],
storedEditorId: EditorTargetId | null | undefined,
): EditorTargetId | null {
if (
storedEditorId &&
availableEditorIds.some((availableEditorId) => availableEditorId === storedEditorId)
) {
return storedEditorId;
}
return availableEditorIds[0] ?? null;
}
export function usePreferredEditor() {
const queryClient = useQueryClient();
const { data, isPending } = useQuery({
queryKey: PREFERRED_EDITOR_QUERY_KEY,
queryFn: loadPreferredEditor,
staleTime: Infinity,
gcTime: Infinity,
});
const updatePreferredEditor = useCallback(
async (editorId: EditorTargetId | null) => {
queryClient.setQueryData(PREFERRED_EDITOR_QUERY_KEY, editorId);
if (editorId) {
await AsyncStorage.setItem(PREFERRED_EDITOR_STORAGE_KEY, editorId);
return;
}
await AsyncStorage.removeItem(PREFERRED_EDITOR_STORAGE_KEY);
},
[queryClient],
);
return {
preferredEditorId: data ?? null,
isLoading: isPending,
updatePreferredEditor,
};
}
@@ -0,0 +1,86 @@
import { useCallback, useEffect, useMemo } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type { ProviderSnapshotEntry } from "@server/server/agent/agent-sdk-types";
import type { DaemonClient } from "@server/client/daemon-client";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useSessionForServer } from "./use-session-directory";
import { queryClient as singletonQueryClient } from "@/query/query-client";
export function providersSnapshotQueryKey(serverId: string | null) {
return ["providersSnapshot", serverId] as const;
}
interface UseProvidersSnapshotResult {
entries: ProviderSnapshotEntry[] | undefined;
isLoading: boolean;
isFetching: boolean;
error: string | null;
supportsSnapshot: boolean;
refresh: () => void;
}
export function useProvidersSnapshot(serverId: string | null): UseProvidersSnapshotResult {
const queryClient = useQueryClient();
const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const supportsSnapshot = useSessionForServer(
serverId,
(session) => session?.serverInfo?.features?.providersSnapshot === true,
);
const queryKey = useMemo(() => providersSnapshotQueryKey(serverId), [serverId]);
const snapshotQuery = useQuery({
queryKey,
enabled: Boolean(supportsSnapshot && serverId && client && isConnected),
staleTime: 60_000,
queryFn: async () => {
if (!client) {
throw new Error("Host is not connected");
}
return client.getProvidersSnapshot();
},
});
useEffect(() => {
if (!supportsSnapshot || !client || !isConnected || !serverId) {
return;
}
return client.on("providers_snapshot_update", (message) => {
if (message.type !== "providers_snapshot_update") {
return;
}
queryClient.setQueryData(queryKey, {
entries: message.payload.entries,
generatedAt: message.payload.generatedAt,
requestId: "providers_snapshot_update",
});
});
}, [client, isConnected, serverId, queryClient, queryKey, supportsSnapshot]);
const refresh = useCallback(() => {
if (!client) {
return;
}
void client.refreshProvidersSnapshot();
}, [client]);
return {
entries: snapshotQuery.data?.entries ?? undefined,
isLoading: snapshotQuery.isLoading,
isFetching: snapshotQuery.isFetching,
error: snapshotQuery.error instanceof Error ? snapshotQuery.error.message : null,
supportsSnapshot,
refresh,
};
}
export function prefetchProvidersSnapshot(serverId: string, client: DaemonClient): void {
const queryKey = providersSnapshotQueryKey(serverId);
void singletonQueryClient.prefetchQuery({
queryKey,
staleTime: 60_000,
queryFn: () => client.getProvidersSnapshot(),
});
}
@@ -1,6 +1,10 @@
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
import type { WorkspaceDescriptorPayload } from "@server/shared/messages";
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
import {
mergeWorkspaceSnapshotWithExisting,
normalizeWorkspaceDescriptor,
useSessionStore,
} from "@/stores/session-store";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
import type { WorkspaceDescriptor } from "@/stores/session-store";
@@ -372,6 +376,7 @@ export function useSidebarWorkspacesList(options?: {
}
void (async () => {
const next = new Map<string, WorkspaceDescriptor>();
const existingWorkspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
let cursor: string | null = null;
try {
while (true) {
@@ -381,7 +386,13 @@ export function useSidebarWorkspacesList(options?: {
});
for (const entry of payload.entries) {
const workspace = toWorkspaceDescriptor(entry);
next.set(workspace.id, workspace);
next.set(
workspace.id,
mergeWorkspaceSnapshotWithExisting({
incoming: workspace,
existing: existingWorkspaces?.get(workspace.id),
}),
);
}
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) {
break;