diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index ed794ebe3923..065ccb1a9ad9 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -15,7 +15,6 @@ import { } from "@t3tools/client-runtime/state/thread-search"; import { type EnvironmentId, - resolveEnvironmentMachineKind, type SidebarProjectGroupingMode, type SidebarThreadSortOrder, } from "@t3tools/contracts"; @@ -40,7 +39,7 @@ import { useAppearancePreferences } from "../settings/appearance/AppearancePrefe import { useThreadJumpShortcuts } from "../keyboard/threadKeyboardShortcuts"; import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { usePendingThreadOrder } from "../../state/thread-order"; -import { environmentServerConfigsAtom } from "../../state/server"; +import { threadListEnvironmentsAtom } from "../../state/server"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { useQueuedThreadKeys } from "../../state/use-thread-outbox"; import { @@ -587,86 +586,25 @@ export function HomeScreen(props: HomeScreenProps) { ); // Threads on servers without the settlement capability never classify as // settled (the user could neither un-settle nor pin them). - const serverConfigs = useAtomValue(environmentServerConfigsAtom); - const settlementEnvironmentIds = useMemo(() => { - const supported = new Set(); - for (const [environmentId, config] of serverConfigs) { - if (config.environment.capabilities.threadSettlement === true) { - supported.add(environmentId); - } - } - return supported; - }, [serverConfigs]); - const snoozeEnvironmentIds = useMemo(() => { - const supported = new Set(); - for (const [environmentId, config] of serverConfigs) { - if (config.environment.capabilities.threadSnooze === true) { - supported.add(environmentId); - } - } - return supported; - }, [serverConfigs]); - const pinningEnvironmentIds = useMemo(() => { - const supported = new Set(); - for (const [environmentId, config] of serverConfigs) { - if (config.environment.capabilities.threadPinning === true) { - supported.add(environmentId); - } - } - return supported; - }, [serverConfigs]); - const pinReorderEnvironmentIds = useMemo(() => { - const supported = new Set(); - for (const [environmentId, config] of serverConfigs) { - if (config.environment.capabilities.threadPinReorder === true) { - supported.add(environmentId); - } - } - return supported; - }, [serverConfigs]); - const activeReorderEnvironmentIds = useMemo(() => { - const supported = new Set(); - for (const [environmentId, config] of serverConfigs) { - if (config.environment.capabilities.threadActiveReorder === true) { - supported.add(environmentId); - } - } - return supported; - }, [serverConfigs]); - const titleRegenerationEnvironmentIds = useMemo(() => { - const supported = new Set(); - for (const [environmentId, config] of serverConfigs) { - if (config.environment.capabilities.threadTitleRegeneration === true) { - supported.add(environmentId); - } - } - return supported; - }, [serverConfigs]); - const machineByEnvironmentId = useMemo( - () => - new Map( - [...serverConfigs].map( - ([environmentId, config]) => - [environmentId, resolveEnvironmentMachineKind(config)] as const, - ), - ), - [serverConfigs], - ); + const listEnvironments = useAtomValue(threadListEnvironmentsAtom); + const { + providersByEnvironmentId, + machineByEnvironmentId, + settlementEnvironmentIds, + snoozeEnvironmentIds, + pinningEnvironmentIds, + pinReorderEnvironmentIds, + activeReorderEnvironmentIds, + titleRegenerationEnvironmentIds, + } = listEnvironments; const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick); const threadMovePlanners = useMemo(() => { const sectionPlanner = (section: "pinned" | "active") => createThreadMovePlanner({ allThreads: props.threads, section, - reorderableEnvironmentIds: new Set( - [...serverConfigs].flatMap(([id, config]) => - (section === "pinned" - ? config.environment.capabilities.threadPinReorder - : config.environment.capabilities.threadActiveReorder) === true - ? [id] - : [], - ), - ), + reorderableEnvironmentIds: + section === "pinned" ? pinReorderEnvironmentIds : activeReorderEnvironmentIds, ordered: getThreadListV2OrderedSection({ threads: props.threads, section, @@ -679,7 +617,8 @@ export function HomeScreen(props: HomeScreenProps) { }); return { pinned: sectionPlanner("pinned"), active: sectionPlanner("active") }; }, [ - serverConfigs, + pinReorderEnvironmentIds, + activeReorderEnvironmentIds, props.threads, pendingOrder, queuedThreadKeys, @@ -859,7 +798,7 @@ export function HomeScreen(props: HomeScreenProps) { projectTitle={v2ProjectTitleByProjectKey.get( scopedProjectKey(thread.environmentId, thread.projectId), )} - providers={serverConfigs.get(thread.environmentId)?.providers} + providers={providersByEnvironmentId.get(thread.environmentId)} environmentLabel={ Object.keys(props.savedConnectionsById).length > 1 ? (props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null) @@ -928,7 +867,7 @@ export function HomeScreen(props: HomeScreenProps) { props.onSelectThread, props.onNewThreadOnBranch, props.savedConnectionsById, - serverConfigs, + providersByEnvironmentId, shelfPreferencesLoaded, settlementEnvironmentIds, snoozeEnvironmentIds, @@ -951,7 +890,7 @@ export function HomeScreen(props: HomeScreenProps) { () => ({ projectByKey, projectTitleByProjectKey: v2ProjectTitleByProjectKey, - serverConfigs, + listEnvironments, savedConnectionsById: props.savedConnectionsById, searchQuery: props.searchQuery, snoozePresetMinute: nowMinute, @@ -961,7 +900,7 @@ export function HomeScreen(props: HomeScreenProps) { projectByKey, props.searchQuery, props.savedConnectionsById, - serverConfigs, + listEnvironments, nowMinute, threadSearchMatchByKey, v2ProjectTitleByProjectKey, diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 3f810afc85b8..fba2e4ead66b 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -11,7 +11,6 @@ import { import { LegendList } from "@legendapp/list/react-native"; import type { MenuAction } from "@react-native-menu/menu"; import { useAtomValue } from "@effect/atom-react"; -import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { LayoutChangeEvent } from "react-native"; import { Platform, Pressable, StyleSheet, TextInput, View } from "react-native"; @@ -32,7 +31,7 @@ import { useThreadSearch } from "../../state/queries"; import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled"; import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences"; import { usePendingThreadOrder } from "../../state/thread-order"; -import { environmentServerConfigsAtom } from "../../state/server"; +import { threadListEnvironmentsAtom } from "../../state/server"; import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; import { useQueuedThreadKeys } from "../../state/use-thread-outbox"; import { useWorkspaceState } from "../../state/workspace"; @@ -415,86 +414,25 @@ function ThreadNavigationSidebarPane( }, [threadListV2Enabled]); // Threads on servers without the settlement capability never classify as // settled (the user could neither un-settle nor pin them). - const serverConfigs = useAtomValue(environmentServerConfigsAtom); - const settlementEnvironmentIds = useMemo(() => { - const supported = new Set(); - for (const [environmentId, config] of serverConfigs) { - if (config.environment.capabilities.threadSettlement === true) { - supported.add(environmentId); - } - } - return supported; - }, [serverConfigs]); - const snoozeEnvironmentIds = useMemo(() => { - const supported = new Set(); - for (const [environmentId, config] of serverConfigs) { - if (config.environment.capabilities.threadSnooze === true) { - supported.add(environmentId); - } - } - return supported; - }, [serverConfigs]); - const pinningEnvironmentIds = useMemo(() => { - const supported = new Set(); - for (const [environmentId, config] of serverConfigs) { - if (config.environment.capabilities.threadPinning === true) { - supported.add(environmentId); - } - } - return supported; - }, [serverConfigs]); - const pinReorderEnvironmentIds = useMemo(() => { - const supported = new Set(); - for (const [environmentId, config] of serverConfigs) { - if (config.environment.capabilities.threadPinReorder === true) { - supported.add(environmentId); - } - } - return supported; - }, [serverConfigs]); - const activeReorderEnvironmentIds = useMemo(() => { - const supported = new Set(); - for (const [environmentId, config] of serverConfigs) { - if (config.environment.capabilities.threadActiveReorder === true) { - supported.add(environmentId); - } - } - return supported; - }, [serverConfigs]); - const titleRegenerationEnvironmentIds = useMemo(() => { - const supported = new Set(); - for (const [environmentId, config] of serverConfigs) { - if (config.environment.capabilities.threadTitleRegeneration === true) { - supported.add(environmentId); - } - } - return supported; - }, [serverConfigs]); - const machineByEnvironmentId = useMemo( - () => - new Map( - [...serverConfigs].map( - ([environmentId, config]) => - [environmentId, resolveEnvironmentMachineKind(config)] as const, - ), - ), - [serverConfigs], - ); + const listEnvironments = useAtomValue(threadListEnvironmentsAtom); + const { + providersByEnvironmentId, + machineByEnvironmentId, + settlementEnvironmentIds, + snoozeEnvironmentIds, + pinningEnvironmentIds, + pinReorderEnvironmentIds, + activeReorderEnvironmentIds, + titleRegenerationEnvironmentIds, + } = listEnvironments; const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick); const threadMovePlanners = useMemo(() => { const sectionPlanner = (section: "pinned" | "active") => createThreadMovePlanner({ allThreads: threads, section, - reorderableEnvironmentIds: new Set( - [...serverConfigs].flatMap(([id, config]) => - (section === "pinned" - ? config.environment.capabilities.threadPinReorder - : config.environment.capabilities.threadActiveReorder) === true - ? [id] - : [], - ), - ), + reorderableEnvironmentIds: + section === "pinned" ? pinReorderEnvironmentIds : activeReorderEnvironmentIds, ordered: getThreadListV2OrderedSection({ threads, section, @@ -507,7 +445,8 @@ function ThreadNavigationSidebarPane( }); return { pinned: sectionPlanner("pinned"), active: sectionPlanner("active") }; }, [ - serverConfigs, + pinReorderEnvironmentIds, + activeReorderEnvironmentIds, threads, pendingOrder, queuedThreadKeys, @@ -788,7 +727,7 @@ function ThreadNavigationSidebarPane( projectByKey, projectTitleByProjectKey, savedConnectionsById, - serverConfigs, + listEnvironments, snoozePresetMinute: nowMinute, threadSearchMatchByKey, }), @@ -797,7 +736,7 @@ function ThreadNavigationSidebarPane( projectByKey, projectTitleByProjectKey, savedConnectionsById, - serverConfigs, + listEnvironments, nowMinute, threadSearchMatchByKey, ], @@ -910,7 +849,7 @@ function ThreadNavigationSidebarPane( snoozeWakeLabelText={item.snoozeWakeLabelText} project={projectByKey.get(scopeKey) ?? null} projectTitle={projectTitleByProjectKey.get(scopeKey)} - providers={serverConfigs.get(thread.environmentId)?.providers} + providers={providersByEnvironmentId.get(thread.environmentId)} environmentLabel={ Object.keys(savedConnectionsById).length > 1 ? (savedConnectionsById[thread.environmentId]?.environmentLabel ?? null) @@ -1100,7 +1039,7 @@ function ThreadNavigationSidebarPane( props.selectedThreadKey, props.width, savedConnectionsById, - serverConfigs, + providersByEnvironmentId, shelfPreferencesLoaded, threadSearchMatchByKey, titleRegenerationEnvironmentIds, diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index affb7fc8fd93..356522e8479f 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -10,7 +10,7 @@ import type { EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state/thread-search"; -import type { EnvironmentMachineKind, ServerConfig } from "@t3tools/contracts"; +import type { EnvironmentMachineKind } from "@t3tools/contracts"; import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort"; import type { MenuAction } from "@react-native-menu/menu"; @@ -18,6 +18,7 @@ import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } import { Alert, Platform, Pressable, useWindowDimensions, View } from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; +import type { ThreadListProvider } from "../../state/thread-list-environments"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; import { ControlPillMenu } from "../../components/ControlPill"; @@ -360,7 +361,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly project: EnvironmentProject | null; readonly projectTitle?: string; /** Keep the environment's provider array stable across unrelated list updates. */ - readonly providers: ServerConfig["providers"] | undefined; + readonly providers: ReadonlyArray | undefined; /** Which machine hosts the thread. Null when only one environment is connected — repeating the same label on every row is noise. Mirrors the web sidebar's remote-environment cloud icon, but as text since diff --git a/apps/mobile/src/features/threads/thread-provider-instance.ts b/apps/mobile/src/features/threads/thread-provider-instance.ts index 2e5b1e11a9e2..1253af49d445 100644 --- a/apps/mobile/src/features/threads/thread-provider-instance.ts +++ b/apps/mobile/src/features/threads/thread-provider-instance.ts @@ -4,8 +4,9 @@ import { resolveProviderInstanceDisplayName, shouldShowInstanceBadge, } from "@t3tools/client-runtime/state/provider-instance-display"; -import type { ProviderDriverKind, ServerConfig } from "@t3tools/contracts"; +import type { ProviderDriverKind } from "@t3tools/contracts"; +import type { ThreadListProvider } from "../../state/thread-list-environments"; /** What a thread row needs to draw the provider glyph and its account badge. */ export interface ThreadRowProviderInstance { readonly driverKind: ProviderDriverKind; @@ -20,7 +21,7 @@ export interface ThreadRowProviderInstance { * names a different account on every server. */ export function resolveThreadProviderInstance( - providers: ServerConfig["providers"] | undefined, + providers: ReadonlyArray | undefined, thread: EnvironmentThreadShell, ): ThreadRowProviderInstance | null { if (providers === undefined) return null; diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 0201d4f16662..e056deeac283 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -15,8 +15,9 @@ import { resolveSettledThreadTimestamp, sortPinnedThreadsByOrderKey, } from "@t3tools/client-runtime/state/thread-sort"; -import type { EnvironmentId, ProjectId, ServerConfig } from "@t3tools/contracts"; +import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import type { ThreadListProvider } from "../../state/thread-list-environments"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { @@ -34,7 +35,7 @@ export { snoozeWakeLabel }; */ export function resolveThreadListV2ProviderDrivers( thread: Pick, - providers: ServerConfig["providers"] | undefined, + providers: ReadonlyArray | undefined, ): ReadonlyArray { if (providers === undefined) return []; const stack = resolveThreadProviderStack(thread); diff --git a/apps/mobile/src/state/server.ts b/apps/mobile/src/state/server.ts index 28cd2af57062..820646c6d898 100644 --- a/apps/mobile/src/state/server.ts +++ b/apps/mobile/src/state/server.ts @@ -4,6 +4,7 @@ import { createEnvironmentServerConfigsAtom } from "@t3tools/client-runtime/stat import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; import { environmentSession } from "./session"; +import { createThreadListEnvironmentsAtom } from "./thread-list-environments"; export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime, { initialConfigValueAtom: environmentSession.initialConfigValueAtom, @@ -14,3 +15,7 @@ export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({ catalogValueAtom: environmentCatalog.catalogValueAtom, serverConfigValueAtom: serverEnvironment.configValueAtom, }); + +export const threadListEnvironmentsAtom = createThreadListEnvironmentsAtom( + environmentServerConfigsAtom, +); diff --git a/apps/mobile/src/state/thread-list-environments.test.ts b/apps/mobile/src/state/thread-list-environments.test.ts new file mode 100644 index 000000000000..fcb31285d5ae --- /dev/null +++ b/apps/mobile/src/state/thread-list-environments.test.ts @@ -0,0 +1,222 @@ +import { + EnvironmentId, + ProviderInstanceId, + ProviderDriverKind, + type ServerConfig, + type ServerProvider, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import { Atom, AtomRegistry } from "effect/unstable/reactivity"; +import { createThreadListEnvironmentsAtom } from "./thread-list-environments"; + +const ID = EnvironmentId.make("one"); +const OTHER_ID = EnvironmentId.make("two"); +const provider: ServerProvider = { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-09-01T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], +}; +const config = { + providers: [provider], + environment: { capabilities: {}, platform: { machine: "laptop" } }, + settings: {}, +} as unknown as ServerConfig; + +function harness() { + const registry = AtomRegistry.make(); + const configs = Atom.make>( + new Map([ + [ID, config], + [OTHER_ID, config], + ]), + ); + const list = createThreadListEnvironmentsAtom(configs); + let sourceNotifications = 0; + let listNotifications = 0; + registry.subscribe(configs, () => sourceNotifications++); + registry.subscribe(list, () => listNotifications++); + registry.get(list); + sourceNotifications = 0; + listNotifications = 0; + return { + registry, + configs, + list, + read: () => registry.get(list), + write: (next: ServerConfig) => + registry.set( + configs, + new Map([ + [ID, next], + [OTHER_ID, config], + ]), + ), + notifications: () => ({ source: sourceNotifications, list: listNotifications }), + }; +} + +describe("thread list environment projection", () => { + it("keeps navigation stable while freshness, catalog and workspace updates reach full-config consumers", () => { + const h = harness(); + try { + const initial = h.read(); + const changed: ServerConfig = { + ...config, + providers: [ + { + ...provider, + checkedAt: "2026-09-02T00:00:00.000Z", + usageLimits: { checkedAt: "2026-09-02T00:00:00.000Z", windows: [] }, + models: [{ slug: "new-model", name: "New model", isCustom: false, capabilities: null }], + workspaceSnapshots: [ + { + cwd: "/repo", + checkedAt: "2026-09-02T00:00:00.000Z", + slashCommands: [], + skills: [], + }, + ], + }, + ], + }; + h.write(changed); + expect(h.read()).toBe(initial); + expect(h.registry.get(h.configs).get(ID)?.providers[0]?.usageLimits?.checkedAt).toBe( + "2026-09-02T00:00:00.000Z", + ); + expect(h.notifications()).toEqual({ source: 1, list: 0 }); + } finally { + h.registry.dispose(); + } + }); + + it.each([ + ["threadSettlement", "settlementEnvironmentIds"], + ["threadSnooze", "snoozeEnvironmentIds"], + ["threadPinning", "pinningEnvironmentIds"], + ["threadPinReorder", "pinReorderEnvironmentIds"], + ["threadActiveReorder", "activeReorderEnvironmentIds"], + ["threadTitleRegeneration", "titleRegenerationEnvironmentIds"], + ] as const)("tracks enabling and removing %s", (capability, collection) => { + const h = harness(); + try { + h.write({ + ...config, + environment: { + ...config.environment, + capabilities: { ...config.environment.capabilities, [capability]: true }, + }, + }); + expect([...h.read()[collection]]).toEqual([ID]); + h.write({ + ...config, + environment: { + ...config.environment, + capabilities: { ...config.environment.capabilities, [capability]: false }, + }, + }); + expect(h.read()[collection].size).toBe(0); + const disabled = h.read(); + h.write(config); + expect(h.read()).toBe(disabled); + expect(h.notifications().list).toBe(2); + } finally { + h.registry.dispose(); + } + }); + + it.each([ + ["instanceId", ProviderInstanceId.make("other")], + ["driver", ProviderDriverKind.make("claudeAgent")], + ["displayName", "Work account"], + ["accentColor", "#123456"], + ["iconUrl", "https://example.test/icon.png"], + ] as const)( + "updates and restores provider %s without changing another environment's providers", + (key, value) => { + const h = harness(); + try { + const otherProviders = h.read().providersByEnvironmentId.get(OTHER_ID); + h.write({ ...config, providers: [{ ...provider, [key]: value }] }); + expect(h.read().providersByEnvironmentId.get(ID)?.[0]?.[key]).toBe(value); + expect(h.read().providersByEnvironmentId.get(OTHER_ID)).toBe(otherProviders); + h.write(config); + expect(h.read().providersByEnvironmentId.get(ID)?.[0]?.[key]).toBe(provider[key]); + expect(h.notifications().list).toBe(2); + } finally { + h.registry.dispose(); + } + }, + ); + + it("tracks provider membership and order in both directions", () => { + const h = harness(); + try { + const second = { ...provider, instanceId: ProviderInstanceId.make("work") }; + h.write({ ...config, providers: [provider, second] }); + expect( + h + .read() + .providersByEnvironmentId.get(ID) + ?.map((p) => p.instanceId), + ).toEqual(["codex", "work"]); + h.write({ ...config, providers: [second, provider] }); + expect( + h + .read() + .providersByEnvironmentId.get(ID) + ?.map((p) => p.instanceId), + ).toEqual(["work", "codex"]); + h.write({ ...config, providers: [] }); + expect(h.read().providersByEnvironmentId.get(ID)).toEqual([]); + h.write(config); + expect( + h + .read() + .providersByEnvironmentId.get(ID) + ?.map((p) => p.instanceId), + ).toEqual(["codex"]); + expect(h.notifications().list).toBe(4); + } finally { + h.registry.dispose(); + } + }); + + it("restores detected machine kind after removing an override", () => { + const h = harness(); + try { + expect(h.read().machineByEnvironmentId.get(ID)).toBe("laptop"); + h.write({ ...config, settings: { ...config.settings, environmentIcon: "server" } }); + expect(h.read().machineByEnvironmentId.get(ID)).toBe("server"); + h.write(config); + expect(h.read().machineByEnvironmentId.get(ID)).toBe("laptop"); + const detected = h.read(); + h.write({ ...config, settings: { ...config.settings, environmentIcon: "laptop" } }); + expect(h.read()).toBe(detected); + } finally { + h.registry.dispose(); + } + }); + + it("removes forgotten environments and repopulates them after reconnect", () => { + const h = harness(); + try { + h.registry.set(h.configs, new Map()); + expect(h.read().providersByEnvironmentId.size).toBe(0); + expect(h.read().machineByEnvironmentId.size).toBe(0); + h.write(config); + expect([...h.read().providersByEnvironmentId.keys()]).toEqual([ID, OTHER_ID]); + expect(h.notifications().list).toBe(2); + } finally { + h.registry.dispose(); + } + }); +}); diff --git a/apps/mobile/src/state/thread-list-environments.ts b/apps/mobile/src/state/thread-list-environments.ts new file mode 100644 index 000000000000..3b4c0062c165 --- /dev/null +++ b/apps/mobile/src/state/thread-list-environments.ts @@ -0,0 +1,124 @@ +import { + resolveEnvironmentMachineKind, + type EnvironmentId, + type EnvironmentMachineKind, + type ServerConfig, + type ServerProvider, +} from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +export type ThreadListProvider = Pick< + ServerProvider, + "instanceId" | "driver" | "displayName" | "accentColor" | "iconUrl" +>; + +const capabilityKeys = [ + "threadSettlement", + "threadSnooze", + "threadPinning", + "threadPinReorder", + "threadActiveReorder", + "threadTitleRegeneration", +] as const; + +function selectEnvironment(config: ServerConfig) { + return { + providers: config.providers.map( + ({ instanceId, driver, displayName, accentColor, iconUrl }) => ({ + instanceId, + driver, + displayName, + accentColor, + iconUrl, + }), + ), + machineKind: resolveEnvironmentMachineKind(config), + capabilities: config.environment.capabilities, + }; +} + +type ListEnvironment = ReturnType; + +function sameProviders( + left: ReadonlyArray, + right: ReadonlyArray, +) { + return ( + left.length === right.length && + left.every((provider, index) => { + const other = right[index]!; + return ( + provider.instanceId === other.instanceId && + provider.driver === other.driver && + provider.displayName === other.displayName && + provider.accentColor === other.accentColor && + provider.iconUrl === other.iconUrl + ); + }) + ); +} + +function collectEnvironments(environments: ReadonlyMap) { + const providersByEnvironmentId = new Map>(); + const machineByEnvironmentId = new Map(); + const settlementEnvironmentIds = new Set(); + const snoozeEnvironmentIds = new Set(); + const pinningEnvironmentIds = new Set(); + const pinReorderEnvironmentIds = new Set(); + const activeReorderEnvironmentIds = new Set(); + const titleRegenerationEnvironmentIds = new Set(); + for (const [id, { providers, machineKind, capabilities }] of environments) { + providersByEnvironmentId.set(id, providers); + machineByEnvironmentId.set(id, machineKind); + if (capabilities.threadSettlement === true) settlementEnvironmentIds.add(id); + if (capabilities.threadSnooze === true) snoozeEnvironmentIds.add(id); + if (capabilities.threadPinning === true) pinningEnvironmentIds.add(id); + if (capabilities.threadPinReorder === true) pinReorderEnvironmentIds.add(id); + if (capabilities.threadActiveReorder === true) activeReorderEnvironmentIds.add(id); + if (capabilities.threadTitleRegeneration === true) titleRegenerationEnvironmentIds.add(id); + } + return { + providersByEnvironmentId, + machineByEnvironmentId, + settlementEnvironmentIds, + snoozeEnvironmentIds, + pinningEnvironmentIds, + pinReorderEnvironmentIds, + activeReorderEnvironmentIds, + titleRegenerationEnvironmentIds, + }; +} + +/** Provider freshness and model catalogs do not affect the navigation lists. */ +export function createThreadListEnvironmentsAtom( + configsAtom: Atom.Atom>, +) { + let previous = new Map(); + let result = collectEnvironments(previous); + return Atom.make((get) => { + const configs = get(configsAtom); + const next = new Map(); + let changed = configs.size !== previous.size; + for (const [id, config] of configs) { + const selected = selectEnvironment(config); + const prior = previous.get(id); + if (prior && sameProviders(prior.providers, selected.providers)) { + selected.providers = prior.providers; + } + const unchanged = + prior && + prior.providers === selected.providers && + prior.machineKind === selected.machineKind && + capabilityKeys.every( + (key) => (prior.capabilities[key] === true) === (selected.capabilities[key] === true), + ); + next.set(id, unchanged ? prior : selected); + if (!unchanged) changed = true; + } + if (changed) { + previous = next; + result = collectEnvironments(next); + } + return result; + }).pipe(Atom.withLabel("thread-list-environments")); +}