diff --git a/src/engines/Simulator/components/RemoteSessionWorkstationSurface.tsx b/src/engines/Simulator/components/RemoteSessionWorkstationSurface.tsx
index 98c994bb05..d9499fa773 100644
--- a/src/engines/Simulator/components/RemoteSessionWorkstationSurface.tsx
+++ b/src/engines/Simulator/components/RemoteSessionWorkstationSurface.tsx
@@ -29,6 +29,11 @@ export interface RemoteSessionWorkstationSurfaceProps {
events: SessionEvent[];
loadStatus: SessionLoadStatus;
loadError: string | null;
+ loadProgress?: {
+ loadedEvents: number;
+ totalEvents: number | null;
+ } | null;
+ onRetry?: () => void;
/** Replay cursor event forwarded to My Station file selection. */
currentEventId?: string | null;
/** Inclusive replay cursor on the full event list. */
@@ -52,6 +57,8 @@ export function RemoteSessionWorkstationSurface({
events,
loadStatus,
loadError,
+ loadProgress = null,
+ onRetry,
currentEventId = null,
replayEndIndex,
}: RemoteSessionWorkstationSurfaceProps) {
@@ -141,6 +148,8 @@ export function RemoteSessionWorkstationSurface({
events={events}
loadStatus={loadStatus}
loadError={loadError}
+ loadProgress={loadProgress}
+ onRetry={onRetry}
currentEventId={currentEventId}
replayEndIndex={replayEndIndex}
/>
diff --git a/src/i18n/locales/en/sessions.json b/src/i18n/locales/en/sessions.json
index fea880320a..93bedc4e79 100644
--- a/src/i18n/locales/en/sessions.json
+++ b/src/i18n/locales/en/sessions.json
@@ -2198,40 +2198,6 @@
"parallelRun": "Parallel run",
"workItem": "Create work item"
},
- "worktreeSource": {
- "title": "Create worktree",
- "confirm": "Use worktree",
- "resolving": "Resolving PR…",
- "selectRepository": "Select a repository before choosing a worktree source.",
- "sourceTabs": "Worktree source",
- "tabs": {
- "smart": "Smart",
- "github": "GitHub",
- "branch": "Branch",
- "name": "Name"
- },
- "smartPlaceholder": "Name, #1234, branch, or GitHub/GitLab URL",
- "smartAria": "Enter a name, PR number, branch, or GitHub/GitLab URL",
- "smartHint": "Type a name, PR number, branch, or paste a PR/MR URL.",
- "refreshGithub": "Refresh GitHub list",
- "githubSearch": "Search GitHub PRs and issues",
- "githubSearchAria": "Search GitHub pull requests and issues",
- "githubError": "GitHub items could not be loaded.",
- "githubEmpty": "No open GitHub PRs or issues.",
- "githubNoMatches": "No matches.",
- "refreshBranches": "Refresh branch list",
- "branchSearch": "Search branches or enter a ref",
- "branchSearchAria": "Search branches or enter a base ref",
- "branchError": "Branches could not be loaded.",
- "branchEmpty": "No branches found in this repository.",
- "branchNoMatches": "No matching branches.",
- "branchUseAsRef": "Use \"{{value}}\" as ref",
- "branchCustomRefHint": "Tag, commit, or any git ref",
- "worktreeLabel": "Worktree label",
- "namePlaceholder": "feature-name",
- "nameBase": "Base: {{branch}}",
- "nameBaseHead": "Base: HEAD"
- },
"searchModels": "Search models...",
"newItem": "New Item",
"solveWorkItem": "Solve Work Item",
@@ -2446,6 +2412,8 @@
}
},
"worktreeSource": {
+ "selectRepository": "Select a repository before choosing a worktree source.",
+ "sourceTabs": "Worktree source",
"baseBranch": "Base branch or ref",
"refreshBranches": "Refresh branch list",
"branchSearch": "Search branches or enter a ref",
@@ -2461,6 +2429,8 @@
"githubNoMatches": "No matches.",
"worktreeLabel": "Worktree label",
"namePlaceholder": "feature-name",
+ "nameBase": "Base: {{branch}}",
+ "nameBaseHead": "Base: HEAD",
"smartLabel": "Name, number, branch, or URL",
"smartPlaceholder": "Name, #1234, branch, or GitHub/GitLab URL",
"smartAria": "Enter a name, PR number, branch, or GitHub/GitLab URL",
diff --git a/src/web/WebApp.tsx b/src/web/WebApp.tsx
index 8889bb7e5c..1e7db8530a 100644
--- a/src/web/WebApp.tsx
+++ b/src/web/WebApp.tsx
@@ -17,10 +17,10 @@ import {
useOrg2CloudOrgs,
} from "@src/features/Org2Cloud/org2CloudOrgsAtom";
import { useOrg2CloudRosterReconcile } from "@src/features/Org2Cloud/org2CloudRosterReconcile";
-import { useOrg2CloudRealtime } from "@src/features/Org2Cloud/useOrg2CloudRealtime";
import { WebAuthCallbackPage } from "./features/auth/WebAuthCallbackPage";
import { WebLoginPage } from "./features/auth/WebLoginPage";
+import { WebCloudRealtimeScope } from "./features/sessions/WebCloudRealtimeScope";
import { WebOrgRemoteSessionSubscriptions } from "./features/sessions/WebOrgRemoteSessionSubscriptions";
import { WebSessionsProvider } from "./features/sessions/WebSessionsContext";
import { WebSessionsPage } from "./features/sessions/WebSessionsPage";
@@ -58,13 +58,13 @@ function RequireCloudAuth() {
function WebCloudRuntime() {
useOrg2CloudOrgs();
useOrg2CloudRosterReconcile();
- useOrg2CloudRealtime();
const auth = useAtomValue(org2CloudAuthAtom);
const orgs = useAtomValue(org2CloudOrgsAtom);
return (
+
org.orgId)} />
diff --git a/src/web/features/auth/WebAuthCallbackPage.test.ts b/src/web/features/auth/WebAuthCallbackPage.test.ts
new file mode 100644
index 0000000000..534d242bef
--- /dev/null
+++ b/src/web/features/auth/WebAuthCallbackPage.test.ts
@@ -0,0 +1,97 @@
+/** @vitest-environment jsdom */
+import React from "react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { createSmokeRoot } from "@src/test/reactSmokeHarness";
+
+import { WebAuthCallbackPage } from "./WebAuthCallbackPage";
+import { WEB_AUTH_STATE_STORAGE_KEY } from "./webAuthFlowState";
+
+const mocks = vi.hoisted(() => ({
+ navigate: vi.fn(),
+ setAuth: vi.fn(),
+}));
+
+vi.mock("jotai", () => ({
+ useSetAtom: () => mocks.setAuth,
+}));
+
+vi.mock("react-router-dom", () => ({
+ useNavigate: () => mocks.navigate,
+}));
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+
+vi.mock("@src/components/Button", () => ({
+ default: ({ children }: { children: React.ReactNode }) =>
+ React.createElement("button", null, children),
+}));
+
+vi.mock("@src/modules/shared/layouts/blocks", () => ({
+ Placeholder: ({ title }: { title: string }) =>
+ React.createElement("div", { "data-error": true }, title),
+}));
+
+function accessToken(userId: string): string {
+ return `header.${btoa(JSON.stringify({ sub: userId }))}.signature`;
+}
+
+describe("WebAuthCallbackPage", () => {
+ const roots: Array
> = [];
+
+ beforeEach(() => {
+ mocks.navigate.mockReset();
+ mocks.setAuth.mockReset();
+ sessionStorage.clear();
+ window.history.replaceState(null, "", "/auth/callback");
+ });
+
+ afterEach(async () => {
+ await Promise.all(roots.splice(0).map((root) => root.unmount()));
+ });
+
+ it("commits credentials only for the matching one-time callback state", async () => {
+ sessionStorage.setItem(WEB_AUTH_STATE_STORAGE_KEY, "expected");
+ const token = accessToken("user-1");
+ window.history.replaceState(
+ null,
+ "",
+ `/auth/callback?state=expected#access_token=${token}&refresh_token=refresh&expires_at=2000000000`
+ );
+ const root = createSmokeRoot();
+ roots.push(root);
+
+ await root.render(React.createElement(WebAuthCallbackPage));
+
+ expect(mocks.setAuth).toHaveBeenCalledWith(
+ expect.objectContaining({
+ userId: "user-1",
+ accessToken: token,
+ refreshToken: "refresh",
+ expiresAt: 2_000_000_000,
+ })
+ );
+ expect(sessionStorage.getItem(WEB_AUTH_STATE_STORAGE_KEY)).toBeNull();
+ expect(mocks.navigate).toHaveBeenCalledWith("/sessions", {
+ replace: true,
+ });
+ });
+
+ it("rejects a token fragment that is not correlated to this tab", async () => {
+ const token = accessToken("attacker");
+ window.history.replaceState(
+ null,
+ "",
+ `/auth/callback?state=untrusted#access_token=${token}&refresh_token=refresh&expires_at=2000000000`
+ );
+ const root = createSmokeRoot();
+ roots.push(root);
+
+ await root.render(React.createElement(WebAuthCallbackPage));
+
+ expect(mocks.setAuth).not.toHaveBeenCalled();
+ expect(root.container.querySelector("[data-error]")).not.toBeNull();
+ });
+});
diff --git a/src/web/features/auth/WebAuthCallbackPage.tsx b/src/web/features/auth/WebAuthCallbackPage.tsx
index 1cf0cc0a9f..a0fd336369 100644
--- a/src/web/features/auth/WebAuthCallbackPage.tsx
+++ b/src/web/features/auth/WebAuthCallbackPage.tsx
@@ -1,5 +1,5 @@
import { useSetAtom } from "jotai";
-import React, { useEffect, useMemo } from "react";
+import React, { useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
@@ -12,16 +12,24 @@ import { getCloudEndpoint } from "@src/features/Org2Cloud/config";
import { org2CloudAuthAtom } from "@src/features/Org2Cloud/org2CloudAuthAtom";
import { Placeholder } from "@src/modules/shared/layouts/blocks";
+import {
+ consumeWebAuthCallbackState,
+ validateWebAuthCallbackState,
+} from "./webAuthFlowState";
+
export function WebAuthCallbackPage() {
const { t } = useTranslation("navigation");
const setAuth = useSetAtom(org2CloudAuthAtom);
const navigate = useNavigate();
+ const committedRef = useRef(false);
const result = useMemo(() => {
- const expected = new URL(
- "/auth/callback",
- window.location.origin
- ).toString();
- const callback = parseAuthCallbackFragment(window.location.href, expected);
+ const validatedState = validateWebAuthCallbackState(window.location.href);
+ const callback = validatedState
+ ? parseAuthCallbackFragment(
+ window.location.href,
+ validatedState.expectedCallbackUrl
+ )
+ : null;
if (!callback) {
return {
ok: false,
@@ -35,11 +43,21 @@ export function WebAuthCallbackPage() {
error: t("web.authCallback.missingIdentity"),
} as const;
}
- return { ok: true, callback, userId } as const;
+ return {
+ ok: true,
+ callback,
+ userId,
+ state: validatedState!.state,
+ } as const;
}, [t]);
useEffect(() => {
- if (!result.ok) return;
+ if (!result.ok || committedRef.current) return;
+ if (!consumeWebAuthCallbackState(result.state)) {
+ navigate("/login", { replace: true });
+ return;
+ }
+ committedRef.current = true;
const endpoint = getCloudEndpoint();
window.history.replaceState(null, "", "/auth/callback");
setAuth({
diff --git a/src/web/features/auth/WebLoginPage.tsx b/src/web/features/auth/WebLoginPage.tsx
index 2e09af83af..52261760dc 100644
--- a/src/web/features/auth/WebLoginPage.tsx
+++ b/src/web/features/auth/WebLoginPage.tsx
@@ -1,5 +1,5 @@
import { useAtomValue } from "jotai";
-import React from "react";
+import React, { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { Navigate } from "react-router-dom";
@@ -10,13 +10,22 @@ import { org2CloudAuthAtom } from "@src/features/Org2Cloud/org2CloudAuthAtom";
import { OnboardingLayout } from "@src/modules/shared/layouts/OnboardingLayout";
import { ONBOARDING_LOGIN_TOKENS } from "@src/modules/shared/layouts/onboardingTokens";
-function webAuthCallbackUrl(): string {
- return new URL("/auth/callback", window.location.origin).toString();
-}
+import { createWebAuthCallbackUrl } from "./webAuthFlowState";
export function WebLoginPage() {
const { t } = useTranslation("navigation");
const auth = useAtomValue(org2CloudAuthAtom);
+ const [startError, setStartError] = useState(null);
+ const startSignIn = useCallback(() => {
+ try {
+ setStartError(null);
+ window.location.assign(
+ buildOrg2CloudLoginUrl(createWebAuthCallbackUrl())
+ );
+ } catch {
+ setStartError(t("web.authCallback.failed"));
+ }
+ }, [t]);
if (auth) return ;
return (
@@ -50,14 +59,18 @@ export function WebLoginPage() {
size="large"
long
className={`${ONBOARDING_LOGIN_TOKENS.actionButton} w-full`}
- onClick={() => {
- window.location.assign(
- buildOrg2CloudLoginUrl(webAuthCallbackUrl())
- );
- }}
+ onClick={startSignIn}
>
{t("web.login.continue")}
+ {startError ? (
+
+ {startError}
+
+ ) : null}
{t("web.login.hint")}
diff --git a/src/web/features/auth/webAuthFlowState.test.ts b/src/web/features/auth/webAuthFlowState.test.ts
new file mode 100644
index 0000000000..b2c6f1997a
--- /dev/null
+++ b/src/web/features/auth/webAuthFlowState.test.ts
@@ -0,0 +1,79 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ WEB_AUTH_STATE_STORAGE_KEY,
+ consumeWebAuthCallbackState,
+ createWebAuthCallbackUrl,
+ validateWebAuthCallbackState,
+} from "./webAuthFlowState";
+
+function memoryStorage(): Storage {
+ const values = new Map();
+ return {
+ get length() {
+ return values.size;
+ },
+ clear: () => values.clear(),
+ getItem: (key) => values.get(key) ?? null,
+ key: (index) => Array.from(values.keys())[index] ?? null,
+ removeItem: (key) => values.delete(key),
+ setItem: (key, value) => values.set(key, value),
+ };
+}
+
+describe("web auth callback state", () => {
+ it("creates a high-entropy callback correlation stored for this tab", () => {
+ const storage = memoryStorage();
+ const callbackUrl = createWebAuthCallbackUrl({
+ origin: "https://app.example.com",
+ storage,
+ fillRandom: (buffer) => {
+ buffer.fill(0xab);
+ return buffer;
+ },
+ });
+ const state = "ab".repeat(32);
+
+ expect(callbackUrl).toBe(
+ `https://app.example.com/auth/callback?state=${state}`
+ );
+ expect(storage.getItem(WEB_AUTH_STATE_STORAGE_KEY)).toBe(state);
+ });
+
+ it("rejects missing, duplicate, or mismatched callback state", () => {
+ const storage = memoryStorage();
+ storage.setItem(WEB_AUTH_STATE_STORAGE_KEY, "expected");
+
+ for (const href of [
+ "https://app.example.com/auth/callback",
+ "https://app.example.com/auth/callback?state=other",
+ "https://app.example.com/auth/callback?state=expected&state=expected",
+ ]) {
+ expect(
+ validateWebAuthCallbackState(href, {
+ origin: "https://app.example.com",
+ storage,
+ })
+ ).toBeNull();
+ }
+ expect(storage.getItem(WEB_AUTH_STATE_STORAGE_KEY)).toBe("expected");
+ });
+
+ it("accepts the matching callback and consumes it exactly once", () => {
+ const storage = memoryStorage();
+ storage.setItem(WEB_AUTH_STATE_STORAGE_KEY, "expected");
+
+ expect(
+ validateWebAuthCallbackState(
+ "https://app.example.com/auth/callback?state=expected#access_token=x",
+ { origin: "https://app.example.com", storage }
+ )
+ ).toEqual({
+ expectedCallbackUrl:
+ "https://app.example.com/auth/callback?state=expected",
+ state: "expected",
+ });
+ expect(consumeWebAuthCallbackState("expected", storage)).toBe(true);
+ expect(consumeWebAuthCallbackState("expected", storage)).toBe(false);
+ });
+});
diff --git a/src/web/features/auth/webAuthFlowState.ts b/src/web/features/auth/webAuthFlowState.ts
new file mode 100644
index 0000000000..e5391ac29e
--- /dev/null
+++ b/src/web/features/auth/webAuthFlowState.ts
@@ -0,0 +1,97 @@
+const WEB_AUTH_STATE_BYTE_LENGTH = 32;
+
+export const WEB_AUTH_STATE_STORAGE_KEY = "orgii:web-auth-state";
+
+type WebAuthStateStorage = Pick;
+
+interface CreateWebAuthCallbackUrlOptions {
+ origin?: string;
+ storage?: WebAuthStateStorage;
+ fillRandom?: (buffer: Uint8Array) => Uint8Array;
+}
+
+interface ValidateWebAuthCallbackStateOptions {
+ origin?: string;
+ storage?: WebAuthStateStorage;
+}
+
+function browserStorage(): WebAuthStateStorage {
+ return window.sessionStorage;
+}
+
+function browserRandom(buffer: Uint8Array): Uint8Array {
+ return window.crypto.getRandomValues(buffer);
+}
+
+function randomState(fillRandom: (buffer: Uint8Array) => Uint8Array): string {
+ const bytes = fillRandom(new Uint8Array(WEB_AUTH_STATE_BYTE_LENGTH));
+ if (bytes.length !== WEB_AUTH_STATE_BYTE_LENGTH) {
+ throw new Error("Web auth state generator returned the wrong byte length");
+ }
+ return Array.from(bytes, (value) => value.toString(16).padStart(2, "0")).join(
+ ""
+ );
+}
+
+/** Start one browser sign-in episode and bind its callback to this tab. */
+export function createWebAuthCallbackUrl(
+ options: CreateWebAuthCallbackUrlOptions = {}
+): string {
+ const origin = options.origin ?? window.location.origin;
+ const storage = options.storage ?? browserStorage();
+ const state = randomState(options.fillRandom ?? browserRandom);
+ storage.setItem(WEB_AUTH_STATE_STORAGE_KEY, state);
+
+ const callbackUrl = new URL("/auth/callback", origin);
+ callbackUrl.searchParams.set("state", state);
+ return callbackUrl.toString();
+}
+
+export interface ValidatedWebAuthCallbackState {
+ expectedCallbackUrl: string;
+ state: string;
+}
+
+/**
+ * Correlate a callback with the sign-in episode started in this tab.
+ * Consumption is separate so malformed credentials cannot burn a valid state.
+ */
+export function validateWebAuthCallbackState(
+ callbackHref: string,
+ options: ValidateWebAuthCallbackStateOptions = {}
+): ValidatedWebAuthCallbackState | null {
+ const origin = options.origin ?? window.location.origin;
+ const storage = options.storage ?? browserStorage();
+ const expectedState = storage.getItem(WEB_AUTH_STATE_STORAGE_KEY);
+ if (!expectedState) return null;
+
+ let callbackUrl: URL;
+ try {
+ callbackUrl = new URL(callbackHref);
+ } catch {
+ return null;
+ }
+ const callbackStates = callbackUrl.searchParams.getAll("state");
+ if (callbackStates.length !== 1 || callbackStates[0] !== expectedState) {
+ return null;
+ }
+
+ const expectedCallbackUrl = new URL("/auth/callback", origin);
+ expectedCallbackUrl.searchParams.set("state", expectedState);
+ return {
+ expectedCallbackUrl: expectedCallbackUrl.toString(),
+ state: expectedState,
+ };
+}
+
+/** Consume a previously validated state exactly once. */
+export function consumeWebAuthCallbackState(
+ expectedState: string,
+ storage: WebAuthStateStorage = browserStorage()
+): boolean {
+ if (storage.getItem(WEB_AUTH_STATE_STORAGE_KEY) !== expectedState) {
+ return false;
+ }
+ storage.removeItem(WEB_AUTH_STATE_STORAGE_KEY);
+ return true;
+}
diff --git a/src/web/features/sessions/WebCloudRealtimeScope.test.ts b/src/web/features/sessions/WebCloudRealtimeScope.test.ts
new file mode 100644
index 0000000000..d6a1167569
--- /dev/null
+++ b/src/web/features/sessions/WebCloudRealtimeScope.test.ts
@@ -0,0 +1,93 @@
+/** @vitest-environment jsdom */
+import { Provider, createStore } from "jotai";
+import React from "react";
+import { MemoryRouter } from "react-router-dom";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import {
+ org2CloudOrgsAtom,
+ sidebarActiveCloudOrgIdAtom,
+} from "@src/features/Org2Cloud/org2CloudOrgsAtom";
+import { createSmokeRoot } from "@src/test/reactSmokeHarness";
+
+import {
+ WebCloudRealtimeScope,
+ resolveWebActiveCloudOrgId,
+} from "./WebCloudRealtimeScope";
+
+const mocks = vi.hoisted(() => ({
+ useRealtime: vi.fn(),
+}));
+
+vi.mock("@src/features/Org2Cloud/useOrg2CloudRealtime", () => ({
+ useOrg2CloudRealtime: () => mocks.useRealtime(),
+}));
+
+describe("resolveWebActiveCloudOrgId", () => {
+ const availableOrgIds = ["org-1", "org two"];
+
+ it("prefers a valid session route over query and fallback scopes", () => {
+ expect(
+ resolveWebActiveCloudOrgId({
+ pathname: "/sessions/org%20two/session-1/replay",
+ search: "?org=org-1",
+ availableOrgIds,
+ })
+ ).toBe("org two");
+ });
+
+ it("uses a valid query scope, then the first available organization", () => {
+ expect(
+ resolveWebActiveCloudOrgId({
+ pathname: "/sessions",
+ search: "?org=org%20two",
+ availableOrgIds,
+ })
+ ).toBe("org two");
+ expect(
+ resolveWebActiveCloudOrgId({
+ pathname: "/sessions/missing/session-1",
+ search: "?org=missing",
+ availableOrgIds,
+ })
+ ).toBe("org-1");
+ });
+});
+
+describe("WebCloudRealtimeScope", () => {
+ const roots: Array> = [];
+
+ afterEach(async () => {
+ await Promise.all(roots.splice(0).map((root) => root.unmount()));
+ mocks.useRealtime.mockReset();
+ });
+
+ it("projects the route org for Realtime and clears it on teardown", async () => {
+ const store = createStore();
+ store.set(org2CloudOrgsAtom, [
+ { orgId: "org-1", name: "One", role: "member" },
+ { orgId: "org-2", name: "Two", role: "member" },
+ ]);
+ const root = createSmokeRoot();
+ roots.push(root);
+
+ await root.render(
+ React.createElement(
+ Provider,
+ { store },
+ React.createElement(
+ MemoryRouter,
+ { initialEntries: ["/sessions/org-2/session-1"] },
+ React.createElement(WebCloudRealtimeScope)
+ )
+ )
+ );
+
+ expect(store.get(sidebarActiveCloudOrgIdAtom)).toBe("org-2");
+ expect(mocks.useRealtime).toHaveBeenCalled();
+
+ await root.unmount();
+ roots.splice(roots.indexOf(root), 1);
+ expect(store.get(sidebarActiveCloudOrgIdAtom)).toBeNull();
+ });
+});
diff --git a/src/web/features/sessions/WebCloudRealtimeScope.tsx b/src/web/features/sessions/WebCloudRealtimeScope.tsx
new file mode 100644
index 0000000000..5d59dab53b
--- /dev/null
+++ b/src/web/features/sessions/WebCloudRealtimeScope.tsx
@@ -0,0 +1,69 @@
+import { useAtomValue, useSetAtom } from "jotai";
+import { useLayoutEffect } from "react";
+import { useLocation } from "react-router-dom";
+
+import {
+ org2CloudOrgsAtom,
+ sidebarActiveCloudOrgIdAtom,
+} from "@src/features/Org2Cloud/org2CloudOrgsAtom";
+import { useOrg2CloudRealtime } from "@src/features/Org2Cloud/useOrg2CloudRealtime";
+
+function decodedPathSegment(value: string | undefined): string | null {
+ if (!value) return null;
+ try {
+ return decodeURIComponent(value);
+ } catch {
+ return null;
+ }
+}
+
+/** Resolve the one cloud organization represented by the current Web route. */
+export function resolveWebActiveCloudOrgId({
+ pathname,
+ search,
+ availableOrgIds,
+}: {
+ pathname: string;
+ search: string;
+ availableOrgIds: readonly string[];
+}): string | null {
+ const available = new Set(availableOrgIds);
+ const routeOrgId = decodedPathSegment(
+ pathname.match(/^\/sessions\/([^/]+)\/[^/]+(?:\/replay)?\/?$/)?.[1]
+ );
+ if (routeOrgId && available.has(routeOrgId)) return routeOrgId;
+
+ const requestedOrgId = new URLSearchParams(search).get("org");
+ if (requestedOrgId && available.has(requestedOrgId)) return requestedOrgId;
+ return availableOrgIds[0] ?? null;
+}
+
+/**
+ * Singleton owner for the Web app's active-org projection and Realtime lease.
+ * It lives inside the auth-keyed sessions provider, so sign-out tears both
+ * down together.
+ */
+export function WebCloudRealtimeScope() {
+ const location = useLocation();
+ const orgs = useAtomValue(org2CloudOrgsAtom);
+ const setActiveOrgId = useSetAtom(sidebarActiveCloudOrgIdAtom);
+ const activeOrgId = resolveWebActiveCloudOrgId({
+ pathname: location.pathname,
+ search: location.search,
+ availableOrgIds: orgs.map((org) => org.orgId),
+ });
+
+ useLayoutEffect(() => {
+ setActiveOrgId(activeOrgId);
+ }, [activeOrgId, setActiveOrgId]);
+
+ useLayoutEffect(
+ () => () => {
+ setActiveOrgId(null);
+ },
+ [setActiveOrgId]
+ );
+
+ useOrg2CloudRealtime();
+ return null;
+}
diff --git a/src/web/features/sessions/WebSessionPage.test.ts b/src/web/features/sessions/WebSessionPage.test.ts
index d053d7f25e..dcfb6c7ba6 100644
--- a/src/web/features/sessions/WebSessionPage.test.ts
+++ b/src/web/features/sessions/WebSessionPage.test.ts
@@ -170,6 +170,7 @@ vi.mock("./useCloudSessionEvents", () => ({
events: testState.cloudEvents,
status: "success",
error: null,
+ progress: null,
refresh: vi.fn(),
}),
}));
diff --git a/src/web/features/sessions/WebSessionPage.tsx b/src/web/features/sessions/WebSessionPage.tsx
index b4ecbf036b..b3f0ecd9fc 100644
--- a/src/web/features/sessions/WebSessionPage.tsx
+++ b/src/web/features/sessions/WebSessionPage.tsx
@@ -90,7 +90,12 @@ interface WebSessionWorkstationPaneProps {
currentEventId: string | null;
loadStatus: SessionLoadStatus;
loadError: string | null;
+ loadProgress: {
+ loadedEvents: number;
+ totalEvents: number | null;
+ } | null;
replayState: ReplayControllerState;
+ onRetry: () => void;
onSeek: (index: number) => void;
onPlay: () => void;
onPause: () => void;
@@ -107,7 +112,9 @@ const WebSessionWorkstationPane = memo(function WebSessionWorkstationPane({
currentEventId,
loadStatus,
loadError,
+ loadProgress,
replayState,
+ onRetry,
onSeek,
onPlay,
onPause,
@@ -129,6 +136,8 @@ const WebSessionWorkstationPane = memo(function WebSessionWorkstationPane({
events={events as SessionEvent[]}
loadStatus={loadStatus}
loadError={loadError}
+ loadProgress={loadProgress}
+ onRetry={onRetry}
currentEventId={currentEventId}
replayEndIndex={deferredReplayEndIndex}
/>
@@ -169,6 +178,7 @@ export function WebSessionPage({
events,
status: transcriptStatus,
error: transcriptError,
+ progress: transcriptProgress,
refresh: refreshTranscript,
} = cloudEvents;
const sessionView = useWebSessionViewMode({
@@ -323,7 +333,9 @@ export function WebSessionPage({
currentEventId={currentEventId}
loadStatus={transcriptStatus}
loadError={transcriptError}
+ loadProgress={transcriptProgress}
replayState={replayState}
+ onRetry={reloadTranscript}
onSeek={replay.seek}
onPlay={replay.play}
onPause={replay.pause}
diff --git a/src/web/features/sessions/useCloudSessionEvents.test.ts b/src/web/features/sessions/useCloudSessionEvents.test.ts
new file mode 100644
index 0000000000..20dfc55ef9
--- /dev/null
+++ b/src/web/features/sessions/useCloudSessionEvents.test.ts
@@ -0,0 +1,397 @@
+/** @vitest-environment jsdom */
+import React from "react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { SessionEvent } from "@src/engines/SessionCore";
+import type { SessionEventSegmentsSnapshot } from "@src/features/TeamCollaboration/sync/CollabSyncBackend";
+import { createSmokeRoot, dispatch } from "@src/test/reactSmokeHarness";
+
+import { useCloudSessionEvents } from "./useCloudSessionEvents";
+import type { WebSessionListItem } from "./useWebSessionRoster";
+
+const mocks = vi.hoisted(() => ({
+ getFreshSession: vi.fn(),
+ readCache: vi.fn(),
+ writeCache: vi.fn(),
+ deleteCache: vi.fn(),
+ canRead: vi.fn(),
+ shouldFetch: vi.fn(),
+ startPoller: vi.fn(),
+ poll: null as null | (() => void | Promise),
+ stream: vi.fn(),
+}));
+
+vi.mock("../auth/useFreshWebCloudSession", () => ({
+ useFreshWebCloudSession: () => mocks.getFreshSession,
+}));
+
+vi.mock("@src/features/Org2Cloud/org2CloudBackendAdapter", () => ({
+ buildCloudSessionFetchClient: (
+ _accessToken: string,
+ _endpoint: unknown,
+ options: {
+ onTransferProgress?: (progress: {
+ decodedEvents: number;
+ totalEvents: number | null;
+ }) => void;
+ }
+ ) => ({
+ getSessionEventSegments: vi.fn(),
+ streamSessionEventSegments: (
+ input: unknown,
+ onPage: (page: SessionEventSegmentsSnapshot) => Promise
+ ) => mocks.stream(input, onPage, options),
+ }),
+}));
+
+vi.mock("./webCloudSessionEventCache", () => ({
+ readWebCloudSessionEventCache: (...args: unknown[]) =>
+ mocks.readCache(...args),
+ writeWebCloudSessionEventCache: (...args: unknown[]) =>
+ mocks.writeCache(...args),
+ deleteWebCloudSessionEventCache: (...args: unknown[]) =>
+ mocks.deleteCache(...args),
+}));
+
+vi.mock("./webCloudSessionCachePolicy", () => ({
+ buildWebCloudSessionCacheKey: () => "cache-key",
+ canReadWebCloudSessionEvents: (value: WebSessionListItem) =>
+ mocks.canRead(value),
+ shouldFetchWebCloudSessionEvents: (...args: unknown[]) =>
+ mocks.shouldFetch(...args),
+}));
+
+vi.mock("@src/shared/scheduling/visibilityAwarePoller", () => ({
+ startVisibilityAwarePoller: (...args: unknown[]) =>
+ mocks.startPoller(...args),
+}));
+
+function event(id: string): SessionEvent {
+ return { id } as SessionEvent;
+}
+
+function page(
+ seq: number,
+ ids: string[],
+ count: number,
+ isTail = false,
+ summary?: { frozenSeq: number; tailHash: string | null }
+): SessionEventSegmentsSnapshot {
+ return {
+ epoch: 1,
+ frozenSeq: summary?.frozenSeq ?? (isTail ? seq - 1 : seq),
+ tailHash: summary?.tailHash ?? (isTail ? `tail-${seq}` : null),
+ count,
+ segments: [
+ {
+ seq: isTail ? 0 : seq,
+ isTail,
+ events: ids.map(event),
+ eventCount: ids.length,
+ segmentHash: `segment-${seq}`,
+ },
+ ],
+ };
+}
+
+function session(id: string, eventsCount = 4): WebSessionListItem {
+ return {
+ id,
+ orgId: "org-1",
+ orgName: "Org One",
+ sourceSessionId: `source-${id}`,
+ status: "stopped",
+ eventsEpoch: 1,
+ eventsCount,
+ writable: false,
+ } as WebSessionListItem;
+}
+
+function Probe({ value }: { value: WebSessionListItem }) {
+ const result = useCloudSessionEvents(value);
+ return React.createElement(
+ "div",
+ {
+ "data-status": result.status,
+ "data-progress": result.progress
+ ? `${result.progress.loadedEvents}/${result.progress.totalEvents}`
+ : "none",
+ },
+ React.createElement(
+ "span",
+ { "data-events": true },
+ result.events.map((item) => item.id).join(",")
+ ),
+ React.createElement(
+ "button",
+ { "data-retry": true, onClick: () => void result.refresh() },
+ "retry"
+ )
+ );
+}
+
+describe("useCloudSessionEvents streaming", () => {
+ const roots: Array> = [];
+
+ beforeEach(() => {
+ mocks.getFreshSession.mockReset().mockResolvedValue({
+ accessToken: "token",
+ });
+ mocks.readCache.mockReset().mockResolvedValue(null);
+ mocks.writeCache.mockReset().mockResolvedValue(undefined);
+ mocks.deleteCache.mockReset().mockResolvedValue(undefined);
+ mocks.canRead
+ .mockReset()
+ .mockImplementation(
+ (value: WebSessionListItem) =>
+ value.accessMode !== "metadata_only" &&
+ value.eventsEpoch !== undefined
+ );
+ mocks.shouldFetch.mockReset().mockReturnValue(true);
+ mocks.poll = null;
+ mocks.startPoller
+ .mockReset()
+ .mockImplementation(
+ (_document: Document, poll: () => void | Promise) => {
+ mocks.poll = poll;
+ return vi.fn();
+ }
+ );
+ mocks.stream.mockReset();
+ });
+
+ afterEach(async () => {
+ await Promise.all(roots.splice(0).map((root) => root.unmount()));
+ });
+
+ it("publishes each decoded page before the full session finishes", async () => {
+ let releaseFinalPage = () => {};
+ const waitForFinalPage = new Promise((resolve) => {
+ releaseFinalPage = resolve;
+ });
+ mocks.stream.mockImplementation(
+ async (
+ _input,
+ onPage: (value: SessionEventSegmentsSnapshot) => Promise,
+ options: {
+ onTransferProgress?: (value: {
+ decodedEvents: number;
+ totalEvents: number | null;
+ }) => void;
+ }
+ ) => {
+ const summary = { frozenSeq: 1, tailHash: "tail-2" };
+ options.onTransferProgress?.({ decodedEvents: 2, totalEvents: 4 });
+ await onPage(page(1, ["one", "two"], 4, false, summary));
+ await waitForFinalPage;
+ options.onTransferProgress?.({ decodedEvents: 4, totalEvents: 4 });
+ await onPage(page(2, ["three", "four"], 4, true, summary));
+ return {
+ epoch: 1,
+ frozenSeq: 1,
+ tailHash: "tail-2",
+ count: 4,
+ };
+ }
+ );
+
+ const root = createSmokeRoot();
+ roots.push(root);
+ await root.render(
+ React.createElement(Probe, { value: session("session-1") })
+ );
+
+ const probe = root.container.firstElementChild;
+ expect(probe?.getAttribute("data-status")).toBe("loading");
+ expect(probe?.getAttribute("data-progress")).toBe("2/4");
+ expect(probe?.querySelector("[data-events]")?.textContent).toBe("one,two");
+
+ await dispatch(releaseFinalPage);
+
+ expect(probe?.getAttribute("data-status")).toBe("loaded");
+ expect(probe?.getAttribute("data-progress")).toBe("none");
+ expect(probe?.querySelector("[data-events]")?.textContent).toBe(
+ "one,two,three,four"
+ );
+ expect(mocks.writeCache).toHaveBeenCalledOnce();
+ });
+
+ it("ignores a late page after the user switches sessions", async () => {
+ let releaseOldSession = () => {};
+ const oldSessionGate = new Promise((resolve) => {
+ releaseOldSession = resolve;
+ });
+ mocks.stream.mockImplementation(
+ async (
+ input: { sessionRowId: string },
+ onPage: (value: SessionEventSegmentsSnapshot) => Promise
+ ) => {
+ if (input.sessionRowId === "session-old") {
+ await oldSessionGate;
+ await onPage(page(1, ["stale"], 1, true));
+ } else {
+ await onPage(page(1, ["current"], 1, true));
+ }
+ return {
+ epoch: 1,
+ frozenSeq: 0,
+ tailHash: "tail-1",
+ count: 1,
+ };
+ }
+ );
+
+ const root = createSmokeRoot();
+ roots.push(root);
+ await root.render(
+ React.createElement(Probe, { value: session("session-old", 1) })
+ );
+ await root.render(
+ React.createElement(Probe, { value: session("session-new", 1) })
+ );
+
+ expect(root.container.querySelector("[data-events]")?.textContent).toBe(
+ "current"
+ );
+ await dispatch(releaseOldSession);
+ expect(root.container.querySelector("[data-events]")?.textContent).toBe(
+ "current"
+ );
+ });
+
+ it("keeps a partial page visible after failure and replaces it on retry", async () => {
+ mocks.stream
+ .mockImplementationOnce(
+ async (
+ _input,
+ onPage: (value: SessionEventSegmentsSnapshot) => Promise
+ ) => {
+ await onPage(page(1, ["partial"], 2));
+ throw new Error("network interrupted");
+ }
+ )
+ .mockImplementationOnce(
+ async (
+ _input,
+ onPage: (value: SessionEventSegmentsSnapshot) => Promise
+ ) => {
+ await onPage(page(1, ["complete-a", "complete-b"], 2, true));
+ return {
+ epoch: 1,
+ frozenSeq: 0,
+ tailHash: "tail-1",
+ count: 2,
+ };
+ }
+ );
+
+ const root = createSmokeRoot();
+ roots.push(root);
+ await root.render(
+ React.createElement(Probe, { value: session("session-retry", 2) })
+ );
+
+ const probe = root.container.firstElementChild;
+ expect(probe?.getAttribute("data-status")).toBe("error");
+ expect(probe?.querySelector("[data-events]")?.textContent).toBe("partial");
+
+ await dispatch(() =>
+ root.container.querySelector("[data-retry]")?.click()
+ );
+
+ expect(probe?.getAttribute("data-status")).toBe("loaded");
+ expect(probe?.querySelector("[data-events]")?.textContent).toBe(
+ "complete-a,complete-b"
+ );
+ });
+
+ it("hides and evicts a transcript immediately after permission downgrade", async () => {
+ mocks.stream.mockImplementation(
+ async (
+ _input,
+ onPage: (value: SessionEventSegmentsSnapshot) => Promise
+ ) => {
+ await onPage(page(1, ["private-event"], 1, true));
+ return {
+ epoch: 1,
+ frozenSeq: 0,
+ tailHash: "tail-1",
+ count: 1,
+ };
+ }
+ );
+ const root = createSmokeRoot();
+ roots.push(root);
+ const readable = session("session-private", 1);
+ await root.render(React.createElement(Probe, { value: readable }));
+ expect(root.container.querySelector("[data-events]")?.textContent).toBe(
+ "private-event"
+ );
+
+ await root.render(
+ React.createElement(Probe, {
+ value: {
+ ...readable,
+ accessMode: "metadata_only",
+ eventsEpoch: undefined,
+ eventsCount: undefined,
+ },
+ })
+ );
+
+ expect(root.container.querySelector("[data-events]")?.textContent).toBe("");
+ expect(root.container.firstElementChild?.getAttribute("data-status")).toBe(
+ "loaded"
+ );
+ expect(mocks.stream).toHaveBeenCalledOnce();
+ expect(mocks.deleteCache).toHaveBeenCalledWith("cache-key");
+ });
+
+ it("bypasses a fresh cache during the running-session safety poll", async () => {
+ const cachedSnapshot = {
+ epoch: 1,
+ frozenSeq: 0,
+ tailHash: "tail-1",
+ count: 1,
+ segments: [],
+ events: [event("cached")],
+ };
+ mocks.readCache
+ .mockResolvedValueOnce(null)
+ .mockResolvedValue({ snapshot: cachedSnapshot });
+ mocks.shouldFetch.mockReturnValue(false);
+ mocks.stream.mockImplementation(
+ async (
+ _input,
+ onPage: (value: SessionEventSegmentsSnapshot) => Promise
+ ) => {
+ const id = mocks.stream.mock.calls.length === 1 ? "initial" : "polled";
+ await onPage(page(1, [id], 1, true));
+ return {
+ epoch: 1,
+ frozenSeq: 0,
+ tailHash: `tail-${id}`,
+ count: 1,
+ };
+ }
+ );
+ const root = createSmokeRoot();
+ roots.push(root);
+ await root.render(
+ React.createElement(Probe, {
+ value: { ...session("session-running", 1), status: "running" },
+ })
+ );
+ expect(mocks.stream).toHaveBeenCalledOnce();
+ expect(mocks.poll).not.toBeNull();
+
+ await React.act(async () => {
+ await mocks.poll?.();
+ });
+
+ expect(mocks.stream).toHaveBeenCalledTimes(2);
+ expect(root.container.querySelector("[data-events]")?.textContent).toBe(
+ "polled"
+ );
+ });
+});
diff --git a/src/web/features/sessions/useCloudSessionEvents.ts b/src/web/features/sessions/useCloudSessionEvents.ts
index 184bfb7e75..c3a3a65240 100644
--- a/src/web/features/sessions/useCloudSessionEvents.ts
+++ b/src/web/features/sessions/useCloudSessionEvents.ts
@@ -3,6 +3,10 @@ import { useCallback, useEffect, useRef, useState } from "react";
import type { SessionEvent } from "@src/engines/SessionCore";
import { mergeCloudSessionEventSnapshot } from "@src/features/Org2Cloud/cloudSessionEventSegmentMerge";
import { buildCloudSessionFetchClient } from "@src/features/Org2Cloud/org2CloudBackendAdapter";
+import type {
+ SessionEventSegmentsSnapshot,
+ SessionEventSegmentsSummary,
+} from "@src/features/TeamCollaboration/sync/CollabSyncBackend";
import { startVisibilityAwarePoller } from "@src/shared/scheduling/visibilityAwarePoller";
import { useFreshWebCloudSession } from "../auth/useFreshWebCloudSession";
@@ -10,9 +14,11 @@ import type { CloudSessionEventSnapshot } from "./cloudSessionSegments";
import type { WebSessionListItem } from "./useWebSessionRoster";
import {
buildWebCloudSessionCacheKey,
+ canReadWebCloudSessionEvents,
shouldFetchWebCloudSessionEvents,
} from "./webCloudSessionCachePolicy";
import {
+ deleteWebCloudSessionEventCache,
readWebCloudSessionEventCache,
writeWebCloudSessionEventCache,
} from "./webCloudSessionEventCache";
@@ -20,12 +26,71 @@ import { cloudSessionEventTarget } from "./webSessionLocation";
/** Poll running sessions lightly while the tab is visible. */
const RUNNING_SESSION_POLL_MS = 30_000;
+const PROGRESS_UPDATE_INTERVAL_MS = 150;
+
+export interface CloudSessionLoadProgress {
+ loadedEvents: number;
+ totalEvents: number | null;
+}
interface CloudSessionEventsState {
sessionKey: string | null;
status: "loading" | "loaded" | "error";
events: SessionEvent[];
error: string | null;
+ progress: CloudSessionLoadProgress | null;
+}
+
+/** Coalesce segment decode ticks before they cross the React boundary. */
+function createProgressReporter(
+ write: (progress: CloudSessionLoadProgress) => void
+): {
+ report: (progress: CloudSessionLoadProgress) => void;
+ cancel: () => void;
+} {
+ let lastWriteAt = 0;
+ let timer: ReturnType | null = null;
+ let pending: CloudSessionLoadProgress | null = null;
+
+ const cancel = () => {
+ if (timer) clearTimeout(timer);
+ timer = null;
+ pending = null;
+ };
+ const commit = (progress: CloudSessionLoadProgress) => {
+ lastWriteAt = Date.now();
+ write(progress);
+ };
+ const flushPending = () => {
+ timer = null;
+ if (!pending) return;
+ const progress = pending;
+ pending = null;
+ commit(progress);
+ };
+
+ return {
+ report(progress) {
+ const elapsed = Date.now() - lastWriteAt;
+ if (elapsed >= PROGRESS_UPDATE_INTERVAL_MS) {
+ cancel();
+ commit(progress);
+ return;
+ }
+ pending = progress;
+ timer ??= setTimeout(flushPending, PROGRESS_UPDATE_INTERVAL_MS - elapsed);
+ },
+ cancel,
+ };
+}
+
+function frozenEventCount(snapshot: CloudSessionEventSnapshot | null): number {
+ if (!snapshot) return 0;
+ return snapshot.segments.reduce(
+ (count, segment) =>
+ segment.isTail ? count : count + segment.events.length,
+ 0
+ );
}
export function useCloudSessionEvents(session: WebSessionListItem | null) {
@@ -35,16 +100,22 @@ export function useCloudSessionEvents(session: WebSessionListItem | null) {
status: "loading",
events: [],
error: null,
+ progress: null,
});
const snapshotRef = useRef(null);
const inFlightRef = useRef | null>(null);
const generationRef = useRef(0);
const abortRef = useRef(null);
const sessionKey = session ? `${session.orgId}:${session.id}` : null;
+ const canReadEvents = session ? canReadWebCloudSessionEvents(session) : false;
const refresh = useCallback(
- (forceFull = false): Promise => {
- if (!session) return Promise.resolve();
+ (
+ forceFull = false,
+ revealProgress = true,
+ bypassCache = false
+ ): Promise => {
+ if (!session || !canReadEvents) return Promise.resolve();
if (inFlightRef.current) return inFlightRef.current;
const generation = generationRef.current;
const request = (async () => {
@@ -53,10 +124,13 @@ export function useCloudSessionEvents(session: WebSessionListItem | null) {
const cacheKey = buildWebCloudSessionCacheKey(fresh, session);
const cachedRecord = await readWebCloudSessionEventCache(cacheKey);
+ if (generation !== generationRef.current) return;
const cachedSnapshot = cachedRecord?.snapshot ?? null;
+ const displayedSnapshot = snapshotRef.current ?? cachedSnapshot;
if (
cachedSnapshot &&
+ !bypassCache &&
!shouldFetchWebCloudSessionEvents(forceFull, cachedSnapshot, session)
) {
snapshotRef.current = cachedSnapshot;
@@ -65,6 +139,7 @@ export function useCloudSessionEvents(session: WebSessionListItem | null) {
status: "loaded",
events: cachedSnapshot.events,
error: null,
+ progress: null,
});
return;
}
@@ -73,44 +148,193 @@ export function useCloudSessionEvents(session: WebSessionListItem | null) {
? null
: (snapshotRef.current ?? cachedSnapshot);
const fullRead = forceFull || !previous;
- if (!previous) {
+ if (displayedSnapshot) {
+ snapshotRef.current = displayedSnapshot;
+ setState({
+ sessionKey,
+ status: "loaded",
+ events: displayedSnapshot.events,
+ error: null,
+ progress: revealProgress
+ ? {
+ loadedEvents: displayedSnapshot.events.length,
+ totalEvents:
+ session.eventsCount ?? displayedSnapshot.count ?? null,
+ }
+ : null,
+ });
+ } else {
setState({
sessionKey,
status: "loading",
events: [],
error: null,
+ progress: revealProgress
+ ? {
+ loadedEvents: 0,
+ totalEvents: session.eventsCount ?? null,
+ }
+ : null,
});
}
+
const controller = new AbortController();
abortRef.current = controller;
try {
- const client = buildCloudSessionFetchClient(fresh.accessToken);
const target = cloudSessionEventTarget(session);
- let incoming = await client.getSessionEventSegments({
- ...target,
- ...(fullRead || previous?.frozenSeq == null
- ? {}
- : { afterSeq: previous.frozenSeq }),
- signal: controller.signal,
- });
- if (
- !fullRead &&
- previous &&
- incoming.epoch !== null &&
- incoming.epoch !== previous.epoch
- ) {
- incoming = await client.getSessionEventSegments({
+ const fetchAttempt = async (
+ base: CloudSessionEventSnapshot | null,
+ attemptFullRead: boolean
+ ): Promise<{
+ snapshot: CloudSessionEventSnapshot;
+ epochChanged: boolean;
+ }> => {
+ let streamedSnapshot: CloudSessionEventSnapshot | null = null;
+ let epochChanged = false;
+ const streamedSegments = attemptFullRead
+ ? []
+ : (base?.segments.filter((segment) => !segment.isTail) ?? []);
+ const streamedEvents = streamedSegments.flatMap(
+ (segment) => segment.events
+ );
+ const baseEvents = attemptFullRead ? 0 : frozenEventCount(base);
+ const writeProgress = (progress: CloudSessionLoadProgress) => {
+ if (
+ !revealProgress ||
+ controller.signal.aborted ||
+ generation !== generationRef.current
+ ) {
+ return;
+ }
+ setState((current) =>
+ current.sessionKey === sessionKey
+ ? { ...current, progress }
+ : current
+ );
+ };
+ const progressReporter = createProgressReporter(writeProgress);
+ const client = buildCloudSessionFetchClient(
+ fresh.accessToken,
+ undefined,
+ {
+ onTransferProgress: ({ decodedEvents, totalEvents }) => {
+ const loadedEvents = baseEvents + decodedEvents;
+ progressReporter.report({
+ loadedEvents:
+ totalEvents === null
+ ? loadedEvents
+ : Math.min(loadedEvents, totalEvents),
+ totalEvents,
+ });
+ },
+ }
+ );
+ const input = {
...target,
+ ...(attemptFullRead || base?.frozenSeq == null
+ ? {}
+ : { afterSeq: base.frozenSeq }),
signal: controller.signal,
- });
+ };
+ const applyPage = async (page: SessionEventSegmentsSnapshot) => {
+ if (
+ controller.signal.aborted ||
+ generation !== generationRef.current
+ ) {
+ return;
+ }
+ if (!attemptFullRead && base && page.epoch !== base.epoch) {
+ if (page.epoch !== null) {
+ epochChanged = true;
+ return;
+ }
+ streamedSegments.length = 0;
+ streamedEvents.length = 0;
+ }
+ if (epochChanged) return;
+ for (const segment of page.segments) {
+ streamedSegments.push(segment);
+ streamedEvents.push(...segment.events);
+ }
+ streamedSnapshot = {
+ ...page,
+ segments: [...streamedSegments],
+ events: [...streamedEvents],
+ };
+ const pageSnapshot = streamedSnapshot;
+ snapshotRef.current = pageSnapshot;
+ const progress = {
+ loadedEvents: pageSnapshot.events.length,
+ totalEvents: page.count ?? session.eventsCount ?? null,
+ };
+ progressReporter.cancel();
+ if (revealProgress || !base) {
+ setState({
+ sessionKey,
+ status: "loading",
+ events: pageSnapshot.events,
+ error: null,
+ progress: revealProgress ? progress : null,
+ });
+ }
+ };
+
+ try {
+ const stream = client.streamSessionEventSegments;
+ let summary: SessionEventSegmentsSummary;
+ if (stream) {
+ summary = await stream(input, applyPage);
+ } else {
+ const page = await client.getSessionEventSegments(input);
+ await applyPage(page);
+ const { segments: _segments, ...pageSummary } = page;
+ summary = pageSummary;
+ }
+ const snapshot =
+ streamedSnapshot ??
+ mergeCloudSessionEventSnapshot(
+ base,
+ { ...summary, segments: [] },
+ attemptFullRead
+ );
+ return { snapshot, epochChanged };
+ } finally {
+ progressReporter.cancel();
+ }
+ };
+
+ let attempt = await fetchAttempt(previous, fullRead);
+ if (attempt.epochChanged) {
+ if (revealProgress) {
+ setState((current) =>
+ current.sessionKey === sessionKey
+ ? {
+ ...current,
+ progress: {
+ loadedEvents: 0,
+ totalEvents: session.eventsCount ?? null,
+ },
+ }
+ : current
+ );
+ }
+ attempt = await fetchAttempt(null, true);
+ }
+ if (
+ controller.signal.aborted ||
+ generation !== generationRef.current
+ ) {
+ return;
}
- if (generation !== generationRef.current) return;
- const merged = mergeCloudSessionEventSnapshot(
- previous,
- incoming,
- fullRead || previous?.epoch !== incoming.epoch
- );
+ const merged = attempt.snapshot;
if (previous && merged === previous) {
+ setState({
+ sessionKey,
+ status: "loaded",
+ events: previous.events,
+ error: null,
+ progress: null,
+ });
return;
}
snapshotRef.current = merged;
@@ -119,26 +343,21 @@ export function useCloudSessionEvents(session: WebSessionListItem | null) {
status: "loaded",
events: merged.events,
error: null,
+ progress: null,
});
void writeWebCloudSessionEventCache(cacheKey, merged);
} catch (error) {
if (controller.signal.aborted || generation !== generationRef.current)
return;
- if (cachedSnapshot) {
- snapshotRef.current = cachedSnapshot;
- setState({
- sessionKey,
- status: "loaded",
- events: cachedSnapshot.events,
- error: null,
- });
- return;
- }
- setState((previousState) => ({
- ...previousState,
+ const fallback = snapshotRef.current ?? cachedSnapshot;
+ if (fallback) snapshotRef.current = fallback;
+ setState({
+ sessionKey,
status: "error",
+ events: fallback?.events ?? [],
error: error instanceof Error ? error.message : String(error),
- }));
+ progress: null,
+ });
} finally {
if (abortRef.current === controller) abortRef.current = null;
}
@@ -148,7 +367,7 @@ export function useCloudSessionEvents(session: WebSessionListItem | null) {
inFlightRef.current = request;
return request;
},
- [getFreshSession, session, sessionKey]
+ [canReadEvents, getFreshSession, session, sessionKey]
);
useEffect(() => {
@@ -157,58 +376,83 @@ export function useCloudSessionEvents(session: WebSessionListItem | null) {
inFlightRef.current = null;
snapshotRef.current = null;
if (!sessionKey || !session) {
- setState({ sessionKey, status: "loading", events: [], error: null });
+ setState({
+ sessionKey,
+ status: "loading",
+ events: [],
+ error: null,
+ progress: null,
+ });
return;
}
-
- void (async () => {
+ if (!canReadEvents) {
+ setState({
+ sessionKey,
+ status: "loaded",
+ events: [],
+ error: null,
+ progress: null,
+ });
const generation = generationRef.current;
- const fresh = await getFreshSession();
- if (!fresh || generation !== generationRef.current) return;
-
- const cacheKey = buildWebCloudSessionCacheKey(fresh, session);
- const cachedRecord = await readWebCloudSessionEventCache(cacheKey);
- const cachedSnapshot = cachedRecord?.snapshot ?? null;
- if (cachedSnapshot) {
- snapshotRef.current = cachedSnapshot;
- setState({
- sessionKey,
- status: "loaded",
- events: cachedSnapshot.events,
- error: null,
- });
- } else {
- setState({ sessionKey, status: "loading", events: [], error: null });
- }
-
- if (!shouldFetchWebCloudSessionEvents(false, cachedSnapshot, session)) {
- return;
- }
-
- await refresh(!cachedSnapshot);
- })();
+ void (async () => {
+ const fresh = await getFreshSession();
+ if (!fresh || generation !== generationRef.current) return;
+ await deleteWebCloudSessionEventCache(
+ buildWebCloudSessionCacheKey(fresh, session)
+ );
+ })();
+ return;
+ }
+ setState({
+ sessionKey,
+ status: "loading",
+ events: [],
+ error: null,
+ progress: {
+ loadedEvents: 0,
+ totalEvents: session.eventsCount ?? null,
+ },
+ });
+ void refresh(false, true);
return () => {
generationRef.current += 1;
abortRef.current?.abort();
};
- }, [getFreshSession, refresh, session, sessionKey]);
+ }, [canReadEvents, getFreshSession, refresh, session, sessionKey]);
useEffect(() => {
- if (!session || session.status !== "running") return undefined;
+ if (!session || !canReadEvents || session.status !== "running") {
+ return undefined;
+ }
return startVisibilityAwarePoller(
document,
- () => refresh(false),
+ () => refresh(false, false, true),
RUNNING_SESSION_POLL_MS
);
- }, [refresh, session]);
+ }, [canReadEvents, refresh, session]);
- const refreshFull = useCallback(() => refresh(true), [refresh]);
+ const refreshFull = useCallback(() => refresh(true, true), [refresh]);
+ if (session && !canReadEvents) {
+ return {
+ status: "loaded" as const,
+ events: [],
+ error: null,
+ progress: null,
+ refresh: refreshFull,
+ };
+ }
if (state.sessionKey !== sessionKey) {
return {
status: "loading" as const,
events: [],
error: null,
+ progress: session
+ ? {
+ loadedEvents: 0,
+ totalEvents: session.eventsCount ?? null,
+ }
+ : null,
refresh: refreshFull,
};
}
diff --git a/src/web/features/sessions/webCloudSessionCachePolicy.test.ts b/src/web/features/sessions/webCloudSessionCachePolicy.test.ts
index eb229ac918..ad7c0c0136 100644
--- a/src/web/features/sessions/webCloudSessionCachePolicy.test.ts
+++ b/src/web/features/sessions/webCloudSessionCachePolicy.test.ts
@@ -4,6 +4,7 @@ import type { CloudSessionEventSnapshot } from "./cloudSessionSegments";
import type { WebSessionListItem } from "./useWebSessionRoster";
import {
buildWebCloudSessionCacheKey,
+ canReadWebCloudSessionEvents,
isWebCloudSessionCacheFresh,
shouldFetchWebCloudSessionEvents,
} from "./webCloudSessionCachePolicy";
@@ -49,13 +50,13 @@ describe("buildWebCloudSessionCacheKey", () => {
});
describe("isWebCloudSessionCacheFresh", () => {
- it("accepts cache when roster summary is absent", () => {
+ it("rejects cache when the authorization-bearing roster summary is absent", () => {
expect(
isWebCloudSessionCacheFresh(
session({ eventsEpoch: undefined }),
snapshot()
)
- ).toBe(true);
+ ).toBe(false);
});
it("rejects cache when epoch or tail hash drift", () => {
@@ -68,6 +69,18 @@ describe("isWebCloudSessionCacheFresh", () => {
});
});
+describe("canReadWebCloudSessionEvents", () => {
+ it("requires a published epoch and more than metadata-only access", () => {
+ expect(canReadWebCloudSessionEvents(session())).toBe(true);
+ expect(
+ canReadWebCloudSessionEvents(session({ eventsEpoch: undefined }))
+ ).toBe(false);
+ expect(
+ canReadWebCloudSessionEvents(session({ accessMode: "metadata_only" }))
+ ).toBe(false);
+ });
+});
+
describe("shouldFetchWebCloudSessionEvents", () => {
it("skips network when a fresh cache exists unless forced", () => {
expect(shouldFetchWebCloudSessionEvents(false, snapshot(), session())).toBe(
@@ -78,4 +91,14 @@ describe("shouldFetchWebCloudSessionEvents", () => {
);
expect(shouldFetchWebCloudSessionEvents(false, null, session())).toBe(true);
});
+
+ it("never fetches an unauthorized transcript", () => {
+ expect(
+ shouldFetchWebCloudSessionEvents(
+ true,
+ snapshot(),
+ session({ accessMode: "metadata_only", eventsEpoch: undefined })
+ )
+ ).toBe(false);
+ });
});
diff --git a/src/web/features/sessions/webCloudSessionCachePolicy.ts b/src/web/features/sessions/webCloudSessionCachePolicy.ts
index 3f074a1114..c4aa04a941 100644
--- a/src/web/features/sessions/webCloudSessionCachePolicy.ts
+++ b/src/web/features/sessions/webCloudSessionCachePolicy.ts
@@ -11,9 +11,22 @@ export function buildWebCloudSessionCacheKey(
return `${org2CloudAuthIdentityKey(auth)}|${session.orgId}|${session.id}`;
}
+/**
+ * The listing adapter removes segment summary metadata when the viewer may
+ * only see session metadata. Treat that omission as an authorization boundary,
+ * not as an old-client cache compatibility signal.
+ */
+export function canReadWebCloudSessionEvents(
+ session: Pick
+): boolean {
+ return (
+ session.accessMode !== "metadata_only" && session.eventsEpoch !== undefined
+ );
+}
+
/**
* Returns true when roster summary metadata matches the cached snapshot.
- * When the roster omits segment summary fields, treat the cache as usable.
+ * An omitted epoch means the current viewer is not authorized to read events.
*/
export function isWebCloudSessionCacheFresh(
session: Pick<
@@ -22,7 +35,7 @@ export function isWebCloudSessionCacheFresh(
>,
snapshot: CloudSessionEventSnapshot
): boolean {
- if (session.eventsEpoch === undefined) return true;
+ if (session.eventsEpoch === undefined) return false;
if (session.eventsEpoch !== snapshot.epoch) return false;
if (
session.eventsFrozenSeq !== undefined &&
@@ -50,6 +63,7 @@ export function shouldFetchWebCloudSessionEvents(
cached: CloudSessionEventSnapshot | null,
session: WebSessionListItem
): boolean {
+ if (!canReadWebCloudSessionEvents(session)) return false;
if (forceFull) return true;
if (!cached) return true;
return !isWebCloudSessionCacheFresh(session, cached);
diff --git a/src/web/features/sessions/webCloudSessionEventCache.ts b/src/web/features/sessions/webCloudSessionEventCache.ts
index b234a4c5ce..15fed7be28 100644
--- a/src/web/features/sessions/webCloudSessionEventCache.ts
+++ b/src/web/features/sessions/webCloudSessionEventCache.ts
@@ -172,3 +172,12 @@ export async function deleteWebCloudSessionEventCache(
// ignore
}
}
+
+/** Remove every transcript snapshot owned by the current browser profile. */
+export async function clearWebCloudSessionEventCache(): Promise {
+ try {
+ await runTransaction("readwrite", (store) => store.clear());
+ } catch {
+ // Cache is best-effort and auth state is cleared synchronously by callers.
+ }
+}
diff --git a/src/web/shell/WebSessionSidebar.test.ts b/src/web/shell/WebSessionSidebar.test.ts
index 32f7c591d7..fbca308eae 100644
--- a/src/web/shell/WebSessionSidebar.test.ts
+++ b/src/web/shell/WebSessionSidebar.test.ts
@@ -23,6 +23,7 @@ const testState = vi.hoisted(() => ({
navigate: vi.fn(),
refresh: vi.fn(),
setAuth: vi.fn(),
+ clearCache: vi.fn(),
sidebarProps: null as Record | null,
}));
@@ -69,6 +70,10 @@ vi.mock("react-router-dom", () => ({
useNavigate: () => testState.navigate,
}));
+vi.mock("../features/sessions/webCloudSessionEventCache", () => ({
+ clearWebCloudSessionEventCache: () => testState.clearCache(),
+}));
+
vi.mock("@src/components/Button", () => ({
default: ({
children,
@@ -178,6 +183,7 @@ describe("WebSessionSidebar", () => {
testState.navigate.mockReset();
testState.refresh.mockReset();
testState.setAuth.mockReset();
+ testState.clearCache.mockReset().mockResolvedValue(undefined);
testState.sidebarProps = null;
});
@@ -258,4 +264,19 @@ describe("WebSessionSidebar", () => {
)
).toBe(true);
});
+
+ it("clears auth and persisted transcripts on sign out", async () => {
+ const root = createSmokeRoot();
+ roots.push(root);
+ await root.render(React.createElement(WebSessionSidebar));
+
+ await dispatch(() =>
+ root.container
+ .querySelector('[aria-label="cloud.signOut"]')
+ ?.click()
+ );
+
+ expect(testState.setAuth).toHaveBeenCalledWith(null);
+ expect(testState.clearCache).toHaveBeenCalledOnce();
+ });
});
diff --git a/src/web/shell/WebSessionSidebar.tsx b/src/web/shell/WebSessionSidebar.tsx
index 9d80612664..dcdcca5ebf 100644
--- a/src/web/shell/WebSessionSidebar.tsx
+++ b/src/web/shell/WebSessionSidebar.tsx
@@ -15,7 +15,9 @@ import {
SidebarOrgSelector,
} from "@src/scaffold/NavigationSidebar";
+import { resolveWebActiveCloudOrgId } from "../features/sessions/WebCloudRealtimeScope";
import { useWebSessions } from "../features/sessions/WebSessionsContext";
+import { clearWebCloudSessionEventCache } from "../features/sessions/webCloudSessionEventCache";
import { webSessionPath } from "../features/sessions/webSessionLocation";
import {
resolveWebCloudSessionMenuItemId,
@@ -48,13 +50,12 @@ export function WebSessionSidebar({ onNavigate }: { onNavigate?: () => void }) {
),
[location.pathname, sessions]
);
- const requestedOrgId = new URLSearchParams(location.search).get("org");
const selectedOrgId =
- selectedSession?.orgId ||
- (requestedOrgId &&
- orgOptions.some((option) => option.value === requestedOrgId)
- ? requestedOrgId
- : String(orgOptions[0]?.value ?? ""));
+ resolveWebActiveCloudOrgId({
+ pathname: location.pathname,
+ search: location.search,
+ availableOrgIds: orgOptions.map((option) => option.value),
+ }) ?? "";
const {
cloudMenuItems,
@@ -99,6 +100,11 @@ export function WebSessionSidebar({ onNavigate }: { onNavigate?: () => void }) {
[navigate, selectedSession?.orgId]
);
+ const handleSignOut = useCallback(() => {
+ setAuth(null);
+ void clearWebCloudSessionEventCache();
+ }, [setAuth]);
+
const sidebarOrgSelector =
orgOptions.length > 0 ? (
void }) {
icon={}
title={t("web.sidebar.signOut", { name: displayName })}
aria-label={t("cloud.signOut")}
- onClick={() => setAuth(null)}
+ onClick={handleSignOut}
/>
}
/>
From e56710d61b5334e62e486d89672c85de38682337 Mon Sep 17 00:00:00 2001
From: hanafish <1106510024@qq.com>
Date: Mon, 24 Aug 2026 14:27:46 +0800
Subject: [PATCH 10/15] fix(web): complete first-use cloud flow
Pre-commit hook ran. Total eslint: 0, total circular: 0
---
.../WebOrganizationOnboarding.md | 43 ++++
.../WebSessionsPage.md | 39 +++
src/api/realtime/codeEditorWebSocket.test.ts | 60 +++++
src/api/realtime/codeEditorWebSocket.ts | 32 ++-
.../registry/initBundledToolRegistry.test.ts | 26 ++
.../rendering/registry/initToolRegistry.ts | 41 ++-
.../Org2Cloud/org2CloudOrgsAtom.test.ts | 70 ++++-
src/features/Org2Cloud/org2CloudOrgsAtom.ts | 136 +++++++++-
src/i18n/locales/en/navigation.json | 14 +-
src/i18n/locales/zh/navigation.json | 14 +-
src/index.tsx | 5 +
.../sessions/WebOrganizationOnboarding.tsx | 225 ++++++++++++++++
.../features/sessions/WebSessionsPage.test.ts | 240 ++++++++++++++++++
src/web/features/sessions/WebSessionsPage.tsx | 32 ++-
.../useWebSessionRoster.integration.test.ts | 157 ++++++++++++
.../features/sessions/useWebSessionRoster.ts | 107 ++++++--
src/web/index.tsx | 4 +-
17 files changed, 1190 insertions(+), 55 deletions(-)
create mode 100644 docs/frontend-ui-audit-2026-08-24/WebOrganizationOnboarding.md
create mode 100644 docs/frontend-ui-audit-2026-08-24/WebSessionsPage.md
create mode 100644 src/api/realtime/codeEditorWebSocket.test.ts
create mode 100644 src/engines/SessionCore/rendering/registry/initBundledToolRegistry.test.ts
create mode 100644 src/web/features/sessions/WebOrganizationOnboarding.tsx
create mode 100644 src/web/features/sessions/WebSessionsPage.test.ts
create mode 100644 src/web/features/sessions/useWebSessionRoster.integration.test.ts
diff --git a/docs/frontend-ui-audit-2026-08-24/WebOrganizationOnboarding.md b/docs/frontend-ui-audit-2026-08-24/WebOrganizationOnboarding.md
new file mode 100644
index 0000000000..c8b5fee9d4
--- /dev/null
+++ b/docs/frontend-ui-audit-2026-08-24/WebOrganizationOnboarding.md
@@ -0,0 +1,43 @@
+# Frontend UI Audit — WebOrganizationOnboarding
+
+**File:** `src/web/features/sessions/WebOrganizationOnboarding.tsx` (225 LOC)
+**Date:** 2026-08-24
+**Auditor:** Codex
+
+## D1 — Raw HTML vs Design System
+
+| Line | Element | Verdict | Reason | Suggested change |
+| ---- | ------------------------------------------------ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- |
+| 180 | `