mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
2069 lines
67 KiB
TypeScript
2069 lines
67 KiB
TypeScript
import {
|
|
getGatewaySessionMessageSubscriptionCoordinator,
|
|
releaseGatewaySessionMessageSubscription,
|
|
resetGatewaySessionMessageSubscriptionCoordinator,
|
|
type GatewaySessionMessageSubscription,
|
|
} from "@openclaw/gateway-client/browser";
|
|
import type { SessionCatalogPullRequestSummary } from "../../../../packages/gateway-protocol/src/schema/sessions-catalog.js";
|
|
import {
|
|
GatewayRequestError,
|
|
type GatewayBrowserClient,
|
|
type GatewayEventFrame,
|
|
type GatewayHelloOk,
|
|
} from "../../api/gateway.ts";
|
|
import type {
|
|
GatewaySessionRow,
|
|
SessionBranch,
|
|
SessionCompactionCheckpoint,
|
|
SessionRunStatus,
|
|
SessionsCompactionBranchResult,
|
|
SessionsCompactionListResult,
|
|
SessionsCompactionRestoreResult,
|
|
SessionsForkResult,
|
|
SessionsBranchesListResult,
|
|
SessionsBranchesSwitchResult,
|
|
SessionsListResult,
|
|
SessionsPatchResult,
|
|
SessionsRewindResult,
|
|
SessionWorkspaceGetResult,
|
|
SessionWorkspaceListResult,
|
|
SessionWorkspaceSetResult,
|
|
} from "../../api/types.ts";
|
|
import type { ApplicationGatewayPhase } from "../../app/gateway.ts";
|
|
import { getSafeLocalStorage } from "../../local-storage.ts";
|
|
import { isGatewayMethodAdvertised } from "../gateway-methods.ts";
|
|
import { isSessionRunActive } from "../session-run-state.ts";
|
|
import {
|
|
requestSessionCreate,
|
|
resolveSessionCreateParams,
|
|
type SessionCreateOutcome,
|
|
type SessionCreateParams,
|
|
} from "./create.ts";
|
|
import { readSessionCustomGroupNames, readSidebarSectionOrder } from "./custom-groups.ts";
|
|
import { scopedAgentListParamsForSession, type SessionArchivedFilter } from "./navigation.ts";
|
|
import type { SessionPatch, SessionPatchOptions, SessionPatchRoute } from "./patch.ts";
|
|
import {
|
|
readSessionChangedEvent,
|
|
reconcileSessionChanged,
|
|
reconcileSessionHistory,
|
|
type SessionChangedResult,
|
|
type SessionReconcileOptions,
|
|
} from "./reconcile.ts";
|
|
import {
|
|
areUiSessionKeysEquivalent,
|
|
isUiGlobalSessionKey,
|
|
normalizeAgentId,
|
|
parseAgentSessionKey,
|
|
resolveUiSelectedGlobalAgentId,
|
|
uiSessionEventMatches,
|
|
uiSessionRowMatchesSelectedChat,
|
|
} from "./session-key.ts";
|
|
import { SwarmActivityTracker } from "./swarm-activity.ts";
|
|
export {
|
|
buildSessionUsageDateParams,
|
|
requestSessionUsage,
|
|
requestSessionUsageLogs,
|
|
requestSessionUsageTimeSeries,
|
|
} from "./usage.ts";
|
|
|
|
type SessionState = {
|
|
result: SessionsListResult | null;
|
|
agentId: string | null;
|
|
modelOverrides: Readonly<Record<string, string | null>>;
|
|
loading: boolean;
|
|
error: string | null;
|
|
deletedSessions: readonly SessionDeleteTarget[];
|
|
/** Gateway-owned custom group catalog in display order. */
|
|
groups: readonly string[];
|
|
/** Gateway-owned sidebar section order; pinned is intentionally absent. */
|
|
sectionOrder: readonly string[];
|
|
};
|
|
|
|
type SessionGroupMutationResult = "completed" | "stale";
|
|
|
|
export type { SessionArchivedFilter } from "./navigation.ts";
|
|
|
|
export type SessionListOptions = {
|
|
agentId?: string;
|
|
spawnedBy?: string;
|
|
boardFace?: "chat" | "dashboard";
|
|
activeMinutes?: number;
|
|
search?: string;
|
|
creatorId?: string;
|
|
offset?: number;
|
|
limit?: number;
|
|
includeGlobal?: boolean;
|
|
includeUnknown?: boolean;
|
|
configuredAgentsOnly?: boolean;
|
|
includeDerivedTitles?: boolean;
|
|
archivedFilter?: SessionArchivedFilter;
|
|
append?: boolean;
|
|
};
|
|
|
|
/** Gateway rosters omit recency so Chat and Settings agree.
|
|
* The explicit cap keeps list work bounded. */
|
|
export const DEFAULT_SESSION_LIST_QUERY = {
|
|
limit: 50,
|
|
} as const satisfies SessionListOptions;
|
|
|
|
const SESSION_EVENT_REFRESH_DEBOUNCE_MS = 200;
|
|
const SESSION_EVENT_REFRESH_MAX_WAIT_MS = 1_000;
|
|
|
|
type SessionRefreshOptions = SessionListOptions & {
|
|
force?: boolean;
|
|
// Sidebar startup hydration must not block session creation or drop the open session.
|
|
backgroundHydrate?: boolean;
|
|
};
|
|
|
|
export type SessionRunTerminal = {
|
|
sessionKeys: readonly string[];
|
|
runId?: string | null;
|
|
/** Latest session status after this owned model run leaves the active registry. */
|
|
status: SessionRunStatus;
|
|
endedAt: number;
|
|
};
|
|
|
|
export type { SessionPatch } from "./patch.ts";
|
|
|
|
type SessionDeleteOptions = {
|
|
agentId?: string;
|
|
deleteTranscript?: boolean;
|
|
archivedOnly?: boolean;
|
|
};
|
|
|
|
type SessionDeleteTarget = {
|
|
key: string;
|
|
agentId?: string;
|
|
deleteTranscript?: boolean;
|
|
archivedOnly?: boolean;
|
|
};
|
|
|
|
/** Dirty/unpushed checkouts survive session deletion; callers surface them. */
|
|
type SessionDeleteOutcome = {
|
|
deleted: boolean;
|
|
worktreePreserved?: { id: string; branch: string; path: string };
|
|
};
|
|
|
|
type SessionDeleteBatchResult = {
|
|
deleted: string[];
|
|
errors: string[];
|
|
/** Dirty/unpushed checkouts kept by the gateway during this batch. */
|
|
preservedWorktrees: Array<{ id: string; branch: string; path: string }>;
|
|
};
|
|
|
|
type SessionCompactResult = {
|
|
ok?: boolean;
|
|
compacted?: boolean;
|
|
reason?: string;
|
|
result?: { tokensBefore?: number; tokensAfter?: number };
|
|
};
|
|
|
|
type SessionSteerResult = {
|
|
runId?: string;
|
|
status?: unknown;
|
|
};
|
|
|
|
type SessionResetOptions = {
|
|
agentId?: string | null;
|
|
};
|
|
|
|
type SessionResetResult = "completed" | "not-started" | "uncertain";
|
|
|
|
type SessionGateway = {
|
|
readonly snapshot: {
|
|
client: GatewayBrowserClient | null;
|
|
phase: ApplicationGatewayPhase;
|
|
hello: GatewayHelloOk | null;
|
|
assistantAgentId?: string | null;
|
|
sessionKey?: string;
|
|
};
|
|
subscribe: (listener: (snapshot: SessionGateway["snapshot"]) => void) => () => void;
|
|
subscribeEvents: (listener: (event: GatewayEventFrame) => void) => () => void;
|
|
};
|
|
|
|
type SessionRequestClient = Pick<GatewayBrowserClient, "request">;
|
|
|
|
type SessionDeleteResponse = {
|
|
deleted: boolean;
|
|
worktreePreserved?: SessionDeleteOutcome["worktreePreserved"];
|
|
};
|
|
|
|
type SessionConnectionScope = {
|
|
client: GatewayBrowserClient;
|
|
epoch: number;
|
|
};
|
|
|
|
type SessionCreateReconciliation = "blocking" | "background";
|
|
|
|
export type SessionMessageSubscription = GatewaySessionMessageSubscription;
|
|
|
|
export type SessionCapability = {
|
|
readonly state: SessionState;
|
|
/** Advances only when a canonical sessions.list result is published. */
|
|
readonly canonicalListRevision: number;
|
|
list: (options?: SessionListOptions) => Promise<SessionsListResult | null>;
|
|
setCreatorFilter: (creatorId: string | null) => Promise<void>;
|
|
reconcile: (
|
|
row: GatewaySessionRow | undefined,
|
|
defaults?: SessionsListResult["defaults"],
|
|
options?: SessionReconcileOptions,
|
|
) => boolean;
|
|
reconcileChanged: (payload: unknown, options?: SessionReconcileOptions) => SessionChangedResult;
|
|
reconcileRunTerminal: (terminal: SessionRunTerminal) => boolean;
|
|
refresh: (options?: SessionRefreshOptions) => Promise<void>;
|
|
refreshReplacement: (agentId?: string | null) => Promise<void>;
|
|
createResult: (
|
|
params?: SessionCreateParams,
|
|
options?: { reconciliation?: SessionCreateReconciliation },
|
|
) => Promise<SessionCreateOutcome | null>;
|
|
create: (params?: SessionCreateParams) => Promise<string | null>;
|
|
patch: SessionPatchRoute;
|
|
setModelOverride: (key: string, value: string | null | undefined) => void;
|
|
/** Applies optimistic row fields to the published snapshot so mid-flight
|
|
* publishes (e.g. a refresh's loading flip) cannot revert them before the
|
|
* post-mutation canonical list lands. */
|
|
patchRowLocal: (key: string, patch: Partial<GatewaySessionRow>) => void;
|
|
/** True while a just-created work session is waiting for its canonical placement row. */
|
|
isPreparedWorkSession: (key: string) => boolean;
|
|
pullRequestSummary: (key: string) => SessionCatalogPullRequestSummary | undefined;
|
|
capturePullRequestEpoch: (key: string) => symbol;
|
|
setPullRequestSummary: (
|
|
key: string,
|
|
summary: SessionCatalogPullRequestSummary | undefined,
|
|
epoch?: symbol,
|
|
) => void;
|
|
delete: (key: string, options?: SessionDeleteOptions) => Promise<SessionDeleteOutcome>;
|
|
deleteMany: (targets: readonly SessionDeleteTarget[]) => Promise<SessionDeleteBatchResult>;
|
|
reset: (key: string, options?: SessionResetOptions) => Promise<SessionResetResult>;
|
|
compact: (key: string, options?: { agentId?: string | null }) => Promise<SessionCompactResult>;
|
|
steer: (
|
|
key: string,
|
|
message: string,
|
|
options?: { agentId?: string | null },
|
|
) => Promise<SessionSteerResult>;
|
|
listFiles: (
|
|
key: string,
|
|
options?: { agentId?: string | null; path?: string; search?: string },
|
|
) => Promise<SessionWorkspaceListResult | null>;
|
|
getFile: (
|
|
key: string,
|
|
path: string,
|
|
options?: { agentId?: string | null },
|
|
) => Promise<SessionWorkspaceGetResult | null>;
|
|
setFile: (
|
|
key: string,
|
|
path: string,
|
|
content: string,
|
|
options: { agentId?: string | null; expectedHash: string },
|
|
) => Promise<SessionWorkspaceSetResult | null>;
|
|
subscribeMessages: (
|
|
key: string,
|
|
options?: { agentId?: string | null; includeApprovals?: boolean },
|
|
) => Promise<SessionMessageSubscription>;
|
|
unsubscribeMessages: (subscription: SessionMessageSubscription) => Promise<void>;
|
|
listCheckpoints: (
|
|
key: string,
|
|
options?: { agentId?: string | null },
|
|
) => Promise<SessionCompactionCheckpoint[]>;
|
|
branchCheckpoint: (
|
|
key: string,
|
|
checkpointId: string,
|
|
options?: { agentId?: string | null },
|
|
) => Promise<SessionsCompactionBranchResult>;
|
|
restoreCheckpoint: (
|
|
key: string,
|
|
checkpointId: string,
|
|
options?: { agentId?: string | null },
|
|
) => Promise<SessionsCompactionRestoreResult>;
|
|
rewind: (
|
|
key: string,
|
|
entryId: string,
|
|
options?: { agentId?: string | null },
|
|
) => Promise<SessionsRewindResult>;
|
|
forkAtMessage: (
|
|
key: string,
|
|
entryId: string,
|
|
options?: { agentId?: string | null },
|
|
) => Promise<SessionsForkResult>;
|
|
listBranches: (key: string, options?: { agentId?: string | null }) => Promise<SessionBranch[]>;
|
|
switchBranch: (
|
|
key: string,
|
|
leafEntryId: string,
|
|
options?: { agentId?: string | null },
|
|
) => Promise<SessionsBranchesSwitchResult>;
|
|
/** Loads the gateway-owned group catalog, coalescing successful connection attempts. */
|
|
groupsLoad: () => Promise<void>;
|
|
/** Replaces the group catalog; stale means the initiating connection retired. */
|
|
groupsPut: (
|
|
names: readonly string[],
|
|
sectionOrder?: readonly string[],
|
|
) => Promise<SessionGroupMutationResult>;
|
|
/** Renames a group; stale means the initiating connection retired before reconciliation. */
|
|
groupsRename: (from: string, to: string) => Promise<SessionGroupMutationResult>;
|
|
/** Deletes a group; stale means the initiating connection retired before reconciliation. */
|
|
groupsDelete: (name: string) => Promise<SessionGroupMutationResult>;
|
|
subscribeCreated: (listener: (key: string) => void) => () => void;
|
|
subscribe: (listener: (state: SessionState) => void) => () => void;
|
|
dispose: () => void;
|
|
};
|
|
|
|
export { requestSessionCreate } from "./create.ts";
|
|
export { resolveSessionKey } from "./navigation.ts";
|
|
export {
|
|
compareSessionRowsByUpdatedAt,
|
|
filterSessionRows,
|
|
filterVisibleSessionRows,
|
|
getVisibleSessionRows,
|
|
resolveSessionNavigation,
|
|
sessionMatchesArchivedFilter,
|
|
scopedAgentIdForSession,
|
|
scopedAgentListParamsForRefreshTarget,
|
|
scopedAgentListParamsForSession,
|
|
scopedAgentParamsForSession,
|
|
visibleSessionMatches,
|
|
} from "./navigation.ts";
|
|
export type {
|
|
SessionRefreshTarget,
|
|
SessionScopeHost,
|
|
SessionScopeHostWithKey,
|
|
} from "./navigation.ts";
|
|
|
|
const SESSION_LIST_PARAMS = {
|
|
includeGlobal: true,
|
|
includeUnknown: true,
|
|
configuredAgentsOnly: true,
|
|
} as const;
|
|
|
|
function buildSessionRequestParams(
|
|
key: string,
|
|
agentId?: string | null,
|
|
): { key: string; agentId?: string } {
|
|
const normalizedKey = key.trim();
|
|
const normalizedAgentId = agentId?.trim();
|
|
return {
|
|
key: normalizedKey,
|
|
...(normalizedAgentId ? { agentId: normalizedAgentId } : {}),
|
|
};
|
|
}
|
|
|
|
function buildTranscriptMutationParams(
|
|
sessionKey: string,
|
|
agentId?: string | null,
|
|
): { sessionKey: string; agentId?: string } {
|
|
const normalizedSessionKey = sessionKey.trim();
|
|
const normalizedAgentId = agentId?.trim();
|
|
return {
|
|
sessionKey: normalizedSessionKey,
|
|
...(normalizedAgentId ? { agentId: normalizedAgentId } : {}),
|
|
};
|
|
}
|
|
|
|
function buildSessionListParams(options: SessionListOptions = {}): Record<string, unknown> {
|
|
const params: Record<string, unknown> = {
|
|
...SESSION_LIST_PARAMS,
|
|
};
|
|
if (options.limit === undefined) {
|
|
params.limit = DEFAULT_SESSION_LIST_QUERY.limit;
|
|
} else if (options.limit > 0) {
|
|
params.limit = Math.floor(options.limit);
|
|
}
|
|
if (options.includeGlobal !== undefined) {
|
|
params.includeGlobal = options.includeGlobal;
|
|
}
|
|
if (options.includeUnknown !== undefined) {
|
|
params.includeUnknown = options.includeUnknown;
|
|
}
|
|
if (options.configuredAgentsOnly !== undefined) {
|
|
params.configuredAgentsOnly = options.configuredAgentsOnly;
|
|
}
|
|
if (options.includeDerivedTitles === true) {
|
|
params.includeDerivedTitles = true;
|
|
}
|
|
if (options.archivedFilter === "archived") {
|
|
params.archived = true;
|
|
} else if (options.archivedFilter === "all") {
|
|
params.archived = "all";
|
|
}
|
|
const activeMinutes =
|
|
options.archivedFilter === "archived" || options.archivedFilter === "all"
|
|
? 0
|
|
: typeof options.activeMinutes === "number" && options.activeMinutes > 0
|
|
? Math.floor(options.activeMinutes)
|
|
: 0;
|
|
if (activeMinutes > 0) {
|
|
params.activeMinutes = activeMinutes;
|
|
}
|
|
const agentId = options.agentId?.trim();
|
|
const spawnedBy = options.spawnedBy?.trim();
|
|
const search = options.search?.trim();
|
|
const creatorId = options.creatorId?.trim();
|
|
if (options.boardFace) {
|
|
params.boardFace = options.boardFace;
|
|
}
|
|
if (agentId) {
|
|
params.agentId = agentId;
|
|
}
|
|
if (spawnedBy) {
|
|
params.spawnedBy = spawnedBy;
|
|
}
|
|
if (search) {
|
|
params.search = search;
|
|
}
|
|
if (creatorId) {
|
|
params.creatorId = creatorId;
|
|
}
|
|
if (typeof options.offset === "number" && options.offset > 0) {
|
|
params.offset = Math.floor(options.offset);
|
|
}
|
|
return params;
|
|
}
|
|
|
|
async function requestSessionList(
|
|
client: SessionRequestClient,
|
|
options: SessionListOptions = {},
|
|
): Promise<SessionsListResult | null> {
|
|
const result = await client.request<SessionsListResult | undefined>(
|
|
"sessions.list",
|
|
buildSessionListParams(options),
|
|
);
|
|
return result ?? null;
|
|
}
|
|
|
|
function requestSessionPatch(
|
|
client: SessionRequestClient,
|
|
key: string,
|
|
patch: SessionPatch,
|
|
options: { agentId?: string | null } = {},
|
|
): Promise<SessionsPatchResult> {
|
|
return client.request<SessionsPatchResult>("sessions.patch", {
|
|
...buildSessionRequestParams(key, options.agentId),
|
|
...patch,
|
|
});
|
|
}
|
|
|
|
function requestSessionDelete(
|
|
client: SessionRequestClient,
|
|
key: string,
|
|
options: SessionDeleteOptions = {},
|
|
): Promise<SessionDeleteResponse> {
|
|
return client.request<SessionDeleteResponse>("sessions.delete", {
|
|
...buildSessionRequestParams(key, options.agentId),
|
|
deleteTranscript: options.deleteTranscript ?? true,
|
|
...(options.archivedOnly === true ? { archivedOnly: true } : {}),
|
|
});
|
|
}
|
|
|
|
function confirmsSessionDeletion(response: SessionDeleteResponse): boolean {
|
|
// A successful RPC can still be a lifecycle no-op. Only the canonical result
|
|
// may drive optimistic removal, navigation, and model-override cleanup.
|
|
return response.deleted;
|
|
}
|
|
|
|
function requestSessionReset(
|
|
client: SessionRequestClient,
|
|
key: string,
|
|
options: SessionResetOptions = {},
|
|
): Promise<void> {
|
|
return client
|
|
.request("sessions.reset", {
|
|
...buildSessionRequestParams(key, options.agentId),
|
|
})
|
|
.then(() => undefined);
|
|
}
|
|
|
|
function requestSessionCompact(
|
|
client: SessionRequestClient,
|
|
key: string,
|
|
options: { agentId?: string | null } = {},
|
|
): Promise<SessionCompactResult> {
|
|
return client.request<SessionCompactResult>("sessions.compact", {
|
|
...buildSessionRequestParams(key, options.agentId),
|
|
});
|
|
}
|
|
|
|
function requestSessionSteer(
|
|
client: SessionRequestClient,
|
|
key: string,
|
|
message: string,
|
|
options: { agentId?: string | null } = {},
|
|
): Promise<SessionSteerResult> {
|
|
return client.request<SessionSteerResult>("sessions.steer", {
|
|
...buildSessionRequestParams(key, options.agentId),
|
|
message,
|
|
});
|
|
}
|
|
|
|
function requestSessionFilesList(
|
|
client: SessionRequestClient,
|
|
key: string,
|
|
options: { agentId?: string | null; path?: string; search?: string } = {},
|
|
): Promise<SessionWorkspaceListResult | null> {
|
|
return client.request<SessionWorkspaceListResult | null>("sessions.files.list", {
|
|
sessionKey: key,
|
|
path: options.path ?? "",
|
|
search: options.search ?? "",
|
|
...(options.agentId?.trim() ? { agentId: options.agentId.trim() } : {}),
|
|
});
|
|
}
|
|
|
|
function requestSessionFile(
|
|
client: SessionRequestClient,
|
|
key: string,
|
|
path: string,
|
|
options: { agentId?: string | null } = {},
|
|
): Promise<SessionWorkspaceGetResult | null> {
|
|
return client.request<SessionWorkspaceGetResult | null>("sessions.files.get", {
|
|
sessionKey: key,
|
|
path,
|
|
...(options.agentId?.trim() ? { agentId: options.agentId.trim() } : {}),
|
|
});
|
|
}
|
|
|
|
function requestSessionFileSet(
|
|
client: SessionRequestClient,
|
|
key: string,
|
|
path: string,
|
|
content: string,
|
|
options: { agentId?: string | null; expectedHash: string },
|
|
): Promise<SessionWorkspaceSetResult | null> {
|
|
return client.request<SessionWorkspaceSetResult | null>("sessions.files.set", {
|
|
sessionKey: key,
|
|
path,
|
|
content,
|
|
expectedHash: options.expectedHash,
|
|
...(options.agentId?.trim() ? { agentId: options.agentId.trim() } : {}),
|
|
});
|
|
}
|
|
|
|
async function listSessionCheckpoints(
|
|
client: SessionRequestClient,
|
|
key: string,
|
|
options: { agentId?: string | null } = {},
|
|
): Promise<SessionsCompactionListResult> {
|
|
return client.request<SessionsCompactionListResult>(
|
|
"sessions.compaction.list",
|
|
buildSessionRequestParams(key, options.agentId),
|
|
);
|
|
}
|
|
|
|
function branchSessionCheckpoint(
|
|
client: SessionRequestClient,
|
|
key: string,
|
|
checkpointId: string,
|
|
options: { agentId?: string | null } = {},
|
|
): Promise<SessionsCompactionBranchResult> {
|
|
return client.request<SessionsCompactionBranchResult>("sessions.compaction.branch", {
|
|
...buildSessionRequestParams(key, options.agentId),
|
|
checkpointId,
|
|
});
|
|
}
|
|
|
|
function restoreSessionCheckpoint(
|
|
client: SessionRequestClient,
|
|
key: string,
|
|
checkpointId: string,
|
|
options: { agentId?: string | null } = {},
|
|
): Promise<SessionsCompactionRestoreResult> {
|
|
return client.request<SessionsCompactionRestoreResult>("sessions.compaction.restore", {
|
|
...buildSessionRequestParams(key, options.agentId),
|
|
checkpointId,
|
|
});
|
|
}
|
|
|
|
function rewindSessionAtMessage(
|
|
client: SessionRequestClient,
|
|
key: string,
|
|
entryId: string,
|
|
options: { agentId?: string | null } = {},
|
|
): Promise<SessionsRewindResult> {
|
|
return client.request<SessionsRewindResult>("sessions.rewind", {
|
|
...buildTranscriptMutationParams(key, options.agentId),
|
|
entryId,
|
|
});
|
|
}
|
|
|
|
function forkSessionAtMessage(
|
|
client: SessionRequestClient,
|
|
key: string,
|
|
entryId: string,
|
|
options: { agentId?: string | null } = {},
|
|
): Promise<SessionsForkResult> {
|
|
return client.request<SessionsForkResult>("sessions.fork", {
|
|
...buildTranscriptMutationParams(key, options.agentId),
|
|
entryId,
|
|
});
|
|
}
|
|
|
|
function listSessionBranches(
|
|
client: SessionRequestClient,
|
|
key: string,
|
|
options: { agentId?: string | null } = {},
|
|
): Promise<SessionsBranchesListResult> {
|
|
return client.request<SessionsBranchesListResult>(
|
|
"sessions.branches.list",
|
|
buildTranscriptMutationParams(key, options.agentId),
|
|
);
|
|
}
|
|
|
|
function switchSessionBranch(
|
|
client: SessionRequestClient,
|
|
key: string,
|
|
leafEntryId: string,
|
|
options: { agentId?: string | null } = {},
|
|
): Promise<SessionsBranchesSwitchResult> {
|
|
return client.request<SessionsBranchesSwitchResult>("sessions.branches.switch", {
|
|
...buildTranscriptMutationParams(key, options.agentId),
|
|
leafEntryId,
|
|
});
|
|
}
|
|
|
|
function appendSessionResults(
|
|
previous: SessionsListResult,
|
|
page: SessionsListResult,
|
|
): SessionsListResult {
|
|
const seen = new Set<string>();
|
|
const sessions = [...previous.sessions, ...page.sessions].filter((row) => {
|
|
if (!row.key || seen.has(row.key)) {
|
|
return false;
|
|
}
|
|
seen.add(row.key);
|
|
return true;
|
|
});
|
|
const totalCount = page.totalCount ?? previous.totalCount;
|
|
const hasMore =
|
|
page.hasMore ??
|
|
(typeof totalCount === "number" && Number.isFinite(totalCount)
|
|
? sessions.length < totalCount
|
|
: false);
|
|
return {
|
|
...page,
|
|
count: sessions.length,
|
|
totalCount,
|
|
hasMore,
|
|
nextOffset: page.nextOffset ?? (hasMore ? sessions.length : null),
|
|
sessions,
|
|
};
|
|
}
|
|
|
|
function isSessionStateEvent(event: GatewayEventFrame): boolean {
|
|
return event.event === "sessions.changed" || event.event === "session.message";
|
|
}
|
|
|
|
export function reconcileSessionRunTerminal(
|
|
result: SessionsListResult | null,
|
|
terminal: SessionRunTerminal,
|
|
): SessionsListResult | null {
|
|
const keys = terminal.sessionKeys.map((key) => key.trim()).filter(Boolean);
|
|
if (!result || keys.length === 0) {
|
|
return result;
|
|
}
|
|
const runId = terminal.runId?.trim() || null;
|
|
let changed = false;
|
|
const sessions = result.sessions.map((row): GatewaySessionRow => {
|
|
if (!keys.some((key) => areUiSessionKeysEquivalent(row.key, key))) {
|
|
return row;
|
|
}
|
|
if (row.hasActiveRun === true || isSessionRunActive(row)) {
|
|
// Active rows without matching identity may describe a newer or embedded
|
|
// run. Only terminalize an active row when this event owns its run ID.
|
|
if (!runId || !row.activeRunIds?.includes(runId)) {
|
|
return row;
|
|
}
|
|
}
|
|
const remainingRunIds = runId ? row.activeRunIds?.filter((id) => id !== runId) : [];
|
|
if (remainingRunIds?.length) {
|
|
// Settling one owned model turn must not retire overlapping active runs.
|
|
changed = true;
|
|
return {
|
|
...row,
|
|
activeRunIds: remainingRunIds,
|
|
hasActiveRun: true,
|
|
status: "running" as const,
|
|
};
|
|
}
|
|
const endedAt = row.endedAt ?? terminal.endedAt;
|
|
const runtimeMs =
|
|
typeof row.startedAt === "number" ? Math.max(0, endedAt - row.startedAt) : row.runtimeMs;
|
|
const activeRunIds = row.activeRunIds?.length ? [] : row.activeRunIds;
|
|
const abortedLastRun =
|
|
terminal.status === "killed"
|
|
? true
|
|
: terminal.status === "running"
|
|
? false
|
|
: row.abortedLastRun;
|
|
if (
|
|
row.hasActiveRun === false &&
|
|
row.status === terminal.status &&
|
|
row.endedAt === endedAt &&
|
|
row.runtimeMs === runtimeMs &&
|
|
row.activeRunIds === activeRunIds &&
|
|
row.abortedLastRun === abortedLastRun
|
|
) {
|
|
return row;
|
|
}
|
|
changed = true;
|
|
return {
|
|
...row,
|
|
activeRunIds,
|
|
hasActiveRun: false,
|
|
status: terminal.status,
|
|
endedAt,
|
|
runtimeMs,
|
|
abortedLastRun,
|
|
};
|
|
});
|
|
return changed ? { ...result, sessions } : result;
|
|
}
|
|
|
|
export function createSessionCapability(gateway: SessionGateway): SessionCapability {
|
|
let state: SessionState = {
|
|
result: null,
|
|
agentId: null,
|
|
modelOverrides: {},
|
|
loading: false,
|
|
error: null,
|
|
deletedSessions: [],
|
|
groups: [],
|
|
sectionOrder: [],
|
|
};
|
|
let inFlight: Promise<void> | null = null;
|
|
let queuedExplicitRefresh: SessionRefreshOptions | null = null;
|
|
let eventRefreshQueued = false;
|
|
let eventRefreshTimer: ReturnType<typeof globalThis.setTimeout> | null = null;
|
|
let eventRefreshDeadline: number | null = null;
|
|
let canonicalListRevision = 0;
|
|
let disposed = false;
|
|
let connectionEpoch = 0;
|
|
let connectionClient = gateway.snapshot.client;
|
|
let connectionConnected = gateway.snapshot.phase === "connected";
|
|
const pendingModelPatches = new Map<
|
|
string,
|
|
{ token: symbol; previous: string | null | undefined }
|
|
>();
|
|
const preparedWorkSessionKeys = new Set<string>();
|
|
const swarmActivity = new SwarmActivityTracker();
|
|
const pullRequestSummaries = new Map<string, SessionCatalogPullRequestSummary>();
|
|
const pullRequestEpochs = new Map<string, symbol>();
|
|
let subscribedClient: GatewayBrowserClient | null = null;
|
|
let lastListOptions: SessionListOptions = {};
|
|
let hasForegroundListOptions = false;
|
|
let hasSeededListOptions = false;
|
|
const listeners = new Set<(next: SessionState) => void>();
|
|
const createdListeners = new Set<(key: string) => void>();
|
|
const ownedMessageSubscriptions = new Set<SessionMessageSubscription>();
|
|
|
|
const captureConnection = (): SessionConnectionScope | null => {
|
|
const snapshot = gateway.snapshot;
|
|
return !disposed && snapshot.phase === "connected" && snapshot.client
|
|
? { client: snapshot.client, epoch: connectionEpoch }
|
|
: null;
|
|
};
|
|
|
|
const isCurrentConnection = (scope: SessionConnectionScope): boolean => {
|
|
const snapshot = gateway.snapshot;
|
|
return (
|
|
!disposed &&
|
|
connectionEpoch === scope.epoch &&
|
|
snapshot.phase === "connected" &&
|
|
snapshot.client === scope.client
|
|
);
|
|
};
|
|
|
|
const requestList = async (
|
|
options: SessionListOptions = {},
|
|
): Promise<SessionsListResult | null> => {
|
|
const scope = captureConnection();
|
|
if (!scope) {
|
|
return null;
|
|
}
|
|
const result = await requestSessionList(scope.client, options);
|
|
return isCurrentConnection(scope) ? swarmActivity.decorate(result ?? null) : null;
|
|
};
|
|
|
|
const publish = (next: SessionState) => {
|
|
state = next;
|
|
for (const listener of listeners) {
|
|
listener(state);
|
|
}
|
|
};
|
|
|
|
const pullRequestSummary = (key: string): SessionCatalogPullRequestSummary | undefined =>
|
|
pullRequestSummaries.get(key.trim());
|
|
|
|
const capturePullRequestEpoch = (key: string): symbol => {
|
|
const normalizedKey = key.trim();
|
|
const epoch = Symbol(normalizedKey);
|
|
pullRequestEpochs.set(normalizedKey, epoch);
|
|
return epoch;
|
|
};
|
|
|
|
const retirePullRequestSummary = (key: string) => {
|
|
const normalizedKey = key.trim();
|
|
pullRequestEpochs.delete(normalizedKey);
|
|
pullRequestSummaries.delete(normalizedKey);
|
|
};
|
|
|
|
// A deleted key stays reusable for a later ordinary thread, so a leftover
|
|
// prepared entry would keep classifying that new session as Coding forever.
|
|
const retirePreparedWorkSession = (key: string) => {
|
|
preparedWorkSessionKeys.delete(key.trim());
|
|
};
|
|
|
|
const setPullRequestSummary = (
|
|
key: string,
|
|
summary: SessionCatalogPullRequestSummary | undefined,
|
|
epoch?: symbol,
|
|
) => {
|
|
const normalizedKey = key.trim();
|
|
if (!normalizedKey || (epoch !== undefined && pullRequestEpochs.get(normalizedKey) !== epoch)) {
|
|
return;
|
|
}
|
|
const previous = pullRequestSummaries.get(normalizedKey);
|
|
const unchanged =
|
|
previous?.state === summary?.state &&
|
|
previous?.numbers.length === summary?.numbers.length &&
|
|
previous?.numbers.every((number, index) => number === summary?.numbers[index]);
|
|
if (unchanged || (!previous && !summary)) {
|
|
return;
|
|
}
|
|
if (summary) {
|
|
pullRequestSummaries.set(normalizedKey, summary);
|
|
} else {
|
|
pullRequestSummaries.delete(normalizedKey);
|
|
}
|
|
publish({ ...state });
|
|
};
|
|
|
|
const setModelOverride = (key: string, value: string | null | undefined) => {
|
|
const normalizedKey = key.trim();
|
|
if (!normalizedKey) {
|
|
return;
|
|
}
|
|
const modelOverrides = { ...state.modelOverrides };
|
|
if (value === undefined) {
|
|
if (!Object.hasOwn(state.modelOverrides, normalizedKey)) {
|
|
return;
|
|
}
|
|
delete modelOverrides[normalizedKey];
|
|
} else {
|
|
const normalizedValue = value === null ? null : value.trim();
|
|
if (
|
|
modelOverrides[normalizedKey] === normalizedValue &&
|
|
Object.hasOwn(modelOverrides, normalizedKey)
|
|
) {
|
|
return;
|
|
}
|
|
modelOverrides[normalizedKey] = normalizedValue;
|
|
}
|
|
publish({ ...state, modelOverrides });
|
|
};
|
|
|
|
// Optimistic session-settings patches (thinking level, speed) must live in
|
|
// the published capability snapshot, not only in consumer copies: every
|
|
// publish replaces consumer state wholesale, so a copy-only patch reverts on
|
|
// the next loading flip until the post-patch canonical list arrives. The
|
|
// revert window loses keyboard commits on the reasoning slider (flaky UI).
|
|
const patchRowLocal = (key: string, patch: Partial<GatewaySessionRow>) => {
|
|
const result = state.result;
|
|
const normalizedKey = key.trim();
|
|
if (!result || !normalizedKey) {
|
|
return;
|
|
}
|
|
let changed = false;
|
|
const sessions = result.sessions.map((row) => {
|
|
if (row.key !== normalizedKey) {
|
|
return row;
|
|
}
|
|
changed = true;
|
|
return { ...row, ...patch };
|
|
});
|
|
if (!changed) {
|
|
return;
|
|
}
|
|
publish({ ...state, result: { ...result, sessions } });
|
|
};
|
|
|
|
const rollbackPendingModelPatches = () => {
|
|
const pending = [...pendingModelPatches];
|
|
pendingModelPatches.clear();
|
|
for (const [key, operation] of pending) {
|
|
setModelOverride(key, operation.previous);
|
|
}
|
|
};
|
|
|
|
const load = async (options: SessionRefreshOptions) => {
|
|
const scope = captureConnection();
|
|
if (!scope) {
|
|
return;
|
|
}
|
|
const { append = false, force: _force, backgroundHydrate = false, ...requestOptions } = options;
|
|
const durableListOptions: SessionListOptions = { ...requestOptions };
|
|
// Pagination is request-local. Replacement refreshes restart at page one
|
|
// while retaining the filters and response enrichments that shape the list.
|
|
delete durableListOptions.offset;
|
|
// Foreground options become authoritative before I/O so a concurrent
|
|
// mutation cannot queue a replacement refresh with stale filters.
|
|
if (!backgroundHydrate) {
|
|
lastListOptions = durableListOptions;
|
|
hasForegroundListOptions = true;
|
|
} else if (!hasForegroundListOptions && !hasSeededListOptions) {
|
|
lastListOptions = durableListOptions;
|
|
hasSeededListOptions = true;
|
|
}
|
|
if (!backgroundHydrate) {
|
|
publish({ ...state, loading: true, error: null, deletedSessions: [] });
|
|
}
|
|
try {
|
|
const result = await requestSessionList(scope.client, requestOptions);
|
|
if (!isCurrentConnection(scope)) {
|
|
return;
|
|
}
|
|
let nextResult =
|
|
result && append && requestOptions.offset && state.result
|
|
? appendSessionResults(state.result, result)
|
|
: result;
|
|
if (backgroundHydrate && nextResult) {
|
|
const currentKey = gateway.snapshot.sessionKey?.trim();
|
|
if (currentKey) {
|
|
const currentAgentId = normalizeAgentId(
|
|
parseAgentSessionKey(currentKey)?.agentId ??
|
|
resolveUiSelectedGlobalAgentId(gateway.snapshot),
|
|
);
|
|
const previousCurrentRow =
|
|
state.result?.sessions.find((row) => areUiSessionKeysEquivalent(row.key, currentKey)) ??
|
|
(state.agentId === currentAgentId
|
|
? state.result?.sessions.find((row) =>
|
|
uiSessionRowMatchesSelectedChat(gateway.snapshot, row.key, currentKey),
|
|
)
|
|
: undefined);
|
|
if (
|
|
previousCurrentRow &&
|
|
!nextResult.sessions.some((row) =>
|
|
uiSessionRowMatchesSelectedChat(gateway.snapshot, row.key, currentKey),
|
|
)
|
|
) {
|
|
const sessions = [...nextResult.sessions, previousCurrentRow];
|
|
nextResult = { ...nextResult, count: sessions.length, sessions };
|
|
}
|
|
}
|
|
}
|
|
nextResult = swarmActivity.decorate(nextResult);
|
|
for (const row of nextResult?.sessions ?? []) {
|
|
if (row.worktree || row.execNode) {
|
|
preparedWorkSessionKeys.delete(row.key);
|
|
}
|
|
}
|
|
canonicalListRevision += 1;
|
|
publish({
|
|
result: nextResult,
|
|
agentId: requestOptions.agentId?.trim() ? normalizeAgentId(requestOptions.agentId) : null,
|
|
modelOverrides: state.modelOverrides,
|
|
loading: backgroundHydrate ? state.loading : false,
|
|
error: null,
|
|
deletedSessions: [],
|
|
groups: state.groups,
|
|
sectionOrder: state.sectionOrder,
|
|
});
|
|
} catch (error) {
|
|
if (isCurrentConnection(scope)) {
|
|
publish({
|
|
...state,
|
|
loading: backgroundHydrate ? state.loading : false,
|
|
error: String(error),
|
|
deletedSessions: [],
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
const clearEventRefreshTimer = () => {
|
|
if (eventRefreshTimer !== null) {
|
|
globalThis.clearTimeout(eventRefreshTimer);
|
|
eventRefreshTimer = null;
|
|
}
|
|
eventRefreshDeadline = null;
|
|
};
|
|
|
|
const absorbPendingEventRefresh = () => {
|
|
clearEventRefreshTimer();
|
|
eventRefreshQueued = false;
|
|
};
|
|
|
|
const takeNextQueuedRefresh = (): SessionRefreshOptions | null => {
|
|
const explicitRefresh = queuedExplicitRefresh;
|
|
queuedExplicitRefresh = null;
|
|
if (explicitRefresh) {
|
|
// A replacement that has not started yet observes every earlier event.
|
|
// Appends still need a canonical replacement after their requested page.
|
|
if (explicitRefresh.append !== true) {
|
|
absorbPendingEventRefresh();
|
|
}
|
|
return explicitRefresh;
|
|
}
|
|
if (!eventRefreshQueued) {
|
|
return null;
|
|
}
|
|
eventRefreshQueued = false;
|
|
return { ...lastListOptions, force: true };
|
|
};
|
|
|
|
const drainRefreshQueue = async (options: SessionRefreshOptions) => {
|
|
const epoch = connectionEpoch;
|
|
let next: SessionRefreshOptions | null = options;
|
|
while (next) {
|
|
await load(next);
|
|
if (disposed || connectionEpoch !== epoch) {
|
|
return;
|
|
}
|
|
next = takeNextQueuedRefresh();
|
|
}
|
|
};
|
|
|
|
const startRefresh = (options: SessionRefreshOptions) => {
|
|
const request = drainRefreshQueue(options).finally(() => {
|
|
if (inFlight === request) {
|
|
inFlight = null;
|
|
}
|
|
});
|
|
inFlight = request;
|
|
return request;
|
|
};
|
|
|
|
const refresh = (options: SessionRefreshOptions = {}) => {
|
|
if (gateway.snapshot.phase !== "connected" || !gateway.snapshot.client || disposed) {
|
|
return Promise.resolve();
|
|
}
|
|
if (inFlight) {
|
|
// Keep event invalidation pending until the queued request actually
|
|
// starts: a later explicit call can still replace this request.
|
|
queuedExplicitRefresh = options;
|
|
return inFlight;
|
|
}
|
|
const hasListOverrides = Object.entries(options).some(
|
|
([key, value]) => key !== "force" && key !== "backgroundHydrate" && value !== undefined,
|
|
);
|
|
if (state.result && !options.force && !hasListOverrides) {
|
|
return Promise.resolve();
|
|
}
|
|
if (options.append !== true) {
|
|
absorbPendingEventRefresh();
|
|
}
|
|
return startRefresh(options);
|
|
};
|
|
|
|
const refreshFromEvent = () => {
|
|
if (gateway.snapshot.phase !== "connected" || !gateway.snapshot.client || disposed) {
|
|
return Promise.resolve();
|
|
}
|
|
if (inFlight) {
|
|
eventRefreshQueued = true;
|
|
return inFlight;
|
|
}
|
|
return startRefresh({ ...lastListOptions, force: true });
|
|
};
|
|
|
|
const flushEventRefresh = () => {
|
|
if (eventRefreshTimer === null) {
|
|
return;
|
|
}
|
|
clearEventRefreshTimer();
|
|
void refreshFromEvent();
|
|
};
|
|
|
|
const scheduleEventRefresh = () => {
|
|
const now = Date.now();
|
|
eventRefreshDeadline ??= now + SESSION_EVENT_REFRESH_MAX_WAIT_MS;
|
|
if (eventRefreshTimer !== null) {
|
|
globalThis.clearTimeout(eventRefreshTimer);
|
|
}
|
|
const delay = Math.min(
|
|
SESSION_EVENT_REFRESH_DEBOUNCE_MS,
|
|
Math.max(0, eventRefreshDeadline - now),
|
|
);
|
|
eventRefreshTimer = globalThis.setTimeout(() => {
|
|
eventRefreshTimer = null;
|
|
eventRefreshDeadline = null;
|
|
void refreshFromEvent();
|
|
}, delay);
|
|
};
|
|
|
|
const handleVisibilityChange = () => {
|
|
if (document.visibilityState === "hidden") {
|
|
flushEventRefresh();
|
|
}
|
|
};
|
|
const observesPageLifecycle =
|
|
typeof document !== "undefined" && typeof globalThis.addEventListener === "function";
|
|
if (observesPageLifecycle) {
|
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
globalThis.addEventListener("pagehide", flushEventRefresh);
|
|
}
|
|
|
|
const refreshReplacement = (agentId?: string | null) => {
|
|
// Mutation refreshes replace the visible sidebar rows. A foreground probe
|
|
// that omitted derived titles must not turn readable names back into ids.
|
|
const options = {
|
|
...lastListOptions,
|
|
includeDerivedTitles: lastListOptions.includeDerivedTitles ?? true,
|
|
};
|
|
const normalizedAgentId = agentId?.trim();
|
|
if (normalizedAgentId) {
|
|
options.agentId = normalizedAgentId;
|
|
}
|
|
return refresh({ ...options, force: true });
|
|
};
|
|
|
|
const setCreatorFilter = (creatorId: string | null) => {
|
|
const options = { ...lastListOptions, creatorId: creatorId?.trim() || undefined };
|
|
delete options.offset;
|
|
return refresh({ ...options, force: true });
|
|
};
|
|
|
|
const createResult = async (
|
|
params: SessionCreateParams = {},
|
|
options: { reconciliation?: SessionCreateReconciliation } = {},
|
|
) => {
|
|
const scope = captureConnection();
|
|
if (!scope) {
|
|
return null;
|
|
}
|
|
try {
|
|
const { currentSessionKey, ...requestParams } = params;
|
|
const result = await requestSessionCreate(scope.client, {
|
|
...requestParams,
|
|
...resolveSessionCreateParams(currentSessionKey, params.agentId),
|
|
});
|
|
if (!isCurrentConnection(scope)) {
|
|
return null;
|
|
}
|
|
// The create response precedes list reconciliation. Carry submitted facts
|
|
// across that gap so a partial history row cannot briefly lose its zone/model.
|
|
if (requestParams.worktree === true || Boolean(requestParams.execNode?.trim())) {
|
|
preparedWorkSessionKeys.add(result.key.trim());
|
|
}
|
|
if (requestParams.model?.trim()) {
|
|
setModelOverride(result.key, requestParams.model);
|
|
} else if (preparedWorkSessionKeys.has(result.key)) {
|
|
publish({ ...state });
|
|
}
|
|
const reconcileCreatedSession = async () => {
|
|
await refreshReplacement(params.agentId);
|
|
if (!isCurrentConnection(scope)) {
|
|
return;
|
|
}
|
|
// Creation may overlap read-only list loading. Notify presentation owners
|
|
// after its queued refresh so they never guess from stale list churn.
|
|
for (const listener of createdListeners) {
|
|
listener(result.key);
|
|
}
|
|
};
|
|
if (options.reconciliation === "background") {
|
|
void reconcileCreatedSession().catch((error: unknown) => {
|
|
if (isCurrentConnection(scope)) {
|
|
publish({ ...state, error: String(error) });
|
|
}
|
|
});
|
|
} else {
|
|
await reconcileCreatedSession();
|
|
if (!isCurrentConnection(scope)) {
|
|
return null;
|
|
}
|
|
}
|
|
return result;
|
|
} catch (error) {
|
|
if (isCurrentConnection(scope)) {
|
|
publish({ ...state, error: String(error) });
|
|
}
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const create = async (params: SessionCreateParams = {}) =>
|
|
(await createResult(params))?.key ?? null;
|
|
|
|
const LEGACY_GROUPS_STORAGE_KEY = "openclaw:sessions:custom-groups";
|
|
const GROUPS_LIST_METHOD = "sessions.groups.list";
|
|
const GROUPS_RETRY_DEFAULT_MS = 500;
|
|
const GROUPS_RETRY_MIN_MS = 100;
|
|
const GROUPS_RETRY_MAX_MS = 30_000;
|
|
let groupsLoadedEpoch = -1;
|
|
let groupsLoadGeneration = 0;
|
|
let groupsRetryTimer: ReturnType<typeof globalThis.setTimeout> | null = null;
|
|
|
|
const clearGroupsRetry = () => {
|
|
if (groupsRetryTimer !== null) {
|
|
globalThis.clearTimeout(groupsRetryTimer);
|
|
groupsRetryTimer = null;
|
|
}
|
|
};
|
|
|
|
const invalidateGroupsLoad = () => {
|
|
groupsLoadedEpoch = -1;
|
|
groupsLoadGeneration += 1;
|
|
clearGroupsRetry();
|
|
};
|
|
|
|
const groupsRetryDelayMs = (error: unknown): number | null => {
|
|
if (!(error instanceof GatewayRequestError) || !error.retryable) {
|
|
return null;
|
|
}
|
|
const requested =
|
|
typeof error.retryAfterMs === "number" && Number.isFinite(error.retryAfterMs)
|
|
? error.retryAfterMs
|
|
: GROUPS_RETRY_DEFAULT_MS;
|
|
return Math.min(Math.max(requested, GROUPS_RETRY_MIN_MS), GROUPS_RETRY_MAX_MS);
|
|
};
|
|
|
|
const publishGroupCatalog = (groups: readonly string[], sectionOrder: readonly string[]) => {
|
|
const groupsUnchanged =
|
|
groups.length === state.groups.length &&
|
|
groups.every((group, i) => group === state.groups[i]);
|
|
const orderUnchanged =
|
|
sectionOrder.length === state.sectionOrder.length &&
|
|
sectionOrder.every((sectionId, i) => sectionId === state.sectionOrder[i]);
|
|
if (groupsUnchanged && orderUnchanged) {
|
|
return;
|
|
}
|
|
publish({ ...state, groups: [...groups], sectionOrder: [...sectionOrder] });
|
|
};
|
|
|
|
const finishGroupMutationFailure = (
|
|
current: boolean,
|
|
error: unknown,
|
|
): SessionGroupMutationResult => {
|
|
if (!current) {
|
|
return "stale";
|
|
}
|
|
publish({ ...state, error: String(error) });
|
|
throw error;
|
|
};
|
|
|
|
const readLegacyStoredGroups = (): string[] => {
|
|
try {
|
|
const raw = getSafeLocalStorage()?.getItem(LEGACY_GROUPS_STORAGE_KEY);
|
|
const parsed: unknown = raw ? JSON.parse(raw) : [];
|
|
return Array.isArray(parsed)
|
|
? [
|
|
...new Set(
|
|
parsed.flatMap((name) => {
|
|
const normalized = typeof name === "string" ? name.trim() : "";
|
|
return normalized ? [normalized] : [];
|
|
}),
|
|
),
|
|
]
|
|
: [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
};
|
|
|
|
const loadGroups = async (
|
|
scope: SessionConnectionScope,
|
|
generation: number,
|
|
advertised: boolean | null,
|
|
) => {
|
|
try {
|
|
const listed = await scope.client.request(GROUPS_LIST_METHOD, {});
|
|
if (!isCurrentConnection(scope) || generation !== groupsLoadGeneration) {
|
|
return;
|
|
}
|
|
let names = readSessionCustomGroupNames(listed);
|
|
let sectionOrder = readSidebarSectionOrder(listed);
|
|
// One-time migration: browser-local catalogs predate the gateway store.
|
|
const legacy = readLegacyStoredGroups();
|
|
if (names.length === 0 && legacy.length > 0) {
|
|
const put = await scope.client.request("sessions.groups.put", { names: legacy });
|
|
if (!isCurrentConnection(scope) || generation !== groupsLoadGeneration) {
|
|
return;
|
|
}
|
|
names = readSessionCustomGroupNames(put);
|
|
sectionOrder = readSidebarSectionOrder(put);
|
|
}
|
|
if (legacy.length > 0) {
|
|
try {
|
|
getSafeLocalStorage()?.removeItem(LEGACY_GROUPS_STORAGE_KEY);
|
|
} catch {
|
|
// The gateway catalog is canonical either way.
|
|
}
|
|
}
|
|
publishGroupCatalog(names, sectionOrder);
|
|
} catch (error) {
|
|
if (
|
|
!isCurrentConnection(scope) ||
|
|
generation !== groupsLoadGeneration ||
|
|
advertised !== true
|
|
) {
|
|
// Gateways without feature metadata retain the legacy one-shot probe.
|
|
return;
|
|
}
|
|
groupsLoadedEpoch = -1;
|
|
const retryDelayMs = groupsRetryDelayMs(error);
|
|
if (retryDelayMs === null) {
|
|
return;
|
|
}
|
|
// The attempt token prevents an older rejection from reviving a retry
|
|
// after a newer event-driven catalog load has already succeeded.
|
|
groupsRetryTimer = globalThis.setTimeout(() => {
|
|
groupsRetryTimer = null;
|
|
if (isCurrentConnection(scope) && generation === groupsLoadGeneration) {
|
|
void groupsLoad();
|
|
}
|
|
}, retryDelayMs);
|
|
}
|
|
};
|
|
|
|
/** Idempotent per connection; list consumers call it when groups become visible. */
|
|
const groupsLoad = async () => {
|
|
const scope = captureConnection();
|
|
if (!scope || groupsLoadedEpoch === scope.epoch) {
|
|
return;
|
|
}
|
|
const advertised = isGatewayMethodAdvertised(gateway.snapshot, GROUPS_LIST_METHOD);
|
|
clearGroupsRetry();
|
|
const generation = ++groupsLoadGeneration;
|
|
groupsLoadedEpoch = scope.epoch;
|
|
if (advertised === false) {
|
|
publishGroupCatalog([], []);
|
|
return;
|
|
}
|
|
await loadGroups(scope, generation, advertised);
|
|
};
|
|
|
|
const groupsPut = async (
|
|
names: readonly string[],
|
|
sectionOrder?: readonly string[],
|
|
): Promise<SessionGroupMutationResult> => {
|
|
const scope = captureConnection();
|
|
if (!scope) {
|
|
return "stale";
|
|
}
|
|
try {
|
|
const result = await scope.client.request("sessions.groups.put", {
|
|
names: [...names],
|
|
...(sectionOrder === undefined ? {} : { sectionOrder: [...sectionOrder] }),
|
|
});
|
|
if (!isCurrentConnection(scope)) {
|
|
return "stale";
|
|
}
|
|
publishGroupCatalog(readSessionCustomGroupNames(result), readSidebarSectionOrder(result));
|
|
return "completed";
|
|
} catch (error) {
|
|
return finishGroupMutationFailure(isCurrentConnection(scope), error);
|
|
}
|
|
};
|
|
|
|
const groupsRename = async (from: string, to: string): Promise<SessionGroupMutationResult> => {
|
|
const scope = captureConnection();
|
|
if (!scope) {
|
|
return "stale";
|
|
}
|
|
try {
|
|
const result = await scope.client.request("sessions.groups.rename", { name: from, to });
|
|
if (!isCurrentConnection(scope)) {
|
|
return "stale";
|
|
}
|
|
publishGroupCatalog(readSessionCustomGroupNames(result), readSidebarSectionOrder(result));
|
|
// The mutation response is the commit point. Reconcile member rows in
|
|
// the background so a later disconnect cannot downgrade confirmed work.
|
|
void refresh({ ...lastListOptions, force: true });
|
|
return "completed";
|
|
} catch (error) {
|
|
return finishGroupMutationFailure(isCurrentConnection(scope), error);
|
|
}
|
|
};
|
|
|
|
const groupsDelete = async (name: string): Promise<SessionGroupMutationResult> => {
|
|
const scope = captureConnection();
|
|
if (!scope) {
|
|
return "stale";
|
|
}
|
|
try {
|
|
const result = await scope.client.request("sessions.groups.delete", { name });
|
|
if (!isCurrentConnection(scope)) {
|
|
return "stale";
|
|
}
|
|
publishGroupCatalog(readSessionCustomGroupNames(result), readSidebarSectionOrder(result));
|
|
// See groupsRename: collapsed-state consumers must observe confirmed
|
|
// completion before an unrelated refresh can outlive the connection.
|
|
void refresh({ ...lastListOptions, force: true });
|
|
return "completed";
|
|
} catch (error) {
|
|
return finishGroupMutationFailure(isCurrentConnection(scope), error);
|
|
}
|
|
};
|
|
|
|
const patch = async (
|
|
key: string,
|
|
patchParams: SessionPatch,
|
|
options: SessionPatchOptions = {},
|
|
): Promise<SessionsPatchResult | null> => {
|
|
const scope = captureConnection();
|
|
if (!scope) {
|
|
return null;
|
|
}
|
|
const hasModelPatch = Object.hasOwn(patchParams, "model");
|
|
const normalizedKey = key.trim();
|
|
const pendingModelPatch = pendingModelPatches.get(normalizedKey);
|
|
const previousModelOverride = pendingModelPatch
|
|
? pendingModelPatch.previous
|
|
: state.modelOverrides[normalizedKey];
|
|
const modelPatchToken = Symbol();
|
|
if (hasModelPatch) {
|
|
pendingModelPatches.set(normalizedKey, {
|
|
token: modelPatchToken,
|
|
previous: previousModelOverride,
|
|
});
|
|
setModelOverride(key, patchParams.model);
|
|
}
|
|
const restoreModelOverride = () => {
|
|
if (pendingModelPatches.get(normalizedKey)?.token !== modelPatchToken) {
|
|
return;
|
|
}
|
|
pendingModelPatches.delete(normalizedKey);
|
|
setModelOverride(key, previousModelOverride);
|
|
};
|
|
try {
|
|
if (options.waitFor) {
|
|
await options.waitFor;
|
|
if (!isCurrentConnection(scope)) {
|
|
restoreModelOverride();
|
|
return null;
|
|
}
|
|
}
|
|
const result = await requestSessionPatch(scope.client, key, patchParams, options);
|
|
if (!isCurrentConnection(scope)) {
|
|
restoreModelOverride();
|
|
return null;
|
|
}
|
|
if (!options.deferListRefresh) {
|
|
await refreshReplacement(options.agentId);
|
|
if (!isCurrentConnection(scope)) {
|
|
restoreModelOverride();
|
|
return null;
|
|
}
|
|
}
|
|
if (pendingModelPatches.get(normalizedKey)?.token === modelPatchToken) {
|
|
pendingModelPatches.delete(normalizedKey);
|
|
setModelOverride(key, patchParams.model);
|
|
}
|
|
return result;
|
|
} catch (error) {
|
|
restoreModelOverride();
|
|
if (!isCurrentConnection(scope)) {
|
|
return null;
|
|
}
|
|
publish({ ...state, error: String(error) });
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
const reconcile = (
|
|
row: GatewaySessionRow | undefined,
|
|
defaults?: SessionsListResult["defaults"],
|
|
options?: SessionReconcileOptions,
|
|
): boolean => {
|
|
const result = swarmActivity.decorate(
|
|
reconcileSessionHistory(state.result, row, defaults, options),
|
|
);
|
|
if (result === state.result) {
|
|
return false;
|
|
}
|
|
publish({
|
|
...state,
|
|
result,
|
|
agentId: options?.resultAgentId?.trim()
|
|
? normalizeAgentId(options.resultAgentId)
|
|
: state.agentId,
|
|
});
|
|
return true;
|
|
};
|
|
|
|
const reconcileChanged = (
|
|
payload: unknown,
|
|
options?: SessionReconcileOptions,
|
|
): SessionChangedResult => {
|
|
const base = reconcileSessionChanged(state.result, payload, options);
|
|
const result = swarmActivity.decorate(base.result);
|
|
const reconciled =
|
|
result === base.result
|
|
? base
|
|
: {
|
|
...base,
|
|
result,
|
|
row: base.row ? result?.sessions.find((row) => row.key === base.row?.key) : undefined,
|
|
};
|
|
if (reconciled.deletedKey) {
|
|
retirePullRequestSummary(reconciled.deletedKey);
|
|
}
|
|
if (reconciled.applied && (reconciled.result !== state.result || reconciled.deletedKey)) {
|
|
publish({
|
|
...state,
|
|
result: reconciled.result,
|
|
agentId: options?.resultAgentId?.trim()
|
|
? normalizeAgentId(options.resultAgentId)
|
|
: state.agentId,
|
|
error: null,
|
|
deletedSessions: reconciled.deletedKey
|
|
? [{ key: reconciled.deletedKey, agentId: reconciled.agentId ?? undefined }]
|
|
: [],
|
|
});
|
|
}
|
|
return reconciled;
|
|
};
|
|
|
|
const reconcileRunTerminal = (terminal: SessionRunTerminal): boolean => {
|
|
const result = reconcileSessionRunTerminal(state.result, terminal);
|
|
if (result === state.result) {
|
|
return false;
|
|
}
|
|
publish({ ...state, result, error: null });
|
|
return true;
|
|
};
|
|
|
|
const remove = async (
|
|
key: string,
|
|
options: SessionDeleteOptions = {},
|
|
): Promise<SessionDeleteOutcome> => {
|
|
const scope = captureConnection();
|
|
if (!scope) {
|
|
return { deleted: false };
|
|
}
|
|
try {
|
|
const response = await requestSessionDelete(scope.client, key, options);
|
|
if (!isCurrentConnection(scope)) {
|
|
return { deleted: false };
|
|
}
|
|
if (!confirmsSessionDeletion(response)) {
|
|
return { deleted: false };
|
|
}
|
|
retirePullRequestSummary(key);
|
|
retirePreparedWorkSession(key);
|
|
publish({ ...state, deletedSessions: [{ key, agentId: options.agentId }] });
|
|
setModelOverride(key, undefined);
|
|
await refreshReplacement(options.agentId);
|
|
return {
|
|
deleted: isCurrentConnection(scope),
|
|
...(response.worktreePreserved ? { worktreePreserved: response.worktreePreserved } : {}),
|
|
};
|
|
} catch (error) {
|
|
if (!isCurrentConnection(scope)) {
|
|
return { deleted: false };
|
|
}
|
|
publish({ ...state, error: String(error) });
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
const removeMany = async (
|
|
targets: readonly SessionDeleteTarget[],
|
|
): Promise<SessionDeleteBatchResult> => {
|
|
const scope = captureConnection();
|
|
if (!scope || targets.length === 0) {
|
|
return { deleted: [], errors: [], preservedWorktrees: [] };
|
|
}
|
|
const deleted: string[] = [];
|
|
const errors: string[] = [];
|
|
const preservedWorktrees: SessionDeleteBatchResult["preservedWorktrees"] = [];
|
|
for (const target of targets) {
|
|
if (!isCurrentConnection(scope)) {
|
|
break;
|
|
}
|
|
try {
|
|
const response = await requestSessionDelete(scope.client, target.key, target);
|
|
if (!isCurrentConnection(scope)) {
|
|
break;
|
|
}
|
|
if (!confirmsSessionDeletion(response)) {
|
|
continue;
|
|
}
|
|
deleted.push(target.key);
|
|
if (response.worktreePreserved) {
|
|
preservedWorktrees.push(response.worktreePreserved);
|
|
}
|
|
} catch (error) {
|
|
errors.push(String(error));
|
|
}
|
|
}
|
|
if (deleted.length > 0 && isCurrentConnection(scope)) {
|
|
for (const key of deleted) {
|
|
retirePullRequestSummary(key);
|
|
retirePreparedWorkSession(key);
|
|
}
|
|
publish({
|
|
...state,
|
|
deletedSessions: targets.filter((target) => deleted.includes(target.key)),
|
|
});
|
|
for (const key of deleted) {
|
|
setModelOverride(key, undefined);
|
|
}
|
|
await refreshReplacement();
|
|
}
|
|
return isCurrentConnection(scope)
|
|
? { deleted, errors, preservedWorktrees }
|
|
: { deleted: [], errors: [], preservedWorktrees: [] };
|
|
};
|
|
|
|
const reset = async (
|
|
key: string,
|
|
options: SessionResetOptions = {},
|
|
): Promise<SessionResetResult> => {
|
|
const scope = captureConnection();
|
|
if (!scope) {
|
|
return "not-started";
|
|
}
|
|
try {
|
|
await requestSessionReset(scope.client, key, options);
|
|
return isCurrentConnection(scope) ? "completed" : "uncertain";
|
|
} catch (error) {
|
|
if (isCurrentConnection(scope)) {
|
|
publish({ ...state, error: String(error) });
|
|
}
|
|
// The gateway commits the new session identity before every awaited
|
|
// post-reset lifecycle step finishes. Once requested, even a rejection
|
|
// on the same connection cannot prove that the destructive reset did not
|
|
// commit, so callers must never retry it automatically.
|
|
return "uncertain";
|
|
}
|
|
};
|
|
|
|
const compact = async (
|
|
key: string,
|
|
options: { agentId?: string | null } = {},
|
|
): Promise<SessionCompactResult> => {
|
|
const scope = captureConnection();
|
|
if (!scope) {
|
|
throw new Error("Session compaction requires an active Gateway connection");
|
|
}
|
|
const result = await requestSessionCompact(scope.client, key, options);
|
|
if (!isCurrentConnection(scope)) {
|
|
throw new Error("Session compaction completed on a replaced Gateway connection");
|
|
}
|
|
return result;
|
|
};
|
|
|
|
const steer = async (
|
|
key: string,
|
|
message: string,
|
|
options: { agentId?: string | null } = {},
|
|
): Promise<SessionSteerResult> => {
|
|
const scope = captureConnection();
|
|
if (!scope) {
|
|
throw new Error("Session steering requires an active Gateway connection");
|
|
}
|
|
const result = await requestSessionSteer(scope.client, key, message, options);
|
|
if (!isCurrentConnection(scope)) {
|
|
throw new Error("Session steering completed on a replaced Gateway connection");
|
|
}
|
|
return result;
|
|
};
|
|
|
|
const listFiles = async (
|
|
key: string,
|
|
options: { agentId?: string | null; path?: string; search?: string } = {},
|
|
): Promise<SessionWorkspaceListResult | null> => {
|
|
const scope = captureConnection();
|
|
if (!scope) {
|
|
return null;
|
|
}
|
|
const result = await requestSessionFilesList(scope.client, key, options);
|
|
return isCurrentConnection(scope) ? result : null;
|
|
};
|
|
|
|
const getFile = async (
|
|
key: string,
|
|
path: string,
|
|
options: { agentId?: string | null } = {},
|
|
): Promise<SessionWorkspaceGetResult | null> => {
|
|
const scope = captureConnection();
|
|
if (!scope) {
|
|
return null;
|
|
}
|
|
const result = await requestSessionFile(scope.client, key, path, options);
|
|
return isCurrentConnection(scope) ? result : null;
|
|
};
|
|
|
|
const setFile = async (
|
|
key: string,
|
|
path: string,
|
|
content: string,
|
|
options: { agentId?: string | null; expectedHash: string },
|
|
): Promise<SessionWorkspaceSetResult | null> => {
|
|
const scope = captureConnection();
|
|
if (!scope) {
|
|
return null;
|
|
}
|
|
const result = await requestSessionFileSet(scope.client, key, path, content, options);
|
|
return isCurrentConnection(scope) ? result : null;
|
|
};
|
|
|
|
const unsubscribeMessages = async (subscription: SessionMessageSubscription): Promise<void> => {
|
|
await releaseGatewaySessionMessageSubscription(subscription);
|
|
ownedMessageSubscriptions.delete(subscription);
|
|
};
|
|
|
|
const subscribeMessages = async (
|
|
key: string,
|
|
options: { agentId?: string | null; includeApprovals?: boolean } = {},
|
|
): Promise<SessionMessageSubscription> => {
|
|
const scope = captureConnection();
|
|
if (!scope) {
|
|
throw new Error("Session message subscription requires an active Gateway connection");
|
|
}
|
|
const normalizedKey = key.trim();
|
|
const agentId =
|
|
isUiGlobalSessionKey(normalizedKey) && options.agentId?.trim()
|
|
? normalizeAgentId(options.agentId)
|
|
: null;
|
|
const subscription = await getGatewaySessionMessageSubscriptionCoordinator(scope.client, {
|
|
keysEquivalent: areUiSessionKeysEquivalent,
|
|
}).acquire(normalizedKey, {
|
|
agentId,
|
|
...(options.includeApprovals ? { includeApprovals: true } : {}),
|
|
});
|
|
ownedMessageSubscriptions.add(subscription);
|
|
if (!isCurrentConnection(scope)) {
|
|
await unsubscribeMessages(subscription).catch(() => undefined);
|
|
throw new Error("Session message subscription completed on a replaced Gateway connection");
|
|
}
|
|
return subscription;
|
|
};
|
|
|
|
const listCheckpoints = async (
|
|
key: string,
|
|
options: { agentId?: string | null } = {},
|
|
): Promise<SessionCompactionCheckpoint[]> => {
|
|
const scope = captureConnection();
|
|
if (!scope) {
|
|
return [];
|
|
}
|
|
const result = await listSessionCheckpoints(scope.client, key, options);
|
|
return isCurrentConnection(scope) ? (result.checkpoints ?? []) : [];
|
|
};
|
|
|
|
const branchCheckpoint = async (
|
|
key: string,
|
|
checkpointId: string,
|
|
options: { agentId?: string | null } = {},
|
|
): Promise<SessionsCompactionBranchResult> => {
|
|
const scope = captureConnection();
|
|
if (!scope) {
|
|
throw new Error("Session checkpoint operation requires an active Gateway connection");
|
|
}
|
|
const result = await branchSessionCheckpoint(scope.client, key, checkpointId, options);
|
|
if (!isCurrentConnection(scope)) {
|
|
throw new Error("Session checkpoint operation completed on a replaced Gateway connection");
|
|
}
|
|
await refreshReplacement(options.agentId ?? state.agentId ?? undefined);
|
|
if (!isCurrentConnection(scope)) {
|
|
throw new Error("Session checkpoint operation completed on a replaced Gateway connection");
|
|
}
|
|
return result;
|
|
};
|
|
|
|
const restoreCheckpoint = async (
|
|
key: string,
|
|
checkpointId: string,
|
|
options: { agentId?: string | null } = {},
|
|
): Promise<SessionsCompactionRestoreResult> => {
|
|
const scope = captureConnection();
|
|
if (!scope) {
|
|
throw new Error("Session checkpoint operation requires an active Gateway connection");
|
|
}
|
|
const result = await restoreSessionCheckpoint(scope.client, key, checkpointId, options);
|
|
if (!isCurrentConnection(scope)) {
|
|
throw new Error("Session checkpoint operation completed on a replaced Gateway connection");
|
|
}
|
|
await refreshReplacement(options.agentId ?? state.agentId ?? undefined);
|
|
if (!isCurrentConnection(scope)) {
|
|
throw new Error("Session checkpoint operation completed on a replaced Gateway connection");
|
|
}
|
|
return result;
|
|
};
|
|
|
|
const rewind = async (
|
|
key: string,
|
|
entryId: string,
|
|
options: { agentId?: string | null } = {},
|
|
): Promise<SessionsRewindResult> => {
|
|
const scope = captureConnection();
|
|
if (!scope) {
|
|
throw new Error("Session rewind requires an active Gateway connection");
|
|
}
|
|
const result = await rewindSessionAtMessage(scope.client, key, entryId, options);
|
|
if (isCurrentConnection(scope)) {
|
|
await refreshReplacement(options.agentId ?? state.agentId ?? undefined).catch(() => {});
|
|
}
|
|
return result;
|
|
};
|
|
|
|
const forkAtMessage = async (
|
|
key: string,
|
|
entryId: string,
|
|
options: { agentId?: string | null } = {},
|
|
): Promise<SessionsForkResult> => {
|
|
const scope = captureConnection();
|
|
if (!scope) {
|
|
throw new Error("Session fork requires an active Gateway connection");
|
|
}
|
|
const result = await forkSessionAtMessage(scope.client, key, entryId, options);
|
|
if (isCurrentConnection(scope)) {
|
|
await refreshReplacement(options.agentId ?? state.agentId ?? undefined).catch(() => {});
|
|
}
|
|
return result;
|
|
};
|
|
|
|
const listBranches = async (
|
|
key: string,
|
|
options: { agentId?: string | null } = {},
|
|
): Promise<SessionBranch[]> => {
|
|
const scope = captureConnection();
|
|
if (!scope) {
|
|
return [];
|
|
}
|
|
const result = await listSessionBranches(scope.client, key, options);
|
|
return isCurrentConnection(scope) ? result.branches : [];
|
|
};
|
|
|
|
const switchBranch = async (
|
|
key: string,
|
|
leafEntryId: string,
|
|
options: { agentId?: string | null } = {},
|
|
): Promise<SessionsBranchesSwitchResult> => {
|
|
const scope = captureConnection();
|
|
if (!scope) {
|
|
throw new Error("Session branch switch requires an active Gateway connection");
|
|
}
|
|
const result = await switchSessionBranch(scope.client, key, leafEntryId, options);
|
|
if (isCurrentConnection(scope)) {
|
|
await refreshReplacement(options.agentId ?? state.agentId ?? undefined).catch(() => {});
|
|
}
|
|
return result;
|
|
};
|
|
|
|
const stopGateway = gateway.subscribe((next) => {
|
|
const previousClient = connectionClient;
|
|
const connected = next.phase === "connected";
|
|
const connectionChanged = next.client !== connectionClient || connected !== connectionConnected;
|
|
connectionClient = next.client;
|
|
connectionConnected = connected;
|
|
if (connectionChanged) {
|
|
const hadPullRequestSummaries = pullRequestSummaries.size > 0;
|
|
clearEventRefreshTimer();
|
|
connectionEpoch += 1;
|
|
if (previousClient) {
|
|
resetGatewaySessionMessageSubscriptionCoordinator(previousClient);
|
|
}
|
|
ownedMessageSubscriptions.clear();
|
|
invalidateGroupsLoad();
|
|
swarmActivity.clear();
|
|
inFlight = null;
|
|
queuedExplicitRefresh = null;
|
|
eventRefreshQueued = false;
|
|
rollbackPendingModelPatches();
|
|
preparedWorkSessionKeys.clear();
|
|
pullRequestSummaries.clear();
|
|
pullRequestEpochs.clear();
|
|
// A connected client replacement needs its own invalidation publish;
|
|
// disconnects publish the cleared state in the branch immediately below.
|
|
if (hadPullRequestSummaries && connected && next.client) {
|
|
publish({ ...state });
|
|
}
|
|
}
|
|
if (!connected || !next.client) {
|
|
subscribedClient = null;
|
|
publish({
|
|
result: null,
|
|
agentId: null,
|
|
modelOverrides: state.modelOverrides,
|
|
loading: false,
|
|
error: null,
|
|
deletedSessions: [],
|
|
groups: state.groups,
|
|
sectionOrder: state.sectionOrder,
|
|
});
|
|
return;
|
|
}
|
|
if (subscribedClient !== next.client) {
|
|
const scope = captureConnection();
|
|
if (!scope) {
|
|
return;
|
|
}
|
|
subscribedClient = scope.client;
|
|
void (async () => {
|
|
try {
|
|
await scope.client.request("sessions.subscribe", {});
|
|
} catch (error) {
|
|
if (isCurrentConnection(scope)) {
|
|
publish({ ...state, error: String(error) });
|
|
}
|
|
} finally {
|
|
if (isCurrentConnection(scope)) {
|
|
const sessionKey = gateway.snapshot.sessionKey?.trim();
|
|
const agentScope = sessionKey
|
|
? scopedAgentListParamsForSession(gateway.snapshot, sessionKey)
|
|
: { agentId: resolveUiSelectedGlobalAgentId(gateway.snapshot) };
|
|
await refresh({
|
|
...agentScope,
|
|
includeDerivedTitles: true,
|
|
backgroundHydrate: true,
|
|
force: true,
|
|
});
|
|
}
|
|
}
|
|
})();
|
|
}
|
|
// Gateway snapshots also change for recovery scope, presence, and canvas metadata.
|
|
// Only a connection transition owns list hydration; refreshing here duplicates startup work.
|
|
});
|
|
const stopEvents = gateway.subscribeEvents((event) => {
|
|
if (isSessionStateEvent(event)) {
|
|
swarmActivity.observe(event.payload);
|
|
// Preserve canonical list filtering: decorate rows already admitted here,
|
|
// then let the refresh below admit new children before applying cached notes.
|
|
const decoratedResult = swarmActivity.decorate(state.result);
|
|
if (decoratedResult !== state.result) {
|
|
publish({ ...state, result: decoratedResult });
|
|
}
|
|
const reconciled = reconcileSessionChanged(state.result, event.payload, {
|
|
resultAgentId: state.agentId,
|
|
archivedFilter: lastListOptions.archivedFilter,
|
|
});
|
|
const eventInfo = readSessionChangedEvent(event.payload);
|
|
// Catalog mutations from other clients invalidate the per-connection
|
|
// groups snapshot. Groups events carry no session key, so read the
|
|
// reason straight off the payload instead of the parsed row info.
|
|
const eventReason = (event.payload as { reason?: unknown } | null)?.reason;
|
|
if (eventReason === "groups") {
|
|
invalidateGroupsLoad();
|
|
void groupsLoad();
|
|
}
|
|
const hasActiveRun = reconciled.hasActiveRun ?? eventInfo?.hasActiveRun;
|
|
const status = reconciled.status ?? eventInfo?.status;
|
|
const runEnded =
|
|
hasActiveRun === false || (status !== null && status !== undefined && status !== "running");
|
|
if (event.event === "session.message" && !runEnded) {
|
|
return;
|
|
}
|
|
if (reconciled.deletedKey) {
|
|
retirePullRequestSummary(reconciled.deletedKey);
|
|
// Preserve remote-deletion navigation before the canonical refresh
|
|
// clears transient event state.
|
|
publish({
|
|
...state,
|
|
deletedSessions: [
|
|
{ key: reconciled.deletedKey, agentId: reconciled.agentId ?? undefined },
|
|
],
|
|
});
|
|
} else if ((eventReason === "create" || eventReason === "new") && eventInfo) {
|
|
const remainingDeletedSessions = state.deletedSessions.filter(
|
|
({ key, agentId }) =>
|
|
!uiSessionEventMatches(
|
|
{
|
|
assistantAgentId: agentId ?? gateway.snapshot.assistantAgentId,
|
|
hello: gateway.snapshot.hello,
|
|
sessionKey: key,
|
|
},
|
|
eventInfo.key,
|
|
eventInfo.agentId,
|
|
),
|
|
);
|
|
if (remainingDeletedSessions.length !== state.deletedSessions.length) {
|
|
// Gateway create events are ordered after the prior deletion. Retire
|
|
// only that generation's marker before the debounced list refresh.
|
|
publish({ ...state, deletedSessions: remainingDeletedSessions });
|
|
}
|
|
}
|
|
// Gateway lists are filtered and windowed. Events cannot preserve server
|
|
// membership or ordering, so the coalesced refresh remains canonical. Only
|
|
// events debounce, with a max wait; explicit refreshes and page exit flush it.
|
|
scheduleEventRefresh();
|
|
}
|
|
});
|
|
|
|
return {
|
|
get state() {
|
|
return state;
|
|
},
|
|
get canonicalListRevision() {
|
|
return canonicalListRevision;
|
|
},
|
|
list: requestList,
|
|
setCreatorFilter,
|
|
reconcile,
|
|
reconcileChanged,
|
|
reconcileRunTerminal,
|
|
refresh,
|
|
refreshReplacement,
|
|
createResult,
|
|
create,
|
|
patch,
|
|
setModelOverride,
|
|
patchRowLocal,
|
|
isPreparedWorkSession(key) {
|
|
return preparedWorkSessionKeys.has(key.trim());
|
|
},
|
|
pullRequestSummary,
|
|
capturePullRequestEpoch,
|
|
setPullRequestSummary,
|
|
delete: remove,
|
|
deleteMany: removeMany,
|
|
reset,
|
|
compact,
|
|
steer,
|
|
listFiles,
|
|
getFile,
|
|
setFile,
|
|
subscribeMessages,
|
|
unsubscribeMessages,
|
|
listCheckpoints,
|
|
branchCheckpoint,
|
|
restoreCheckpoint,
|
|
rewind,
|
|
forkAtMessage,
|
|
listBranches,
|
|
switchBranch,
|
|
groupsLoad,
|
|
groupsPut,
|
|
groupsRename,
|
|
groupsDelete,
|
|
subscribeCreated(listener) {
|
|
createdListeners.add(listener);
|
|
return () => createdListeners.delete(listener);
|
|
},
|
|
subscribe(listener) {
|
|
listeners.add(listener);
|
|
return () => listeners.delete(listener);
|
|
},
|
|
dispose() {
|
|
// Page teardown must start the trailing refresh synchronously before the
|
|
// disposed guard makes the capability inert.
|
|
flushEventRefresh();
|
|
if (observesPageLifecycle) {
|
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
globalThis.removeEventListener("pagehide", flushEventRefresh);
|
|
}
|
|
for (const subscription of ownedMessageSubscriptions) {
|
|
void unsubscribeMessages(subscription).catch(() => undefined);
|
|
}
|
|
disposed = true;
|
|
connectionEpoch += 1;
|
|
invalidateGroupsLoad();
|
|
connectionConnected = false;
|
|
inFlight = null;
|
|
queuedExplicitRefresh = null;
|
|
eventRefreshQueued = false;
|
|
subscribedClient = null;
|
|
pendingModelPatches.clear();
|
|
preparedWorkSessionKeys.clear();
|
|
swarmActivity.clear();
|
|
pullRequestSummaries.clear();
|
|
pullRequestEpochs.clear();
|
|
stopGateway();
|
|
stopEvents();
|
|
createdListeners.clear();
|
|
listeners.clear();
|
|
},
|
|
};
|
|
}
|
|
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|