Merge origin/main into dev (second round)

Resolve 21 conflicts from latest main (provider profiles, Windows fixes, 0.1.55-rc.1).
Fix all type errors and syntax issues from merge artifacts.
This commit is contained in:
Mohamed Boudra
2026-04-14 04:07:36 +07:00
249 changed files with 9118 additions and 4973 deletions
@@ -1,11 +1,12 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { AppState, Platform } from "react-native";
import { AppState } from "react-native";
import type { DaemonClient } from "@server/client/daemon-client";
import {
shouldClearAgentAttention,
type AgentAttentionClearTrigger,
} from "@/utils/agent-attention";
import { getIsAppActivelyVisible } from "@/utils/app-visibility";
import { isWeb } from "@/constants/platform";
type AttentionReason = "finished" | "error" | "permission" | null | undefined;
@@ -70,7 +71,7 @@ export function useAgentAttentionClear({
const appStateSubscription = AppState.addEventListener("change", updateVisibility);
if (Platform.OS === "web" && typeof document !== "undefined") {
if (isWeb && typeof document !== "undefined") {
document.addEventListener("visibilitychange", updateVisibility);
window.addEventListener("focus", updateVisibility);
window.addEventListener("blur", updateVisibility);
@@ -1,12 +1,117 @@
import { describe, expect, it } from "vitest";
import { __private__ } from "./use-agent-form-state";
import {
AGENT_PROVIDER_DEFINITIONS,
type AgentProviderDefinition,
} from "@server/server/agent/provider-manifest";
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
import { buildProviderDefinitions } from "@/utils/provider-definitions";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import type {
AgentModelDefinition,
AgentProvider,
ProviderSnapshotEntry,
} from "@server/server/agent/agent-sdk-types";
const TEST_CODEX_DEFINITION: AgentProviderDefinition = {
id: "codex",
label: "Codex",
description: "Codex test provider",
defaultModeId: "auto",
modes: [
{ id: "auto", label: "Auto", icon: "ShieldAlert", colorTier: "moderate" },
{ id: "full-access", label: "Full Access", icon: "ShieldAlert", colorTier: "dangerous" },
],
};
const TEST_CLAUDE_DEFINITION: AgentProviderDefinition = {
id: "claude",
label: "Claude",
description: "Claude test provider",
defaultModeId: "default",
modes: [
{ id: "default", label: "Always Ask", icon: "ShieldCheck", colorTier: "safe" },
{ id: "acceptEdits", label: "Accept File Edits", icon: "ShieldAlert", colorTier: "moderate" },
{ id: "plan", label: "Plan Mode", icon: "ShieldCheck", colorTier: "planning" },
{ id: "bypassPermissions", label: "Bypass", icon: "ShieldAlert", colorTier: "dangerous" },
],
};
function makeProviderMap(
...definitions: AgentProviderDefinition[]
): Map<AgentProvider, AgentProviderDefinition> {
return new Map(definitions.map((d) => [d.id as AgentProvider, d]));
}
const codexProviderMap = makeProviderMap(TEST_CODEX_DEFINITION);
const claudeProviderMap = makeProviderMap(TEST_CLAUDE_DEFINITION);
describe("useAgentFormState", () => {
describe("buildProviderDefinitions", () => {
it("returns empty array when snapshot data is unavailable", () => {
expect(buildProviderDefinitions(undefined)).toEqual([]);
expect(buildProviderDefinitions([])).toEqual([]);
});
it("builds custom provider definitions from snapshot metadata", () => {
const entries: ProviderSnapshotEntry[] = [
{
provider: "zai",
status: "ready",
label: "ZAI",
description: "Claude with ZAI config",
defaultModeId: "default",
modes: [
{
id: "default",
label: "Default",
description: "Safe mode",
icon: "ShieldCheck",
colorTier: "safe",
},
],
},
{
provider: "claude",
status: "ready",
label: "Claude",
description: "Anthropic Claude",
defaultModeId: "default",
modes: [{ id: "default", label: "Always Ask", icon: "ShieldCheck", colorTier: "safe" }],
},
];
const definitions = buildProviderDefinitions(entries);
expect(definitions).toEqual([
{
id: "zai",
label: "ZAI",
description: "Claude with ZAI config",
defaultModeId: "default",
modes: [
{
id: "default",
label: "Default",
description: "Safe mode",
icon: "ShieldCheck",
colorTier: "safe",
},
],
},
{
id: "claude",
label: "Claude",
description: "Anthropic Claude",
defaultModeId: "default",
modes: [
{
id: "default",
label: "Always Ask",
icon: "ShieldCheck",
colorTier: "safe",
},
],
},
]);
});
});
describe("__private__.combineInitialValues", () => {
it("returns undefined when no initial values and no initial server id", () => {
expect(__private__.combineInitialValues(undefined, null)).toBeUndefined();
@@ -78,6 +183,7 @@ describe("useAgentFormState", () => {
workingDir: "",
},
new Set<string>(),
codexProviderMap,
);
expect(resolved.model).toBe("");
@@ -106,6 +212,7 @@ describe("useAgentFormState", () => {
workingDir: "",
},
new Set<string>(),
codexProviderMap,
);
expect(resolved.model).toBe("gpt-5.3-codex");
@@ -134,6 +241,7 @@ describe("useAgentFormState", () => {
workingDir: "",
},
new Set<string>(),
codexProviderMap,
);
expect(resolved.thinkingOptionId).toBe("xhigh");
@@ -161,6 +269,7 @@ describe("useAgentFormState", () => {
workingDir: "",
},
new Set<string>(),
codexProviderMap,
);
expect(resolved.model).toBe("gpt-5.3-codex");
@@ -188,6 +297,7 @@ describe("useAgentFormState", () => {
workingDir: "",
},
new Set<string>(),
codexProviderMap,
);
expect(resolved.model).toBe("gpt-5.3-codex");
@@ -215,6 +325,7 @@ describe("useAgentFormState", () => {
workingDir: "",
},
new Set<string>(),
codexProviderMap,
);
expect(resolved.model).toBe("gpt-5.3-codex");
@@ -256,6 +367,7 @@ describe("useAgentFormState", () => {
workingDir: "",
},
new Set<string>(),
claudeProviderMap,
);
expect(resolved.model).toBe("default");
@@ -263,11 +375,6 @@ describe("useAgentFormState", () => {
});
it("resolves provider only from allowed provider map", () => {
const allowedProviderMap = new Map<AgentProvider, AgentProviderDefinition>(
AGENT_PROVIDER_DEFINITIONS.filter((definition) => definition.id === "claude").map(
(definition) => [definition.id as AgentProvider, definition],
),
);
const resolved = __private__.resolveFormState(
undefined,
{ provider: "codex" },
@@ -289,7 +396,7 @@ describe("useAgentFormState", () => {
workingDir: "",
},
new Set<string>(),
allowedProviderMap,
claudeProviderMap,
);
expect(resolved.provider).toBe("claude");
+14 -20
View File
@@ -1,8 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
AGENT_PROVIDER_DEFINITIONS,
type AgentProviderDefinition,
} from "@server/server/agent/provider-manifest";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import type {
AgentMode,
AgentModelDefinition,
@@ -10,6 +7,7 @@ import type {
ProviderSnapshotEntry,
} from "@server/server/agent/agent-sdk-types";
import { useHosts } from "@/runtime/host-runtime";
import { buildProviderDefinitions } from "@/utils/provider-definitions";
import { useProvidersSnapshot } from "./use-providers-snapshot";
import {
useFormPreferences,
@@ -99,13 +97,8 @@ export type UseAgentFormStateResult = {
persistFormPreferences: () => Promise<void>;
};
const allProviderDefinitions = AGENT_PROVIDER_DEFINITIONS;
const allProviderDefinitionMap = new Map<AgentProvider, AgentProviderDefinition>(
allProviderDefinitions.map((definition) => [definition.id, definition]),
);
const fallbackDefinition = allProviderDefinitions[0];
const DEFAULT_PROVIDER: AgentProvider = fallbackDefinition?.id ?? "claude";
const DEFAULT_MODE_FOR_DEFAULT_PROVIDER = fallbackDefinition?.defaultModeId ?? "";
const DEFAULT_PROVIDER: AgentProvider = "claude";
const DEFAULT_MODE_FOR_DEFAULT_PROVIDER = "default";
function normalizeSelectedModelId(modelId: string | null | undefined): string {
const normalized = typeof modelId === "string" ? modelId.trim() : "";
@@ -180,7 +173,7 @@ function resolveFormState(
userModified: UserModifiedFields,
currentState: FormState,
validServerIds: Set<string>,
allowedProviderMap: Map<AgentProvider, AgentProviderDefinition> = allProviderDefinitionMap,
allowedProviderMap: Map<AgentProvider, AgentProviderDefinition>,
): FormState {
// Start with current state - we only update non-user-modified fields
const result = { ...currentState };
@@ -374,10 +367,10 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
} = useProvidersSnapshot(formState.serverId);
const allProviderEntries = useMemo(() => snapshotEntries ?? [], [snapshotEntries]);
const snapshotProviderDefinitions = useMemo(() => {
const snapshotProviders = new Set((snapshotEntries ?? []).map((entry) => entry.provider));
return allProviderDefinitions.filter((definition) => snapshotProviders.has(definition.id));
}, [snapshotEntries]);
const snapshotProviderDefinitions = useMemo(
() => buildProviderDefinitions(snapshotEntries),
[snapshotEntries],
);
const snapshotProviderDefinitionMap = useMemo(
() =>
new Map<AgentProvider, AgentProviderDefinition>(
@@ -386,17 +379,18 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
[snapshotProviderDefinitions],
);
const snapshotSelectableProviderDefinitionMap = useMemo(() => {
if (!snapshotEntries?.length) {
return snapshotProviderDefinitionMap;
}
const readyProviders = new Set(
(snapshotEntries ?? [])
.filter((entry) => entry.status === "ready")
.map((entry) => entry.provider),
snapshotEntries.filter((entry) => entry.status === "ready").map((entry) => entry.provider),
);
return new Map<AgentProvider, AgentProviderDefinition>(
snapshotProviderDefinitions
.filter((definition) => readyProviders.has(definition.id))
.map((definition) => [definition.id, definition]),
);
}, [snapshotEntries, snapshotProviderDefinitions]);
}, [snapshotEntries, snapshotProviderDefinitionMap, snapshotProviderDefinitions]);
const snapshotAllProviderModels = useMemo(() => {
const map = new Map<string, AgentModelDefinition[]>();
for (const entry of snapshotEntries ?? []) {
@@ -1,5 +1,4 @@
import { useCallback } from "react";
import { Platform } from "react-native";
import { useSessionStore } from "@/stores/session-store";
import type { DaemonClient } from "@server/client/daemon-client";
import {
@@ -10,13 +9,14 @@ import {
rejectInitDeferred,
} from "@/utils/agent-initialization";
import { deriveInitialTimelineRequest } from "@/contexts/session-timeline-bootstrap-policy";
import { isWeb } from "@/constants/platform";
const INIT_TIMEOUT_MS = 5 * 60_000;
const NATIVE_INITIAL_TIMELINE_LIMIT = 200;
const UNBOUNDED_TIMELINE_LIMIT = 0;
function resolveInitialTimelineLimit(): number {
return Platform.OS === "web" ? UNBOUNDED_TIMELINE_LIMIT : NATIVE_INITIAL_TIMELINE_LIMIT;
return isWeb ? UNBOUNDED_TIMELINE_LIMIT : NATIVE_INITIAL_TIMELINE_LIMIT;
}
export const __private__ = {
@@ -123,7 +123,9 @@ function buildDraftComposerCommandConfig(input: {
return {
provider: input.provider,
cwd,
...(input.modeOptions.length > 0 && input.selectedMode !== "" ? { modeId: input.selectedMode } : {}),
...(input.modeOptions.length > 0 && input.selectedMode !== ""
? { modeId: input.selectedMode }
: {}),
...(input.effectiveModelId ? { model: input.effectiveModelId } : {}),
...(input.effectiveThinkingOptionId
? { thinkingOptionId: input.effectiveThinkingOptionId }
+4 -3
View File
@@ -1,6 +1,7 @@
import { useEffect, useSyncExternalStore } from "react";
import { AppState, Platform } from "react-native";
import { AppState } from "react-native";
import { getIsAppActivelyVisible } from "@/utils/app-visibility";
import { isWeb } from "@/constants/platform";
let current = getIsAppActivelyVisible();
const listeners = new Set<() => void>();
@@ -29,7 +30,7 @@ export function useAppVisible(): boolean {
useEffect(() => {
const appStateSubscription = AppState.addEventListener("change", notify);
if (Platform.OS === "web" && typeof document !== "undefined") {
if (isWeb && typeof document !== "undefined") {
document.addEventListener("visibilitychange", notify);
window.addEventListener("focus", notify);
window.addEventListener("blur", notify);
@@ -37,7 +38,7 @@ export function useAppVisible(): boolean {
return () => {
appStateSubscription.remove();
if (Platform.OS === "web" && typeof document !== "undefined") {
if (isWeb && typeof document !== "undefined") {
document.removeEventListener("visibilitychange", notify);
window.removeEventListener("focus", notify);
window.removeEventListener("blur", notify);
@@ -135,18 +135,8 @@ describe("useArchiveAgent", () => {
it("can apply archived agent close results without invalidating cached lists", () => {
const queryClient = new QueryClient();
useSessionStore
.getState()
.initializeSession("server-a", {} as DaemonClient);
useSessionStore.getState().setAgents(
"server-a",
new Map([
[
"agent-1",
makeAgent(),
],
]),
);
useSessionStore.getState().initializeSession("server-a", {} as DaemonClient);
useSessionStore.getState().setAgents("server-a", new Map([["agent-1", makeAgent()]]));
queryClient.setQueryData(["sidebarAgentsList", "server-a"], {
entries: [{ agent: { id: "agent-1" } }, { agent: { id: "agent-2" } }],
});
+5 -1
View File
@@ -181,7 +181,11 @@ function restoreArchivedAgentListCacheSnapshot(
serverId: string,
snapshot: ArchivedAgentListCacheSnapshot,
): void {
restoreCachedListSnapshot(queryClient, ["sidebarAgentsList", serverId], snapshot.sidebarAgentsList);
restoreCachedListSnapshot(
queryClient,
["sidebarAgentsList", serverId],
snapshot.sidebarAgentsList,
);
restoreCachedListSnapshot(queryClient, ["allAgents", serverId], snapshot.allAgents);
}
@@ -1,6 +1,7 @@
import { useEffect, useRef, useCallback } from "react";
import { AppState, Platform } from "react-native";
import { AppState } from "react-native";
import type { DaemonClient } from "@server/client/daemon-client";
import { isWeb, isNative } from "@/constants/platform";
const HEARTBEAT_INTERVAL_MS = 15_000;
const ACTIVITY_HEARTBEAT_THROTTLE_MS = 5_000;
@@ -32,7 +33,7 @@ export function useClientActivity({
const prevFocusedAgentIdRef = useRef<string | null>(focusedAgentId);
const lastImmediateHeartbeatAtRef = useRef<number>(0);
const deviceType = Platform.OS === "web" ? "web" : "mobile";
const deviceType = isWeb ? "web" : "mobile";
const recordUserActivity = useCallback(() => {
lastActivityAtRef.current = new Date();
@@ -96,7 +97,7 @@ export function useClientActivity({
// Track user activity on web for accurate staleness.
useEffect(() => {
if (Platform.OS !== "web") return;
if (isNative) return;
if (typeof document === "undefined") return;
const handleUserActivity = () => {
+5 -9
View File
@@ -1,5 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { Platform } from "react-native";
import { useShallow } from "zustand/shallow";
import { getIsElectronRuntimeMac } from "@/constants/layout";
import { useAggregatedAgents } from "./use-aggregated-agents";
@@ -9,6 +8,7 @@ import {
deriveMacDockBadgeCountFromWorkspaceStatuses,
type DesktopBadgeWorkspaceStatus,
} from "@/utils/desktop-badge-state";
import { isNative } from "@/constants/platform";
type FaviconStatus = "none" | "running" | "attention";
type ColorScheme = "dark" | "light";
@@ -76,18 +76,14 @@ function updateFavicon(status: FaviconStatus, colorScheme: ColorScheme) {
}
function getSystemColorScheme(): ColorScheme {
if (
Platform.OS !== "web" ||
typeof window === "undefined" ||
typeof window.matchMedia !== "function"
) {
if (isNative || typeof window === "undefined" || typeof window.matchMedia !== "function") {
return "dark";
}
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
async function updateMacDockBadge(count?: number) {
if (Platform.OS !== "web" || !getIsElectronRuntimeMac()) return;
if (isNative || !getIsElectronRuntimeMac()) return;
const desktopWindow = getDesktopHost()?.window?.getCurrentWindow?.();
if (!desktopWindow || typeof desktopWindow.setBadgeCount !== "function") {
@@ -119,7 +115,7 @@ export function useFaviconStatus() {
// Listen for system color scheme changes
useEffect(() => {
if (Platform.OS !== "web" || typeof window === "undefined") return;
if (isNative || typeof window === "undefined") return;
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handler = (e: MediaQueryListEvent) => {
@@ -132,7 +128,7 @@ export function useFaviconStatus() {
// Update favicon when agents or color scheme changes
useEffect(() => {
if (Platform.OS !== "web") return;
if (isNative) return;
const status = deriveFaviconStatus(agents);
updateFavicon(status, colorScheme);
+2 -2
View File
@@ -1,8 +1,8 @@
import { useState, useRef, useEffect } from "react";
import { Platform } from "react-native";
import type { ImageAttachment } from "@/components/message-input";
import { getDesktopHost } from "@/desktop/host";
import { persistAttachmentFromBlob, persistAttachmentFromFileUri } from "@/attachments/service";
import { isWeb } from "@/constants/platform";
interface UseFileDropZoneOptions {
onFilesDropped: (files: ImageAttachment[]) => void;
@@ -14,7 +14,7 @@ interface UseFileDropZoneReturn {
containerRef: React.RefObject<HTMLElement | null>;
}
const IS_WEB = Platform.OS === "web";
const IS_WEB = isWeb;
const IMAGE_MIME_BY_EXTENSION: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
@@ -8,6 +8,7 @@ import {
openImagePathsWithDesktopDialog,
type PickedImageAttachmentInput,
} from "@/hooks/image-attachment-picker";
import { isWeb } from "@/constants/platform";
interface UseImageAttachmentPickerResult {
pickImages: () => Promise<PickedImageAttachmentInput[] | null>;
@@ -45,7 +46,7 @@ export function useImageAttachmentPicker(): UseImageAttachmentPickerResult {
isPickingRef.current = true;
try {
if (Platform.OS === "web" && isElectronRuntime()) {
if (isWeb && isElectronRuntime()) {
const selectedPaths = await openImagePathsWithDesktopDialog();
if (selectedPaths.length === 0) {
return null;
@@ -1,11 +1,11 @@
import { useEffect, useMemo, useRef } from "react";
import { Platform } from "react-native";
import { usePathname } from "expo-router";
import { usePathname, useRouter } from "expo-router";
import { getIsElectronRuntime } from "@/constants/layout";
import { useHosts } from "@/runtime/host-runtime";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
import {
buildHostSettingsRoute,
parseHostAgentRouteFromPathname,
parseServerIdFromPathname,
parseHostWorkspaceRouteFromPathname,
@@ -26,6 +26,7 @@ import { resolveKeyboardFocusScope } from "@/keyboard/focus-scope";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { isNative } from "@/constants/platform";
export function useKeyboardShortcuts({
enabled,
@@ -47,6 +48,7 @@ export function useKeyboardShortcuts({
cycleTheme?: () => void;
}) {
const pathname = usePathname();
const router = useRouter();
const hosts = useHosts();
const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers);
const { overrides } = useKeyboardShortcutOverrides();
@@ -65,7 +67,7 @@ export function useKeyboardShortcuts({
useEffect(() => {
if (!enabled) return;
if (Platform.OS !== "web") return;
if (isNative) return;
if (isMobile) return;
const isDesktopApp = getIsElectronRuntime();
@@ -244,6 +246,16 @@ export function useKeyboardShortcuts({
case "sidebar.toggle.left":
toggleAgentList();
return true;
case "settings.toggle":
if (pathname.endsWith("/settings")) {
router.back();
return true;
}
if (!activeServerId) {
return false;
}
router.push(buildHostSettingsRoute(activeServerId));
return true;
case "sidebar.toggle.both":
if (toggleBothSidebars) {
toggleBothSidebars();
@@ -93,7 +93,9 @@ describe("openProjectDirectly", () => {
expect(result).toBe(true);
expect(useSessionStore.getState().sessions[SERVER_ID]?.hasHydratedWorkspaces).toBe(true);
expect(Array.from(useSessionStore.getState().sessions[SERVER_ID]?.workspaces.values() ?? [])).toEqual([
expect(
Array.from(useSessionStore.getState().sessions[SERVER_ID]?.workspaces.values() ?? []),
).toEqual([
expect.objectContaining({
id: "1",
projectId: "1",
+5 -1
View File
@@ -2,7 +2,11 @@ import { useCallback } from "react";
import { router } from "expo-router";
import type { DaemonClient } from "@server/client/daemon-client";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
import {
normalizeWorkspaceDescriptor,
type WorkspaceDescriptor,
useSessionStore,
} from "@/stores/session-store";
import {
buildWorkspaceTabPersistenceKey,
useWorkspaceLayoutStore,
@@ -4,6 +4,7 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
import * as Notifications from "expo-notifications";
import Constants from "expo-constants";
import type { DaemonClient } from "@server/client/daemon-client";
import { isWeb } from "@/constants/platform";
const STORAGE_PREFIX = "@paseo:expo-push-token:";
@@ -31,7 +32,7 @@ export function usePushTokenRegistration(params: { client: DaemonClient; serverI
const lastSentTokenRef = useRef<string | null>(null);
const registerIfPossible = useCallback(async () => {
if (Platform.OS === "web") return;
if (isWeb) return;
if (!client.isConnected) return;
const token = tokenRef.current;
if (!token) return;
@@ -41,7 +42,7 @@ export function usePushTokenRegistration(params: { client: DaemonClient; serverI
}, [client]);
useEffect(() => {
if (Platform.OS === "web") return;
if (isWeb) return;
const storageKey = `${STORAGE_PREFIX}${serverId}`;
let cancelled = false;
@@ -236,7 +236,6 @@ function getWorkspaceOrderScopeKey(serverId: string, projectKey: string): string
function toWorkspaceDescriptor(payload: WorkspaceDescriptorPayload): WorkspaceDescriptor {
return normalizeWorkspaceDescriptor(payload);
}
export function useSidebarWorkspacesList(options?: {