fix: scope instance-served state to the connected instance

Linear and GitHub logins, quotas, MCP status, skills and agent memory are
served by whichever instance is connected, but each was cached globally or
by directory alone — which two instances can share. Switching instances left
the previous instance's answers on screen and its Linear login usable against
a runtime that has no Linear.

Reset them all through runtimeEndpointReset, each store guarding its in-flight
requests with a generation so a response for the previous instance cannot land
in the new one. The Linear team filter is now persisted per instance: a team
belongs to one workspace, so carrying it across filtered the new instance's
issue list down to nothing.

Usage also waits for the instance to report itself initialised before loading.
Providers report themselves as configured only once the instance can read their
credentials, so a fetch fired at mount answered "nothing configured" for every
provider and cached it — which is why Usage stayed missing from the work-status
panel until Settings -> Usage forced a fresh fetch.
This commit is contained in:
Bohdan Triapitsyn
2026-09-03 11:49:34 +03:00
parent e8ba8e996f
commit 6fa2cb66d0
18 changed files with 509 additions and 26 deletions
+6 -1
View File
@@ -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();
@@ -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());
@@ -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
@@ -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<Props> = ({ 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
@@ -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<Props> = ({ 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<string | null>(null);
// The panel must not depend on the header dropdown having been mounted or
@@ -35,9 +37,12 @@ export const WorkStatusMcpSection: React.FC<Props> = ({ 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)),
@@ -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;
@@ -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);
+1 -1
View File
@@ -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
@@ -13,6 +13,8 @@ type GitHubAuthStore = {
runtimeGitHub?: RuntimeAPIs['github'],
options?: { force?: boolean }
) => Promise<GitHubAuthStatusWithError | null>;
/** 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<GitHubAuthStatusWithError | null> | 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<GitHubAuthStore>((set, get) => ({
status: null,
@@ -50,13 +55,16 @@ export const useGitHubAuthStore = create<GitHubAuthStore>((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<GitHubAuthStore>((set, get) => ({
return _inFlightAuthRefresh;
},
resetForRuntimeSwitch: () => {
authGeneration += 1;
_inFlightAuthRefresh = null;
set({ status: null, isLoading: false, hasChecked: false });
},
}));
@@ -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 = <T>() => {
let resolve!: (value: T) => void
const promise = new Promise<T>((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<LinearAuthStatus>()
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")
})
})
@@ -12,6 +12,13 @@ type LinearAuthStore = {
runtimeLinear?: RuntimeAPIs['linear'],
options?: { force?: boolean }
) => Promise<LinearAuthStatusWithError | null>;
/**
* 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<LinearAuthStatusWithError | null> | 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<LinearAuthStore>((set, get) => ({
status: null,
@@ -41,13 +51,16 @@ export const useLinearAuthStore = create<LinearAuthStore>((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<LinearAuthStore>((set, get) => ({
return inFlightAuthRefresh;
},
resetForRuntimeSwitch: () => {
authGeneration += 1;
inFlightAuthRefresh = null;
set({ status: null, isLoading: false, hasChecked: false });
},
}));
+17
View File
@@ -91,6 +91,12 @@ interface McpStore {
completeAuth: (name: string, code: string, directory?: string | null) => Promise<void>;
clearAuth: (name: string, directory?: string | null) => Promise<void>;
testConnection: (name: string, directory?: string | null) => Promise<TestConnectionResult>;
/**
* 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<McpStore>()(
@@ -101,6 +107,17 @@ export const useMcpStore = create<McpStore>()(
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;
@@ -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)
})
})
+64 -3
View File
@@ -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<string, boolean>;
@@ -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<void>;
resetForRuntimeSwitch: () => void;
}
const parseSettings = (data: Record<string, unknown> | null): QuotaSettingsState => {
@@ -119,6 +139,7 @@ export const useQuotaStore = create<QuotaStore>()(
devtools(
(set, get) => ({
results: [],
loadedRuntimeKey: null,
selectedProviderId: null,
isLoading: false,
isFetchingProvider: {},
@@ -130,8 +151,10 @@ export const useQuotaStore = create<QuotaStore>()(
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<QuotaStore>()(
},
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<QuotaStore>()(
},
fetchProviderQuota: async (providerId) => {
const generation = quotaGeneration;
set((state) => ({
isFetchingProvider: { ...state.isFetchingProvider, [providerId]: true }
}));
@@ -169,6 +196,7 @@ export const useQuotaStore = create<QuotaStore>()(
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<QuotaStore>()(
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<QuotaStore>()(
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 }),
+12
View File
@@ -173,6 +173,12 @@ interface SkillsStore {
renameSkill: (name: string, newName: string, directory?: string | null) => Promise<boolean>;
deleteSkill: (name: string, directory?: string | null) => Promise<boolean>;
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<string | null>;
@@ -279,6 +285,12 @@ export const useSkillsStore = create<SkillsStore>()(
isLoading: false,
skillDraft: null,
resetForRuntimeSwitch: () => {
skillsLastLoadedAt.clear();
skillsLoadInFlight.clear();
set({ skills: [], skillsByDirectory: {}, isLoading: false });
},
setSelectedSkill: (name: string | null) => {
set({ selectedSkillName: name });
},
@@ -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' });
});
});
+72 -6
View File
@@ -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<string, string>,
teamId: string,
): Record<string, string> {
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<string, string> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
const entries: Record<string, string> = {};
// SAFETY: guarded above as a non-array object; every value is re-checked below.
for (const [runtimeKey, teamId] of Object.entries(value as Record<string, unknown>)) {
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<string, string>;
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<UIStore>()(
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<UIStore>()(
},
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<UIStore>()(
},
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<UIStore>()(
{
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<UIStore>()(
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<UIStore>()(
gitChangesViewMode: state.gitChangesViewMode,
linearIssueListStatus: state.linearIssueListStatus,
linearIssueListAssignee: state.linearIssueListAssignee,
linearIssueListTeamId: state.linearIssueListTeamId,
linearIssueListTeamIdByRuntime: state.linearIssueListTeamIdByRuntime,
linearIssueListPriority: state.linearIssueListPriority,
nativeNotificationsEnabled: state.nativeNotificationsEnabled,
notificationMode: state.notificationMode,
@@ -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,