Merge origin/main into deferred OpenCode restart branch.

Adopt main's providerAuth helpers (OAuth index preservation, OAuth-only API
key hiding, always-load auth methods) while keeping deferred Apply & Restart
for provider mutations.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-08-04 13:29:46 +00:00
co-authored by Serhii Dziupin
133 changed files with 6361 additions and 714 deletions
+5 -2
View File
@@ -47,17 +47,20 @@ Examples:
- `useProjectsStore.ts`
- `useGlobalSessionsStore.ts`
- `useSessionFoldersStore.ts`
- `messageQueueStore.ts`
These stores coordinate persistent project/session metadata across multiple views.
`messageQueueStore.ts` keeps a queued message until its own send resolves, so between dispatch and resolution the entry is still visible to every reader. Dispatchers must therefore mark the send (`markSending`/`clearSending`) and read `getSendableQueue()` — or filter `sendingIds` themselves — instead of dispatching straight from `queuedMessages`; otherwise a composer submit merges a message the auto-send hook is already delivering and it is sent twice (the window is seconds over a relay). `clearQueue()` retains in-flight entries for the same reason. `sendingIds` is deliberately not persisted: a restart has no in-flight sends, and a stale flag would strand a queued message.
`useGlobalSessionsStore.ts` owns cold/global active and archived session coverage, including `sessionsByDirectory`. It is complementary to directory child stores: it is not the source of live busy/retry status or session messages.
User-visible session ordering is also not owned by the global cache array order. `sync/session-ordering.ts` combines lifecycle rank with timestamp fallbacks, and session surfaces must use that shared comparator instead of independently sorting global sessions by `time.updated`.
Global refresh rules:
- The OpenCode `archived` list flag means "also include archived sessions": the server only drops its `time_archived IS NULL` condition. `listGlobalSessionPages` therefore narrows archived requests to records carrying `time.archived`, at the data boundary, so the archived cache never holds active sessions and no consumer has to re-derive that. Pagination progress stays measured on the raw response, so a page that is full upstream but filtered out here is not mistaken for the last page.
- Per-directory refresh is bounded to two requests across callers and prioritizes the current directory.
- The OpenCode `archived` list flag means "also include archived sessions": the server only drops its `time_archived IS NULL` condition. The global cache therefore loads with one inclusive request (`archived: true`) and splits active/archived client-side via `splitGlobalSessionsByArchived` — an `archived: false` request cannot be truthful because the server filter excludes restored sessions (`time.archived` falsy-but-present, see "Restore (unarchive) contract" in `sync/DOCUMENTATION.md`). For callers that still want only archived records, `listGlobalSessionPages` narrows inclusive responses at the data boundary (default `narrowToArchived`), so the archived cache never holds active sessions and no consumer has to re-derive that. Pagination progress stays measured on the raw response, so a page that is full upstream but filtered out here is not mistaken for the last page.
- Per-directory refresh issues one inclusive request per directory (previously two), bounded to two requests across callers and prioritizing the current directory.
- Each directory is an independent completeness scope. A failed directory preserves its previous sessions while successful directories reconcile normally.
- Fetch failure must remain distinguishable from a successful empty list; failed scopes cannot destructively clear cached sessions.
- Runtime switch increments the load generation and clears the previous runtime's snapshot so stale in-flight work cannot commit.
+35 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, test } from 'bun:test'
import type { OpencodeClient } from '@opencode-ai/sdk/v2'
import { listGlobalSessionPages } from './globalSessions'
import { listGlobalSessionPages, splitGlobalSessionsByArchived } from './globalSessions'
describe('listGlobalSessionPages', () => {
test('sanitizes session list records before returning them', async () => {
@@ -138,6 +138,27 @@ describe('listGlobalSessionPages', () => {
expect(sessions.map((session) => session.id)).toEqual(['ses_active_1', 'ses_active_2'])
})
test('returns the inclusive response unfiltered when narrowing is disabled', async () => {
const apiClient = {
experimental: {
session: {
list: async () => ({
data: [
{ id: 'ses_active', time: { created: 1, updated: 20 } },
{ id: 'ses_archived', time: { created: 1, updated: 10, archived: 15 } },
{ id: 'ses_restored', time: { created: 1, updated: 5, archived: 0 } },
],
response: { headers: new Headers() },
}),
},
},
} as unknown as OpencodeClient
const sessions = await listGlobalSessionPages(apiClient, { archived: true, narrowToArchived: false, pageSize: 500 })
expect(sessions.map((session) => session.id)).toEqual(['ses_active', 'ses_archived', 'ses_restored'])
})
test('keeps paginating archived pages that are full of non-archived records', async () => {
const calls: Array<Record<string, unknown>> = []
const apiClient = {
@@ -279,3 +300,16 @@ describe('listGlobalSessionPages', () => {
expect(sessions.map((session) => session.id)).toEqual(['ses_1'])
})
})
describe('splitGlobalSessionsByArchived', () => {
test('classifies restored (falsy archived) records as active', () => {
const { active, archived } = splitGlobalSessionsByArchived([
{ id: 'ses_active', time: { created: 1, updated: 20 } },
{ id: 'ses_archived', time: { created: 1, updated: 10, archived: 15 } },
{ id: 'ses_restored', time: { created: 1, updated: 5, archived: 0 } },
] as unknown as Parameters<typeof splitGlobalSessionsByArchived>[0])
expect(active.map((session) => session.id)).toEqual(['ses_active', 'ses_restored'])
expect(archived.map((session) => session.id)).toEqual(['ses_archived'])
})
})
+31 -4
View File
@@ -84,11 +84,38 @@ const unwrapSessionList = (
*/
const isArchivedSession = (session: GlobalSessionRecord): boolean => Boolean(session.time?.archived);
/**
* Split an inclusive (`archived: true`) session page stream into active and
* archived buckets. Restored sessions carry `time.archived === 0` (see
* `UNARCHIVED_TIMESTAMP` in `sync/session-actions.ts`); the truthiness check
* classifies them as active even though the server's own
* `time_archived IS NULL` filter would still exclude them, which is why the
* global cache must split client-side instead of issuing an
* `archived: false` request for its active list.
*/
export const splitGlobalSessionsByArchived = <T extends GlobalSessionRecord>(
sessions: T[],
): { active: T[]; archived: T[] } => {
const active: T[] = [];
const archived: T[] = [];
for (const session of sessions) {
if (isArchivedSession(session)) archived.push(session);
else active.push(session);
}
return { active, archived };
};
export async function listGlobalSessionPages(
apiClient: OpencodeClient,
options: {
directory?: string;
archived: boolean;
/**
* When `archived` is true, narrow results to records carrying a truthy
* `time.archived` (default true). Pass false to receive the inclusive
* server response unfiltered, e.g. to split active/archived locally.
*/
narrowToArchived?: boolean;
roots?: boolean;
pageSize: number;
onPage?: (sessions: GlobalSessionRecord[]) => void;
@@ -97,17 +124,17 @@ export async function listGlobalSessionPages(
const all: GlobalSessionRecord[] = [];
const seenIds = new Set<string>();
let cursor: number | undefined;
const narrowToArchived = options.narrowToArchived !== false;
let operation: string;
if (!options.directory) {
operation = `global-sessions.${options.archived ? "archived" : "active"}`;
operation = `global-sessions.${options.archived ? (narrowToArchived ? "archived" : "all") : "active"}`;
} else if (options.roots === true) {
operation = "bootstrap.sessions.roots";
} else if (options.archived) {
operation = "bootstrap.sessions.archived";
operation = narrowToArchived ? "bootstrap.sessions.archived" : "bootstrap.sessions.all";
} else {
operation = "bootstrap.sessions.all";
}
while (true) {
let attempts = 0;
const finishPerformanceEvent = startSessionLoadPerformanceEvent({
@@ -150,7 +177,7 @@ export async function listGlobalSessionPages(
if (!session?.id || seenIds.has(session.id)) continue;
seenIds.add(session.id);
appended += 1;
if (options.archived && !isArchivedSession(session)) continue;
if (options.archived && narrowToArchived && !isArchivedSession(session)) continue;
all.push(session);
accepted.push(session);
}
@@ -8,7 +8,7 @@ import {
} from "./messageQueueStore"
beforeEach(() => {
useMessageQueueStore.setState({ queuedMessages: {}, quarantinedLegacyMessages: {} })
useMessageQueueStore.setState({ queuedMessages: {}, quarantinedLegacyMessages: {}, sendingIds: {} })
})
describe("message queue runtime ownership", () => {
@@ -49,3 +49,48 @@ describe("message queue runtime ownership", () => {
expect(queue[0]?.content).toBe("message-5")
})
})
describe("in-flight queued sends", () => {
test("hides a dispatched message from the sendable queue but keeps it visible", () => {
const target = createMessageQueueTarget("session-1", "/repo", "runtime-a")!
const store = useMessageQueueStore.getState()
store.addToQueue(target, { content: "first" })
store.addToQueue(target, { content: "second" })
const [first] = useMessageQueueStore.getState().getQueueForTarget(target)
useMessageQueueStore.getState().markSending(target, first.id)
expect(useMessageQueueStore.getState().getQueueForTarget(target)).toHaveLength(2)
const sendable = useMessageQueueStore.getState().getSendableQueue(target)
expect(sendable).toHaveLength(1)
expect(sendable[0]?.content).toBe("second")
useMessageQueueStore.getState().clearSending(target, first.id)
expect(useMessageQueueStore.getState().getSendableQueue(target)).toHaveLength(2)
expect(useMessageQueueStore.getState().sendingIds).toEqual({})
})
test("clearQueue retains a message whose send is still awaiting the server", () => {
const target = createMessageQueueTarget("session-1", "/repo", "runtime-a")!
const store = useMessageQueueStore.getState()
store.addToQueue(target, { content: "in flight" })
store.addToQueue(target, { content: "merged by composer" })
const [inFlight] = useMessageQueueStore.getState().getQueueForTarget(target)
useMessageQueueStore.getState().markSending(target, inFlight.id)
useMessageQueueStore.getState().clearQueue(target)
const remaining = useMessageQueueStore.getState().getQueueForTarget(target)
expect(remaining).toHaveLength(1)
expect(remaining[0]?.id).toBe(inFlight.id)
})
test("clearQueue drops everything once no send is in flight", () => {
const target = createMessageQueueTarget("session-1", "/repo", "runtime-a")!
useMessageQueueStore.getState().addToQueue(target, { content: "queued" })
useMessageQueueStore.getState().clearQueue(target)
expect(useMessageQueueStore.getState().getQueueForTarget(target)).toHaveLength(0)
})
})
+59 -1
View File
@@ -85,6 +85,19 @@ interface MessageQueueState {
queuedMessages: Record<string, QueuedMessage[]>; // runtime + directory + session → queue
quarantinedLegacyMessages: Record<string, QueuedMessage[]>;
followUpBehavior: FollowUpBehavior;
/**
* Queued messages whose send is currently awaiting the server, per target.
*
* A queued item is removed only after its send resolves, so between
* dispatch and resolution it is still visible to every other reader — and
* a composer submit merges the whole queue into its own send. Over a relay
* that window is seconds, long enough for the same message to be delivered
* twice. Dispatchers must skip entries listed here.
*
* Never persisted: a restart has no in-flight sends, and a stale flag would
* strand a queued message permanently.
*/
sendingIds: Record<string, string[]>;
}
interface MessageQueueActions {
@@ -94,6 +107,9 @@ interface MessageQueueActions {
popToInput: (target: MessageQueueTarget, messageId: string) => QueuedMessage | null;
clearQueue: (target: MessageQueueTarget) => void;
clearAllQueues: () => void;
markSending: (target: MessageQueueTarget, messageId: string) => void;
clearSending: (target: MessageQueueTarget, messageId: string) => void;
getSendableQueue: (target: MessageQueueTarget) => QueuedMessage[];
setFollowUpBehavior: (behavior: FollowUpBehavior) => void;
getQueueForTarget: (target: MessageQueueTarget) => QueuedMessage[];
}
@@ -127,6 +143,7 @@ export const useMessageQueueStore = create<MessageQueueStore>()(
queuedMessages: {},
quarantinedLegacyMessages: {},
followUpBehavior: DEFAULT_FOLLOW_UP_BEHAVIOR,
sendingIds: {},
addToQueue: (target, message) => {
const key = getMessageQueueKey(target);
@@ -237,6 +254,14 @@ export const useMessageQueueStore = create<MessageQueueStore>()(
clearQueue: (target) => {
const key = getMessageQueueKey(target);
set((state) => {
// Clearing drops what is still queued, never a message
// already handed to the server: that send will resolve
// and must find its entry to remove or restore.
const sending = state.sendingIds[key] ?? [];
const retained = (state.queuedMessages[key] ?? []).filter((m) => sending.includes(m.id));
if (retained.length > 0) {
return { queuedMessages: { ...state.queuedMessages, [key]: retained } };
}
const { [key]: _removed, ...rest } = state.queuedMessages;
void _removed;
return { queuedMessages: rest };
@@ -244,7 +269,40 @@ export const useMessageQueueStore = create<MessageQueueStore>()(
},
clearAllQueues: () => {
set({ queuedMessages: {} });
set({ queuedMessages: {}, sendingIds: {} });
},
markSending: (target, messageId) => {
const key = getMessageQueueKey(target);
set((state) => {
const current = state.sendingIds[key] ?? [];
if (current.includes(messageId)) return state;
return { sendingIds: { ...state.sendingIds, [key]: [...current, messageId] } };
});
},
clearSending: (target, messageId) => {
const key = getMessageQueueKey(target);
set((state) => {
const current = state.sendingIds[key];
if (!current || !current.includes(messageId)) return state;
const next = current.filter((id) => id !== messageId);
if (next.length === 0) {
const { [key]: _removed, ...rest } = state.sendingIds;
void _removed;
return { sendingIds: rest };
}
return { sendingIds: { ...state.sendingIds, [key]: next } };
});
},
getSendableQueue: (target) => {
const key = getMessageQueueKey(target);
const state = get();
const queue = state.queuedMessages[key] ?? [];
const sending = state.sendingIds[key];
if (!sending || sending.length === 0) return queue;
return queue.filter((message) => !sending.includes(message.id));
},
setFollowUpBehavior: (behavior) => {
@@ -20,14 +20,17 @@ const deferred = <T>(): Deferred<T> => {
return { promise, resolve, reject }
}
let activeRequest: Deferred<Session[]>
let archivedRequest: Deferred<Session[]>
let listRequest: Deferred<Session[]>
// The store issues one inclusive (`archived: true`) paginated request per
// load/refresh scope and splits active/archived client-side, so restored
// sessions (`time.archived` falsy-but-present) stay visible in the active
// list. The mock serves that single request.
const sdk = {
experimental: {
session: {
list: async (options: { archived?: boolean }) => ({
data: await (options.archived ? archivedRequest.promise : activeRequest.promise),
list: async () => ({
data: await listRequest.promise,
response: { headers: new Headers() },
}),
},
@@ -38,13 +41,12 @@ const originalGetSdkClient = opencodeClient.getSdkClient
const session = (id: string, title = id, archived?: number): Session => ({
id,
title,
time: { created: 1, updated: 1, ...(archived ? { archived } : {}) },
time: { created: 1, updated: 1, ...(archived !== undefined ? { archived } : {}) },
} as Session)
describe("global session mutation reconciliation", () => {
beforeEach(() => {
activeRequest = deferred<Session[]>()
archivedRequest = deferred<Session[]>()
listRequest = deferred<Session[]>()
opencodeClient.getSdkClient = () => sdk
useGlobalSessionsStore.getState().resetForRuntimeSwitch()
})
@@ -57,8 +59,7 @@ describe("global session mutation reconciliation", () => {
const loading = useGlobalSessionsStore.getState().loadSessions()
useGlobalSessionsStore.getState().upsertSession(session("created"))
activeRequest.resolve([])
archivedRequest.resolve([])
listRequest.resolve([])
await loading
expect(useGlobalSessionsStore.getState().activeSessions.map((item) => item.id)).toEqual(["created"])
@@ -70,8 +71,7 @@ describe("global session mutation reconciliation", () => {
const loading = useGlobalSessionsStore.getState().loadSessions()
useGlobalSessionsStore.getState().removeSessions([stale.id])
activeRequest.resolve([stale])
archivedRequest.resolve([])
listRequest.resolve([stale])
await loading
expect(useGlobalSessionsStore.getState().activeSessions).toEqual([])
@@ -84,8 +84,7 @@ describe("global session mutation reconciliation", () => {
const loading = useGlobalSessionsStore.getState().loadSessions()
useGlobalSessionsStore.getState().archiveSessions([stale.id], 10)
activeRequest.resolve([stale])
archivedRequest.resolve([])
listRequest.resolve([stale])
await loading
expect(useGlobalSessionsStore.getState().activeSessions).toEqual([])
@@ -98,26 +97,35 @@ describe("global session mutation reconciliation", () => {
const loading = useGlobalSessionsStore.getState().loadSessions()
useGlobalSessionsStore.getState().upsertSession(session("updated", "New"))
activeRequest.resolve([stale])
archivedRequest.resolve([])
listRequest.resolve([stale])
await loading
expect(useGlobalSessionsStore.getState().activeSessions[0]?.title).toBe("New")
})
test("uses commit-time state when one side of the load fails", async () => {
test("uses commit-time state when the load fails", async () => {
const created = session("created")
const loading = useGlobalSessionsStore.getState().loadSessions()
useGlobalSessionsStore.getState().upsertSession(created)
activeRequest.reject(new Error("unavailable"))
archivedRequest.resolve([])
listRequest.reject(new Error("unavailable"))
await loading
expect(useGlobalSessionsStore.getState().activeSessions).toEqual([created])
expect(useGlobalSessionsStore.getState().status).toBe("error")
})
test("splits a restored session into the active list", async () => {
const loading = useGlobalSessionsStore.getState().loadSessions()
listRequest.resolve([session("active"), session("archived", "archived", 5), session("restored", "restored", 0)])
await loading
expect(useGlobalSessionsStore.getState().activeSessions.map((item) => item.id)).toEqual(["active", "restored"])
expect(useGlobalSessionsStore.getState().archivedSessions.map((item) => item.id)).toEqual(["archived"])
expect(useGlobalSessionsStore.getState().status).toBe("ready")
})
test("does not undo a move while refreshing the source directory", async () => {
const source = { ...session("moved"), directory: "/source" } as Session
const destination = { ...source, directory: "/destination" } as Session
@@ -125,11 +133,24 @@ describe("global session mutation reconciliation", () => {
const refreshing = useGlobalSessionsStore.getState().refreshSessionsForDirectories(["/source"])
useGlobalSessionsStore.getState().upsertSession(destination)
activeRequest.resolve([source])
archivedRequest.resolve([])
listRequest.resolve([source])
await refreshing
expect(useGlobalSessionsStore.getState().sessionsByDirectory.get("/source")).toBe(undefined)
expect(useGlobalSessionsStore.getState().sessionsByDirectory.get("/destination")?.[0]?.id).toBe("moved")
})
test("keeps a restore mutation newer than the directory refresh", async () => {
const archived = { ...session("restored", "restored", 5), directory: "/source" } as Session
useGlobalSessionsStore.getState().applySnapshot([], [archived])
const refreshing = useGlobalSessionsStore.getState().refreshSessionsForDirectories(["/source"])
useGlobalSessionsStore.getState().upsertSession({ ...archived, time: { ...archived.time, archived: 0 } })
// The server still reports the pre-restore row for this directory.
listRequest.resolve([archived])
await refreshing
expect(useGlobalSessionsStore.getState().activeSessions.map((item) => item.id)).toEqual(["restored"])
expect(useGlobalSessionsStore.getState().archivedSessions).toEqual([])
})
})
@@ -1,7 +1,7 @@
import { create } from 'zustand';
import type { OpencodeClient, Session } from '@opencode-ai/sdk/v2';
import { opencodeClient } from '@/lib/opencode/client';
import { listGlobalSessionPages } from '@/stores/globalSessions';
import { listGlobalSessionPages, splitGlobalSessionsByArchived } from '@/stores/globalSessions';
import { getReviewTransferDirection, type ReviewTransferDirection } from '@/lib/reviewFlow';
import { getOriginalSessionID, getReviewSessionID } from '@/lib/sessionReviewMetadata';
import { normalizePath } from '@/lib/pathNormalization';
@@ -253,7 +253,6 @@ type DirectoryPageResult = {
const fetchDirectoryPages = async (
sdk: OpencodeClient,
directories: Set<string>,
archived: boolean,
): Promise<DirectoryPageResult> => {
const currentDirectory = normalizePath(opencodeClient.getDirectory());
const orderedDirectories = [...directories].sort((left, right) => {
@@ -267,8 +266,11 @@ const fetchDirectoryPages = async (
status: 'fulfilled' as const,
value: {
directory,
// One inclusive request per directory: the server has no filter that
// returns only active sessions including restored (`time.archived`
// falsy-but-present) rows, so fetch everything and split client-side.
sessions: await withDirectorySessionRefreshSlot(() => (
listGlobalSessionPages(sdk, { directory, archived, pageSize: PAGE_SIZE })
listGlobalSessionPages(sdk, { directory, archived: true, narrowToArchived: false, pageSize: PAGE_SIZE })
)),
},
};
@@ -526,35 +528,25 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
const loadPromise = (async () => {
try {
const sdk = opencodeClient.getSdkClient();
const [activeResult, archivedResult] = await Promise.allSettled([
listGlobalSessionPages(sdk, { archived: false, pageSize: PAGE_SIZE }),
listGlobalSessionPages(sdk, { archived: true, pageSize: PAGE_SIZE }),
]);
if (activeResult.status === 'rejected') {
console.warn('[GlobalSessions] Failed to load active sessions, preserving existing snapshot with fallback merge:', activeResult.reason);
}
if (archivedResult.status === 'rejected') {
console.warn('[GlobalSessions] Failed to load archived sessions, preserving current snapshot:', archivedResult.reason);
}
// One inclusive fetch, split client-side. The server's
// `time_archived IS NULL` active filter would exclude restored
// sessions (`time.archived` falsy-but-present), so an
// `archived: false` request cannot produce a truthful active list.
const allSessions = await listGlobalSessionPages(sdk, {
archived: true,
narrowToArchived: false,
pageSize: PAGE_SIZE,
});
if (generation !== loadGeneration) {
// Runtime switched mid-load: this snapshot belongs to the previous
// instance — drop it.
return { activeSessions: [], archivedSessions: [] };
}
const status = activeResult.status === 'fulfilled' && archivedResult.status === 'fulfilled'
? 'ready'
: 'error';
const { active, archived } = splitGlobalSessionsByArchived(allSessions);
set((state) => {
const fetchedActive = activeResult.status === 'fulfilled'
? activeResult.value
: mergeSessionLists(state.activeSessions, fallbackActive);
const fetchedArchived = archivedResult.status === 'fulfilled'
? archivedResult.value
: state.archivedSessions;
const reconciled = overlayMutationsSince(state, fetchedActive, fetchedArchived, baselineRevision);
return applySnapshot(state, reconciled.activeSessions, reconciled.archivedSessions, status);
const reconciled = overlayMutationsSince(state, active, archived, baselineRevision);
return applySnapshot(state, reconciled.activeSessions, reconciled.archivedSessions, 'ready');
});
const committed = get();
return { activeSessions: committed.activeSessions, archivedSessions: committed.archivedSessions };
@@ -597,31 +589,27 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
const generation = loadGeneration;
const baselineRevision = get().mutationRevision;
const sdk = opencodeClient.getSdkClient();
const [active, archived] = await Promise.all([
fetchDirectoryPages(sdk, directorySet, false),
fetchDirectoryPages(sdk, directorySet, true),
]);
const fetched = await fetchDirectoryPages(sdk, directorySet);
if (generation !== loadGeneration) {
const state = get();
return { activeSessions: state.activeSessions, archivedSessions: state.archivedSessions };
}
if (active.errors.length > 0) {
console.warn('[GlobalSessions] Failed to refresh active sessions for some directories:', active.errors[0]);
}
if (archived.errors.length > 0) {
console.warn('[GlobalSessions] Failed to refresh archived sessions for some directories:', archived.errors[0]);
if (fetched.errors.length > 0) {
console.warn('[GlobalSessions] Failed to refresh sessions for some directories:', fetched.errors[0]);
}
const { active, archived } = splitGlobalSessionsByArchived(fetched.sessions);
set((state) => {
let nextActiveSessions = replaceSessionsForDirectories(state.activeSessions, active.sessions, active.directories);
let nextActiveSessions = replaceSessionsForDirectories(state.activeSessions, active, fetched.directories);
nextActiveSessions = mergeSessionLists(nextActiveSessions, fallbackActive);
if (sameSessionList(state.activeSessions, nextActiveSessions)) {
nextActiveSessions = state.activeSessions;
}
let nextArchivedSessions = replaceSessionsForDirectories(state.archivedSessions, archived.sessions, archived.directories);
let nextArchivedSessions = replaceSessionsForDirectories(state.archivedSessions, archived, fetched.directories);
if (sameSessionList(state.archivedSessions, nextArchivedSessions)) {
nextArchivedSessions = state.archivedSessions;
}