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
+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,