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
@@ -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);