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:
@@ -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.
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user