diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index e52bcb1e..21581e28 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -348,7 +348,12 @@ function App({ apis }: AppProps) { void refreshGitHubAuthStatus(apis.github, { force: true }); void refreshLinearAuthStatus(apis.linear, { force: true }); - }, [apis.github, apis.linear, embeddedSessionChat, refreshGitHubAuthStatus, refreshLinearAuthStatus]); + // `apis` is the same object across an instance switch, so without the epoch + // this ran once for the whole app session and both statuses kept describing + // whichever instance happened to be connected at startup. `isConnected` is + // here to re-ask, not to gate: both integrations answer independently of + // OpenCode, but a switch can race the transport and the retry is deduped. + }, [apis.github, apis.linear, embeddedSessionChat, isConnected, refreshGitHubAuthStatus, refreshLinearAuthStatus, runtimeEndpointEpoch]); useAppFontEffects(); diff --git a/packages/ui/src/apps/runtimeEndpointReset.ts b/packages/ui/src/apps/runtimeEndpointReset.ts index cd1f33ce..afd90a7d 100644 --- a/packages/ui/src/apps/runtimeEndpointReset.ts +++ b/packages/ui/src/apps/runtimeEndpointReset.ts @@ -11,6 +11,13 @@ import { useFileSearchStore } from '@/stores/useFileSearchStore'; import { useGitStore } from '@/stores/useGitStore'; import { useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore'; import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore'; +import { useLinearAuthStore } from '@/stores/useLinearAuthStore'; +import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; +import { useQuotaStore } from '@/stores/useQuotaStore'; +import { useMcpStore } from '@/stores/useMcpStore'; +import { useSkillsStore } from '@/stores/useSkillsStore'; +import { useAgentMemoryStore } from '@/stores/useAgentMemoryStore'; +import { useUIStore } from '@/stores/useUIStore'; import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore'; import { useTerminalStore } from '@/stores/useTerminalStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; @@ -68,6 +75,22 @@ export const resetAppForRuntimeEndpointChange = (detail: RuntimeEndpointChangedD useGitHubPrStatusStore.getState().resetForRuntimeSwitch(); useSessionFoldersStore.getState().resetForRuntimeSwitch(detail.runtimeKey); useFilesViewTabsStore.getState().resetForRuntimeSwitch(detail.runtimeKey); + // Linear and GitHub are authenticated on the instance, not in the browser. + // Left in place, the previous instance's login stayed visible and usable — + // its rail tab, its issue pickers, its work-status rows — against a runtime + // that has no such integration. `App` re-asks once the new instance answers. + useLinearAuthStore.getState().resetForRuntimeSwitch(); + useGitHubAuthStore.getState().resetForRuntimeSwitch(); + // Work-status readouts served from the instance: quotas, MCP servers, skills + // and agent memory. All were cached globally or by directory alone, so they + // reported the previous instance until something happened to refetch. + useQuotaStore.getState().resetForRuntimeSwitch(); + useMcpStore.getState().resetForRuntimeSwitch(); + useSkillsStore.getState().resetForRuntimeSwitch(); + useAgentMemoryStore.getState().reset(); + // The Linear team filter names a team in one workspace. Carried across, it + // filters the new instance's issue list down to nothing. + useUIStore.getState().applyLinearIssueListFiltersForRuntime(); useSessionUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey); resetStreamingState(); queueMicrotask(() => void syncDesktopSettings()); diff --git a/packages/ui/src/components/chat/work-status/DOCUMENTATION.md b/packages/ui/src/components/chat/work-status/DOCUMENTATION.md index 00a1abd3..d96a6b6d 100644 --- a/packages/ui/src/components/chat/work-status/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/work-status/DOCUMENTATION.md @@ -347,6 +347,28 @@ the matching header dropdown: discovered relative to the active project. It does not wrap the call in `runBackgroundNetworkTask`: the store already gates its own fetch. +Usage waits for the instance to say it is initialised. Quota providers report +themselves as configured only once the instance can read their credentials, +which on a remote instance is not true when the UI mounts — a fetch fired at +mount gets "nothing configured" for every provider, and since each one then has +a result, nothing asks again until the three-minute refresh. That is why Usage +could stay missing from the panel until Settings -> Usage forced a fresh fetch. +`useQuotaStore.ensureLoadedForRuntime` owns both the readiness rule and the +once-per-instance bookkeeping, so every caller can ask on each connection +change. + +### These readouts belong to the connected instance + +Quotas, MCP status, skills, agent memory and the Linear/GitHub logins are all +served by whichever OpenChamber instance is connected, and each was cached +globally or by directory alone — which two instances can share. A switch left +the previous instance's answers on screen, and its Linear login usable against +a runtime that has no Linear. `apps/runtimeEndpointReset.ts` now drops all of +them, each store guarding its own in-flight requests with a generation so a +response for the previous instance cannot land in the new one. The MCP and +skills effects take `isConnected` as a dependency — not a gate — because +`directory` alone does not change when both instances hold the same path. + The panel now performs these itself, silently and through the background-network gate, so it cannot compete with chat bootstrap traffic for sockets. Usage additionally provides an explicit refresh action in its section diff --git a/packages/ui/src/components/chat/work-status/WorkStatusContextSection.tsx b/packages/ui/src/components/chat/work-status/WorkStatusContextSection.tsx index b549a38a..fe188fc8 100644 --- a/packages/ui/src/components/chat/work-status/WorkStatusContextSection.tsx +++ b/packages/ui/src/components/chat/work-status/WorkStatusContextSection.tsx @@ -15,6 +15,7 @@ import { resolveProjectContextId } from '@/lib/projectContextApi'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useMobileAppActions } from '@/apps/mobileAppContext'; import { useLinearAuthStore } from '@/stores/useLinearAuthStore'; +import { useConfigStore } from '@/stores/useConfigStore'; import { useUIStore } from '@/stores/useUIStore'; import { WorkStatusCollapsibleSection, WorkStatusRow, WorkStatusValue } from './WorkStatusPrimitives'; import { useReportWorkStatusPresence } from './presenceContext'; @@ -61,9 +62,15 @@ export const WorkStatusContextSection: React.FC = ({ sessionId, directory // here: `loadSkills` already gates its own fetch, and wrapping it again // would hold a second slot idle for the length of the first. const loadSkills = useSkillsStore((state) => state.loadSkills); + // `isConnected` is a dependency, not a gate: skills are discovered on the + // connected instance and their caches are dropped when instances switch, so + // the count has to be asked for again once the new instance is up. Two + // instances can hold the same project path, which leaves `directory` + // unchanged across a switch. + const isConnected = useConfigStore((state) => state.isConnected); React.useEffect(() => { void loadSkills(); - }, [directory, loadSkills]); + }, [directory, isConnected, loadSkills]); /** * What this session carries. Read from the server diff --git a/packages/ui/src/components/chat/work-status/WorkStatusMcpSection.tsx b/packages/ui/src/components/chat/work-status/WorkStatusMcpSection.tsx index d44a846e..2ac0efb0 100644 --- a/packages/ui/src/components/chat/work-status/WorkStatusMcpSection.tsx +++ b/packages/ui/src/components/chat/work-status/WorkStatusMcpSection.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { useI18n } from '@/lib/i18n'; import { Switch } from '@/components/ui/switch'; import { useMcpStore } from '@/stores/useMcpStore'; +import { useConfigStore } from '@/stores/useConfigStore'; import { McpIcon } from '@/components/icons/McpIcon'; import { runBackgroundNetworkTask } from '@/lib/background-network'; import { toast } from 'sonner'; @@ -28,6 +29,7 @@ export const WorkStatusMcpSection: React.FC = ({ directory }) => { const ensureMcpFresh = useMcpStore((state) => state.ensureFresh); const connect = useMcpStore((state) => state.connect); const disconnect = useMcpStore((state) => state.disconnect); + const isConnected = useConfigStore((state) => state.isConnected); const [busyServer, setBusyServer] = React.useState(null); // The panel must not depend on the header dropdown having been mounted or @@ -35,9 +37,12 @@ export const WorkStatusMcpSection: React.FC = ({ directory }) => { // compete with chat bootstrap traffic for sockets. The section remounts on // every session switch, so it only asks for a status that is missing or // older than a minute; connect/disconnect/auth refresh on their own. + // `isConnected` is a dependency, not a gate: MCP status is cached by + // directory alone and dropped on an instance switch, and two instances can + // hold the same project path — so the switch itself has to trigger the ask. React.useEffect(() => { void runBackgroundNetworkTask(() => ensureMcpFresh({ directory, silent: true, maxAgeMs: MCP_STATUS_MAX_AGE_MS })); - }, [directory, ensureMcpFresh]); + }, [directory, ensureMcpFresh, isConnected]); const mcpServers = React.useMemo( () => Object.entries(mcpStatus ?? {}).sort(([left], [right]) => left.localeCompare(right)), diff --git a/packages/ui/src/components/chat/work-status/WorkStatusUsageSection.tsx b/packages/ui/src/components/chat/work-status/WorkStatusUsageSection.tsx index 3484512e..41deccbb 100644 --- a/packages/ui/src/components/chat/work-status/WorkStatusUsageSection.tsx +++ b/packages/ui/src/components/chat/work-status/WorkStatusUsageSection.tsx @@ -43,9 +43,10 @@ export const WorkStatusUsageSection: React.FC = () => { const groups = useUsageProviderGroups(); const displayMode = useQuotaStore((state) => state.displayMode); const isLoading = useQuotaStore((state) => state.isLoading); - const quotaResults = useQuotaStore((state) => state.results); const dropdownProviderIds = useQuotaStore((state) => state.dropdownProviderIds); const fetchQuotas = useQuotaStore((state) => state.fetchQuotas); + const ensureQuotasLoadedForRuntime = useQuotaStore((state) => state.ensureLoadedForRuntime); + const isInitialized = useConfigStore((state) => state.isInitialized); const timeFormatPreference = useUIStore((state) => state.timeFormatPreference); const currentProviderId = useConfigStore((state) => state.currentProviderId); @@ -54,17 +55,13 @@ export const WorkStatusUsageSection: React.FC = () => { // `useQuotaAutoRefresh` only schedules an interval — it never performs the // first fetch. That was owned by the header dropdown's open handler, so the - // panel stayed empty until the user opened it. Kick off the initial load for - // any enabled provider that has not reported yet, background-gated so it - // cannot compete with chat bootstrap traffic. + // panel stayed empty until the user opened it. `ensureLoadedForRuntime` owns + // the once-per-instance load and its readiness rule; asking again is a no-op, + // so this is safe to run on every connection change. React.useEffect(() => { - if (isLoading || dropdownProviderIds.length === 0) return; - const missingProvider = dropdownProviderIds.some( - (providerId) => !quotaResults.some((result) => result.providerId === providerId), - ); - if (!missingProvider) return; - void runBackgroundNetworkTask(() => fetchQuotas(dropdownProviderIds)); - }, [dropdownProviderIds, fetchQuotas, isLoading, quotaResults]); + if (!isInitialized) return; + void runBackgroundNetworkTask(() => ensureQuotasLoadedForRuntime()); + }, [ensureQuotasLoadedForRuntime, isInitialized]); React.useEffect(() => { if (groups.length === 0) return; diff --git a/packages/ui/src/components/views/LinearIssuesView.tsx b/packages/ui/src/components/views/LinearIssuesView.tsx index 8454e81c..f6ae069b 100644 --- a/packages/ui/src/components/views/LinearIssuesView.tsx +++ b/packages/ui/src/components/views/LinearIssuesView.tsx @@ -251,6 +251,14 @@ export const LinearIssuesView: React.FC = () => { const setListPriority = useUIStore((state) => state.setLinearIssueListPriority); const resetListFilters = useUIStore((state) => state.resetLinearIssueListFilters); const setLinearIssueFocus = useUIStore((state) => state.setLinearIssueFocus); + const applyLinearFiltersForRuntime = useUIStore((state) => state.applyLinearIssueListFiltersForRuntime); + + // The team filter is stored per instance, and rehydration can run before the + // runtime endpoint is known. Reading it here means the view always opens on + // the filter belonging to the instance it is about to query. + React.useEffect(() => { + applyLinearFiltersForRuntime(); + }, [applyLinearFiltersForRuntime]); const [query, setQuery] = React.useState(''); const [searchOpen, setSearchOpen] = React.useState(false); diff --git a/packages/ui/src/stores/DOCUMENTATION.md b/packages/ui/src/stores/DOCUMENTATION.md index ac82998a..355db701 100644 --- a/packages/ui/src/stores/DOCUMENTATION.md +++ b/packages/ui/src/stores/DOCUMENTATION.md @@ -38,7 +38,7 @@ Examples: - `useFeatureFlagsStore.ts` - `useUpdateStore.ts` -These stores coordinate visible app state, navigation, selected context-panel tabs, dialogs, and lightweight feature flags. `useUIStore.activeSurface` selects the primary mobile view and the few desktop views that are promoted out of the context panel. It is not a desktop tab selection. Linear panel list filters (status, assignee, team, priority) live here too: the Linear rail surface remounts on switch, so those filters restore from this store rather than component state. `resetLinearIssueListFilters` restores those four defaults together; search stays local to the rail. `linearIssueFocus` is a one-shot identifier so work-status can open a specific issue in that panel; it is not persisted. +These stores coordinate visible app state, navigation, selected context-panel tabs, dialogs, and lightweight feature flags. `useUIStore.activeSurface` selects the primary mobile view and the few desktop views that are promoted out of the context panel. It is not a desktop tab selection. Linear panel list filters (status, assignee, team, priority) live here too: the Linear rail surface remounts on switch, so those filters restore from this store rather than component state. `resetLinearIssueListFilters` restores those four defaults together; search stays local to the rail. The team filter is the one that is not a plain preference: a Linear team belongs to one workspace, and each OpenChamber instance has its own Linear login, so it is persisted per instance in `linearIssueListTeamIdByRuntime` and the flat `linearIssueListTeamId` is derived from it by `applyLinearIssueListFiltersForRuntime` — on an instance switch and when the rail mounts, since rehydration can run before the runtime endpoint is known. Carried across, a team id filters the new instance's list down to nothing. `linearIssueFocus` is a one-shot identifier so work-status can open a specific issue in that panel; it is not persisted. Context-panel session chats mount only the active chat iframe. After installing its message listener, the iframe requests its authoritative visibility from the diff --git a/packages/ui/src/stores/useGitHubAuthStore.ts b/packages/ui/src/stores/useGitHubAuthStore.ts index 410267cc..2d16b773 100644 --- a/packages/ui/src/stores/useGitHubAuthStore.ts +++ b/packages/ui/src/stores/useGitHubAuthStore.ts @@ -13,6 +13,8 @@ type GitHubAuthStore = { runtimeGitHub?: RuntimeAPIs['github'], options?: { force?: boolean } ) => Promise; + /** Same instance-scoping as Linear: the login lives on the connected instance. */ + resetForRuntimeSwitch: () => void; }; const fetchStatus = async ( @@ -36,6 +38,9 @@ const fetchStatus = async ( // In-flight dedup for refreshStatus let _inFlightAuthRefresh: Promise | null = null; +// Bumped by every reset so a response already in flight for the previous +// instance cannot write itself into the new instance's status. +let authGeneration = 0; export const useGitHubAuthStore = create((set, get) => ({ status: null, @@ -50,13 +55,16 @@ export const useGitHubAuthStore = create((set, get) => ({ if (_inFlightAuthRefresh) return _inFlightAuthRefresh; + const generation = authGeneration; set({ isLoading: true }); _inFlightAuthRefresh = (async () => { try { const payload = await fetchStatus(runtimeGitHub); + if (generation !== authGeneration) return null; set({ status: payload, isLoading: false, hasChecked: true }); return payload; } catch (error) { + if (generation !== authGeneration) return null; const message = error instanceof Error ? error.message : String(error); set({ status: { connected: false, error: message }, @@ -69,4 +77,9 @@ export const useGitHubAuthStore = create((set, get) => ({ return _inFlightAuthRefresh; }, + resetForRuntimeSwitch: () => { + authGeneration += 1; + _inFlightAuthRefresh = null; + set({ status: null, isLoading: false, hasChecked: false }); + }, })); diff --git a/packages/ui/src/stores/useLinearAuthStore.test.ts b/packages/ui/src/stores/useLinearAuthStore.test.ts new file mode 100644 index 00000000..931b129e --- /dev/null +++ b/packages/ui/src/stores/useLinearAuthStore.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test" +import type { LinearAPI, LinearAuthStatus } from "@/lib/api/types" + +mock.module("@/lib/runtime-fetch", () => ({ runtimeFetch: async () => new Response("{}") })) + +const { useLinearAuthStore } = await import("./useLinearAuthStore") + +const deferred = () => { + let resolve!: (value: T) => void + const promise = new Promise((res) => { resolve = res }) + return { promise, resolve } +} + +// Only `authStatus` is exercised here; the rest of the surface is present so +// the stub is a real `LinearAPI` rather than an assertion over a fragment. +const unreachable = () => Promise.reject(new Error("not used in this test")) +const linearApi = (authStatus: LinearAPI["authStatus"]): LinearAPI => ({ + authStatus, + authStart: unreachable, + authDisconnect: unreachable, + authActivate: unreachable, + issuesList: unreachable, + issueGet: unreachable, + issueStates: unreachable, + issueUpdate: unreachable, + mappingGet: unreachable, + mappingSet: unreachable, + sessionStatusPost: unreachable, + preferencesGet: unreachable, + preferencesSet: unreachable, +}) + +describe("Linear auth is scoped to the connected instance", () => { + beforeEach(() => { + useLinearAuthStore.getState().resetForRuntimeSwitch() + }) + + test("a switch drops the previous instance's login", async () => { + await useLinearAuthStore.getState().refreshStatus( + linearApi(async () => ({ connected: true })), + { force: true }, + ) + expect(useLinearAuthStore.getState().status?.connected).toBe(true) + + useLinearAuthStore.getState().resetForRuntimeSwitch() + + expect(useLinearAuthStore.getState().status).toBeNull() + expect(useLinearAuthStore.getState().hasChecked).toBe(false) + }) + + test("a status still in flight for the previous instance cannot land in the new one", async () => { + const pending = deferred() + const refresh = useLinearAuthStore.getState().refreshStatus( + linearApi(() => pending.promise), + { force: true }, + ) + + useLinearAuthStore.getState().resetForRuntimeSwitch() + pending.resolve({ connected: true }) + await refresh + + expect(useLinearAuthStore.getState().status).toBeNull() + expect(useLinearAuthStore.getState().hasChecked).toBe(false) + }) + + test("a failed check is not an authoritative disconnect", async () => { + await useLinearAuthStore.getState().refreshStatus( + linearApi(async () => ({ connected: true })), + { force: true }, + ) + await useLinearAuthStore.getState().refreshStatus( + linearApi(async () => { throw new Error("offline") }), + { force: true }, + ) + + expect(useLinearAuthStore.getState().status?.connected).toBe(true) + expect(useLinearAuthStore.getState().status?.error).toBe("offline") + }) +}) diff --git a/packages/ui/src/stores/useLinearAuthStore.ts b/packages/ui/src/stores/useLinearAuthStore.ts index 95560a2f..70aebfcc 100644 --- a/packages/ui/src/stores/useLinearAuthStore.ts +++ b/packages/ui/src/stores/useLinearAuthStore.ts @@ -12,6 +12,13 @@ type LinearAuthStore = { runtimeLinear?: RuntimeAPIs['linear'], options?: { force?: boolean } ) => Promise; + /** + * Linear is authenticated on the OpenChamber instance, not in the browser, so + * this status belongs to whichever instance is connected. Switching instances + * must drop it — otherwise the previous instance's login stays on screen and + * its issue surfaces remain usable against a runtime that has no Linear at all. + */ + resetForRuntimeSwitch: () => void; }; const fetchStatus = async ( @@ -24,6 +31,9 @@ const fetchStatus = async ( }; let inFlightAuthRefresh: Promise | null = null; +// Bumped by every reset so a response already in flight for the previous +// instance cannot write itself into the new instance's status. +let authGeneration = 0; export const useLinearAuthStore = create((set, get) => ({ status: null, @@ -41,13 +51,16 @@ export const useLinearAuthStore = create((set, get) => ({ if (inFlightAuthRefresh) return inFlightAuthRefresh; + const generation = authGeneration; set({ isLoading: true }); inFlightAuthRefresh = (async () => { try { const payload = await fetchStatus(runtimeLinear); + if (generation !== authGeneration) return null; set({ status: payload, isLoading: false, hasChecked: true }); return payload; } catch (error) { + if (generation !== authGeneration) return null; const message = error instanceof Error ? error.message : String(error); // A failed request is not an authoritative disconnect. Keep the last // known status and leave `hasChecked` false so the next caller retries @@ -64,4 +77,9 @@ export const useLinearAuthStore = create((set, get) => ({ return inFlightAuthRefresh; }, + resetForRuntimeSwitch: () => { + authGeneration += 1; + inFlightAuthRefresh = null; + set({ status: null, isLoading: false, hasChecked: false }); + }, })); diff --git a/packages/ui/src/stores/useMcpStore.ts b/packages/ui/src/stores/useMcpStore.ts index 915c83c1..ab4ac80a 100644 --- a/packages/ui/src/stores/useMcpStore.ts +++ b/packages/ui/src/stores/useMcpStore.ts @@ -91,6 +91,12 @@ interface McpStore { completeAuth: (name: string, code: string, directory?: string | null) => Promise; clearAuth: (name: string, directory?: string | null) => Promise; testConnection: (name: string, directory?: string | null) => Promise; + /** + * MCP status is keyed by directory alone, and two instances can hold the same + * project path — so on a switch the previous instance's servers would be + * reported for the new one. Drop everything and let consumers re-ask. + */ + resetForRuntimeSwitch: () => void; } export const useMcpStore = create()( @@ -101,6 +107,17 @@ export const useMcpStore = create()( lastErrorKeys: {}, refreshedAtKeys: {}, + resetForRuntimeSwitch: () => { + ensureFreshInFlight.clear(); + set({ + byDirectory: {}, + diagnosticsByDirectory: {}, + loadingKeys: {}, + lastErrorKeys: {}, + refreshedAtKeys: {}, + }); + }, + getStatusForDirectory: (directory) => { const key = toKey(directory ?? useDirectoryStore.getState().currentDirectory); return get().byDirectory[key] ?? EMPTY_STATUS; diff --git a/packages/ui/src/stores/useQuotaStore.test.ts b/packages/ui/src/stores/useQuotaStore.test.ts new file mode 100644 index 00000000..036d3d3f --- /dev/null +++ b/packages/ui/src/stores/useQuotaStore.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test" +import type { ProviderResult } from "@/types" + +let runtimeKey = "url:https://instance-a" +let isInitialized = true +const fetched: string[] = [] + +type StubPayload = { usageDropdownProviders: string[] } | ProviderResult +const json = (body: StubPayload) => new Response( + JSON.stringify(body), + { status: 200, headers: { "content-type": "application/json" } }, +) + +// Spread the real modules so the overrides stay a patch: `mock.module` is +// process-global, and a partial replacement would break every other module +// that imports something else from these files. +const runtimeSwitch = await import("@/lib/runtime-switch") +mock.module("@/lib/runtime-switch", () => ({ ...runtimeSwitch, getRuntimeKey: () => runtimeKey })) + +const runtimeFetchModule = await import("@/lib/runtime-fetch") +mock.module("@/lib/runtime-fetch", () => ({ + ...runtimeFetchModule, + runtimeFetch: async (path: string) => { + fetched.push(path) + if (path.startsWith("/api/config/settings")) return json({ usageDropdownProviders: ["claude"] }) + return json({ providerId: "claude", providerName: "Claude", ok: true, configured: true, usage: null, fetchedAt: 1 }) + }, +})) + +const configStoreModule = await import("@/stores/useConfigStore") +mock.module("@/stores/useConfigStore", () => ({ + ...configStoreModule, + useConfigStore: { ...configStoreModule.useConfigStore, getState: () => ({ isInitialized }) }, +})) + +const { useQuotaStore } = await import("./useQuotaStore") + +describe("Usage quotas are loaded once per ready instance", () => { + beforeEach(() => { + runtimeKey = "url:https://instance-a" + isInitialized = true + fetched.length = 0 + useQuotaStore.getState().resetForRuntimeSwitch() + }) + + test("nothing is fetched while the instance has not reported itself initialised", async () => { + isInitialized = false + await useQuotaStore.getState().ensureLoadedForRuntime() + + expect(fetched).toHaveLength(0) + expect(useQuotaStore.getState().loadedRuntimeKey).toBeNull() + + // The instance finishes starting up: the same call now performs the load + // that a mount-time fetch would have answered "nothing configured". + isInitialized = true + await useQuotaStore.getState().ensureLoadedForRuntime() + + expect(fetched.length).toBeGreaterThan(0) + expect(useQuotaStore.getState().results.length).toBeGreaterThan(0) + }) + + test("a second ask for the same instance does not refetch", async () => { + await useQuotaStore.getState().ensureLoadedForRuntime() + const afterFirst = fetched.length + await useQuotaStore.getState().ensureLoadedForRuntime() + + expect(fetched.length).toBe(afterFirst) + }) + + test("a switch drops the previous instance's quotas and reloads for the new one", async () => { + await useQuotaStore.getState().ensureLoadedForRuntime() + expect(useQuotaStore.getState().results.length).toBeGreaterThan(0) + + useQuotaStore.getState().resetForRuntimeSwitch() + expect(useQuotaStore.getState().results).toEqual([]) + expect(useQuotaStore.getState().lastUpdated).toBeNull() + + runtimeKey = "url:https://instance-b" + fetched.length = 0 + await useQuotaStore.getState().ensureLoadedForRuntime() + + expect(fetched.length).toBeGreaterThan(0) + expect(useQuotaStore.getState().loadedRuntimeKey).toBe("url:https://instance-b") + }) + + test("a quota still in flight for the previous instance cannot land in the new one", async () => { + const pending = useQuotaStore.getState().fetchProviderQuota("claude") + useQuotaStore.getState().resetForRuntimeSwitch() + await pending + + expect(useQuotaStore.getState().results).toEqual([]) + }) + + test("a transient runtime key loads nothing", async () => { + runtimeKey = "mobile-disconnected" + await useQuotaStore.getState().ensureLoadedForRuntime() + + expect(fetched).toHaveLength(0) + }) +}) diff --git a/packages/ui/src/stores/useQuotaStore.ts b/packages/ui/src/stores/useQuotaStore.ts index 19667325..a6f8b396 100644 --- a/packages/ui/src/stores/useQuotaStore.ts +++ b/packages/ui/src/stores/useQuotaStore.ts @@ -8,8 +8,14 @@ import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { getDefaultModels } from '@/lib/quota/model-families'; import { updateDesktopSettings } from '@/lib/persistence'; import { runtimeFetch } from '@/lib/runtime-fetch'; +import { getRuntimeKey, isTransientRuntimeKey } from '@/lib/runtime-switch'; +import { useConfigStore } from '@/stores/useConfigStore'; const QUOTA_REFRESH_INTERVAL_MS = 3 * 60 * 1000; +// Quotas and their display settings are read from the connected OpenChamber +// instance, so both belong to that instance. Bumped on every reset so a +// response in flight for the previous instance cannot land in the new one. +let quotaGeneration = 0; let quotaAutoRefreshConsumers = 0; let quotaAutoRefreshInterval: number | null = null; @@ -22,6 +28,8 @@ interface QuotaSettingsState { interface QuotaStore extends QuotaSettingsState { results: ProviderResult[]; + /** Instance whose quotas `results` describes, or `null` when nothing is loaded. */ + loadedRuntimeKey: string | null; selectedProviderId: QuotaProviderId | null; isLoading: boolean; isFetchingProvider: Record; @@ -40,6 +48,18 @@ interface QuotaStore extends QuotaSettingsState { setExpandedFamilies: (providerId: string, familyIds: string[]) => void; toggleFamilyExpanded: (providerId: string, familyId: string) => void; applyDefaultSelections: (providerId: string, availableModels: string[]) => void; + /** + * Load settings and quotas once per instance, when that instance is ready. + * + * Providers report themselves as configured only after the instance can read + * their credentials, which on a remote instance is not true the moment the UI + * mounts. A fetch fired at mount therefore answers "nothing configured", and + * because every provider then has a result, no consumer asks again until the + * three-minute refresh — which is why Usage stayed missing from the + * work-status panel until Settings -> Usage forced a fresh fetch. + */ + ensureLoadedForRuntime: () => Promise; + resetForRuntimeSwitch: () => void; } const parseSettings = (data: Record | null): QuotaSettingsState => { @@ -119,6 +139,7 @@ export const useQuotaStore = create()( devtools( (set, get) => ({ results: [], + loadedRuntimeKey: null, selectedProviderId: null, isLoading: false, isFetchingProvider: {}, @@ -130,8 +151,10 @@ export const useQuotaStore = create()( expandedFamilies: {}, loadSettings: async () => { + const generation = quotaGeneration; try { const settings = await loadSettingsFromRuntime(); + if (generation !== quotaGeneration) return; set(settings); } catch (error) { console.warn('Failed to load usage settings:', error); @@ -139,16 +162,19 @@ export const useQuotaStore = create()( }, fetchQuotas: async (providerIds) => { + const generation = quotaGeneration; set({ isLoading: true, error: null }); try { await Promise.all( providerIds.map((providerId) => get().fetchProviderQuota(providerId)) ); + if (generation !== quotaGeneration) return; set({ isLoading: false, lastUpdated: Date.now() }); } catch (error) { + if (generation !== quotaGeneration) return; const message = error instanceof Error ? error.message : 'Failed to fetch quotas'; set({ isLoading: false, error: message }); } @@ -159,6 +185,7 @@ export const useQuotaStore = create()( }, fetchProviderQuota: async (providerId) => { + const generation = quotaGeneration; set((state) => ({ isFetchingProvider: { ...state.isFetchingProvider, [providerId]: true } })); @@ -169,6 +196,7 @@ export const useQuotaStore = create()( throw new Error(payload?.error || 'Failed to fetch quota'); } + if (generation !== quotaGeneration) return; const result = payload as ProviderResult; set((state) => { const next = state.results.filter((entry) => entry.providerId !== providerId); @@ -176,6 +204,7 @@ export const useQuotaStore = create()( return { results: next, error: null }; }); } catch (error) { + if (generation !== quotaGeneration) return; const message = error instanceof Error ? error.message : 'Failed to fetch quota'; const fallback: ProviderResult = { providerId, @@ -192,12 +221,44 @@ export const useQuotaStore = create()( return { results: next, error: message }; }); } finally { - set((state) => ({ - isFetchingProvider: { ...state.isFetchingProvider, [providerId]: false } - })); + if (generation === quotaGeneration) { + set((state) => ({ + isFetchingProvider: { ...state.isFetchingProvider, [providerId]: false } + })); + } } }, + ensureLoadedForRuntime: async () => { + const runtimeKey = getRuntimeKey(); + if (isTransientRuntimeKey(runtimeKey)) return; + // Wait for the instance to report itself initialised. Asking earlier + // gets an honest-looking "not configured" for every provider, which is + // then cached as if it were the answer. + if (!useConfigStore.getState().isInitialized) return; + if (get().loadedRuntimeKey === runtimeKey) return; + set({ loadedRuntimeKey: runtimeKey }); + const generation = quotaGeneration; + await get().loadSettings(); + if (generation !== quotaGeneration) return; + const { dropdownProviderIds, fetchQuotas } = get(); + if (dropdownProviderIds.length === 0) return; + await fetchQuotas(dropdownProviderIds); + }, + + resetForRuntimeSwitch: () => { + quotaGeneration += 1; + set({ + results: [], + loadedRuntimeKey: null, + selectedProviderId: null, + isLoading: false, + isFetchingProvider: {}, + lastUpdated: null, + error: null, + }); + }, + setSelectedProvider: (providerId) => set({ selectedProviderId: providerId }), setDisplayMode: (mode) => set({ displayMode: mode }), setDropdownProviderIds: (providerIds) => set({ dropdownProviderIds: providerIds }), diff --git a/packages/ui/src/stores/useSkillsStore.ts b/packages/ui/src/stores/useSkillsStore.ts index a4c6a4e2..068eae4c 100644 --- a/packages/ui/src/stores/useSkillsStore.ts +++ b/packages/ui/src/stores/useSkillsStore.ts @@ -173,6 +173,12 @@ interface SkillsStore { renameSkill: (name: string, newName: string, directory?: string | null) => Promise; deleteSkill: (name: string, directory?: string | null) => Promise; getSkillByName: (name: string, directory?: string | null) => DiscoveredSkill | undefined; + /** + * Skills are discovered on the connected instance and cached by directory, + * which two instances can share — so a switch must drop the caches rather + * than report the previous instance's skills for the new one. + */ + resetForRuntimeSwitch: () => void; // Supporting files readSupportingFile: (skillName: string, filePath: string, directory?: string | null) => Promise; @@ -279,6 +285,12 @@ export const useSkillsStore = create()( isLoading: false, skillDraft: null, + resetForRuntimeSwitch: () => { + skillsLastLoadedAt.clear(); + skillsLoadInFlight.clear(); + set({ skills: [], skillsByDirectory: {}, isLoading: false }); + }, + setSelectedSkill: (name: string | null) => { set({ selectedSkillName: name }); }, diff --git a/packages/ui/src/stores/useUIStore.linearFilters.test.ts b/packages/ui/src/stores/useUIStore.linearFilters.test.ts index 44789aa4..952f79cc 100644 --- a/packages/ui/src/stores/useUIStore.linearFilters.test.ts +++ b/packages/ui/src/stores/useUIStore.linearFilters.test.ts @@ -1,12 +1,19 @@ -import { beforeEach, describe, expect, test } from 'bun:test'; -import { LINEAR_ISSUE_LIST_ALL_TEAMS, useUIStore } from './useUIStore'; +import { beforeEach, describe, expect, mock, test } from 'bun:test'; + +let runtimeKey = 'url:https://instance-a'; +const runtimeSwitch = await import('@/lib/runtime-switch'); +mock.module('@/lib/runtime-switch', () => ({ ...runtimeSwitch, getRuntimeKey: () => runtimeKey })); + +const { LINEAR_ISSUE_LIST_ALL_TEAMS, useUIStore } = await import('./useUIStore'); describe('linear issue list filters', () => { beforeEach(() => { + runtimeKey = 'url:https://instance-a'; useUIStore.setState({ linearIssueListStatus: 'all', linearIssueListAssignee: 'any', linearIssueListTeamId: LINEAR_ISSUE_LIST_ALL_TEAMS, + linearIssueListTeamIdByRuntime: {}, linearIssueListPriority: 'all', linearIssueFocus: null, }); @@ -67,4 +74,43 @@ describe('linear issue list filters', () => { useUIStore.getState().setLinearIssueFocus(null); expect(useUIStore.getState().linearIssueFocus).toBeNull(); }); + + test('keeps the team filter with the instance that owns the workspace', () => { + useUIStore.getState().setLinearIssueListTeamId('team-eng'); + expect(useUIStore.getState().linearIssueListTeamId).toBe('team-eng'); + + // Switching instances: a team belongs to one Linear workspace, so the new + // instance opens on all teams rather than on a filter matching nothing. + runtimeKey = 'url:https://instance-b'; + useUIStore.getState().applyLinearIssueListFiltersForRuntime(); + expect(useUIStore.getState().linearIssueListTeamId).toBe(LINEAR_ISSUE_LIST_ALL_TEAMS); + + useUIStore.getState().setLinearIssueListTeamId('team-ops'); + expect(useUIStore.getState().linearIssueListTeamId).toBe('team-ops'); + + // Switching back restores the first instance's own choice. + runtimeKey = 'url:https://instance-a'; + useUIStore.getState().applyLinearIssueListFiltersForRuntime(); + expect(useUIStore.getState().linearIssueListTeamId).toBe('team-eng'); + }); + + test('a transient runtime key stores nothing and reads as all teams', () => { + runtimeKey = 'mobile-disconnected'; + useUIStore.getState().setLinearIssueListTeamId('team-eng'); + + expect(useUIStore.getState().linearIssueListTeamIdByRuntime).toEqual({}); + + useUIStore.getState().applyLinearIssueListFiltersForRuntime(); + expect(useUIStore.getState().linearIssueListTeamId).toBe(LINEAR_ISSUE_LIST_ALL_TEAMS); + }); + + test('resetting filters clears the stored team for this instance only', () => { + useUIStore.getState().setLinearIssueListTeamId('team-eng'); + runtimeKey = 'url:https://instance-b'; + useUIStore.getState().setLinearIssueListTeamId('team-ops'); + + useUIStore.getState().resetLinearIssueListFilters(); + + expect(useUIStore.getState().linearIssueListTeamIdByRuntime).toEqual({ 'url:https://instance-a': 'team-eng' }); + }); }); diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 0066881c..a5b00cf7 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -12,6 +12,7 @@ import type { ProjectRef } from '@/lib/projectContextApi'; import { useFilesViewTabsStore } from './useFilesViewTabsStore'; import { isWindowsArm64 } from '@/lib/platform'; import { isVSCodeRuntime } from '@/lib/desktop'; +import { getRuntimeKey, isTransientRuntimeKey } from '@/lib/runtime-switch'; export type PendingDiffScope = 'working' | 'staged' | 'turn' | 'branch'; export type ContextPanelMode = 'diff' | 'walkthrough' | 'file' | 'context' | 'plan' | 'chat' | 'browser' | 'git' | 'pr' | 'linear' | 'notes' | 'terminal'; @@ -65,6 +66,38 @@ function sanitizeLinearIssueListTeamId(value: unknown): string { return teamId || LINEAR_ISSUE_LIST_ALL_TEAMS; } +/** + * Store the team filter under the connected instance, dropping the entry when + * it falls back to all teams so the map does not accumulate defaults. Transient + * keys (uninitialised, mobile-disconnected) name no instance and are not written. + */ +function writeLinearTeamIdForRuntime( + entries: Record, + teamId: string, +): Record { + const runtimeKey = getRuntimeKey(); + if (isTransientRuntimeKey(runtimeKey)) return entries; + const next = { ...entries }; + if (teamId === LINEAR_ISSUE_LIST_ALL_TEAMS) { + delete next[runtimeKey]; + } else { + next[runtimeKey] = teamId; + } + return next; +} + +function sanitizeLinearIssueListTeamIdByRuntime(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + const entries: Record = {}; + // SAFETY: guarded above as a non-array object; every value is re-checked below. + for (const [runtimeKey, teamId] of Object.entries(value as Record)) { + if (!runtimeKey.trim() || typeof teamId !== 'string') continue; + const sanitized = sanitizeLinearIssueListTeamId(teamId); + if (sanitized !== LINEAR_ISSUE_LIST_ALL_TEAMS) entries[runtimeKey] = sanitized; + } + return entries; +} + function sanitizeLinearIssueListPriority(value: unknown): LinearIssueListPriority { return value === 'none' || value === 'urgent' || value === 'high' || value === 'medium' || value === 'low' || value === 'all' ? value @@ -820,7 +853,16 @@ interface UIStore { gitChangesViewMode: 'flat' | 'tree'; linearIssueListStatus: LinearIssueListStatus; linearIssueListAssignee: LinearIssueListAssignee; + /** + * Team filter for the instance currently connected. A Linear team belongs to + * one workspace, and each OpenChamber instance has its own Linear login, so + * this is derived from `linearIssueListTeamIdByRuntime` rather than persisted + * on its own — a team id carried across a switch filters the new instance's + * list down to nothing. + */ linearIssueListTeamId: string; + /** Team filter per instance, keyed the same way every runtime-scoped cache is. */ + linearIssueListTeamIdByRuntime: Record; linearIssueListPriority: LinearIssueListPriority; /** One-shot identifier for opening a Linear issue in the rail panel. Not persisted. */ linearIssueFocus: string | null; @@ -1023,6 +1065,8 @@ interface UIStore { setLinearIssueListStatus: (status: LinearIssueListStatus) => void; setLinearIssueListAssignee: (assignee: LinearIssueListAssignee) => void; setLinearIssueListTeamId: (teamId: string) => void; + /** Re-read the team filter for the instance now connected. */ + applyLinearIssueListFiltersForRuntime: () => void; setLinearIssueListPriority: (priority: LinearIssueListPriority) => void; resetLinearIssueListFilters: () => void; setLinearIssueFocus: (identifier: string | null) => void; @@ -1186,6 +1230,7 @@ export const useUIStore = create()( linearIssueListStatus: 'all', linearIssueListAssignee: 'any', linearIssueListTeamId: LINEAR_ISSUE_LIST_ALL_TEAMS, + linearIssueListTeamIdByRuntime: {}, linearIssueListPriority: 'all', linearIssueFocus: null, isTimelineDialogOpen: false, @@ -2113,7 +2158,20 @@ export const useUIStore = create()( }, setLinearIssueListTeamId: (teamId) => { - set({ linearIssueListTeamId: sanitizeLinearIssueListTeamId(teamId) }); + const sanitized = sanitizeLinearIssueListTeamId(teamId); + set((state) => ({ + linearIssueListTeamId: sanitized, + linearIssueListTeamIdByRuntime: writeLinearTeamIdForRuntime(state.linearIssueListTeamIdByRuntime, sanitized), + })); + }, + + applyLinearIssueListFiltersForRuntime: () => { + const runtimeKey = getRuntimeKey(); + set((state) => ({ + linearIssueListTeamId: isTransientRuntimeKey(runtimeKey) + ? LINEAR_ISSUE_LIST_ALL_TEAMS + : state.linearIssueListTeamIdByRuntime[runtimeKey] ?? LINEAR_ISSUE_LIST_ALL_TEAMS, + })); }, setLinearIssueListPriority: (priority) => { @@ -2121,12 +2179,16 @@ export const useUIStore = create()( }, resetLinearIssueListFilters: () => { - set({ + set((state) => ({ linearIssueListStatus: 'all', linearIssueListAssignee: 'any', linearIssueListTeamId: LINEAR_ISSUE_LIST_ALL_TEAMS, + linearIssueListTeamIdByRuntime: writeLinearTeamIdForRuntime( + state.linearIssueListTeamIdByRuntime, + LINEAR_ISSUE_LIST_ALL_TEAMS, + ), linearIssueListPriority: 'all', - }); + })); }, setLinearIssueFocus: (identifier) => { @@ -2581,7 +2643,7 @@ export const useUIStore = create()( { name: 'ui-store', storage: createDeferredSafeJSONStorage(), - version: 18, + version: 19, migrate: (persistedState, version) => { if (!persistedState || typeof persistedState !== 'object') { return persistedState; @@ -2792,7 +2854,11 @@ export const useUIStore = create()( state.linearIssueListStatus = sanitizeLinearIssueListStatus(state.linearIssueListStatus); state.linearIssueListAssignee = sanitizeLinearIssueListAssignee(state.linearIssueListAssignee); - state.linearIssueListTeamId = sanitizeLinearIssueListTeamId(state.linearIssueListTeamId); + // v18 -> v19: the team filter became per instance. The legacy flat + // value names a team in one workspace with nothing to say which + // instance it came from, so it is dropped rather than guessed at. + delete state.linearIssueListTeamId; + state.linearIssueListTeamIdByRuntime = sanitizeLinearIssueListTeamIdByRuntime(state.linearIssueListTeamIdByRuntime); state.linearIssueListPriority = sanitizeLinearIssueListPriority(state.linearIssueListPriority); state.fileEditorKeymap = normalizeFileEditorKeymap(state.fileEditorKeymap); @@ -2874,7 +2940,7 @@ export const useUIStore = create()( gitChangesViewMode: state.gitChangesViewMode, linearIssueListStatus: state.linearIssueListStatus, linearIssueListAssignee: state.linearIssueListAssignee, - linearIssueListTeamId: state.linearIssueListTeamId, + linearIssueListTeamIdByRuntime: state.linearIssueListTeamIdByRuntime, linearIssueListPriority: state.linearIssueListPriority, nativeNotificationsEnabled: state.nativeNotificationsEnabled, notificationMode: state.notificationMode, diff --git a/packages/ui/src/sync/__tests__/issue-2039.test.ts b/packages/ui/src/sync/__tests__/issue-2039.test.ts index 7558bac5..b6fe2b09 100644 --- a/packages/ui/src/sync/__tests__/issue-2039.test.ts +++ b/packages/ui/src/sync/__tests__/issue-2039.test.ts @@ -186,7 +186,11 @@ mock.module("../selection-store", () => ({ }, })) +// Spread the real module so the stub stays a patch: anything else importing +// runtime-switch in this process still gets its remaining exports. +const runtimeSwitchModule = await import("@/lib/runtime-switch") mock.module("@/lib/runtime-switch", () => ({ + ...runtimeSwitchModule, getRuntimeApiBaseUrl: () => "", getRuntimeKey: () => "test-runtime", initializeRuntimeEndpoint: () => undefined,