fix(ui): preserve VS Code themes during settings broadcasts
This commit is contained in:
@@ -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
|
||||
@@ -84,7 +84,7 @@ Permission auto-accept policy is authoritative in the active Web server or VS Co
|
||||
|
||||
Shared safe storage treats durable failures per key. A quota or access failure creates an ephemeral override or tombstone for that key without disabling reads and writes for unrelated keys; later writes retry the durable backend. Deferred adapters retain failed operations for a later flush, and malformed Zustand JSON is removed and treated as missing so hydration can recover.
|
||||
|
||||
Project and UI settings use successful settings synchronization as authority. Omitted fields in a complete snapshot reset to canonical client defaults, including an omitted project list becoming empty; transport or settings-load failure dispatches no synchronization event and preserves current state. Settings save responses are partial patches and must not clear unrelated in-memory preferences or local mirrors. Debounced settings writes flush best-effort on page hide, document hidden, app freeze, and unload — canceling the pending timer so the write happens exactly once — because a write lost inside the debounce window lets the stale server snapshot override the change on next startup; a hard process kill can still lose the in-flight request. The unload flush uses `keepalive: true` on the HTTP write, because a plain fetch started from `pagehide`/`beforeunload` is cancelled with the document; `navigator.sendBeacon` is not used, as it cannot carry the runtime bearer header. On Capacitor neither `pagehide` nor `beforeunload` fires when the OS suspends the app, so the flush also runs on `App.appStateChange` going inactive.
|
||||
Project and UI settings use successful settings synchronization as authority. Omitted fields in a complete snapshot reset to canonical client defaults, including an omitted project list becoming empty. Theme fields are the exception: only bootstrap-grade theme adoption applies fields supplied by the server, while omitted fields preserve this window's current runtime-scoped theme and settings save echoes never adopt a theme. VS Code settings broadcasts may still adopt shared workspace pointers without replacing each webview's editor-derived theme. Transport or settings-load failure dispatches no synchronization event and preserves current state. Settings save responses are partial patches and must not clear unrelated in-memory preferences or local mirrors. Debounced settings writes flush best-effort on page hide, document hidden, app freeze, and unload — canceling the pending timer so the write happens exactly once — because a write lost inside the debounce window lets the stale server snapshot override the change on next startup; a hard process kill can still lose the in-flight request. The unload flush uses `keepalive: true` on the HTTP write, because a plain fetch started from `pagehide`/`beforeunload` is cancelled with the document; `navigator.sendBeacon` is not used, as it cannot carry the runtime bearer header. On Capacitor neither `pagehide` nor `beforeunload` fires when the OS suspends the app, so the flush also runs on `App.appStateChange` going inactive.
|
||||
|
||||
Project ordering defaults to manual. Session display persistence v3 migrates the previously shipped `recent` project order to `manual` while preserving every other explicit sort mode.
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||
import type { McpStatus } from '@opencode-ai/sdk/v2';
|
||||
import type { McpStatusMap } from './useMcpStore';
|
||||
|
||||
type Deferred<T> = { promise: Promise<T>; resolve: (value: T) => void };
|
||||
const deferred = <T>(): Deferred<T> => {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((res) => { resolve = res; });
|
||||
return { promise, resolve };
|
||||
};
|
||||
|
||||
type McpStatusResult = Awaited<ReturnType<ReturnType<typeof opencodeModule.opencodeClient.getApiClient>['mcp']['status']>>;
|
||||
let mcpStatusResponse: Deferred<McpStatusResult> = deferred();
|
||||
const opencodeModule = await import('@/lib/opencode/client');
|
||||
// Derived from the real client rather than spread from it: the client is a
|
||||
// class instance, so a spread drops every prototype method the other modules
|
||||
// loaded in this process call at import time.
|
||||
// SAFETY: `Object.create` returns `any`; the object delegates to the real
|
||||
// client for everything the two overrides below do not define.
|
||||
const opencodeClientStub = Object.create(opencodeModule.opencodeClient) as typeof opencodeModule.opencodeClient;
|
||||
// The SDK client is derived the same way, so only `mcp.status` is replaced and
|
||||
// every other endpoint keeps its real implementation and type.
|
||||
type McpApiClient = ReturnType<typeof opencodeModule.opencodeClient.getApiClient>;
|
||||
const realApiClient = opencodeModule.opencodeClient.getApiClient();
|
||||
const mcpApiStub: McpApiClient = Object.create(realApiClient, {
|
||||
mcp: { value: { ...realApiClient.mcp, status: () => mcpStatusResponse.promise } },
|
||||
});
|
||||
opencodeClientStub.getApiClient = () => mcpApiStub;
|
||||
opencodeClientStub.getScopedApiClient = () => mcpApiStub;
|
||||
mock.module('@/lib/opencode/client', () => ({ ...opencodeModule, opencodeClient: opencodeClientStub }));
|
||||
|
||||
let skillsResponse: Deferred<Response> = deferred();
|
||||
const runtimeFetchModule = await import('@/lib/runtime-fetch');
|
||||
mock.module('@/lib/runtime-fetch', () => ({
|
||||
...runtimeFetchModule,
|
||||
runtimeFetch: () => skillsResponse.promise,
|
||||
}));
|
||||
|
||||
const { useMcpStore } = await import('./useMcpStore');
|
||||
const { useSkillsStore } = await import('./useSkillsStore');
|
||||
|
||||
const mcpStatusResult = (data: McpStatusMap): McpStatusResult => ({
|
||||
data,
|
||||
request: new Request('http://localhost/mcp'),
|
||||
response: new Response(),
|
||||
});
|
||||
|
||||
const connectedServer = (name: string): McpStatusMap => ({
|
||||
// SAFETY: the store only reads `status` off each entry; the SDK type carries
|
||||
// fields no consumer in this test path touches.
|
||||
[name]: { status: 'connected' } as McpStatus,
|
||||
});
|
||||
|
||||
describe('instance-scoped stores reject responses from the previous instance', () => {
|
||||
beforeEach(() => {
|
||||
mcpStatusResponse = deferred();
|
||||
skillsResponse = deferred();
|
||||
useMcpStore.getState().resetForRuntimeSwitch();
|
||||
useSkillsStore.getState().resetForRuntimeSwitch();
|
||||
});
|
||||
|
||||
test('an MCP status in flight during a switch does not land in the new instance', async () => {
|
||||
const refresh = useMcpStore.getState().refresh({ directory: '/repo', silent: true });
|
||||
|
||||
useMcpStore.getState().resetForRuntimeSwitch();
|
||||
mcpStatusResponse.resolve(mcpStatusResult(connectedServer('from-instance-a')));
|
||||
await refresh;
|
||||
|
||||
expect(useMcpStore.getState().getStatusForDirectory('/repo')).toEqual({});
|
||||
});
|
||||
|
||||
test('an MCP status that arrives with no switch is stored', async () => {
|
||||
const refresh = useMcpStore.getState().refresh({ directory: '/repo', silent: true });
|
||||
mcpStatusResponse.resolve(mcpStatusResult(connectedServer('server-a')));
|
||||
await refresh;
|
||||
|
||||
expect(Object.keys(useMcpStore.getState().getStatusForDirectory('/repo'))).toEqual(['server-a']);
|
||||
});
|
||||
|
||||
test('a skills load in flight during a switch does not land in the new instance', async () => {
|
||||
const load = useSkillsStore.getState().loadSkills('/repo');
|
||||
|
||||
useSkillsStore.getState().resetForRuntimeSwitch();
|
||||
skillsResponse.resolve(new Response(
|
||||
JSON.stringify({ skills: [{ name: 'from-instance-a', path: '/repo/.agents/skills/a/SKILL.md' }] }),
|
||||
{ status: 200, headers: { 'content-type': 'application/json' } },
|
||||
));
|
||||
await load;
|
||||
|
||||
expect(useSkillsStore.getState().skillsByDirectory['/repo']).toBe(undefined);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -54,6 +54,10 @@ type RefreshOptions = {
|
||||
};
|
||||
|
||||
const ensureFreshInFlight = new Map<string, Promise<void>>();
|
||||
// Bumped on every runtime switch. Status is keyed by directory alone and two
|
||||
// instances can hold the same project path, so a request already in flight for
|
||||
// the previous instance would otherwise write its servers over the new one's.
|
||||
let mcpGeneration = 0;
|
||||
|
||||
type TestConnectionResult = {
|
||||
status?: McpStatus;
|
||||
@@ -91,6 +95,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 +111,18 @@ export const useMcpStore = create<McpStore>()(
|
||||
lastErrorKeys: {},
|
||||
refreshedAtKeys: {},
|
||||
|
||||
resetForRuntimeSwitch: () => {
|
||||
mcpGeneration += 1;
|
||||
ensureFreshInFlight.clear();
|
||||
set({
|
||||
byDirectory: {},
|
||||
diagnosticsByDirectory: {},
|
||||
loadingKeys: {},
|
||||
lastErrorKeys: {},
|
||||
refreshedAtKeys: {},
|
||||
});
|
||||
},
|
||||
|
||||
getStatusForDirectory: (directory) => {
|
||||
const key = toKey(directory ?? useDirectoryStore.getState().currentDirectory);
|
||||
return get().byDirectory[key] ?? EMPTY_STATUS;
|
||||
@@ -127,9 +149,11 @@ export const useMcpStore = create<McpStore>()(
|
||||
}));
|
||||
}
|
||||
|
||||
const generation = mcpGeneration;
|
||||
try {
|
||||
const api = getMcpApiClient(directory);
|
||||
const result = await api.mcp.status();
|
||||
if (generation !== mcpGeneration) return;
|
||||
const data = (result.data ?? {}) as McpStatusMap;
|
||||
|
||||
set((state) => ({
|
||||
@@ -145,6 +169,7 @@ export const useMcpStore = create<McpStore>()(
|
||||
refreshedAtKeys: { ...state.refreshedAtKeys, [key]: Date.now() },
|
||||
}));
|
||||
} catch (error) {
|
||||
if (generation !== mcpGeneration) return;
|
||||
const message = error instanceof Error ? error.message : 'Failed to load MCP status';
|
||||
set((state) => ({
|
||||
loadingKeys: { ...state.loadingKeys, [key]: false },
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
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
|
||||
let quotaRequestsFail = false;
|
||||
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 (quotaRequestsFail) throw new Error("network down")
|
||||
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
|
||||
quotaRequestsFail = false
|
||||
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)
|
||||
})
|
||||
|
||||
test("a failed load is not recorded as loaded, so the next ask retries it", async () => {
|
||||
quotaRequestsFail = true
|
||||
await useQuotaStore.getState().ensureLoadedForRuntime()
|
||||
|
||||
expect(useQuotaStore.getState().loadedRuntimeKey).toBeNull()
|
||||
|
||||
quotaRequestsFail = false
|
||||
fetched.length = 0
|
||||
await useQuotaStore.getState().ensureLoadedForRuntime()
|
||||
|
||||
expect(fetched.length).toBeGreaterThan(0)
|
||||
expect(useQuotaStore.getState().loadedRuntimeKey).toBe("url:https://instance-a")
|
||||
})
|
||||
|
||||
test("concurrent asks share one load", async () => {
|
||||
await Promise.all([
|
||||
useQuotaStore.getState().ensureLoadedForRuntime(),
|
||||
useQuotaStore.getState().ensureLoadedForRuntime(),
|
||||
])
|
||||
|
||||
expect(fetched.filter((path) => path.startsWith("/api/quota/"))).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("a switch drops the previous instance's display settings", async () => {
|
||||
await useQuotaStore.getState().ensureLoadedForRuntime()
|
||||
expect(useQuotaStore.getState().dropdownProviderIds).toEqual(["claude"])
|
||||
useQuotaStore.getState().setDisplayMode("remaining")
|
||||
|
||||
useQuotaStore.getState().resetForRuntimeSwitch()
|
||||
|
||||
// `dropdownProviderIds` decides which providers get queried, so carrying it
|
||||
// over would ask the new instance through the old one's selection.
|
||||
expect(useQuotaStore.getState().dropdownProviderIds.length).toBeGreaterThan(1)
|
||||
expect(useQuotaStore.getState().displayMode).toBe("usage")
|
||||
})
|
||||
})
|
||||
@@ -8,8 +8,15 @@ 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 inFlightRuntimeLoad: Promise<void> | null = null;
|
||||
let quotaAutoRefreshConsumers = 0;
|
||||
let quotaAutoRefreshInterval: number | null = null;
|
||||
|
||||
@@ -22,6 +29,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>;
|
||||
@@ -30,8 +39,10 @@ interface QuotaStore extends QuotaSettingsState {
|
||||
|
||||
loadSettings: () => Promise<void>;
|
||||
fetchAllQuotas: () => Promise<void>;
|
||||
fetchQuotas: (providerIds: QuotaProviderId[]) => Promise<void>;
|
||||
fetchProviderQuota: (providerId: QuotaProviderId) => Promise<void>;
|
||||
/** Resolves true when at least one provider answered — see `ensureLoadedForRuntime`. */
|
||||
fetchQuotas: (providerIds: QuotaProviderId[]) => Promise<boolean>;
|
||||
/** Resolves true when the instance answered, false on a transport failure. */
|
||||
fetchProviderQuota: (providerId: QuotaProviderId) => Promise<boolean>;
|
||||
setSelectedProvider: (providerId: QuotaProviderId | null) => void;
|
||||
setDisplayMode: (mode: 'usage' | 'remaining') => void;
|
||||
setDropdownProviderIds: (providerIds: QuotaProviderId[]) => void;
|
||||
@@ -40,6 +51,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 => {
|
||||
@@ -84,6 +107,13 @@ const parseSettings = (data: Record<string, unknown> | null): QuotaSettingsState
|
||||
};
|
||||
};
|
||||
|
||||
const defaultQuotaSettings = (): QuotaSettingsState => ({
|
||||
displayMode: 'usage',
|
||||
dropdownProviderIds: QUOTA_PROVIDERS.map((provider) => provider.id),
|
||||
selectedModels: {},
|
||||
expandedFamilies: {},
|
||||
});
|
||||
|
||||
const loadSettingsFromRuntime = async (): Promise<QuotaSettingsState> => {
|
||||
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
|
||||
if (runtimeSettings) {
|
||||
@@ -107,18 +137,14 @@ const loadSettingsFromRuntime = async (): Promise<QuotaSettingsState> => {
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
displayMode: 'usage',
|
||||
dropdownProviderIds: QUOTA_PROVIDERS.map((provider) => provider.id),
|
||||
selectedModels: {},
|
||||
expandedFamilies: {},
|
||||
};
|
||||
return defaultQuotaSettings();
|
||||
};
|
||||
|
||||
export const useQuotaStore = create<QuotaStore>()(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
results: [],
|
||||
loadedRuntimeKey: null,
|
||||
selectedProviderId: null,
|
||||
isLoading: false,
|
||||
isFetchingProvider: {},
|
||||
@@ -130,8 +156,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,18 +167,23 @@ export const useQuotaStore = create<QuotaStore>()(
|
||||
},
|
||||
|
||||
fetchQuotas: async (providerIds) => {
|
||||
const generation = quotaGeneration;
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
await Promise.all(
|
||||
const answered = await Promise.all(
|
||||
providerIds.map((providerId) => get().fetchProviderQuota(providerId))
|
||||
);
|
||||
if (generation !== quotaGeneration) return false;
|
||||
set({
|
||||
isLoading: false,
|
||||
lastUpdated: Date.now()
|
||||
});
|
||||
return answered.some(Boolean);
|
||||
} catch (error) {
|
||||
if (generation !== quotaGeneration) return false;
|
||||
const message = error instanceof Error ? error.message : 'Failed to fetch quotas';
|
||||
set({ isLoading: false, error: message });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -159,6 +192,7 @@ export const useQuotaStore = create<QuotaStore>()(
|
||||
},
|
||||
|
||||
fetchProviderQuota: async (providerId) => {
|
||||
const generation = quotaGeneration;
|
||||
set((state) => ({
|
||||
isFetchingProvider: { ...state.isFetchingProvider, [providerId]: true }
|
||||
}));
|
||||
@@ -169,13 +203,16 @@ export const useQuotaStore = create<QuotaStore>()(
|
||||
throw new Error(payload?.error || 'Failed to fetch quota');
|
||||
}
|
||||
|
||||
if (generation !== quotaGeneration) return false;
|
||||
const result = payload as ProviderResult;
|
||||
set((state) => {
|
||||
const next = state.results.filter((entry) => entry.providerId !== providerId);
|
||||
next.push(result);
|
||||
return { results: next, error: null };
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (generation !== quotaGeneration) return false;
|
||||
const message = error instanceof Error ? error.message : 'Failed to fetch quota';
|
||||
const fallback: ProviderResult = {
|
||||
providerId,
|
||||
@@ -191,13 +228,62 @@ export const useQuotaStore = create<QuotaStore>()(
|
||||
next.push(fallback);
|
||||
return { results: next, error: message };
|
||||
});
|
||||
return false;
|
||||
} 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;
|
||||
if (inFlightRuntimeLoad) return inFlightRuntimeLoad;
|
||||
|
||||
const generation = quotaGeneration;
|
||||
inFlightRuntimeLoad = (async () => {
|
||||
await get().loadSettings();
|
||||
if (generation !== quotaGeneration) return;
|
||||
const { dropdownProviderIds, fetchQuotas } = get();
|
||||
if (dropdownProviderIds.length === 0) return;
|
||||
const answered = await fetchQuotas(dropdownProviderIds);
|
||||
// Mark the instance loaded only once it actually answered. Claiming it
|
||||
// up front meant a load that failed on a cold or briefly unreachable
|
||||
// instance was never attempted again — Usage would stay empty until
|
||||
// the three-minute refresh, or forever after a switch.
|
||||
if (answered && generation === quotaGeneration) set({ loadedRuntimeKey: runtimeKey });
|
||||
})().finally(() => { inFlightRuntimeLoad = null; });
|
||||
|
||||
return inFlightRuntimeLoad;
|
||||
},
|
||||
|
||||
resetForRuntimeSwitch: () => {
|
||||
quotaGeneration += 1;
|
||||
inFlightRuntimeLoad = null;
|
||||
set({
|
||||
// Display mode, the provider selection and the per-provider model
|
||||
// picks all come from the instance's own settings, and
|
||||
// `dropdownProviderIds` decides what gets fetched — carrying them
|
||||
// over would query the new instance through the old one's choices.
|
||||
...defaultQuotaSettings(),
|
||||
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 }),
|
||||
|
||||
@@ -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>;
|
||||
@@ -192,6 +198,10 @@ const SKILLS_LOAD_CACHE_TTL_MS = 5000;
|
||||
const DEFAULT_SKILLS_CACHE_KEY = '__default__';
|
||||
const skillsLastLoadedAt = new Map<string, number>();
|
||||
const skillsLoadInFlight = new Map<string, Promise<boolean>>();
|
||||
// Bumped on every runtime switch. Skills are discovered on the connected
|
||||
// instance and cached by directory, which two instances can share, so a load
|
||||
// already in flight for the previous instance must not write into the new one.
|
||||
let skillsGeneration = 0;
|
||||
|
||||
const getSkillsCacheKey = (directory: string | null): string => {
|
||||
return directory?.trim() || DEFAULT_SKILLS_CACHE_KEY;
|
||||
@@ -279,6 +289,13 @@ export const useSkillsStore = create<SkillsStore>()(
|
||||
isLoading: false,
|
||||
skillDraft: null,
|
||||
|
||||
resetForRuntimeSwitch: () => {
|
||||
skillsGeneration += 1;
|
||||
skillsLastLoadedAt.clear();
|
||||
skillsLoadInFlight.clear();
|
||||
set({ skills: [], skillsByDirectory: {}, isLoading: false });
|
||||
},
|
||||
|
||||
setSelectedSkill: (name: string | null) => {
|
||||
set({ selectedSkillName: name });
|
||||
},
|
||||
@@ -304,6 +321,7 @@ export const useSkillsStore = create<SkillsStore>()(
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
const generation = skillsGeneration;
|
||||
const request = (async () => {
|
||||
set({ isLoading: true });
|
||||
// Failure must never look like an empty project. The mirror is the
|
||||
@@ -349,6 +367,7 @@ export const useSkillsStore = create<SkillsStore>()(
|
||||
data.externalSkills ?? null,
|
||||
);
|
||||
|
||||
if (generation !== skillsGeneration) return false;
|
||||
set((state) => {
|
||||
const next: Partial<SkillsStore> = {
|
||||
skillsByDirectory: { ...state.skillsByDirectory, [cacheKey]: visibleSkills },
|
||||
@@ -367,6 +386,7 @@ export const useSkillsStore = create<SkillsStore>()(
|
||||
}
|
||||
|
||||
console.error("Failed to load skills:", lastError);
|
||||
if (generation !== skillsGeneration) return false;
|
||||
set((state) => {
|
||||
const next: Partial<SkillsStore> = {
|
||||
skillsByDirectory: { ...state.skillsByDirectory, [cacheKey]: previousSkills },
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user