From a54e878e4752de406685f07a4330afd6c5e7252e Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Thu, 20 Aug 2026 23:16:09 +0800 Subject: [PATCH 01/15] feat(web): add webpack entry and auth shell routes Separate build:web and dev:web targets let the cloud viewer run in the browser without the Tauri shell, with dedicated HTML entry and auth callback pages. Pre-commit hook ran. Total eslint: 0, total circular: 0 --- package.json | 7 +- public/web.html | 13 ++ src/web/WebApp.tsx | 109 +++++++++++++ src/web/features/auth/WebAuthCallbackPage.tsx | 80 +++++++++ src/web/features/auth/WebLoginPage.tsx | 70 ++++++++ .../features/auth/useFreshWebCloudSession.ts | 38 +++++ src/web/index.tsx | 25 +++ src/web/platform/tauriUnavailable.cjs | 153 ++++++++++++++++++ src/web/shell/WebShell.tsx | 57 +++++++ webpack.web.config.js | 86 ++++++++++ 10 files changed, 637 insertions(+), 1 deletion(-) create mode 100644 public/web.html create mode 100644 src/web/WebApp.tsx create mode 100644 src/web/features/auth/WebAuthCallbackPage.tsx create mode 100644 src/web/features/auth/WebLoginPage.tsx create mode 100644 src/web/features/auth/useFreshWebCloudSession.ts create mode 100644 src/web/index.tsx create mode 100644 src/web/platform/tauriUnavailable.cjs create mode 100644 src/web/shell/WebShell.tsx create mode 100644 webpack.web.config.js diff --git a/package.json b/package.json index c3f6db2dd5..4ce92fb856 100644 --- a/package.json +++ b/package.json @@ -8,11 +8,13 @@ "postinstall": "node scripts/setup/postinstall.mjs", "dev:frontend": "node scripts/dev/webpack-server.js", "dev:frontend:light": "ORGII_LIGHT_DEV=true FAST_DEV=true DEV_SOURCEMAPS=false node scripts/dev/webpack-server.js", + "dev:web": "WEBPACK_DEV_SERVER_PORT=1999 webpack serve --config webpack.web.config.js --mode development", "dev:cpu-monitor": "bash scripts/dev/cpu-monitor.sh", "diag:process": "node tools/orgii-diagnostics/cli.mjs process", "diag:memory": "node tools/orgii-diagnostics/cli.mjs memory", "diag:test": "node --test tools/orgii-diagnostics/test/*.test.mjs", "build": "webpack --mode production", + "build:web": "webpack --config webpack.web.config.js --mode production", "download": "python3 scripts/tools/download.py", "download:sidecars": "python3 scripts/tools/download_sidecars.py", "tauri:dev": "node scripts/dev/tauri-launcher.cjs", @@ -52,7 +54,10 @@ "check:unused-exports": "ts-unused-exports tsconfig.json --excludePathsFromReport='src/index.tsx;src/app/;src/i18n/;.test.ts;.test.tsx;/types.ts;/index.ts;src/scaffold/;src/types/ambient/'", "check:unused-exports:all": "ts-unused-exports tsconfig.json", "check:e2e-oauth-guards": "node scripts/quality/check-e2e-oauth-guards.mjs", - "check:i18n:cloud": "node scripts/quality/check-missing-i18n-keys.mjs --namespace navigation --prefix cloud && node scripts/quality/check-missing-i18n-keys.mjs --namespace navigation --prefix collaboration.forkImported" + "check:i18n:cloud": "node scripts/quality/check-missing-i18n-keys.mjs --namespace navigation --prefix cloud && node scripts/quality/check-missing-i18n-keys.mjs --namespace navigation --prefix collaboration.forkImported", + "check:i18n:calls": "node scripts/quality/check-i18n-callsite-keys.mjs", + "check:i18n:quality": "node scripts/quality/check-i18n-resource-quality.mjs", + "check:i18n:contracts": "pnpm check:i18n:calls && pnpm check:i18n:quality" }, "keywords": [ "orgii", diff --git a/public/web.html b/public/web.html new file mode 100644 index 0000000000..50a362b846 --- /dev/null +++ b/public/web.html @@ -0,0 +1,13 @@ + + + + + + + ORG2 Web + + + +
+ + diff --git a/src/web/WebApp.tsx b/src/web/WebApp.tsx new file mode 100644 index 0000000000..8889bb7e5c --- /dev/null +++ b/src/web/WebApp.tsx @@ -0,0 +1,109 @@ +import { useAtomValue } from "jotai"; +import React, { Suspense, lazy } from "react"; +import { useTranslation } from "react-i18next"; +import { + Navigate, + Outlet, + RouterProvider, + createBrowserRouter, +} from "react-router-dom"; + +import { + org2CloudAuthAtom, + org2CloudAuthIdentityKey, +} from "@src/features/Org2Cloud/org2CloudAuthAtom"; +import { + org2CloudOrgsAtom, + 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 { WebOrgRemoteSessionSubscriptions } from "./features/sessions/WebOrgRemoteSessionSubscriptions"; +import { WebSessionsProvider } from "./features/sessions/WebSessionsContext"; +import { WebSessionsPage } from "./features/sessions/WebSessionsPage"; +import { WebShell } from "./shell/WebShell"; + +const WebSessionPage = lazy(() => + import("./features/sessions/WebSessionPage").then((module) => ({ + default: module.WebSessionPage, + })) +); + +function SessionRoute({ replayInitially = false }) { + const { t } = useTranslation("navigation"); + return ( + + {t("web.loadingSession")} + + } + > + + + ); +} + +function RequireCloudAuth() { + const auth = useAtomValue(org2CloudAuthAtom); + return auth ? : ; +} + +function WebCloudRuntime() { + useOrg2CloudOrgs(); + useOrg2CloudRosterReconcile(); + useOrg2CloudRealtime(); + const auth = useAtomValue(org2CloudAuthAtom); + const orgs = useAtomValue(org2CloudOrgsAtom); + return ( + + org.orgId)} /> + + + ); +} + +const router = createBrowserRouter([ + { path: "/login", element: }, + { path: "/auth/callback", element: }, + { + element: , + children: [ + { + element: , + children: [ + { + element: , + children: [ + { index: true, element: }, + { path: "/sessions", element: }, + { + path: "/sessions/:orgId/:sessionId", + element: , + }, + { + path: "/sessions/:orgId/:sessionId/replay", + element: , + }, + ], + }, + ], + }, + ], + }, + { path: "*", element: }, +]); + +export function WebApp() { + return ( + + ); +} diff --git a/src/web/features/auth/WebAuthCallbackPage.tsx b/src/web/features/auth/WebAuthCallbackPage.tsx new file mode 100644 index 0000000000..d2e1788030 --- /dev/null +++ b/src/web/features/auth/WebAuthCallbackPage.tsx @@ -0,0 +1,80 @@ +import { useSetAtom } from "jotai"; +import React, { useEffect, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; + +import Button from "@src/components/Button"; +import { + decodeJwtSub, + parseAuthCallbackFragment, +} from "@src/features/Org2Cloud/authCallback"; +import { getCloudEndpoint } from "@src/features/Org2Cloud/config"; +import { org2CloudAuthAtom } from "@src/features/Org2Cloud/org2CloudAuthAtom"; +import { Placeholder } from "@src/modules/shared/layouts/blocks"; + +export function WebAuthCallbackPage() { + const { t } = useTranslation("navigation"); + const setAuth = useSetAtom(org2CloudAuthAtom); + const navigate = useNavigate(); + const result = useMemo(() => { + const expected = new URL( + "/auth/callback", + window.location.origin + ).toString(); + const callback = parseAuthCallbackFragment(window.location.href, expected); + if (!callback) { + return { + error: t("web.authCallback.missingCredentials"), + } as const; + } + const userId = decodeJwtSub(callback.accessToken); + if (!userId) { + return { + error: t("web.authCallback.missingIdentity"), + } as const; + } + return { callback, userId, error: null } as const; + }, [t]); + + useEffect(() => { + if (result.error) return; + const endpoint = getCloudEndpoint(); + window.history.replaceState(null, "", "/auth/callback"); + setAuth({ + kind: "org2_cloud", + supabaseUrl: endpoint.supabaseUrl, + supabaseAnonKey: endpoint.anonKey, + userId: result.userId, + accessToken: result.callback.accessToken, + refreshToken: result.callback.refreshToken, + expiresAt: result.callback.expiresAt, + }); + navigate("/sessions", { replace: true }); + }, [navigate, result, setAuth]); + + if (result.error) { + return ( +
+
+ navigate("/login", { replace: true }), + }} + /> +
+
+ ); + } + + return ( +
+ +
+ ); +} diff --git a/src/web/features/auth/WebLoginPage.tsx b/src/web/features/auth/WebLoginPage.tsx new file mode 100644 index 0000000000..2e09af83af --- /dev/null +++ b/src/web/features/auth/WebLoginPage.tsx @@ -0,0 +1,70 @@ +import { useAtomValue } from "jotai"; +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Navigate } from "react-router-dom"; + +import AppLogo from "@src/components/AppLogo"; +import Button from "@src/components/Button"; +import { buildOrg2CloudLoginUrl } from "@src/features/Org2Cloud/config"; +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(); +} + +export function WebLoginPage() { + const { t } = useTranslation("navigation"); + const auth = useAtomValue(org2CloudAuthAtom); + if (auth) return ; + + return ( +
+ +
+ +

+ {t("cloud.title")} +

+

+ {t("web.login.title")} +

+

+ {t("web.login.subtitle")} +

+
+ +
+ +

+ {t("web.login.hint")} +

+
+ + } + /> +
+ ); +} diff --git a/src/web/features/auth/useFreshWebCloudSession.ts b/src/web/features/auth/useFreshWebCloudSession.ts new file mode 100644 index 0000000000..2e2f8e47ce --- /dev/null +++ b/src/web/features/auth/useFreshWebCloudSession.ts @@ -0,0 +1,38 @@ +import { useAtom } from "jotai"; +import { useCallback, useEffect, useRef } from "react"; + +import { + type Org2CloudAuthState, + clearRejectedAuth, + commitRefreshedAuth, + isSameOrg2CloudSession, + org2CloudAuthAtom, +} from "@src/features/Org2Cloud/org2CloudAuthAtom"; +import { ensureFreshSession } from "@src/features/Org2Cloud/org2CloudClient"; + +/** Browser-safe, stale-session-guarded access-token resolver. */ +export function useFreshWebCloudSession(): () => Promise { + const [auth, setAuth] = useAtom(org2CloudAuthAtom); + const authRef = useRef(auth); + + useEffect(() => { + authRef.current = auth; + }, [auth]); + + return useCallback(async () => { + const current = authRef.current; + if (!current) return null; + + const fresh = await ensureFreshSession(current, { + onRefreshRejected: () => { + if (clearRejectedAuth(setAuth, current)) authRef.current = null; + }, + }); + if (!fresh || !isSameOrg2CloudSession(authRef.current, current)) { + return null; + } + if (!commitRefreshedAuth(setAuth, current, fresh)) return null; + authRef.current = fresh; + return fresh; + }, [setAuth]); +} diff --git a/src/web/index.tsx b/src/web/index.tsx new file mode 100644 index 0000000000..a24ae9a9bf --- /dev/null +++ b/src/web/index.tsx @@ -0,0 +1,25 @@ +import { createRoot } from "react-dom/client"; + +import { AppProviders } from "@src/app/root/AppProviders"; +import ErrorBoundary from "@src/components/ErrorBoundary"; +import { initToolRegistry } from "@src/engines/SessionCore/rendering/registry/initToolRegistry"; +import { i18nReady } from "@src/i18n"; +import "@src/index.scss"; +import { initTheme } from "@src/util/core/init/themeInit"; + +import { WebApp } from "./WebApp"; + +async function mountWebApp(): Promise { + await Promise.all([i18nReady, initTheme(), initToolRegistry()]); + const rootElement = document.getElementById("root"); + if (!rootElement) throw new Error("ORG2 Web root element is missing"); + createRoot(rootElement).render( + + + + + + ); +} + +void mountWebApp(); diff --git a/src/web/platform/tauriUnavailable.cjs b/src/web/platform/tauriUnavailable.cjs new file mode 100644 index 0000000000..f4b7dd82bd --- /dev/null +++ b/src/web/platform/tauriUnavailable.cjs @@ -0,0 +1,153 @@ +class WebPlatformUnsupportedError extends Error { + constructor(capability) { + super(`${capability} is only available in the ORG2 desktop app`); + this.name = "WebPlatformUnsupportedError"; + } +} + +const unsupported = (capability) => + Promise.reject(new WebPlatformUnsupportedError(capability)); +const unlisten = async () => () => undefined; + +class Channel { + constructor(onmessage = () => undefined) { + this.id = 0; + this.onmessage = onmessage; + } +} + +class LazyStore { + constructor() { + throw new WebPlatformUnsupportedError("Tauri store"); + } +} + +class Update { + downloadAndInstall() { + return unsupported("desktop updates"); + } +} + +class Menu { + static async new() { + return new Menu(); + } + + popup() { + return unsupported("native menus"); + } +} + +class Command { + static create() { + return new Command(); + } + + execute() { + return unsupported("native command execution"); + } + + spawn() { + return unsupported("native command execution"); + } +} + +class LogicalPosition { + constructor(x, y) { + this.x = x; + this.y = y; + } +} + +class PhysicalPosition extends LogicalPosition {} + +const webWindow = { + label: "web", + isFocused: async () => + typeof document === "undefined" ? true : document.hasFocus(), + onFocusChanged: unlisten, + onCloseRequested: unlisten, + listen: unlisten, + emit: async () => undefined, +}; + +class WebviewWindow { + static getByLabel() { + return null; + } + + static getCurrent() { + return webWindow; + } + + constructor() { + return webWindow; + } +} + +async function openUrl(url) { + if (typeof window === "undefined") return; + window.open(String(url), "_blank", "noopener,noreferrer"); +} + +async function join(...parts) { + return parts + .map((part) => String(part).replace(/^\/+|\/+$/g, "")) + .filter(Boolean) + .join("/"); +} + +const unsupportedFileSystem = () => unsupported("local filesystem access"); +const unsupportedNativeDialog = () => unsupported("native file dialog"); + +module.exports = { + Channel, + Command, + LazyStore, + LogicalPosition, + Menu, + PhysicalPosition, + Update, + WebviewWindow, + appCacheDir: unsupportedFileSystem, + appDataDir: unsupportedFileSystem, + ask: unsupportedNativeDialog, + convertFileSrc: (source) => String(source), + copyFile: unsupportedFileSystem, + documentDir: unsupportedFileSystem, + emit: async () => undefined, + exists: async () => false, + getCurrentWebview: () => webWindow, + getCurrentWebviewWindow: () => webWindow, + getCurrentWindow: () => webWindow, + getVersion: async () => "web", + homeDir: unsupportedFileSystem, + invoke: (command) => unsupported(`Tauri command ${String(command)}`), + isPermissionGranted: async () => false, + isTauri: () => false, + join, + listen: unlisten, + load: () => unsupported("Tauri store"), + message: unsupportedNativeDialog, + mkdir: unsupportedFileSystem, + onAction: unlisten, + open: unsupportedNativeDialog, + openPath: () => unsupported("opening a local path"), + openUrl, + readDir: unsupportedFileSystem, + readFile: unsupportedFileSystem, + readTextFile: unsupportedFileSystem, + registerActionTypes: async () => undefined, + relaunch: () => unsupported("desktop relaunch"), + remove: unsupportedFileSystem, + rename: unsupportedFileSystem, + requestPermission: async () => "denied", + resolveResource: unsupportedFileSystem, + revealItemInDir: () => unsupported("revealing a local path"), + save: unsupportedNativeDialog, + sendNotification: async () => undefined, + stat: unsupportedFileSystem, + transformCallback: () => 0, + writeFile: unsupportedFileSystem, + writeTextFile: unsupportedFileSystem, +}; diff --git a/src/web/shell/WebShell.tsx b/src/web/shell/WebShell.tsx new file mode 100644 index 0000000000..cb707717c4 --- /dev/null +++ b/src/web/shell/WebShell.tsx @@ -0,0 +1,57 @@ +import { Menu, X } from "lucide-react"; +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Outlet } from "react-router-dom"; + +import Button from "@src/components/Button"; +import { DEFAULT_SIDEBAR_WIDTH } from "@src/store/ui/sidebarAtom"; + +import { WebSessionSidebar } from "./WebSessionSidebar"; + +export function WebShell() { + const { t } = useTranslation("navigation"); + const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); + + return ( +
+ + + {mobileSidebarOpen && ( +
+
+ )} + +
+
+
+
+ +
+
+
+ ); +} diff --git a/webpack.web.config.js b/webpack.web.config.js new file mode 100644 index 0000000000..21ca79b508 --- /dev/null +++ b/webpack.web.config.js @@ -0,0 +1,86 @@ +const path = require("path"); +const HtmlWebpackPlugin = require("html-webpack-plugin"); + +const createDesktopConfig = require("./webpack.config"); + +/** + * Browser entry that reuses the desktop compiler, aliases, design tokens and + * chunk strategy while keeping output and HTML independent from Tauri. + */ +module.exports = (env, argv) => { + const config = createDesktopConfig(env, argv); + const webTauriAdapter = path.resolve( + __dirname, + "src/web/platform/tauriUnavailable.cjs" + ); + const port = Number.parseInt( + process.env.WEBPACK_DEV_SERVER_PORT ?? process.env.PORT ?? "1999", + 10 + ); + + config.entry = { web: "./src/web/index.tsx" }; + config.output = { + ...config.output, + path: path.resolve(__dirname, "build-web"), + }; + config.cache = { + ...config.cache, + version: `${config.cache.version}-web`, + buildDependencies: { + ...config.cache.buildDependencies, + config: [__filename, path.resolve(__dirname, "webpack.config.js")], + }, + }; + config.resolve.alias = { + "@src/engines/ChatPanel/runtime/sessionTranscriptPlatform$": path.resolve( + __dirname, + "src/web/platform/sessionTranscriptPlatform.ts" + ), + ...config.resolve.alias, + "@tauri-apps/api/app$": webTauriAdapter, + "@tauri-apps/api/core$": webTauriAdapter, + "@tauri-apps/api/dpi$": webTauriAdapter, + "@tauri-apps/api/event$": webTauriAdapter, + "@tauri-apps/api/menu$": webTauriAdapter, + "@tauri-apps/api/path$": webTauriAdapter, + "@tauri-apps/api/webview$": webTauriAdapter, + "@tauri-apps/api/webviewWindow$": webTauriAdapter, + "@tauri-apps/api/window$": webTauriAdapter, + "@tauri-apps/plugin-deep-link$": webTauriAdapter, + "@tauri-apps/plugin-dialog$": webTauriAdapter, + "@tauri-apps/plugin-fs$": webTauriAdapter, + "@tauri-apps/plugin-notification$": webTauriAdapter, + "@tauri-apps/plugin-opener$": webTauriAdapter, + "@tauri-apps/plugin-process$": webTauriAdapter, + "@tauri-apps/plugin-shell$": webTauriAdapter, + "@tauri-apps/plugin-store$": webTauriAdapter, + "@tauri-apps/plugin-updater$": webTauriAdapter, + }; + config.plugins = [ + ...config.plugins.filter( + (plugin) => !(plugin instanceof HtmlWebpackPlugin) + ), + new HtmlWebpackPlugin({ + template: "./public/web.html", + chunks: ["web"], + filename: "index.html", + inject: "body", + }), + ]; + config.devServer = { + ...config.devServer, + port, + client: + config.devServer.client === false + ? false + : { + ...config.devServer.client, + webSocketURL: { + ...config.devServer.client.webSocketURL, + port, + }, + }, + }; + + return config; +}; From 1d46064191e69275a27b96128bc588f8cc62227b Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Thu, 20 Aug 2026 23:20:08 +0800 Subject: [PATCH 02/15] feat(web): add remote session surfaces and transcript platform Extract SessionTranscriptPlatform so ChatHistory and workstation shells share one read-only cloud viewer path, with RemoteSession chat and simulator workspace wrappers for browser deployment. Pre-commit hook ran. Total eslint: 180, total circular: 0 --- .../ChatPanel/ChatFloatingComposer.tsx | 4 +- .../components/ChatHistoryView.tsx | 14 +- .../ChatHistory/hooks/useChatEmptyState.ts | 26 +- .../ChatHistory/hooks/useChatHistoryState.ts | 37 +-- src/engines/ChatPanel/ChatHistory/index.tsx | 34 +-- src/engines/ChatPanel/ChatPanelHeader.tsx | 73 +---- .../ChatPanel/ChatPanelTabBar/TabPill.tsx | 2 +- .../ChatViewGroupChatHistoryAction.tsx | 2 +- .../ModeSwitchCard/useModeSwitchActions.ts | 13 +- .../components/SessionReadOnlyBar.tsx | 18 +- .../SlashCommandPortal/slashItemUtils.test.ts | 6 +- .../SlashCommandPortal/slashItemUtils.ts | 7 +- .../SessionTranscriptRuntimeContext.tsx | 37 +++ .../ChatPanel/blocks/CreatePlanCard/index.tsx | 78 +---- .../blocks/MessageReferenceCards.tsx | 8 +- .../RemoteSessionChatPanelSurface.test.ts | 145 +++++++++ .../RemoteSessionChatPanelSurface.tsx | 193 ++++++++++++ .../components/SessionTranscriptSurface.tsx | 61 ++++ src/engines/ChatPanel/header/index.ts | 1 + .../useBrowserAddToConversationAction.ts | 2 +- .../hooks/useChatViewScrollToBottom.tsx | 4 +- .../ChatPanel/hooks/useReplyQuestion.tsx | 70 +---- .../runtime/sessionTranscriptPlatform.ts | 96 ++++++ .../sessionTranscriptPlatform.types.ts | 26 ++ .../SessionCore/hooks/useAgentADEActions.ts | 35 +-- .../rendering/registry/initToolRegistry.ts | 6 +- .../RemoteSessionReplayControls.test.ts | 156 ++++++++++ .../RemoteSessionReplayControls.tsx | 78 +++++ .../RemoteSessionWorkspaceSurface.test.ts | 181 ++++++++++++ .../RemoteSessionWorkspaceSurface.tsx | 143 +++++++++ .../RemoteSessionWorkstationSurface.test.ts | 140 +++++++++ .../RemoteSessionWorkstationSurface.tsx | 154 ++++++++++ .../remoteSessionWorkspaceSelection.test.ts | 140 +++++++++ .../__tests__/useRemoteSessionReplay.test.ts | 103 +++++++ .../components/remoteSessionWorkspace.test.ts | 128 ++++++++ .../components/remoteSessionWorkspace.ts | 197 +++++++++++++ .../remoteSessionWorkspaceSelection.ts | 60 ++++ .../components/useRemoteSessionReplay.ts | 275 ++++++++++++++++++ src/web/platform/sessionTranscriptPlatform.ts | 42 +++ 39 files changed, 2472 insertions(+), 323 deletions(-) create mode 100644 src/engines/ChatPanel/SessionTranscriptRuntimeContext.tsx create mode 100644 src/engines/ChatPanel/components/RemoteSessionChatPanelSurface.test.ts create mode 100644 src/engines/ChatPanel/components/RemoteSessionChatPanelSurface.tsx create mode 100644 src/engines/ChatPanel/components/SessionTranscriptSurface.tsx create mode 100644 src/engines/ChatPanel/runtime/sessionTranscriptPlatform.ts create mode 100644 src/engines/ChatPanel/runtime/sessionTranscriptPlatform.types.ts create mode 100644 src/engines/Simulator/components/RemoteSessionReplayControls.test.ts create mode 100644 src/engines/Simulator/components/RemoteSessionReplayControls.tsx create mode 100644 src/engines/Simulator/components/RemoteSessionWorkspaceSurface.test.ts create mode 100644 src/engines/Simulator/components/RemoteSessionWorkspaceSurface.tsx create mode 100644 src/engines/Simulator/components/RemoteSessionWorkstationSurface.test.ts create mode 100644 src/engines/Simulator/components/RemoteSessionWorkstationSurface.tsx create mode 100644 src/engines/Simulator/components/__tests__/remoteSessionWorkspaceSelection.test.ts create mode 100644 src/engines/Simulator/components/__tests__/useRemoteSessionReplay.test.ts create mode 100644 src/engines/Simulator/components/remoteSessionWorkspace.test.ts create mode 100644 src/engines/Simulator/components/remoteSessionWorkspace.ts create mode 100644 src/engines/Simulator/components/remoteSessionWorkspaceSelection.ts create mode 100644 src/engines/Simulator/components/useRemoteSessionReplay.ts create mode 100644 src/web/platform/sessionTranscriptPlatform.ts diff --git a/src/engines/ChatPanel/ChatFloatingComposer.tsx b/src/engines/ChatPanel/ChatFloatingComposer.tsx index c4da0d731e..4ba38028d3 100644 --- a/src/engines/ChatPanel/ChatFloatingComposer.tsx +++ b/src/engines/ChatPanel/ChatFloatingComposer.tsx @@ -206,8 +206,8 @@ const ChatFloatingComposer: React.FC = memo( shape="round" icon={} iconOnly - aria-label={t("common:chat.scrollToBottom")} - title={t("common:chat.scrollToBottom")} + aria-label={t("common:inbox.scrollToBottom")} + title={t("common:inbox.scrollToBottom")} onClick={scrollNav.onScrollToBottom} className={`shrink-0 ${PILL_CONTROL_IDLE_SURFACE_CLASS}`} /> diff --git a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryView.tsx b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryView.tsx index b94db2a7ef..fe6595d844 100644 --- a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryView.tsx +++ b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryView.tsx @@ -76,6 +76,7 @@ interface ChatHistoryViewProps { search: UseChatSearchIntegrationReturn; surfaceBgClass: string; turnPaginationEnabled: boolean; + turnMetadataEnabled: boolean; viewport: ViewportModel; } @@ -111,6 +112,7 @@ const ChatHistoryView: React.FC = ({ search, surfaceBgClass, turnPaginationEnabled, + turnMetadataEnabled, viewport, }) => { const { @@ -457,11 +459,13 @@ const ChatHistoryView: React.FC = ({
{activeProjectionHistory.length > 0 ? ( <> - + {turnMetadataEnabled && ( + + )} 0; - const isPendingCancelRef = useRef(false); useLayoutEffect(() => { isPendingCancelRef.current = isPendingCancel; diff --git a/src/engines/ChatPanel/ChatHistory/hooks/useChatHistoryState.ts b/src/engines/ChatPanel/ChatHistory/hooks/useChatHistoryState.ts index 09927441b8..aa747a1579 100644 --- a/src/engines/ChatPanel/ChatHistory/hooks/useChatHistoryState.ts +++ b/src/engines/ChatPanel/ChatHistory/hooks/useChatHistoryState.ts @@ -25,15 +25,9 @@ import { useChatHistory, useChatHistoryActions, } from "@src/contexts/workspace/ChatContext"; -import useReplyQuestion from "@src/engines/ChatPanel/hooks/useReplyQuestion"; -import { - isExploringAtom, - loadErrorAtom, - loadStatusAtom, -} from "@src/engines/SessionCore"; +import type { SessionTranscriptPlatformState } from "@src/engines/ChatPanel/runtime/sessionTranscriptPlatform.types"; import type { SessionLoadStatus } from "@src/engines/SessionCore"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; -import { useAgentWorkingRef } from "@src/hooks/streaming"; import { chatCodeFontSizeAtom, chatFontSizeAtom, @@ -59,7 +53,9 @@ function useSyncRef(value: T): MutableRefObject { // Props Interface // ============================================ -export type UseChatHistoryStateProps = Record; +export interface UseChatHistoryStateProps { + platform: SessionTranscriptPlatformState; +} // ============================================ // Return Type @@ -108,9 +104,9 @@ export interface UseChatHistoryStateReturn { // Hook // ============================================ -export function useChatHistoryState( - _props: UseChatHistoryStateProps = {} -): UseChatHistoryStateReturn { +export function useChatHistoryState({ + platform, +}: UseChatHistoryStateProps): UseChatHistoryStateReturn { // ============================================ // Context & Atoms // ============================================ @@ -124,16 +120,9 @@ export function useChatHistoryState( const { setIsChatScrolledToBottom, chatContainerRef } = useChatHistoryActions(); - const isExploring = useAtomValue(isExploringAtom); - const sessionLoadStatus = useAtomValue(loadStatusAtom); - const sessionLoadError = useAtomValue(loadErrorAtom); - // Colocated subscription: read agent working state via EventStore selector - // instead of isSessionActiveAtom to avoid unnecessary re-renders. - const isWpGeneWorkingRef = useAgentWorkingRef(); const chatFontSize = useAtomValue(chatFontSizeAtom); const chatCodeFontSize = useAtomValue(chatCodeFontSizeAtom); const chatLineHeight = useAtomValue(chatLineHeightAtom); - const { handleReplyQuestion, handleIgnoreQuestion } = useReplyQuestion(); // ============================================ // Local State @@ -168,9 +157,9 @@ export function useChatHistoryState( // PERFORMANCE OPTIMIZATION: Store handler references in refs for stable callback identity // This prevents renderChatItem from being recreated when these handlers change - const handleIgnoreQuestionRef = useSyncRef(handleIgnoreQuestion); - const isExploringRef = useSyncRef(isExploring); - const handleReplyQuestionRef = useSyncRef(handleReplyQuestion); + const handleIgnoreQuestionRef = useSyncRef(platform.onIgnoreQuestion); + const isExploringRef = useSyncRef(platform.isExploring); + const handleReplyQuestionRef = useSyncRef(platform.onReplyQuestion); // ============================================ // Return @@ -185,7 +174,7 @@ export function useChatHistoryState( // Refs chatContainerRef, virtualListRef, - isWpGeneWorkingRef, + isWpGeneWorkingRef: platform.isAgentWorkingRef, isExploringRef, handleReplyQuestionRef, handleIgnoreQuestionRef, @@ -203,8 +192,8 @@ export function useChatHistoryState( codeBlockContainerWidth, // Session loading - sessionLoadStatus, - sessionLoadError, + sessionLoadStatus: platform.loadStatus, + sessionLoadError: platform.loadError, // Callbacks from context setIsChatScrolledToBottom, diff --git a/src/engines/ChatPanel/ChatHistory/index.tsx b/src/engines/ChatPanel/ChatHistory/index.tsx index df55afa20a..cd05f289fd 100644 --- a/src/engines/ChatPanel/ChatHistory/index.tsx +++ b/src/engines/ChatPanel/ChatHistory/index.tsx @@ -6,15 +6,12 @@ import { useAtomValue } from "jotai"; import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { useSessionTranscriptPlatform } from "@src/engines/ChatPanel/runtime/sessionTranscriptPlatform"; import { loadEventComponent } from "@src/engines/SessionCore/rendering/registry/events"; -import { usePinnedSession } from "@src/features/Org2Cloud/SessionConversation/usePinnedSession"; import { org2CloudRemoteSessionsAtom } from "@src/features/Org2Cloud/org2CloudRemoteSessionsAtom"; import { getSessionForkedFrom } from "@src/features/TeamCollaboration/forkSession"; import type { RemoteTeammateSessionMetadata } from "@src/store/collaboration/types"; -import { isSessionActiveAtom } from "@src/store/session/cliSessionStatusAtom"; -import { cursorIdeTurnSummariesAtomFamily } from "@src/store/session/cursorIdeTurnSummariesAtom"; -import { type Session } from "@src/store/session/sessionAtom"; -import { isCursorIdeSession } from "@src/util/session/sessionDispatch"; +import type { Session } from "@src/store/session/sessionAtom"; import { SharedConversationSenderProvider } from "../ChatItems/SharedConversationSenderContext"; import { useChatSessionId } from "../ChatSessionContext"; @@ -33,7 +30,6 @@ import { useChatNavigationController, useChatSearchIntegration, useChatViewportController, - useReloadSession, } from "./hooks"; import "./index.scss"; @@ -115,15 +111,12 @@ const ChatHistory: React.FC = ({ planningIndicatorScope = null, }) => { const activeId = useChatSessionId() ?? null; - const rawCursorIdeTurnSummaries = useAtomValue( - cursorIdeTurnSummariesAtomFamily(activeId ?? "") - ); - const activeSession = usePinnedSession(activeId ?? ""); - const isCursorIde = activeId ? isCursorIdeSession(activeId) : false; - const cursorIdeTurnSummaries = isCursorIde ? rawCursorIdeTurnSummaries : []; - const handleReloadSession = useReloadSession(activeId); - const historyState = useChatHistoryState(); - const isAgentWorking = useAtomValue(isSessionActiveAtom); + const platform = useSessionTranscriptPlatform(activeId); + const activeSession = platform.session; + const cursorIdeTurnSummaries = platform.cursorIdeTurnSummaries; + const isCursorIde = platform.isCursorIde; + const handleReloadSession = platform.onReload; + const historyState = useChatHistoryState({ platform }); const groupChat = useGroupChatContext(); const remoteEntries = useAtomValue(org2CloudRemoteSessionsAtom); const sharedConversationSender = useMemo( @@ -132,12 +125,13 @@ const ChatHistory: React.FC = ({ ); useEffect(() => { + if (!platform.capabilities.canvasInline) return; // Canvas payloads can reach the WorkStation as soon as the tool call is // stored. Warm the chat renderer while the user is still waiting for the // agent so the persisted canvas event can take over without a Suspense // placeholder between the live and historical render paths. void loadEventComponent("canvas_inline"); - }, []); + }, [platform.capabilities.canvasInline]); const [planningIndicatorCount, setPlanningIndicatorCount] = useState<0 | 1>( 0 @@ -159,7 +153,7 @@ const ChatHistory: React.FC = ({ forceCollapseAllTurns, groupChat, hideGroupUserMessage, - isAgentWorking, + isAgentWorking: platform.isAgentWorking, isCursorIde, planningIndicatorCount, sessionStatus: activeSession?.status, @@ -183,9 +177,12 @@ const ChatHistory: React.FC = ({ virtualListRef: historyState.virtualListRef, }); const emptyState = useChatEmptyState({ - activeSessionId: activeId, sessionLoadStatus: historyState.sessionLoadStatus, optimizedLen: historyState.chatHistory.length, + isAgentWorking: platform.isAgentWorking, + isPendingCancel: platform.isPendingCancel, + isRolledBack: platform.isRolledBack, + isHydrating: platform.isHydrating, }); const search = useChatSearchIntegration({ chatHistory: historyState.chatHistory, @@ -264,6 +261,7 @@ const ChatHistory: React.FC = ({ search={search} surfaceBgClass={surfaceBgClass} turnPaginationEnabled={turnPaginationEnabled} + turnMetadataEnabled={platform.capabilities.turnMetadata} viewport={viewport} /> diff --git a/src/engines/ChatPanel/ChatPanelHeader.tsx b/src/engines/ChatPanel/ChatPanelHeader.tsx index db411c3eef..6211071214 100644 --- a/src/engines/ChatPanel/ChatPanelHeader.tsx +++ b/src/engines/ChatPanel/ChatPanelHeader.tsx @@ -13,25 +13,16 @@ import { KeyboardShortcutTooltipContent } from "@src/components/KeyboardShortcut import RegionNoticeButton from "@src/components/RegionNoticeButton"; import Tooltip from "@src/components/Tooltip"; import type { DropdownEnginePosition } from "@src/hooks/dropdown"; -import { getCollapsedSidebarChromeOffset } from "@src/hooks/ui/sidebar/useCollapsedSidebarChromeOffset"; import { TabBarTrailingIconButton } from "@src/modules/WorkStation/shared/TabBar/components/TabBarTrailingIconButton"; import { HEADER_ICON_SIZE } from "@src/modules/WorkStation/shared/tokens"; -import { CollapsedSidebarButton } from "@src/scaffold/NavigationSidebar/CollapsedSidebarButton"; import type { ChatHistoryDisplayMode } from "@src/store/ui/chatPanelAtom"; -import { isWindows } from "@src/util/platform/tauri"; import { SessionHeaderActionsMenu } from "./components/SessionHeaderActionsMenu"; import { - CHAT_PANEL_HEADER_DRAG_STYLE, CHAT_PANEL_HEADER_NO_DRAG_STYLE, - ChatPanelPublishedHeader, + ChatPanelChrome, chatPanelHeaderSlotsAtom, } from "./header"; -import { - CHAT_PANEL_GLASS_SURFACE_CLASS, - CHAT_PANEL_HEADER_STACK_HEIGHT_PX, - CHAT_PANEL_TAB_HEADER_HEIGHT_PX, -} from "./header/chatPanelHeaderLayout"; import type { ChatPanelRegionNotice } from "./types"; const CHAT_PANEL_HEADER_ICON_SIZE = 14; @@ -138,7 +129,6 @@ export function ChatPanelHeader({ overlayPublishedHeader = false, }: ChatPanelHeaderProps): React.ReactNode { const publishedHeaderSlots = useAtomValue(chatPanelHeaderSlotsAtom); - const windowsHost = isWindows(); if (!showHeader) return null; const chatFocusLabel = isChatFocus @@ -282,57 +272,14 @@ export function ChatPanelHeader({ ); return ( - <> -
-
- {shouldOffsetHeaderForCollapsedSidebar ? ( -
- -
- ) : null} - {tabStrip} - {tabBarToolbar} -
- {overlayPublishedHeader && effectivePublishedHeaderSlots ? ( -
- -
- ) : ( - - )} - + ); } diff --git a/src/engines/ChatPanel/ChatPanelTabBar/TabPill.tsx b/src/engines/ChatPanel/ChatPanelTabBar/TabPill.tsx index c0ff05422b..3ec32a25ef 100644 --- a/src/engines/ChatPanel/ChatPanelTabBar/TabPill.tsx +++ b/src/engines/ChatPanel/ChatPanelTabBar/TabPill.tsx @@ -128,7 +128,7 @@ export const TabPill = memo(function TabPill({ kanban: t("sessions:simulator.tabs.kanban"), work: t("navigation:labels.workItems"), }, - sessionFallback: t("chat.defaultTitle"), + sessionFallback: t("sessions:chat.defaultTitle"), }); const displayTitle = tab.type !== "start-page" diff --git a/src/engines/ChatPanel/ChatViewGroupChatHistoryAction.tsx b/src/engines/ChatPanel/ChatViewGroupChatHistoryAction.tsx index 3656c95107..2f69a3e87c 100644 --- a/src/engines/ChatPanel/ChatViewGroupChatHistoryAction.tsx +++ b/src/engines/ChatPanel/ChatViewGroupChatHistoryAction.tsx @@ -40,7 +40,7 @@ export function ChatViewGroupChatHistoryAction({ defaultValue: "History unavailable", })}: ${groupChatHistoryError}`} > - {t("common:retry", { + {t("common:actions.retry", { defaultValue: "Retry", })} diff --git a/src/engines/ChatPanel/InputArea/ModeSwitchCard/useModeSwitchActions.ts b/src/engines/ChatPanel/InputArea/ModeSwitchCard/useModeSwitchActions.ts index 6512fefebd..ee69fe60ab 100644 --- a/src/engines/ChatPanel/InputArea/ModeSwitchCard/useModeSwitchActions.ts +++ b/src/engines/ChatPanel/InputArea/ModeSwitchCard/useModeSwitchActions.ts @@ -24,6 +24,7 @@ import { sessionByIdAtom, upsertSession } from "@src/store/session/sessionAtom"; import { activeSessionIdAtom } from "@src/store/session/viewAtom"; import { getInstrumentedStore } from "@src/util/core/state/instrumentedStore"; import { resolveModelForMessage } from "@src/util/session/resolveModelForMessage"; +import { selectionFromSession } from "@src/util/session/selectionFromSession"; import { isAgentSession } from "@src/util/session/sessionDispatch"; // ============================================ @@ -172,17 +173,7 @@ async function switchAgentMode( // user row — so the new-mode run stays inside the original round. const sessionForSend = store.get(sessionByIdAtom(sessionId)); const fallback = store.get(creatorDefaultModelSelectionAtom); - const lastModelSelection = sessionForSend?.model - ? { - ...fallback, - keySource: sessionForSend.keySource ?? fallback?.keySource, - model: sessionForSend.model, - selectedAccountId: - sessionForSend.accountId ?? fallback?.selectedAccountId, - cliAgentType: sessionForSend.cliAgentType ?? fallback?.cliAgentType, - tier: sessionForSend.tier ?? fallback?.tier, - } - : fallback; + const lastModelSelection = selectionFromSession(sessionForSend, fallback); const { model, accountId } = resolveModelForMessage(lastModelSelection); // Mode-switch re-runs bypass useMessageDispatch, so set the optimistic diff --git a/src/engines/ChatPanel/InputArea/components/SessionReadOnlyBar.tsx b/src/engines/ChatPanel/InputArea/components/SessionReadOnlyBar.tsx index a1dd9d6ad5..e02f9ea430 100644 --- a/src/engines/ChatPanel/InputArea/components/SessionReadOnlyBar.tsx +++ b/src/engines/ChatPanel/InputArea/components/SessionReadOnlyBar.tsx @@ -26,10 +26,14 @@ interface SessionReadOnlyBarProps { pills?: React.ReactNode; /** Override the right-side badge text. Defaults to the i18n "Read-only" string. */ label?: string; + /** Optional non-editable text row that preserves the full desktop composer silhouette. */ + placeholder?: string; + /** Hide local context controls when the host has no local workspace. */ + showContextInfo?: boolean; } const SessionReadOnlyBar: React.FC = memo( - ({ pills, label }) => { + ({ pills, label, placeholder, showContextInfo = true }) => { const { t } = useTranslation("sessions"); const badgeLabel = label ?? t("chat.readOnly", { defaultValue: "Read-only" }); @@ -43,9 +47,19 @@ const SessionReadOnlyBar: React.FC = memo( dropdownDirection="up" showContextInfo={false} pills={pills} + editorSlot={ + placeholder ? ( +
+ {placeholder} +
+ ) : undefined + } submitButton={
- + {showContextInfo && }
{badgeLabel} diff --git a/src/engines/ChatPanel/InputArea/components/SlashCommandPortal/slashItemUtils.test.ts b/src/engines/ChatPanel/InputArea/components/SlashCommandPortal/slashItemUtils.test.ts index 8482cdac8f..8f277a61c3 100644 --- a/src/engines/ChatPanel/InputArea/components/SlashCommandPortal/slashItemUtils.test.ts +++ b/src/engines/ChatPanel/InputArea/components/SlashCommandPortal/slashItemUtils.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import { - buildSlashActionCommand, - insertAtomicSlashActionPill, -} from "./slashItemUtils"; +import { insertAtomicSlashActionPill } from "./slashItemUtils"; describe("built-in slash action insertion", () => { it("inserts Canvas and Compact as atomic composer pills", () => { @@ -32,6 +29,5 @@ describe("built-in slash action insertion", () => { expect(insertAtomicSlashActionPill(composer, "setup-repo")).toBe(false); expect(composer.insertFilePill).not.toHaveBeenCalled(); - expect(buildSlashActionCommand("setup-repo")).toBe("/setup-repo "); }); }); diff --git a/src/engines/ChatPanel/InputArea/components/SlashCommandPortal/slashItemUtils.ts b/src/engines/ChatPanel/InputArea/components/SlashCommandPortal/slashItemUtils.ts index 8d2de28c2f..c951648a36 100644 --- a/src/engines/ChatPanel/InputArea/components/SlashCommandPortal/slashItemUtils.ts +++ b/src/engines/ChatPanel/InputArea/components/SlashCommandPortal/slashItemUtils.ts @@ -2,7 +2,7 @@ * Shared utilities for slash-menu item construction. * Used by useSlashItemsCache, useSlashCommand, PinnedActionsBar, and FlyoutSubmenu. */ -import type { ComposerInputRef } from "@src/components/ComposerInput"; +import type { ComposerInputRef } from "@src/components/ComposerInput/types"; import { type InstalledSkill, SLASH_ACTIONS } from "@src/types/extensions"; /** @@ -90,11 +90,6 @@ export function buildMcpToolCommand( return `/mcp__${serverSlug}__${toolName} `; } -/** Build the canonical editable text inserted for a built-in slash action. */ -export function buildSlashActionCommand(actionName: string): string { - return `/${actionName} `; -} - const ATOMIC_SLASH_ACTIONS = new Set([ SLASH_ACTIONS.CANVAS, SLASH_ACTIONS.COMPACT, diff --git a/src/engines/ChatPanel/SessionTranscriptRuntimeContext.tsx b/src/engines/ChatPanel/SessionTranscriptRuntimeContext.tsx new file mode 100644 index 0000000000..9e518929af --- /dev/null +++ b/src/engines/ChatPanel/SessionTranscriptRuntimeContext.tsx @@ -0,0 +1,37 @@ +import { createContext, useContext } from "react"; + +import type { SessionLoadStatus } from "@src/engines/SessionCore"; + +/** + * Platform capabilities and state consumed by the shared transcript surface. + * + * Desktop does not provide this context and keeps using the existing Jotai / + * EventStore path. Browser and other remote surfaces provide it so the same + * ChatHistory tree can render without pretending that local Tauri services + * exist. + */ +export interface SessionTranscriptRuntime { + loadStatus: SessionLoadStatus; + loadError: string | null; + isAgentWorking: boolean; + isExploring?: boolean; + onReload: () => void; + /** Remote surfaces wire chat block locate to their replay controller. */ + onNavigateToEvent?: (eventId: string) => void; + onReplyQuestion?: (input: { reply: string; chunk_id: string }) => void; + onIgnoreQuestion?: (eventId: string) => void; + capabilities?: { + canvasInline?: boolean; + turnMetadata?: boolean; + }; +} + +const SessionTranscriptRuntimeContext = + createContext(null); + +export const SessionTranscriptRuntimeProvider = + SessionTranscriptRuntimeContext.Provider; + +export function useSessionTranscriptRuntime(): SessionTranscriptRuntime | null { + return useContext(SessionTranscriptRuntimeContext); +} diff --git a/src/engines/ChatPanel/blocks/CreatePlanCard/index.tsx b/src/engines/ChatPanel/blocks/CreatePlanCard/index.tsx index 3b2777a08e..8805c5135d 100644 --- a/src/engines/ChatPanel/blocks/CreatePlanCard/index.tsx +++ b/src/engines/ChatPanel/blocks/CreatePlanCard/index.tsx @@ -12,15 +12,11 @@ import { X } from "lucide-react"; import React, { memo, useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { respondPlanApproval } from "@src/api/tauri/agent"; import Button from "@src/components/Button"; import Markdown from "@src/components/MarkDown"; import Message from "@src/components/Message"; import { getToolIcon } from "@src/config/toolIcons"; -import { - beginOptimisticTurn, - failOptimisticTurn, -} from "@src/engines/SessionCore/control/optimisticTurnStatus"; +import { submitPlanDecision } from "@src/engines/SessionCore/control/submitPlanDecision"; import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; import type { ToolUsageMetadata } from "@src/engines/SessionCore/core/types"; import { @@ -39,14 +35,10 @@ import { usePendingPlanApproval } from "@src/hooks/session/usePendingPlanApprova import { FileService } from "@src/services/file"; import { sessionRuntimeStatusAtom } from "@src/store/session/cliSessionStatusAtom"; import { creatorDefaultModelSelectionAtom } from "@src/store/session/creatorDefaultModelAtom"; -import { - clearPendingPlanApproval, - pendingPlanApprovalsAtom, -} from "@src/store/session/planApprovalAtom"; +import { pendingPlanApprovalsAtom } from "@src/store/session/planApprovalAtom"; import { sessionByIdAtom } from "@src/store/session/sessionAtom"; import { activeSessionIdAtom } from "@src/store/session/viewAtom"; import { activeWorkspaceRootPathAtom } from "@src/store/workspace"; -import { resolveModelForMessage } from "@src/util/session/resolveModelForMessage"; import ToolUsageBadge from "../ToolCallBlock/ToolUsageBadge"; import { @@ -59,10 +51,6 @@ import { import { useBlockHeader } from "../useBlockLocate"; const PLAN_ICON_SIZE = 14; -// Generous bound: approval does plan-file IO + may register a session before -// returning; normal completion is <1s, the timeout only guards a wedged IPC. -const PLAN_APPROVAL_RPC_TIMEOUT_MS = 30_000; - function deriveDisplayTitle(title: string, content: string): string { const trimmedTitle = title.trim(); if (trimmedTitle) return trimmedTitle; @@ -243,57 +231,16 @@ const CreatePlanCard: React.FC = memo( submittingRef.current = true; setSubmitting(true); try { - const sessionSelection = planSession - ? { - ...creatorDefaultSelection, - keySource: - planSession.keySource ?? creatorDefaultSelection?.keySource, - model: planSession.model ?? creatorDefaultSelection?.model, - selectedAccountId: - planSession.accountId ?? - creatorDefaultSelection?.selectedAccountId, - cliAgentType: - planSession.cliAgentType ?? - creatorDefaultSelection?.cliAgentType, - tier: planSession.tier ?? creatorDefaultSelection?.tier, - } - : creatorDefaultSelection; - const { model, accountId } = resolveModelForMessage(sessionSelection); - const workspacePath = - planSession?.repoPath ?? activeWorkspaceRootPath; - // Build kicks off a synthetic turn on the backend without going - // through useMessageDispatch — optimistically flip to running - // BEFORE the RPC await so the planning indicator appears - // immediately (P3), not one round-trip later. Skip stays idle. - // The setter's session gate drops the write for background plans. - if (choice !== "reject") { - beginOptimisticTurn(sessionId); - } - try { - // Timeout fallback: if the approval RPC hangs (backend wedged, - // IPC drop), roll back the optimistic running state instead of - // leaving the session stuck in a running state with no terminal - // event ever arriving. - await Promise.race([ - respondPlanApproval(sessionId, choice, edited, { - model, - accountId, - workspacePath, - }), - new Promise((_, reject) => { - window.setTimeout( - () => reject(new Error(t("planDoc.buildFailed"))), - PLAN_APPROVAL_RPC_TIMEOUT_MS - ); - }), - ]); - } catch (rpcError) { - if (choice !== "reject") failOptimisticTurn(sessionId); - throw rpcError; - } - setPendingPlanApprovals((prev) => - clearPendingPlanApproval(prev, sessionId, cardRevisionId) - ); + await submitPlanDecision({ + sessionId, + choice, + editedContent: edited, + session: planSession, + fallbackSelection: creatorDefaultSelection, + fallbackWorkspacePath: activeWorkspaceRootPath, + pendingPlanId: cardRevisionId, + timeoutMessage: t("planDoc.buildFailed"), + }); if (mountedRef.current) setIsEditing(false); } catch (err) { Message.error( @@ -310,7 +257,6 @@ const CreatePlanCard: React.FC = memo( planSession, creatorDefaultSelection, activeWorkspaceRootPath, - setPendingPlanApprovals, cardRevisionId, t, mountedRef, diff --git a/src/engines/ChatPanel/blocks/MessageReferenceCards.tsx b/src/engines/ChatPanel/blocks/MessageReferenceCards.tsx index 825318ba77..353cadfd35 100644 --- a/src/engines/ChatPanel/blocks/MessageReferenceCards.tsx +++ b/src/engines/ChatPanel/blocks/MessageReferenceCards.tsx @@ -82,9 +82,9 @@ const SessionReferenceCard: React.FC<{ item: MessageReferenceItem }> = ({ const handleCopy = useCallback(async () => { try { await copyText(referencedSessionId); - Message.success(tCommon("copied")); + Message.success(tCommon("status.copied")); } catch { - Message.error(t("failedToCopyContent")); + Message.error(t("chat.failedToCopyContent")); } }, [referencedSessionId, t, tCommon]); @@ -155,7 +155,7 @@ const MessageReferenceCard: React.FC = ({ ? t("cards.path.copied") : isCommit ? tCommon("git.commit.shaCopied") - : tCommon("copied"); + : tCommon("status.copied"); const openLabel = isLocalPath ? t("cards.path.open") : t("cards.url.open"); const openInAppLabel = t("cards.actions.openInApp"); const externalOpenLabel = isCommit @@ -202,7 +202,7 @@ const MessageReferenceCard: React.FC = ({ await copyText(item.sha ?? item.value); Message.success(copiedLabel); } catch { - Message.error(t("failedToCopyContent")); + Message.error(t("chat.failedToCopyContent")); } }, [copiedLabel, item.sha, item.value, t]); diff --git a/src/engines/ChatPanel/components/RemoteSessionChatPanelSurface.test.ts b/src/engines/ChatPanel/components/RemoteSessionChatPanelSurface.test.ts new file mode 100644 index 0000000000..42c957fc40 --- /dev/null +++ b/src/engines/ChatPanel/components/RemoteSessionChatPanelSurface.test.ts @@ -0,0 +1,145 @@ +import React, { type ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +import { RemoteSessionChatPanelSurface } from "./RemoteSessionChatPanelSurface"; + +vi.mock("@src/components/SelectorPill", () => ({ + default: ({ label, disabled }: { label: string; disabled?: boolean }) => + React.createElement("span", { + "data-selector-pill": label, + "data-disabled": disabled, + }), +})); + +const capturedShellProps = vi.fn(); + +vi.mock("../ChatPanelShell", () => ({ + ChatPanelShell: (props: { + headerSection: ReactNode; + chatColumn: ReactNode; + terminalTabs: unknown[]; + activeTab: null; + isTerminalTabActive: boolean; + }) => { + capturedShellProps(props); + return React.createElement( + "div", + { "data-shared-chat-panel-shell": true }, + props.headerSection, + props.chatColumn + ); + }, +})); + +vi.mock("../InputArea/components/SessionReadOnlyBar", () => ({ + default: ({ + label, + placeholder, + showContextInfo, + pills, + }: { + label: string; + placeholder: string; + showContextInfo: boolean; + pills: ReactNode; + }) => + React.createElement( + "div", + { + "data-shared-read-only-composer": true, + "data-label": label, + "data-placeholder": placeholder, + "data-show-context": showContextInfo, + }, + pills + ), +})); + +vi.mock("../header", () => ({ + ChatPanelPublishedHeader: ({ + slots, + }: { + slots: { content: ReactNode; trailing: ReactNode }; + }) => + React.createElement( + "header", + { "data-shared-published-header": true }, + slots.content, + slots.trailing + ), +})); + +vi.mock("./SessionTranscriptSurface", () => ({ + SessionTranscriptSurface: () => + React.createElement("div", { "data-shared-transcript": true }), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, defaultValue?: string) => { + const labels: Record = { + "web.readOnly.headerTrailing": "Cloud · Read only", + "web.readOnly.barLabel": "Read only", + "web.readOnly.barPlaceholder": "Cloud session is read-only", + }; + return labels[key] ?? defaultValue ?? key; + }, + }), +})); + +describe("RemoteSessionChatPanelSurface", () => { + it("passes terminal-safe shell props for read-only remote sessions", () => { + capturedShellProps.mockClear(); + renderToStaticMarkup( + React.createElement(RemoteSessionChatPanelSurface, { + sessionId: "session-1", + events: [], + runtime: { + loadStatus: "loaded", + loadError: null, + isAgentWorking: false, + onReload: vi.fn(), + }, + }) + ); + + expect(capturedShellProps).toHaveBeenCalledWith( + expect.objectContaining({ + activeTab: null, + terminalTabs: [], + isTerminalTabActive: false, + }) + ); + }); + + it("composes the remote transcript without a tab row or replay controls", () => { + capturedShellProps.mockClear(); + const markup = renderToStaticMarkup( + React.createElement(RemoteSessionChatPanelSurface, { + sessionId: "session-1", + agentDisplayName: "SDE Agent", + events: [], + runtime: { + loadStatus: "loaded", + loadError: null, + isAgentWorking: false, + onReload: vi.fn(), + }, + }) + ); + + expect(markup).toContain("data-remote-session-chat-panel"); + expect(markup).toContain("data-shared-chat-panel-shell"); + expect(markup).toContain("data-shared-published-header"); + expect(markup).toContain("data-shared-transcript"); + expect(markup).toContain("data-shared-read-only-composer"); + expect(markup).toContain('data-placeholder="Cloud session is read-only"'); + expect(markup).toContain('data-show-context="false"'); + expect(markup).toContain('data-selector-pill="SDE Agent"'); + expect(markup).toContain("Cloud · Read only"); + expect(markup).not.toContain("data-shared-tab-pill"); + expect(markup).not.toContain("data-shared-replay-controls"); + expect(markup).not.toContain("Cloud replay"); + }); +}); diff --git a/src/engines/ChatPanel/components/RemoteSessionChatPanelSurface.tsx b/src/engines/ChatPanel/components/RemoteSessionChatPanelSurface.tsx new file mode 100644 index 0000000000..3b0fea7fac --- /dev/null +++ b/src/engines/ChatPanel/components/RemoteSessionChatPanelSurface.tsx @@ -0,0 +1,193 @@ +import React, { useMemo, useRef } from "react"; +import { useTranslation } from "react-i18next"; + +import SelectorPill from "@src/components/SelectorPill"; +import { resolveAgentIcon } from "@src/config/agentIcons"; +import { COMPOSER_BOTTOM_DOCK_PADDING_CLASS } from "@src/config/composerStackTokens"; +import { DETAIL_PANEL_TOKENS } from "@src/config/detailPanelTokens"; +import type { SessionEvent } from "@src/engines/SessionCore"; +import type { RemoteTeammateSessionMetadata } from "@src/store/collaboration/types"; +import { resolveSessionDisplayMetadata } from "@src/util/session/sessionDisplayMetadata"; + +import { ChatPanelShell } from "../ChatPanelShell"; +import SessionReadOnlyBar from "../InputArea/components/SessionReadOnlyBar"; +import type { SessionTranscriptRuntime } from "../SessionTranscriptRuntimeContext"; +import { ChatPanelPublishedHeader } from "../header"; +import type { SessionViewMode } from "../hooks/useSessionViewMode"; +import { SessionTranscriptSurface } from "./SessionTranscriptSurface"; + +type RemoteSessionIdentity = Pick< + RemoteTeammateSessionMetadata, + | "sourceSessionId" + | "cliAgentType" + | "agentDisplayName" + | "agentDefinitionId" + | "model" + | "origin" +>; + +export interface RemoteSessionChatPanelSurfaceProps { + sessionId: string; + agentDisplayName?: string | null; + remoteSession?: RemoteSessionIdentity | null; + events: SessionEvent[]; + runtime: SessionTranscriptRuntime; + /** Replaces the default agent-only header leading content when provided. */ + headerContent?: React.ReactNode; + /** Extra trailing header nodes rendered before the read-only label. */ + headerExtras?: React.ReactNode; + sessionViewMode?: SessionViewMode; + alternateSessionView?: React.ReactNode; +} + +/** + * Desktop ChatPanel presentation backed by caller-owned remote events. + * It intentionally has no send or replay controls: the transcript is live, + * while the shared composer chrome communicates Cloud read-only mode. Replay + * remains owned by the sibling WorkStation surface. + */ +export function RemoteSessionChatPanelSurface({ + sessionId, + agentDisplayName, + remoteSession, + events, + runtime, + headerContent, + headerExtras, + sessionViewMode = "gui", + alternateSessionView, +}: RemoteSessionChatPanelSurfaceProps) { + const { t } = useTranslation("navigation"); + const { t: tSessions } = useTranslation("sessions"); + const panelRef = useRef(null); + const display = useMemo( + () => + remoteSession + ? resolveSessionDisplayMetadata({ + kind: "remote", + session: remoteSession, + }) + : null, + [remoteSession] + ); + const agentLabel = + agentDisplayName || + display?.agentLabel || + tSessions("chat.agentFallback", "Agent"); + const sessionIconElement = useMemo( + () => + React.createElement(resolveAgentIcon(display?.agentIconId), { + size: 14, + className: "shrink-0 text-text-3", + "aria-hidden": true, + }), + [display?.agentIconId] + ); + const readOnlyHeaderTrailing = t("web.readOnly.headerTrailing"); + const readOnlyBarLabel = t("web.readOnly.barLabel"); + const readOnlyBarPlaceholder = t("web.readOnly.barPlaceholder"); + + const publishedHeaderSlots = useMemo( + () => ({ + content: headerContent ?? ( +
+ {sessionIconElement} + + {agentLabel} + +
+ ), + trailing: ( +
+ {headerExtras} + + {readOnlyHeaderTrailing} + +
+ ), + }), + [ + sessionIconElement, + agentLabel, + headerContent, + headerExtras, + readOnlyHeaderTrailing, + ] + ); + + const headerSection = ( + + ); + + const alternateActive = sessionViewMode !== "gui"; + + const chatColumn = ( +
+
+ +
+ {alternateActive ? alternateSessionView : null} +
+
+
+ + } + /> +
+
+
+
+ ); + + return ( +
+ undefined} + panelRef={panelRef} + sessionModals={null} + showResizeHandle={false} + terminalTabs={[]} + useExternalWidth + /> +
+ ); +} diff --git a/src/engines/ChatPanel/components/SessionTranscriptSurface.tsx b/src/engines/ChatPanel/components/SessionTranscriptSurface.tsx new file mode 100644 index 0000000000..ac2cf7d880 --- /dev/null +++ b/src/engines/ChatPanel/components/SessionTranscriptSurface.tsx @@ -0,0 +1,61 @@ +import React from "react"; + +import { ChatProvider } from "@src/contexts/workspace/ChatContext"; +import { AgentMessageClampProvider } from "@src/engines/ChatPanel/blocks/AgentMessageBlock"; +import type { SessionEvent } from "@src/engines/SessionCore"; + +import ChatHistory from "../ChatHistory"; +import { ChatHistoryOverrideContext } from "../ChatHistoryOverrideContext"; +import { ChatSessionContext } from "../ChatSessionContext"; +import { + type SessionTranscriptRuntime, + SessionTranscriptRuntimeProvider, +} from "../SessionTranscriptRuntimeContext"; + +export interface SessionTranscriptSurfaceProps { + sessionId: string; + events: SessionEvent[]; + runtime: SessionTranscriptRuntime; + className?: string; + surfaceBgClass?: string; + turnPaginationEnabled?: boolean; +} + +/** + * Shared, platform-neutral Session transcript shell. + * + * It deliberately accepts events and runtime actions as inputs. Desktop may + * keep its current store-backed ChatView while Web supplies Cloud-backed + * events; both render the canonical ChatHistory and event components. + */ +export function SessionTranscriptSurface({ + sessionId, + events, + runtime, + className = "", + surfaceBgClass = "bg-chat-pane", + turnPaginationEnabled = true, +}: SessionTranscriptSurfaceProps) { + return ( + + + + + +
+ +
+
+
+
+
+
+ ); +} diff --git a/src/engines/ChatPanel/header/index.ts b/src/engines/ChatPanel/header/index.ts index 36d9e7ceca..5a762c78f0 100644 --- a/src/engines/ChatPanel/header/index.ts +++ b/src/engines/ChatPanel/header/index.ts @@ -1,4 +1,5 @@ export * from "./chatPanelHeaderSlots"; +export * from "./ChatPanelChrome"; export * from "./ChatPanelHeaderPrimitives"; export * from "./ChatPanelPublishedHeader"; export * from "./usePublishChatPanelHeader"; diff --git a/src/engines/ChatPanel/hooks/useBrowserAddToConversationAction.ts b/src/engines/ChatPanel/hooks/useBrowserAddToConversationAction.ts index 2fca72400c..16d97691f9 100644 --- a/src/engines/ChatPanel/hooks/useBrowserAddToConversationAction.ts +++ b/src/engines/ChatPanel/hooks/useBrowserAddToConversationAction.ts @@ -24,7 +24,7 @@ export function useBrowserAddToConversationAction(): UseBrowserAddToConversation const browserCallbacks = useAtomValue(browserStatusBarCallbacksAtom); const addToConversationLabel = t("browser.selectedElement.addElement"); - const cancelAddToConversationLabel = t("actions.clearSelection"); + const cancelAddToConversationLabel = t("tooltips.clearSelection"); const selectedElementLabel = browserStatus.browserSelectedElementLabel; const onSendSelectedElementToChat = browserCallbacks.onSendSelectedElementToChat; diff --git a/src/engines/ChatPanel/hooks/useChatViewScrollToBottom.tsx b/src/engines/ChatPanel/hooks/useChatViewScrollToBottom.tsx index 7e27e6163f..daf0003754 100644 --- a/src/engines/ChatPanel/hooks/useChatViewScrollToBottom.tsx +++ b/src/engines/ChatPanel/hooks/useChatViewScrollToBottom.tsx @@ -29,8 +29,8 @@ export function useChatViewScrollToBottom() { shape="round" icon={} iconOnly - aria-label={t("common:chat.scrollToBottom")} - title={t("common:chat.scrollToBottom")} + aria-label={t("common:inbox.scrollToBottom")} + title={t("common:inbox.scrollToBottom")} onClick={scrollNav.onScrollToBottom} className={`shrink-0 ${PILL_CONTROL_IDLE_SURFACE_CLASS}`} /> diff --git a/src/engines/ChatPanel/hooks/useReplyQuestion.tsx b/src/engines/ChatPanel/hooks/useReplyQuestion.tsx index 561131627d..bed3a89426 100644 --- a/src/engines/ChatPanel/hooks/useReplyQuestion.tsx +++ b/src/engines/ChatPanel/hooks/useReplyQuestion.tsx @@ -1,22 +1,12 @@ import { useSetAtom } from "jotai"; import throttle from "lodash/throttle"; -import { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { useSearchParams } from "react-router-dom"; -import { - createUnifiedSessionApi, - isHostedFromSearchParams, -} from "@src/api/http/session/unified"; import { rejectQuestion, respondQuestion } from "@src/api/tauri/agent"; import Message from "@src/components/Message"; import { updateEventByIdAtom, useStepState } from "@src/engines/SessionCore"; import { useSessionId } from "@src/engines/SessionCore/hooks/session"; import { createLogger } from "@src/hooks/logger"; -import { - isAgentSession, - isCliSession, -} from "@src/util/session/sessionDispatch"; const log = createLogger("useReplyQuestion"); @@ -37,14 +27,6 @@ const useReplyQuestion = () => { const updateEventById = useSetAtom(updateEventByIdAtom); const { setIsStepWaiting } = useStepState(); - const [searchParams] = useSearchParams(); - - const isHosted = useMemo( - () => isHostedFromSearchParams(searchParams), - [searchParams] - ); - const api = useMemo(() => createUnifiedSessionApi(isHosted), [isHosted]); - const { sessionId: resolvedId } = useSessionId(); const sessionId = resolvedId || ""; @@ -61,45 +43,17 @@ const useReplyQuestion = () => { return; } - // Agent and CLI sessions: use unified agent API - if (isAgentSession(sessionId) || isCliSession(sessionId)) { - await respondQuestion(sessionId, chunk_id, [[reply.trim()]]); - updateEventById({ - id: chunk_id, - updater: (event) => ({ - ...event, - result: { ...event.result, status: "responsed" }, - displayStatus: "completed" as const, - }), - }); - setIsStepWaiting(false); - Message.success(t("toasts.answerSubmitted")); - return; - } - - // Backend (HTTP) sessions: Use Session API - const res = await api.answerQuestion(sessionId, { - question_id: chunk_id, - answer: reply, + await respondQuestion(sessionId, chunk_id, [[reply.trim()]]); + updateEventById({ + id: chunk_id, + updater: (event) => ({ + ...event, + result: { ...event.result, status: "responsed" }, + displayStatus: "completed" as const, + }), }); - - const response = res as - | { status?: number; data?: { success?: boolean } } - | undefined; - if (response?.status === 0 && response?.data?.success) { - updateEventById({ - id: chunk_id, - updater: (event) => ({ - ...event, - result: { ...event.result, status: "responsed" }, - displayStatus: "completed" as const, - }), - }); - setIsStepWaiting(false); - Message.success(t("toasts.answerSubmitted")); - } else { - Message.error(t("toasts.answerFailed")); - } + setIsStepWaiting(false); + Message.success(t("toasts.answerSubmitted")); } catch (error) { log.error("Error replying to question:", error); Message.error(t("toasts.replyError")); @@ -109,9 +63,7 @@ const useReplyQuestion = () => { ); const handleIgnoreQuestion = (chunkId: string) => { - if (isAgentSession(sessionId) || isCliSession(sessionId)) { - rejectQuestion(sessionId, chunkId).catch(() => {}); - } + rejectQuestion(sessionId, chunkId).catch(() => {}); updateEventById({ id: chunkId, diff --git a/src/engines/ChatPanel/runtime/sessionTranscriptPlatform.ts b/src/engines/ChatPanel/runtime/sessionTranscriptPlatform.ts new file mode 100644 index 0000000000..058750d443 --- /dev/null +++ b/src/engines/ChatPanel/runtime/sessionTranscriptPlatform.ts @@ -0,0 +1,96 @@ +import { useAtomValue, useSetAtom } from "jotai"; +import { useCallback, useEffect, useRef } from "react"; + +import { + clearSessionLoadErrorAtom, + isExploringAtom, + loadErrorAtom, + loadStatusAtom, + sessionHydrationByIdAtom, + triggerSessionReloadAtom, +} from "@src/engines/SessionCore"; +import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; +import { useAgentWorkingRef } from "@src/hooks/streaming"; +import { activeSessionIdAtom, sessionByIdAtom } from "@src/store/session"; +import { + isPendingCancelAtom, + isSessionActiveAtom, + sessionRolledBackAtom, +} from "@src/store/session/cliSessionStatusAtom"; +import { cursorIdeTurnSummariesAtomFamily } from "@src/store/session/cursorIdeTurnSummariesAtom"; +import { isCursorIdeSession } from "@src/util/session/sessionDispatch"; + +import { useSessionTranscriptRuntime } from "../SessionTranscriptRuntimeContext"; +import { useReplyQuestion } from "../hooks/useReplyQuestion"; +import type { SessionTranscriptPlatformState } from "./sessionTranscriptPlatform.types"; + +/** Desktop adapter for the shared transcript. Webpack replaces this module in + * the browser entry with the Cloud/context-backed implementation. */ +export function useSessionTranscriptPlatform( + sessionId: string | null +): SessionTranscriptPlatformState { + const runtime = useSessionTranscriptRuntime(); + const session = useAtomValue(sessionByIdAtom(sessionId ?? "")); + const rawCursorIdeTurnSummaries = useAtomValue( + cursorIdeTurnSummariesAtomFamily(sessionId ?? "") + ); + const desktopIsAgentWorking = useAtomValue(isSessionActiveAtom); + const desktopIsAgentWorkingRef = useAgentWorkingRef(); + const desktopIsExploring = useAtomValue(isExploringAtom); + const desktopLoadStatus = useAtomValue(loadStatusAtom); + const desktopLoadError = useAtomValue(loadErrorAtom); + const isPendingCancel = useAtomValue(isPendingCancelAtom); + const isRolledBack = useAtomValue(sessionRolledBackAtom); + const hydration = useAtomValue(sessionHydrationByIdAtom(sessionId ?? "")); + const { handleReplyQuestion, handleIgnoreQuestion } = useReplyQuestion(); + + const clearSessionLoadError = useSetAtom(clearSessionLoadErrorAtom); + const setLoadStatus = useSetAtom(loadStatusAtom); + const triggerSessionReload = useSetAtom(triggerSessionReloadAtom); + const setActiveSessionId = useSetAtom(activeSessionIdAtom); + + const desktopReload = useCallback(() => { + if (!sessionId) return; + eventStoreProxy.evictSession(sessionId); + clearSessionLoadError(); + setLoadStatus("loading"); + setActiveSessionId(sessionId); + triggerSessionReload(sessionId); + }, [ + clearSessionLoadError, + sessionId, + setActiveSessionId, + setLoadStatus, + triggerSessionReload, + ]); + + const runtimeAgentWorkingRef = useRef(runtime?.isAgentWorking ?? false); + useEffect(() => { + runtimeAgentWorkingRef.current = runtime?.isAgentWorking ?? false; + }, [runtime?.isAgentWorking]); + + const isCursorIde = sessionId ? isCursorIdeSession(sessionId) : false; + + return { + session, + cursorIdeTurnSummaries: isCursorIde ? rawCursorIdeTurnSummaries : [], + isCursorIde, + isAgentWorking: runtime?.isAgentWorking ?? desktopIsAgentWorking, + isAgentWorkingRef: runtime + ? runtimeAgentWorkingRef + : desktopIsAgentWorkingRef, + isExploring: runtime?.isExploring ?? desktopIsExploring, + loadStatus: runtime?.loadStatus ?? desktopLoadStatus, + loadError: runtime?.loadError ?? desktopLoadError, + isPendingCancel: runtime ? false : isPendingCancel, + isRolledBack: runtime ? false : isRolledBack, + isHydrating: runtime ? false : (hydration?.count ?? 0) > 0, + onReload: runtime?.onReload ?? desktopReload, + onReplyQuestion: runtime?.onReplyQuestion ?? handleReplyQuestion, + onIgnoreQuestion: runtime?.onIgnoreQuestion ?? handleIgnoreQuestion, + capabilities: { + canvasInline: runtime?.capabilities?.canvasInline !== false, + turnMetadata: runtime?.capabilities?.turnMetadata !== false, + }, + }; +} diff --git a/src/engines/ChatPanel/runtime/sessionTranscriptPlatform.types.ts b/src/engines/ChatPanel/runtime/sessionTranscriptPlatform.types.ts new file mode 100644 index 0000000000..835989f673 --- /dev/null +++ b/src/engines/ChatPanel/runtime/sessionTranscriptPlatform.types.ts @@ -0,0 +1,26 @@ +import type { MutableRefObject } from "react"; + +import type { CursorIdeTurnSummary } from "@src/api/tauri/externalHistory"; +import type { SessionLoadStatus } from "@src/engines/SessionCore"; +import type { Session } from "@src/store/session"; + +export interface SessionTranscriptPlatformState { + session: Session | undefined; + cursorIdeTurnSummaries: CursorIdeTurnSummary[]; + isCursorIde: boolean; + isAgentWorking: boolean; + isAgentWorkingRef: MutableRefObject; + isExploring: boolean; + loadStatus: SessionLoadStatus; + loadError: string | null; + isPendingCancel: boolean; + isRolledBack: boolean; + isHydrating: boolean; + onReload: () => void; + onReplyQuestion: (input: { reply: string; chunk_id: string }) => void; + onIgnoreQuestion: (eventId: string) => void; + capabilities: { + canvasInline: boolean; + turnMetadata: boolean; + }; +} diff --git a/src/engines/SessionCore/hooks/useAgentADEActions.ts b/src/engines/SessionCore/hooks/useAgentADEActions.ts index ef9fcebfef..c03cc44afe 100644 --- a/src/engines/SessionCore/hooks/useAgentADEActions.ts +++ b/src/engines/SessionCore/hooks/useAgentADEActions.ts @@ -17,7 +17,6 @@ * Also ensures that ActionSystem actions are registered (via registerCoreActions) * so they're available even if the Workstation editor isn't mounted. */ -import { Channel, invoke } from "@tauri-apps/api/core"; import { useAtomValue } from "jotai"; import { useEffect, useRef } from "react"; @@ -29,6 +28,7 @@ import { } from "@src/ActionSystem"; import { sendAdeActionResult } from "@src/api/tauri/agent"; import { clearSessionAtom } from "@src/engines/SessionCore/core/atoms/actions"; +import { subscribeToSessionEvents } from "@src/engines/SessionCore/sync/useSessionChannel"; import { reposAtom } from "@src/store/repo/atoms"; import { SESSION_TARGET_KIND, @@ -193,12 +193,7 @@ export function useAgentADEActions(): void { useEffect(() => { const sessionId = ""; - const channel = new Channel(); - let cancelled = false; - let channelId: number | null = null; - - channel.onmessage = (rawMessage: string) => { - if (cancelled) return; + return subscribeToSessionEvents(sessionId, (rawMessage) => { recordPushEvent("channel", "ade-actions"); try { const detail = parseAdeActionEnvelope(rawMessage); @@ -206,31 +201,7 @@ export function useAgentADEActions(): void { } catch { return; } - }; - - invoke("subscribe_session_events", { - sessionId, - onEvent: channel, - }) - .then((id) => { - if (cancelled) { - void invoke("unsubscribe_session_events", { - sessionId, - channelId: id, - }); - return; - } - channelId = id; - }) - .catch(() => {}); - - return () => { - cancelled = true; - channel.onmessage = () => undefined; - if (channelId !== null) { - void invoke("unsubscribe_session_events", { sessionId, channelId }); - } - }; + }); }, []); // Register actions and listen for ADE action events diff --git a/src/engines/SessionCore/rendering/registry/initToolRegistry.ts b/src/engines/SessionCore/rendering/registry/initToolRegistry.ts index 6c40c758ec..579df7cf1c 100644 --- a/src/engines/SessionCore/rendering/registry/initToolRegistry.ts +++ b/src/engines/SessionCore/rendering/registry/initToolRegistry.ts @@ -258,17 +258,17 @@ export async function initToolRegistry(): Promise { publishToolClassifierRegistry(); } catch (err) { log.error("[initToolRegistry] Failed to fetch from Rust:", err); - builtinSimulatorAppMap = new Map(); builtinIconIdMap = new Map(); builtinActionIconsMap = new Map(); builtinStatusIconsMap = new Map(); - builtinAppSubtoolMap = new Map(); builtinChatBlockMap = new Map(BASELINE_CHAT_BLOCKS); builtinDisplayBehaviorMap = new Map(); builtinActionsMap = new Map(); builtinLabelsMap = new Map(); builtinStatusLabelsMap = new Map(); - cliAliasMap = new Map(); + const { applyBundledToolRegistryFallback } = + await import("./bundledToolRegistryFallback"); + applyBundledToolRegistryFallback(); publishToolClassifierRegistry(); } } diff --git a/src/engines/Simulator/components/RemoteSessionReplayControls.test.ts b/src/engines/Simulator/components/RemoteSessionReplayControls.test.ts new file mode 100644 index 0000000000..7e6c254850 --- /dev/null +++ b/src/engines/Simulator/components/RemoteSessionReplayControls.test.ts @@ -0,0 +1,156 @@ +/** @vitest-environment jsdom */ +import React from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { createSmokeRoot, dispatch } from "@src/test/reactSmokeHarness"; + +import { RemoteSessionReplayControls } from "./RemoteSessionReplayControls"; + +vi.mock("./MusicPlayerReplayBar", () => ({ + MusicPlayerReplayBarView: ({ + onNavigateToIndex, + onFollowLatest, + }: { + onNavigateToIndex: (index: number) => void; + onFollowLatest: () => void; + }) => + React.createElement( + "div", + { "data-desktop-replay-progress": true }, + React.createElement( + "button", + { onClick: () => onNavigateToIndex(2) }, + "scrub" + ), + React.createElement( + "button", + { onClick: onFollowLatest }, + "scrub-to-live" + ) + ), +})); + +vi.mock("./SimulatorStatusBar", () => ({ + SimulatorStatusBarView: ({ + replayMode, + onPrevious, + onPlayPause, + onNext, + onPlaybackSpeedChange, + onEnterReplay, + onFollow, + }: { + replayMode: string; + onPrevious: () => void; + onPlayPause: () => void; + onNext: () => void; + onPlaybackSpeedChange: (speed: number) => void; + onEnterReplay: () => void; + onFollow: () => void; + }) => + React.createElement( + "div", + { "data-desktop-replay-status": replayMode }, + ...[ + ["previous", onPrevious], + ["play-pause", onPlayPause], + ["next", onNext], + ["speed-6", () => onPlaybackSpeedChange(6)], + ["speed-invalid", () => onPlaybackSpeedChange(3)], + ["browse", onEnterReplay], + ["follow", onFollow], + ].map(([label, onClick]) => + React.createElement( + "button", + { key: label as string, onClick: onClick as () => void }, + label as string + ) + ) + ), +})); + +function button(container: HTMLElement, label: string) { + return Array.from(container.querySelectorAll("button")).find( + (candidate) => candidate.textContent === label + ); +} + +describe("RemoteSessionReplayControls", () => { + const roots: Array> = []; + + afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => root.unmount())); + }); + + it("uses the desktop status view in follow mode and enters browsing", async () => { + const root = createSmokeRoot(); + roots.push(root); + const onBrowse = vi.fn(); + + await root.render( + React.createElement(RemoteSessionReplayControls, { + state: { phase: "follow", eventCount: 4, index: 3, speed: 1 }, + onSeek: vi.fn(), + onPlay: vi.fn(), + onPause: vi.fn(), + onBrowse, + onFollow: vi.fn(), + onSpeedChange: vi.fn(), + }) + ); + + expect( + root.container.querySelector("[data-desktop-replay-progress]") + ).toBeNull(); + expect( + root.container + .querySelector("[data-desktop-replay-status]") + ?.getAttribute("data-desktop-replay-status") + ).toBe("follow"); + + await dispatch(() => button(root.container, "browse")?.click()); + expect(onBrowse).toHaveBeenCalledOnce(); + }); + + it("maps desktop replay transport actions to the Web controller", async () => { + const root = createSmokeRoot(); + roots.push(root); + const onSeek = vi.fn(); + const onPlay = vi.fn(); + const onFollow = vi.fn(); + const onSpeedChange = vi.fn(); + + await root.render( + React.createElement(RemoteSessionReplayControls, { + state: { phase: "paused", eventCount: 4, index: 1, speed: 1 }, + onSeek, + onPlay, + onPause: vi.fn(), + onBrowse: vi.fn(), + onFollow, + onSpeedChange, + }) + ); + + expect( + root.container.querySelector("[data-desktop-replay-progress]") + ).not.toBeNull(); + for (const label of [ + "previous", + "play-pause", + "next", + "scrub", + "speed-6", + "speed-invalid", + "follow", + ]) { + await dispatch(() => button(root.container, label)?.click()); + } + + expect(onSeek.mock.calls).toEqual([[0], [2], [2]]); + expect(onPlay).toHaveBeenCalledOnce(); + expect(onSpeedChange).toHaveBeenCalledOnce(); + expect(onSpeedChange).toHaveBeenCalledWith(6); + expect(onFollow).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/engines/Simulator/components/RemoteSessionReplayControls.tsx b/src/engines/Simulator/components/RemoteSessionReplayControls.tsx new file mode 100644 index 0000000000..2eb30bab03 --- /dev/null +++ b/src/engines/Simulator/components/RemoteSessionReplayControls.tsx @@ -0,0 +1,78 @@ +import React, { useCallback } from "react"; + +import type { + ReplayControllerState, + ReplaySpeed, +} from "@src/engines/SessionCore/replay/replayController"; +import { REPLAY_SPEEDS } from "@src/engines/SessionCore/replay/replayController"; + +import { MusicPlayerReplayBarView } from "./MusicPlayerReplayBar"; +import { SimulatorStatusBarView } from "./SimulatorStatusBar"; + +export interface RemoteSessionReplayControlsProps { + state: ReplayControllerState; + onSeek: (index: number) => void; + onPlay: () => void; + onPause: () => void; + onBrowse: () => void; + onFollow: () => void; + onSpeedChange: (speed: ReplaySpeed) => void; +} + +/** + * Web-only state adapter around the desktop replay UI. This component owns no + * replay styling or transport primitives: those stay in the Simulator's + * shared MusicPlayerReplayBarView and SimulatorStatusBarView. + */ +export function RemoteSessionReplayControls({ + state, + onSeek, + onPlay, + onPause, + onBrowse, + onFollow, + onSpeedChange, +}: RemoteSessionReplayControlsProps) { + const handleSpeedChange = useCallback( + (speed: number) => { + if (REPLAY_SPEEDS.includes(speed as ReplaySpeed)) { + onSpeedChange(speed as ReplaySpeed); + } + }, + [onSpeedChange] + ); + + return ( +
+ {state.phase !== "follow" ? ( +
+ +
+ ) : null} +
+ onSeek(state.index - 1)} + onPlayPause={state.phase === "playing" ? onPause : onPlay} + onNext={() => onSeek(state.index + 1)} + onPlaybackSpeedChange={handleSpeedChange} + onEnterReplay={onBrowse} + onFollow={onFollow} + /> +
+
+ ); +} diff --git a/src/engines/Simulator/components/RemoteSessionWorkspaceSurface.test.ts b/src/engines/Simulator/components/RemoteSessionWorkspaceSurface.test.ts new file mode 100644 index 0000000000..a42255882d --- /dev/null +++ b/src/engines/Simulator/components/RemoteSessionWorkspaceSurface.test.ts @@ -0,0 +1,181 @@ +/** @vitest-environment jsdom */ +import React from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import { createSmokeRoot } from "@src/test/reactSmokeHarness"; + +import { RemoteSessionWorkspaceSurface } from "./RemoteSessionWorkspaceSurface"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +vi.mock( + "@src/modules/WorkStation/CodeEditor/SessionReplay/FileSidebar", + () => ({ + FileSidebar: ({ + fileOperations, + currentEventId, + }: { + fileOperations: Array<{ eventId: string; fileName: string }>; + currentEventId: string; + }) => + React.createElement( + "aside", + { + "data-remote-file-sidebar": true, + "data-current-event": currentEventId, + }, + fileOperations.map((operation) => + React.createElement( + "div", + { key: operation.eventId, "data-file-op": operation.fileName }, + operation.fileName + ) + ) + ), + }) +); + +vi.mock("@src/modules/WorkStation/CodeEditor/SessionReplay/CodePanel", () => ({ + CodePanel: ({ operation }: { operation?: { fileName?: string } | null }) => + React.createElement( + "div", + { "data-remote-code-panel": true }, + operation?.fileName ?? "empty" + ), +})); + +vi.mock("@src/modules/WorkStation/shared", () => ({ + buildPrimarySidebarConfig: (config: { content: React.ReactNode }) => config, + WorkStationShell: ({ + primarySidebarConfig, + content, + }: { + primarySidebarConfig: { content: React.ReactNode }; + content: React.ReactNode; + }) => React.createElement("div", null, primarySidebarConfig.content, content), +})); + +vi.mock("@src/modules/shared/layouts/blocks", () => ({ + Placeholder: ({ + variant, + title, + subtitle, + }: { + variant: string; + title?: string; + subtitle?: string; + }) => + React.createElement( + "div", + { "data-placeholder-variant": variant }, + title, + subtitle + ), +})); + +function readEvent(content: string): SessionEvent { + return { + id: "read", + chunk_id: "read", + sessionId: "remote-session", + createdAt: "2026-08-19T00:00:00.000Z", + functionName: "read_file", + uiCanonical: "read_file", + actionType: "tool_call", + args: { path: "/repo/src/app.ts" }, + result: { + output: { success: { content } }, + }, + source: "assistant", + displayText: "Read app.ts", + displayStatus: "completed", + displayVariant: "tool_call", + activityStatus: "processed", + repoPath: "/repo", + extracted: { + kind: "file", + filePath: "/repo/src/app.ts", + fileName: "app.ts", + language: "typescript", + content, + }, + } as SessionEvent; +} + +describe("RemoteSessionWorkspaceSurface", () => { + const roots: Array> = []; + + afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => root.unmount())); + }); + + it("uses the desktop FileSidebar + CodePanel replay stack for event-backed files", async () => { + const root = createSmokeRoot(); + roots.push(root); + await root.render( + React.createElement(RemoteSessionWorkspaceSurface, { + events: [readEvent("export const ready = true;")], + loadStatus: "loaded", + loadError: null, + currentEventId: "read", + }) + ); + + expect( + root.container.querySelector("[data-remote-file-sidebar]") + ).not.toBeNull(); + expect( + root.container.querySelector("[data-remote-code-panel]")?.textContent + ).toBe("app.ts"); + expect( + root.container.querySelector("[data-file-op='app.ts']") + ).not.toBeNull(); + }); + + it("shows explicit empty and failure states instead of a blank editor", async () => { + const root = createSmokeRoot(); + roots.push(root); + await root.render( + React.createElement(RemoteSessionWorkspaceSurface, { + events: [], + loadStatus: "loaded", + loadError: null, + }) + ); + expect(root.container.textContent).toContain( + "web.sessionPage.workstationEmptyTitle" + ); + + await root.render( + React.createElement(RemoteSessionWorkspaceSurface, { + events: [], + loadStatus: "loading", + loadError: null, + }) + ); + expect( + root.container.querySelector("[data-placeholder-variant='loading']") + ).not.toBeNull(); + expect(root.container.textContent).toContain( + "web.sessionPage.workstationLoading" + ); + expect(root.container.textContent).not.toContain("status.loading"); + + await root.render( + React.createElement(RemoteSessionWorkspaceSurface, { + events: [], + loadStatus: "error", + loadError: "Cloud request failed", + }) + ); + expect( + root.container.querySelector("[data-placeholder-variant='error']") + ).not.toBeNull(); + expect(root.container.textContent).toContain("Cloud request failed"); + }); +}); diff --git a/src/engines/Simulator/components/RemoteSessionWorkspaceSurface.tsx b/src/engines/Simulator/components/RemoteSessionWorkspaceSurface.tsx new file mode 100644 index 0000000000..80b306abf1 --- /dev/null +++ b/src/engines/Simulator/components/RemoteSessionWorkspaceSurface.tsx @@ -0,0 +1,143 @@ +import React, { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { WORK_STATION_PRIMARY_SIDEBAR } from "@src/config/workStationPrimarySidebar"; +import type { + SessionEvent, + SessionLoadStatus, +} from "@src/engines/SessionCore/core/types"; +import { CodePanel } from "@src/modules/WorkStation/CodeEditor/SessionReplay/CodePanel"; +import { FileSidebar } from "@src/modules/WorkStation/CodeEditor/SessionReplay/FileSidebar"; +import { FILE_PANEL_VIEW_MODE } from "@src/modules/WorkStation/CodeEditor/SessionReplay/types"; +import { + WorkStationShell, + buildPrimarySidebarConfig, +} from "@src/modules/WorkStation/shared"; +import { Placeholder } from "@src/modules/shared/layouts/blocks"; + +import { useRemoteSessionReplay } from "./useRemoteSessionReplay"; + +export interface RemoteSessionWorkspaceSurfaceProps { + events: SessionEvent[]; + loadStatus: SessionLoadStatus; + loadError: string | null; + /** Replay cursor event; file read/edit rows follow this during scrubbing. */ + currentEventId?: string | null; + /** Inclusive replay cursor on the full event list. */ + replayEndIndex?: number; +} + +export function RemoteSessionWorkspaceSurface({ + events, + loadStatus, + loadError, + currentEventId = null, + replayEndIndex, +}: RemoteSessionWorkspaceSurfaceProps) { + const { t } = useTranslation("navigation"); + const replay = useRemoteSessionReplay({ + events, + currentEventId, + replayEndIndex, + }); + const [sidebarWidth, setSidebarWidth] = useState( + WORK_STATION_PRIMARY_SIDEBAR.defaultWidth + ); + + const sidebarFileViewMode = + replay.fileViewMode === FILE_PANEL_VIEW_MODE.TOOL + ? FILE_PANEL_VIEW_MODE.TERMINAL + : replay.fileViewMode; + + const sidebar = useMemo( + () => ( + + ), + [currentEventId, replay, sidebarFileViewMode] + ); + + if (!replay.hasAnyOperations) { + const isLoading = loadStatus === "idle" || loadStatus === "loading"; + return ( + + ); + } + + const mainContent = ( +
+ +
+ ); + + return ( +
+ {loadStatus === "error" ? ( +
+ {t("web.sessionPage.workstationRefreshFailedBanner")} +
+ ) : null} +
+ +
+
+ ); +} diff --git a/src/engines/Simulator/components/RemoteSessionWorkstationSurface.test.ts b/src/engines/Simulator/components/RemoteSessionWorkstationSurface.test.ts new file mode 100644 index 0000000000..e25576e92d --- /dev/null +++ b/src/engines/Simulator/components/RemoteSessionWorkstationSurface.test.ts @@ -0,0 +1,140 @@ +/** @vitest-environment jsdom */ +import React from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { createSmokeRoot, dispatch } from "@src/test/reactSmokeHarness"; + +import { RemoteSessionWorkstationSurface } from "./RemoteSessionWorkstationSurface"; + +vi.mock("../ActivitySimulator", () => ({ + default: () => React.createElement("div", { "data-agent-replay": true }), +})); + +vi.mock("./RemoteSessionWorkspaceSurface", () => ({ + RemoteSessionWorkspaceSurface: () => + React.createElement("div", { "data-session-workspace": true }), +})); + +vi.mock("@src/modules/WorkStation/shared/StationModePill", () => ({ + StationModePillView: ({ + stationMode, + onStationModeChange, + }: { + stationMode: "my-station" | "agent-station"; + onStationModeChange: (mode: "my-station" | "agent-station") => void; + }) => + React.createElement( + "div", + null, + React.createElement( + "button", + { + "data-switch-station": "my-station", + "aria-pressed": stationMode === "my-station", + onClick: () => onStationModeChange("my-station"), + }, + "My Station" + ), + React.createElement( + "button", + { + "data-switch-station": "agent-station", + "aria-pressed": stationMode === "agent-station", + onClick: () => onStationModeChange("agent-station"), + }, + "Agent Station" + ) + ), +})); + +describe("RemoteSessionWorkstationSurface", () => { + const roots: Array> = []; + + afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => root.unmount())); + }); + + it("defaults to My Station and switches to Agent Station on demand", async () => { + const root = createSmokeRoot(); + roots.push(root); + await root.render( + React.createElement(RemoteSessionWorkstationSurface, { + sessionId: "session-1", + events: [], + loadStatus: "loaded", + loadError: null, + }) + ); + + const agentPanel = root.container.querySelector( + '[data-remote-station-panel="agent-station"]' + ); + const workspacePanel = root.container.querySelector( + '[data-remote-station-panel="my-station"]' + ); + expect(agentPanel).toBeNull(); + expect(workspacePanel).not.toBeNull(); + expect(root.container.querySelector("[data-agent-replay]")).toBeNull(); + expect( + root.container.querySelector("[data-session-workspace]") + ).not.toBeNull(); + + const agentStationButton = root.container.querySelector( + '[data-switch-station="agent-station"]' + ); + await dispatch(() => agentStationButton?.click()); + + expect( + root.container.querySelector( + '[data-remote-station-panel="agent-station"]' + ) + ).not.toBeNull(); + expect( + root.container.querySelector('[data-remote-station-panel="my-station"]') + ).toBeNull(); + expect(root.container.querySelector("[data-agent-replay]")).not.toBeNull(); + expect(root.container.querySelector("[data-session-workspace]")).toBeNull(); + + const myStationButton = root.container.querySelector( + '[data-switch-station="my-station"]' + ); + await dispatch(() => myStationButton?.click()); + + expect( + root.container.querySelector( + '[data-remote-station-panel="agent-station"]' + ) + ).toBeNull(); + expect( + root.container.querySelector('[data-remote-station-panel="my-station"]') + ).not.toBeNull(); + }); + + it("returns to My Station when the remote session changes", async () => { + const root = createSmokeRoot(); + roots.push(root); + const renderSession = (sessionId: string) => + React.createElement(RemoteSessionWorkstationSurface, { + sessionId, + events: [], + loadStatus: "loaded" as const, + loadError: null, + }); + + await root.render(renderSession("session-1")); + const myStationButton = root.container.querySelector( + '[data-switch-station="my-station"]' + ); + await dispatch(() => myStationButton?.click()); + await root.render(renderSession("session-2")); + + expect( + root.container.querySelector('[data-remote-station-panel="my-station"]') + ).not.toBeNull(); + expect( + root.container + .querySelector('[data-switch-station="my-station"]') + ?.getAttribute("aria-pressed") + ).toBe("true"); + }); +}); diff --git a/src/engines/Simulator/components/RemoteSessionWorkstationSurface.tsx b/src/engines/Simulator/components/RemoteSessionWorkstationSurface.tsx new file mode 100644 index 0000000000..98c994bb05 --- /dev/null +++ b/src/engines/Simulator/components/RemoteSessionWorkstationSurface.tsx @@ -0,0 +1,154 @@ +import { Provider, createStore } from "jotai"; +import React, { useCallback, useLayoutEffect, useMemo, useState } from "react"; + +import { ChatSessionContext } from "@src/engines/ChatPanel/ChatSessionContext"; +import { + currentEventIdAtom, + loadErrorAtom, + loadStatusAtom, + replayBarValueAtom, + replayModeAtom, + replayTimeRangeAtom, + sessionIdAtom, + specsAtom, +} from "@src/engines/SessionCore"; +import { derivedSnapshotAtom } from "@src/engines/SessionCore/core/atoms/events"; +import type { + SessionEvent, + SessionLoadStatus, +} from "@src/engines/SessionCore/core/types"; +import { buildRemoteReplaySnapshot } from "@src/engines/SessionCore/replay/remoteReplaySnapshot"; +import { StationModePillView } from "@src/modules/WorkStation/shared/StationModePill"; +import type { StationMode } from "@src/store/ui/simulatorAtom"; + +import ActivitySimulator from "../ActivitySimulator"; +import { RemoteSessionWorkspaceSurface } from "./RemoteSessionWorkspaceSurface"; + +export interface RemoteSessionWorkstationSurfaceProps { + sessionId: string; + events: SessionEvent[]; + loadStatus: SessionLoadStatus; + loadError: string | null; + /** Replay cursor event forwarded to My Station file selection. */ + currentEventId?: string | null; + /** Inclusive replay cursor on the full event list. */ + replayEndIndex?: number; +} + +function createRemoteReplayStore(sessionId: string) { + const store = createStore(); + store.set(sessionIdAtom, sessionId); + return store; +} + +/** + * Runs the canonical desktop ActivitySimulator against Cloud events without + * mutating the desktop/global EventStore. The nested store is one replay + * sandbox per remote session; the Web replay controller owns the cursor by + * passing the visible event prefix. + */ +export function RemoteSessionWorkstationSurface({ + sessionId, + events, + loadStatus, + loadError, + currentEventId = null, + replayEndIndex, +}: RemoteSessionWorkstationSurfaceProps) { + const replayStore = useMemo( + () => createRemoteReplayStore(sessionId), + [sessionId] + ); + const snapshot = useMemo( + () => buildRemoteReplaySnapshot(events, { endIndex: replayEndIndex }), + [events, replayEndIndex] + ); + const [stationSelection, setStationSelection] = useState<{ + sessionId: string; + mode: StationMode; + }>(() => ({ sessionId, mode: "my-station" })); + const stationMode = + stationSelection.sessionId === sessionId + ? stationSelection.mode + : "my-station"; + + const handleStationModeChange = useCallback( + (mode: StationMode) => { + setStationSelection({ sessionId, mode }); + }, + [sessionId] + ); + + useLayoutEffect(() => { + const simulatorEvents = snapshot.sortedSimulatorEvents; + const firstEvent = simulatorEvents[0] ?? null; + const lastEvent = simulatorEvents[simulatorEvents.length - 1] ?? null; + + replayStore.set(sessionIdAtom, sessionId); + replayStore.set(derivedSnapshotAtom, snapshot); + replayStore.set(specsAtom, []); + replayStore.set(loadStatusAtom, loadStatus); + replayStore.set(loadErrorAtom, loadError); + replayStore.set( + currentEventIdAtom, + currentEventId ?? lastEvent?.id ?? null + ); + replayStore.set(replayModeAtom, "follow"); + replayStore.set(replayBarValueAtom, 200); + replayStore.set(replayTimeRangeAtom, { + start: firstEvent?.createdAt ?? "", + end: lastEvent?.createdAt ?? "", + }); + }, [currentEventId, loadError, loadStatus, replayStore, sessionId, snapshot]); + + return ( + + +
+
+ + + {stationMode === "agent-station" + ? "Agent replay · Read only" + : "Session workspace · Read only"} + +
+
+ {stationMode === "agent-station" ? ( +
+ +
+ ) : null} + {stationMode === "my-station" ? ( +
+ +
+ ) : null} +
+
+
+
+ ); +} diff --git a/src/engines/Simulator/components/__tests__/remoteSessionWorkspaceSelection.test.ts b/src/engines/Simulator/components/__tests__/remoteSessionWorkspaceSelection.test.ts new file mode 100644 index 0000000000..412fde4d45 --- /dev/null +++ b/src/engines/Simulator/components/__tests__/remoteSessionWorkspaceSelection.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vitest"; + +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; + +import { buildRemoteSessionWorkspaceFiles } from "../remoteSessionWorkspace"; +import { + resolveRemoteWorkspacePathForEvent, + resolveRemoteWorkspaceSelectionPath, +} from "../remoteSessionWorkspaceSelection"; + +function event( + id: string, + overrides: Partial = {} +): SessionEvent { + return { + id, + chunk_id: id, + sessionId: "remote-session", + createdAt: "2026-08-19T00:00:00.000Z", + functionName: "message", + uiCanonical: "message", + actionType: "message", + args: {}, + result: {}, + source: "assistant", + displayText: id, + displayStatus: "completed", + displayVariant: "message", + activityStatus: "processed", + repoPath: "/repo", + ...overrides, + } as SessionEvent; +} + +describe("resolveRemoteWorkspacePathForEvent", () => { + it("returns the workspace-relative path for read and edit events", () => { + const read = event("read", { + functionName: "read_file", + uiCanonical: "read_file", + actionType: "tool_call", + displayVariant: "tool_call", + extracted: { + kind: "file", + filePath: "/repo/src/app.ts", + fileName: "app.ts", + language: "typescript", + content: "const version = 1;", + }, + }); + const edit = event("edit", { + functionName: "edit_file_by_replace", + uiCanonical: "edit_file", + actionType: "tool_call", + displayVariant: "tool_call", + extracted: { + kind: "edit", + filePath: "/repo/src/app.ts", + fileName: "app.ts", + language: "typescript", + oldContent: "const version = 1;", + newContent: "const version = 2;", + isDeleted: false, + applyPatchSegments: [], + }, + }); + + expect(resolveRemoteWorkspacePathForEvent(read)).toBe("src/app.ts"); + expect(resolveRemoteWorkspacePathForEvent(edit)).toBe("src/app.ts"); + expect(resolveRemoteWorkspacePathForEvent(event("msg"))).toBeNull(); + }); +}); + +describe("resolveRemoteWorkspaceSelectionPath", () => { + it("follows the replay cursor onto the active file event", () => { + const read = event("read", { + functionName: "read_file", + uiCanonical: "read_file", + actionType: "tool_call", + displayVariant: "tool_call", + extracted: { + kind: "file", + filePath: "/repo/src/read.ts", + fileName: "read.ts", + language: "typescript", + content: "read me", + }, + }); + const edit = event("edit", { + createdAt: "2026-08-19T00:00:01.000Z", + functionName: "edit_file_by_replace", + uiCanonical: "edit_file", + actionType: "tool_call", + displayVariant: "tool_call", + extracted: { + kind: "edit", + filePath: "/repo/src/edit.ts", + fileName: "edit.ts", + language: "typescript", + oldContent: "a", + newContent: "b", + isDeleted: false, + applyPatchSegments: [], + }, + }); + const prefix = [read, event("msg"), edit]; + const files = buildRemoteSessionWorkspaceFiles(prefix); + + expect( + resolveRemoteWorkspaceSelectionPath(prefix, files, "read", "src/edit.ts") + ).toBe("src/read.ts"); + expect( + resolveRemoteWorkspaceSelectionPath(prefix, files, "edit", "src/read.ts") + ).toBe("src/edit.ts"); + }); + + it("keeps manual selection when the replay cursor is not on a file event", () => { + const read = event("read", { + functionName: "read_file", + uiCanonical: "read_file", + actionType: "tool_call", + displayVariant: "tool_call", + extracted: { + kind: "file", + filePath: "/repo/src/read.ts", + fileName: "read.ts", + language: "typescript", + content: "read me", + }, + }); + const prefix = [read, event("msg")]; + const files = buildRemoteSessionWorkspaceFiles(prefix); + + expect( + resolveRemoteWorkspaceSelectionPath(prefix, files, "msg", "src/read.ts") + ).toBe("src/read.ts"); + expect( + resolveRemoteWorkspaceSelectionPath(prefix, files, "msg", null) + ).toBe("src/read.ts"); + }); +}); diff --git a/src/engines/Simulator/components/__tests__/useRemoteSessionReplay.test.ts b/src/engines/Simulator/components/__tests__/useRemoteSessionReplay.test.ts new file mode 100644 index 0000000000..6865da1718 --- /dev/null +++ b/src/engines/Simulator/components/__tests__/useRemoteSessionReplay.test.ts @@ -0,0 +1,103 @@ +/** @vitest-environment jsdom */ +import React from "react"; +import { afterEach, describe, expect, it } from "vitest"; + +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import { createSmokeRoot } from "@src/test/reactSmokeHarness"; + +import { useRemoteSessionReplay } from "../useRemoteSessionReplay"; + +function event( + id: string, + overrides: Partial = {} +): SessionEvent { + return { + id, + chunk_id: id, + sessionId: "remote-session", + createdAt: "2026-08-19T00:00:00.000Z", + functionName: "message", + uiCanonical: "message", + actionType: "message", + args: {}, + result: {}, + source: "assistant", + displayText: id, + displayStatus: "completed", + displayVariant: "message", + activityStatus: "processed", + repoPath: "/repo", + ...overrides, + } as SessionEvent; +} + +function ReplayProbe({ + events, + currentEventId, +}: { + events: SessionEvent[]; + currentEventId: string; +}) { + const state = useRemoteSessionReplay({ events, currentEventId }); + return React.createElement("div", { + "data-file-op-count": String(state.allFileOperations.length), + "data-selected-file": state.selectedFileOperation?.fileName ?? "", + "data-selected-event": state.selectedFileOperation?.eventId ?? "", + }); +} + +describe("useRemoteSessionReplay", () => { + const roots: Array> = []; + + afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => root.unmount())); + }); + + it("dedupes read operations like the desktop FileSidebar", async () => { + const readA = event("read-a", { + functionName: "read_file", + uiCanonical: "read_file", + actionType: "tool_call", + displayVariant: "tool_call", + args: { path: "/repo/src/hooks/useInlineWebview.ts" }, + extracted: { + kind: "file", + filePath: "/repo/src/hooks/useInlineWebview.ts", + fileName: "useInlineWebview.ts", + language: "typescript", + content: "first", + }, + }); + const readB = event("read-b", { + createdAt: "2026-08-19T00:00:01.000Z", + functionName: "read_file", + uiCanonical: "read_file", + actionType: "tool_call", + displayVariant: "tool_call", + args: { path: "/repo/src/hooks/useInlineWebview.ts" }, + extracted: { + kind: "file", + filePath: "/repo/src/hooks/useInlineWebview.ts", + fileName: "useInlineWebview.ts", + language: "typescript", + content: "second", + }, + }); + + const root = createSmokeRoot(); + roots.push(root); + await root.render( + React.createElement(ReplayProbe, { + events: [readA, readB], + currentEventId: "read-b", + }) + ); + + const probe = root.container.firstElementChild; + expect(probe?.getAttribute("data-file-op-count")).toBe("1"); + expect(probe?.getAttribute("data-selected-file")).toBe( + "useInlineWebview.ts" + ); + expect(probe?.getAttribute("data-selected-event")).toBe("read-b"); + }); +}); diff --git a/src/engines/Simulator/components/remoteSessionWorkspace.test.ts b/src/engines/Simulator/components/remoteSessionWorkspace.test.ts new file mode 100644 index 0000000000..42621df3f8 --- /dev/null +++ b/src/engines/Simulator/components/remoteSessionWorkspace.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from "vitest"; + +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; + +import { buildRemoteSessionWorkspaceFiles } from "./remoteSessionWorkspace"; + +function event( + id: string, + overrides: Partial = {} +): SessionEvent { + return { + id, + chunk_id: id, + sessionId: "remote-session", + createdAt: "2026-08-19T00:00:00.000Z", + functionName: "message", + uiCanonical: "message", + actionType: "message", + args: {}, + result: {}, + source: "assistant", + displayText: id, + displayStatus: "completed", + displayVariant: "message", + activityStatus: "processed", + repoPath: "/repo", + ...overrides, + } as SessionEvent; +} + +describe("buildRemoteSessionWorkspaceFiles", () => { + it("uses the latest event-backed file state without inventing untouched files", () => { + const read = event("read", { + functionName: "read_file", + uiCanonical: "read_file", + actionType: "tool_call", + displayVariant: "tool_call", + filePath: "/repo/src/app.ts", + extracted: { + kind: "file", + filePath: "/repo/src/app.ts", + fileName: "app.ts", + language: "typescript", + content: "const version = 1;", + }, + }); + const edit = event("edit", { + createdAt: "2026-08-19T00:00:01.000Z", + functionName: "edit_file_by_replace", + uiCanonical: "edit_file", + actionType: "tool_call", + displayVariant: "tool_call", + filePath: "/repo/src/app.ts", + extracted: { + kind: "edit", + filePath: "/repo/src/app.ts", + fileName: "app.ts", + language: "typescript", + oldContent: "const version = 1;", + newContent: "const version = 2;", + isDeleted: false, + applyPatchSegments: [], + }, + }); + + const files = buildRemoteSessionWorkspaceFiles([ + edit, + event("message"), + read, + ]); + + expect(files).toHaveLength(1); + expect(files[0]).toMatchObject({ + path: "src/app.ts", + fileName: "app.ts", + eventId: "edit", + mode: "diff", + status: "modified", + oldContent: "const version = 1;", + newContent: "const version = 2;", + partial: true, + }); + }); + + it("marks ranged reads as partial and paths without bodies as unavailable", () => { + const rangedRead = event("ranged", { + functionName: "read_file", + uiCanonical: "read_file", + actionType: "tool_call", + displayVariant: "tool_call", + args: { path: "/repo/src/ranged.ts", offset: 10, limit: 20 }, + extracted: { + kind: "file", + filePath: "/repo/src/ranged.ts", + fileName: "ranged.ts", + language: "typescript", + content: "line eleven", + startLine: 11, + }, + }); + const missingBody = event("missing", { + functionName: "read_file", + uiCanonical: "read_file", + actionType: "tool_call", + displayVariant: "tool_call", + filePath: "/repo/src/missing.ts", + extracted: { + kind: "file", + filePath: "/repo/src/missing.ts", + fileName: "missing.ts", + language: "typescript", + }, + }); + + const files = buildRemoteSessionWorkspaceFiles([rangedRead, missingBody]); + + expect(files.find((file) => file.path === "src/ranged.ts")).toMatchObject({ + mode: "content", + content: "line eleven", + contentStartLine: 11, + partial: true, + }); + expect(files.find((file) => file.path === "src/missing.ts")).toMatchObject({ + mode: "unavailable", + status: "unavailable", + }); + }); +}); diff --git a/src/engines/Simulator/components/remoteSessionWorkspace.ts b/src/engines/Simulator/components/remoteSessionWorkspace.ts new file mode 100644 index 0000000000..cc9d9b3fbe --- /dev/null +++ b/src/engines/Simulator/components/remoteSessionWorkspace.ts @@ -0,0 +1,197 @@ +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import { convertToFileOperation } from "@src/modules/WorkStation/CodeEditor/SessionReplay/converters/fileConverter"; +import { resolveFileOperationPayload } from "@src/modules/WorkStation/CodeEditor/SessionReplay/resolveFilePayload"; +import { + FILE_OPERATION_TYPE, + type FileOperationEntry, +} from "@src/modules/WorkStation/CodeEditor/SessionReplay/types"; +import { buildSessionReplayDiffSectionItems } from "@src/modules/WorkStation/shared"; +import { getFileName } from "@src/util/file/pathUtils"; + +export type RemoteSessionWorkspaceFileMode = + | "content" + | "diff" + | "deleted" + | "unavailable"; + +export type RemoteSessionWorkspaceFileStatus = + | "read" + | "modified" + | "added" + | "deleted" + | "unavailable"; + +export interface RemoteSessionWorkspaceFile { + id: string; + path: string; + sourcePath: string; + fileName: string; + eventId: string; + createdAt: string; + language?: string; + mode: RemoteSessionWorkspaceFileMode; + status: RemoteSessionWorkspaceFileStatus; + content?: string; + contentStartLine?: number; + oldContent?: string; + newContent?: string; + oldStartLine?: number; + newStartLine?: number; + /** Event payloads can be ranged reads or compact diffs, not full files. */ + partial: boolean; +} + +function eventTime(event: SessionEvent): number { + const parsed = Date.parse(event.createdAt); + return Number.isFinite(parsed) ? parsed : 0; +} + +function compareEvents(left: SessionEvent, right: SessionEvent): number { + return eventTime(left) - eventTime(right) || left.id.localeCompare(right.id); +} + +function normalizeWorkspacePath(filePath: string, repoPath?: string): string { + const normalizedFilePath = filePath.replace(/\\/g, "/"); + const normalizedRepoPath = repoPath?.replace(/\\/g, "/").replace(/\/$/, ""); + + if ( + normalizedRepoPath && + (normalizedFilePath === normalizedRepoPath || + normalizedFilePath.startsWith(`${normalizedRepoPath}/`)) + ) { + const relativePath = normalizedFilePath.slice(normalizedRepoPath.length); + return relativePath.replace(/^\/+/, "") || getFileName(normalizedFilePath); + } + + return normalizedFilePath.replace(/^\.\//, "").replace(/^\/+/, ""); +} + +function fileId(path: string): string { + return `remote-session-file:${path}`; +} + +function fallbackFile( + event: SessionEvent, + operation: FileOperationEntry +): RemoteSessionWorkspaceFile { + const path = normalizeWorkspacePath(operation.filePath, event.repoPath); + const payload = resolveFileOperationPayload(operation); + const common = { + id: fileId(path), + path, + sourcePath: operation.filePath, + fileName: operation.fileName || getFileName(path), + eventId: event.id, + createdAt: event.createdAt, + language: payload.language ?? operation.language, + }; + + if (operation.type === FILE_OPERATION_TYPE.DELETE) { + return { + ...common, + mode: "deleted", + status: "deleted", + partial: false, + }; + } + + if (operation.type === FILE_OPERATION_TYPE.READ) { + const contentAvailable = payload.content !== undefined; + const rangedRead = + payload.contentStartLine !== undefined || + typeof event.args?.limit === "number"; + return { + ...common, + mode: contentAvailable ? "content" : "unavailable", + status: contentAvailable ? "read" : "unavailable", + content: payload.content, + contentStartLine: payload.contentStartLine, + partial: rangedRead, + }; + } + + if (payload.oldContent !== undefined || payload.newContent !== undefined) { + return { + ...common, + mode: "diff", + status: payload.oldContent ? "modified" : "added", + oldContent: payload.oldContent ?? "", + newContent: payload.newContent ?? "", + oldStartLine: payload.oldStartLine, + newStartLine: payload.newStartLine, + partial: true, + }; + } + + return { + ...common, + mode: "unavailable", + status: "unavailable", + partial: true, + }; +} + +/** + * Projects the event prefix at the replay cursor into the files the Cloud + * transcript can actually prove. It never invents a repository snapshot. + */ +export function buildRemoteSessionWorkspaceFiles( + events: readonly SessionEvent[] +): RemoteSessionWorkspaceFile[] { + const files = new Map(); + const sortedEvents = [...events].sort(compareEvents); + + for (const event of sortedEvents) { + const operation = convertToFileOperation(event, false); + if (!operation) continue; + + if (operation.type === FILE_OPERATION_TYPE.WRITE) { + const sections = buildSessionReplayDiffSectionItems({ + entryId: event.id, + event, + filePath: operation.filePath, + fileName: operation.fileName, + }); + + if (sections.length > 0) { + for (const section of sections) { + const path = normalizeWorkspacePath( + section.file.path, + event.repoPath + ); + const isDeleted = section.file.status === "deleted"; + const status: RemoteSessionWorkspaceFileStatus = isDeleted + ? "deleted" + : section.file.status === "added" + ? "added" + : "modified"; + const nextFile: RemoteSessionWorkspaceFile = { + id: fileId(path), + path, + sourcePath: section.file.path, + fileName: getFileName(path), + eventId: event.id, + createdAt: event.createdAt, + language: operation.language, + mode: isDeleted ? "deleted" : "diff", + status, + oldContent: section.file.oldContent, + newContent: section.file.newContent, + oldStartLine: section.file.oldStartLine, + newStartLine: section.file.newStartLine, + partial: !isDeleted, + }; + files.set(path, nextFile); + } + continue; + } + } + + const nextFile = fallbackFile(event, operation); + if (nextFile.path) files.set(nextFile.path, nextFile); + } + + return Array.from(files.values()).sort((left, right) => + left.path.localeCompare(right.path) + ); +} diff --git a/src/engines/Simulator/components/remoteSessionWorkspaceSelection.ts b/src/engines/Simulator/components/remoteSessionWorkspaceSelection.ts new file mode 100644 index 0000000000..aa9e41aff1 --- /dev/null +++ b/src/engines/Simulator/components/remoteSessionWorkspaceSelection.ts @@ -0,0 +1,60 @@ +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import { convertToFileOperation } from "@src/modules/WorkStation/CodeEditor/SessionReplay/converters/fileConverter"; +import { getFileName } from "@src/util/file/pathUtils"; + +import type { RemoteSessionWorkspaceFile } from "./remoteSessionWorkspace"; + +function normalizeWorkspacePath(filePath: string, repoPath?: string): string { + const normalizedFilePath = filePath.replace(/\\/g, "/"); + const normalizedRepoPath = repoPath?.replace(/\\/g, "/").replace(/\/$/, ""); + + if ( + normalizedRepoPath && + (normalizedFilePath === normalizedRepoPath || + normalizedFilePath.startsWith(`${normalizedRepoPath}/`)) + ) { + const relativePath = normalizedFilePath.slice(normalizedRepoPath.length); + return relativePath.replace(/^\/+/, "") || getFileName(normalizedFilePath); + } + + return normalizedFilePath.replace(/^\.\//, "").replace(/^\/+/, ""); +} + +/** Maps a replay cursor event to the workspace-relative path it touches. */ +export function resolveRemoteWorkspacePathForEvent( + event: SessionEvent | null | undefined +): string | null { + if (!event) return null; + const operation = convertToFileOperation(event, false); + if (!operation?.filePath) return null; + const path = normalizeWorkspacePath(operation.filePath, event.repoPath); + return path || null; +} + +/** + * Picks the file row My Station should show during replay scrubbing. + * File events at the replay cursor win; otherwise keep manual selection. + */ +export function resolveRemoteWorkspaceSelectionPath( + events: readonly SessionEvent[], + files: readonly RemoteSessionWorkspaceFile[], + currentEventId: string | null | undefined, + manualSelectedPath: string | null +): string | null { + if (files.length === 0) return null; + + const filePaths = new Set(files.map((file) => file.path)); + if (currentEventId) { + const currentEvent = events.find((event) => event.id === currentEventId); + const pathFromEvent = resolveRemoteWorkspacePathForEvent(currentEvent); + if (pathFromEvent && filePaths.has(pathFromEvent)) { + return pathFromEvent; + } + } + + if (manualSelectedPath && filePaths.has(manualSelectedPath)) { + return manualSelectedPath; + } + + return files[0]?.path ?? null; +} diff --git a/src/engines/Simulator/components/useRemoteSessionReplay.ts b/src/engines/Simulator/components/useRemoteSessionReplay.ts new file mode 100644 index 0000000000..c530eaef36 --- /dev/null +++ b/src/engines/Simulator/components/useRemoteSessionReplay.ts @@ -0,0 +1,275 @@ +import { useCallback, useMemo, useState } from "react"; + +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import { getIDEEventType } from "@src/engines/SessionCore/rendering/registry/toolRegistryDomain"; +import { + deriveIDEState, + isGenericIDEFallbackToolEvent, + matchesIDEEventRecord, +} from "@src/modules/WorkStation/CodeEditor/SessionReplay/config"; +import { isExplorePanelTool } from "@src/modules/WorkStation/CodeEditor/SessionReplay/converters/exploreTypeResolver"; +import { isShellSearchEvent } from "@src/modules/WorkStation/CodeEditor/SessionReplay/converters/shellSearchConverter"; +import { + resolveSelectedExploreOperation, + resolveSelectedFileOperation, + resolveSelectedShellOperation, + resolveSelectedToolOperation, +} from "@src/modules/WorkStation/CodeEditor/SessionReplay/resolveSelectedOperations"; +import { + CODE_PANEL_MODE, + type CodePanelMode, + FILE_OPERATION_TYPE, + FILE_PANEL_VIEW_MODE, + type FilePanelViewMode, + type IDEEventType, + IDE_EVENT_TYPE, +} from "@src/modules/WorkStation/CodeEditor/SessionReplay/types"; + +export interface UseRemoteSessionReplayOptions { + events: SessionEvent[]; + currentEventId?: string | null; + /** Inclusive replay cursor on the full event list. */ + replayEndIndex?: number; +} + +function resolveCurrentEventType(event: SessionEvent | null): IDEEventType { + if (!event) return IDE_EVENT_TYPE.READ; + const functionName = event.functionName || ""; + if (isExplorePanelTool(functionName) || isShellSearchEvent(event)) { + return IDE_EVENT_TYPE.EXPLORE; + } + if (isGenericIDEFallbackToolEvent(event)) return IDE_EVENT_TYPE.TOOL; + return getIDEEventType(functionName); +} + +export function useRemoteSessionReplay({ + events, + currentEventId = null, + replayEndIndex, +}: UseRemoteSessionReplayOptions) { + const resolvedEndIndex = useMemo(() => { + if (events.length === 0) return -1; + if (replayEndIndex === undefined) return events.length - 1; + return Math.min(Math.max(replayEndIndex, 0), events.length - 1); + }, [events, replayEndIndex]); + + const currentEvent = useMemo(() => { + if (currentEventId) { + return events.find((event) => event.id === currentEventId) ?? null; + } + return resolvedEndIndex >= 0 ? (events[resolvedEndIndex] ?? null) : null; + }, [currentEventId, events, resolvedEndIndex]); + const currentEventType = useMemo( + () => resolveCurrentEventType(currentEvent), + [currentEvent] + ); + + const appEvents = useMemo(() => { + if (events.length === 0) return []; + const endIndex = currentEventId + ? events.findIndex((event) => event.id === currentEventId) + : resolvedEndIndex; + const boundedEndIndex = + endIndex >= 0 ? endIndex : Math.max(resolvedEndIndex, 0); + return events + .slice(0, boundedEndIndex + 1) + .filter((event) => matchesIDEEventRecord(event)); + }, [currentEventId, events, resolvedEndIndex]); + + const derivedState = useMemo( + () => deriveIDEState(appEvents, currentEventId), + [appEvents, currentEventId] + ); + + const { + fileOperations: allFileOperations, + shellOperations: allShellOperations, + exploreOperations: allExploreOperations, + toolOperations: allToolOperations, + } = derivedState; + + const defaultViewMode = useMemo((): FilePanelViewMode => { + if (currentEventType === IDE_EVENT_TYPE.WRITE) { + return FILE_PANEL_VIEW_MODE.WRITE; + } + if (currentEventType === IDE_EVENT_TYPE.EXPLORE) { + return FILE_PANEL_VIEW_MODE.EXPLORE; + } + if (currentEventType === IDE_EVENT_TYPE.SHELL) { + return FILE_PANEL_VIEW_MODE.TERMINAL; + } + if (currentEventType === IDE_EVENT_TYPE.TOOL) { + return FILE_PANEL_VIEW_MODE.TOOL; + } + if (allFileOperations.length > 0) { + const lastFileOp = allFileOperations[allFileOperations.length - 1]; + return lastFileOp.type === FILE_OPERATION_TYPE.WRITE || + lastFileOp.type === FILE_OPERATION_TYPE.DELETE + ? FILE_PANEL_VIEW_MODE.WRITE + : FILE_PANEL_VIEW_MODE.EXPLORE; + } + return FILE_PANEL_VIEW_MODE.EXPLORE; + }, [allFileOperations, currentEventType]); + + const [userViewModeOverride, setUserViewModeOverride] = + useState(null); + const [prevEventId, setPrevEventId] = useState(currentEventId); + const [userSelectedFileEventId, setUserSelectedFileEventId] = useState< + string | null + >(null); + const [userSelectedShellEventId, setUserSelectedShellEventId] = useState< + string | null + >(null); + const [userSelectedExploreEventId, setUserSelectedExploreEventId] = useState< + string | null + >(null); + const [userSelectedToolEventId, setUserSelectedToolEventId] = useState< + string | null + >(null); + + if (prevEventId !== currentEventId) { + setPrevEventId(currentEventId); + if (userViewModeOverride !== null) setUserViewModeOverride(null); + if (userSelectedFileEventId !== null) setUserSelectedFileEventId(null); + if (userSelectedShellEventId !== null) setUserSelectedShellEventId(null); + if (userSelectedExploreEventId !== null) + setUserSelectedExploreEventId(null); + if (userSelectedToolEventId !== null) setUserSelectedToolEventId(null); + } + + const fileViewMode = useMemo((): FilePanelViewMode => { + if (userViewModeOverride !== null) return userViewModeOverride; + if (currentEventType === IDE_EVENT_TYPE.WRITE) { + return FILE_PANEL_VIEW_MODE.WRITE; + } + if (currentEventType === IDE_EVENT_TYPE.EXPLORE) { + return FILE_PANEL_VIEW_MODE.EXPLORE; + } + if (currentEventType === IDE_EVENT_TYPE.READ) { + return FILE_PANEL_VIEW_MODE.EXPLORE; + } + if (currentEventType === IDE_EVENT_TYPE.SHELL) { + return FILE_PANEL_VIEW_MODE.TERMINAL; + } + if (currentEventType === IDE_EVENT_TYPE.TOOL) { + return FILE_PANEL_VIEW_MODE.TOOL; + } + return defaultViewMode; + }, [currentEventType, defaultViewMode, userViewModeOverride]); + + const setFileViewMode = useCallback((mode: FilePanelViewMode) => { + setUserViewModeOverride(mode); + }, []); + + const filteredFileOperations = useMemo(() => { + const typeFilter = + fileViewMode === FILE_PANEL_VIEW_MODE.EXPLORE + ? FILE_OPERATION_TYPE.READ + : fileViewMode; + return allFileOperations.filter( + (operation) => operation.type === typeFilter + ); + }, [allFileOperations, fileViewMode]); + + const selectedFileOperation = useMemo( + () => + resolveSelectedFileOperation( + allFileOperations, + filteredFileOperations, + null, + userSelectedFileEventId, + currentEventId ?? undefined + ), + [ + allFileOperations, + filteredFileOperations, + currentEventId, + userSelectedFileEventId, + ] + ); + + const selectedShellOperation = useMemo( + () => + resolveSelectedShellOperation( + allShellOperations, + null, + userSelectedShellEventId + ), + [allShellOperations, userSelectedShellEventId] + ); + + const selectedExploreOperation = useMemo( + () => + resolveSelectedExploreOperation( + allExploreOperations, + userSelectedExploreEventId + ), + [allExploreOperations, userSelectedExploreEventId] + ); + + const selectedToolOperation = useMemo( + () => + resolveSelectedToolOperation(allToolOperations, userSelectedToolEventId), + [allToolOperations, userSelectedToolEventId] + ); + + const codePanelMode = useMemo((): CodePanelMode => { + if (fileViewMode === FILE_PANEL_VIEW_MODE.TOOL) { + return CODE_PANEL_MODE.TOOL; + } + if (fileViewMode === FILE_PANEL_VIEW_MODE.TERMINAL) { + return CODE_PANEL_MODE.TERMINAL; + } + if ( + fileViewMode === FILE_PANEL_VIEW_MODE.EXPLORE && + selectedExploreOperation + ) { + return CODE_PANEL_MODE.EXPLORE; + } + return CODE_PANEL_MODE.FILE; + }, [fileViewMode, selectedExploreOperation]); + + const selectFileOperation = useCallback((eventId: string) => { + setUserSelectedFileEventId(eventId); + }, []); + + const selectShellOperation = useCallback((eventId: string) => { + setUserSelectedShellEventId(eventId); + }, []); + + const selectExploreOperation = useCallback((eventId: string) => { + setUserSelectedExploreEventId(eventId); + }, []); + + const selectToolOperation = useCallback((eventId: string) => { + setUserSelectedToolEventId(eventId); + }, []); + + const hasAnyOperations = + allFileOperations.length > 0 || + allExploreOperations.length > 0 || + allShellOperations.length > 0 || + allToolOperations.length > 0; + + return { + currentEvent, + currentEventType, + fileViewMode, + setFileViewMode, + filteredFileOperations, + allFileOperations, + allShellOperations, + allExploreOperations, + allToolOperations, + selectedFileOperation, + selectedShellOperation, + selectedExploreOperation, + selectedToolOperation, + codePanelMode, + selectFileOperation, + selectShellOperation, + selectExploreOperation, + selectToolOperation, + hasAnyOperations, + }; +} diff --git a/src/web/platform/sessionTranscriptPlatform.ts b/src/web/platform/sessionTranscriptPlatform.ts new file mode 100644 index 0000000000..f99cb19824 --- /dev/null +++ b/src/web/platform/sessionTranscriptPlatform.ts @@ -0,0 +1,42 @@ +import { useEffect, useRef } from "react"; + +import { useSessionTranscriptRuntime } from "@src/engines/ChatPanel/SessionTranscriptRuntimeContext"; +import type { SessionTranscriptPlatformState } from "@src/engines/ChatPanel/runtime/sessionTranscriptPlatform.types"; + +/** Browser adapter: all state and actions come from the Cloud-backed surface. */ +export function useSessionTranscriptPlatform( + _sessionId: string | null +): SessionTranscriptPlatformState { + const runtime = useSessionTranscriptRuntime(); + if (!runtime) { + throw new Error( + "Web Session transcript must be rendered inside SessionTranscriptRuntimeProvider" + ); + } + + const isAgentWorkingRef = useRef(runtime.isAgentWorking); + useEffect(() => { + isAgentWorkingRef.current = runtime.isAgentWorking; + }, [runtime.isAgentWorking]); + + return { + session: undefined, + cursorIdeTurnSummaries: [], + isCursorIde: false, + isAgentWorking: runtime.isAgentWorking, + isAgentWorkingRef, + isExploring: runtime.isExploring ?? false, + loadStatus: runtime.loadStatus, + loadError: runtime.loadError, + isPendingCancel: false, + isRolledBack: false, + isHydrating: false, + onReload: runtime.onReload, + onReplyQuestion: runtime.onReplyQuestion ?? (() => undefined), + onIgnoreQuestion: runtime.onIgnoreQuestion ?? (() => undefined), + capabilities: { + canvasInline: runtime.capabilities?.canvasInline !== false, + turnMetadata: runtime.capabilities?.turnMetadata !== false, + }, + }; +} From 73e7e41861febaa8ab32b5bc23a0911df74c3e4c Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Thu, 20 Aug 2026 23:24:23 +0800 Subject: [PATCH 03/15] feat(web): wire cloud session event runtime and HTTP session API Load roster and transcript segments over the unified session HTTP client with merge-aware event caching so the browser viewer stays consistent with live cloud updates. Pre-commit hook ran. Total eslint: 180, total circular: 0 --- src/api/http/session/index.ts | 43 +--- src/api/http/session/local.ts | 140 ------------ src/api/http/session/unified.ts | 18 -- .../cloudSessionEventSegmentMerge.ts | 96 ++++++++ .../WebOrgRemoteSessionSubscriptions.tsx | 23 ++ .../cloudTurnSummaryProjection.test.ts | 35 +++ .../__tests__/useWebSessionRoster.test.ts | 56 +++++ .../sessions/cloudSessionSegments.test.ts | 165 ++++++++++++++ .../features/sessions/cloudSessionSegments.ts | 5 + .../sessions/cloudTurnSummaryProjection.ts | 33 +++ .../sessions/useCloudSessionEvents.ts | 215 ++++++++++++++++++ .../sessions/useCloudSessionTurnIndex.ts | 108 +++++++++ .../sessions/useWebSessionRawTranscript.ts | 67 ++++++ .../features/sessions/useWebSessionRoster.ts | 153 +++++++++++++ .../webCloudSessionCachePolicy.test.ts | 81 +++++++ .../sessions/webCloudSessionCachePolicy.ts | 56 +++++ .../sessions/webCloudSessionEventCache.ts | 93 ++++++++ 17 files changed, 1187 insertions(+), 200 deletions(-) delete mode 100644 src/api/http/session/local.ts create mode 100644 src/features/Org2Cloud/cloudSessionEventSegmentMerge.ts create mode 100644 src/web/features/sessions/WebOrgRemoteSessionSubscriptions.tsx create mode 100644 src/web/features/sessions/__tests__/cloudTurnSummaryProjection.test.ts create mode 100644 src/web/features/sessions/__tests__/useWebSessionRoster.test.ts create mode 100644 src/web/features/sessions/cloudSessionSegments.test.ts create mode 100644 src/web/features/sessions/cloudSessionSegments.ts create mode 100644 src/web/features/sessions/cloudTurnSummaryProjection.ts create mode 100644 src/web/features/sessions/useCloudSessionEvents.ts create mode 100644 src/web/features/sessions/useCloudSessionTurnIndex.ts create mode 100644 src/web/features/sessions/useWebSessionRawTranscript.ts create mode 100644 src/web/features/sessions/useWebSessionRoster.ts create mode 100644 src/web/features/sessions/webCloudSessionCachePolicy.test.ts create mode 100644 src/web/features/sessions/webCloudSessionCachePolicy.ts create mode 100644 src/web/features/sessions/webCloudSessionEventCache.ts diff --git a/src/api/http/session/index.ts b/src/api/http/session/index.ts index 2464e338af..fe5ee4b9f0 100644 --- a/src/api/http/session/index.ts +++ b/src/api/http/session/index.ts @@ -1,45 +1,9 @@ /** * Session API Endpoints * - * Combined exports for all session-related endpoints: - * - local: Own_key session management (create, status, cancel, etc.) - * - hostedKey: Hosted-key (ORGII key) activity storage and retrieval - * - unified: Unified API that routes to own_key or hosted_key by context + * Hosted-key activity endpoints and URL-source helpers. */ -// My_key session API (main session operations) -export { - // Session lifecycle - createSession, - getSessionStatus, - cancelSession, - // Pause/Resume/Interrupt - pauseSession, - resumeSession, - interruptSession, - // User interaction - sendMessage, - sendMessageAndResume, - answerQuestion, - // Continue completed session - continueSession, - // Stage Approval - approveStage, - isWaitingForQuestion, - // Activity polling - getActivityChunks, - // Session discovery - listSessions, - listActiveSessions, - getLastSession, - cancelAllSessions, - // Utilities - isSessionTerminal, - isSessionActive, - // Namespace export - sessionApi, -} from "./local"; - // Hosted key activity API (for hosted ORGII sessions) export { getHostedKeyCursor, @@ -55,13 +19,8 @@ export { type HostedKeyActivityBatchData, } from "./hostedKey"; -// Unified session API (local Rust-backed session API) export { isHostedFromUrl, isHostedFromSearchParams, - createUnifiedSessionApi, unifiedSessionApi, - type UnifiedSessionApi, } from "./unified"; -// Default export - main my_key session API -export { sessionApi as default } from "./local"; diff --git a/src/api/http/session/local.ts b/src/api/http/session/local.ts deleted file mode 100644 index 665260528d..0000000000 --- a/src/api/http/session/local.ts +++ /dev/null @@ -1,140 +0,0 @@ -/** - * Session API - Local - * - * Utility functions for session status checks. - * API stubs are no-ops — real session operations go through: - * - Tauri commands (OS Agent, CLI sessions) - * - Market API via unified.ts (market sessions) - */ -import type { - ActivityChunk, - ActivityListParams, - SessionStatusData, -} from "@src/types/session/session"; -import { - isActiveStatus as isSessionActive, - isTerminalStatus as isSessionTerminal, -} from "@src/types/session/session"; - -// ============================================ -// Utility Functions -// ============================================ - -const isWaitingForQuestion = ( - sessionData: SessionStatusData | null -): boolean => { - if (!sessionData) return false; - return ( - sessionData.status === "waiting_for_user" && - (sessionData.waiting_for === "question" || - (sessionData.pending_questions_count ?? 0) > 0) - ); -}; - -// ============================================ -// No-op stubs (kept for UnifiedSessionApi shape) -// ============================================ - -const noop = (..._args: unknown[]) => Promise.resolve(undefined); - -const createSession = noop; -const getSessionStatus = (_sessionId: string) => - Promise.resolve( - undefined as { status: number; data: SessionStatusData } | undefined - ); -const cancelSession = noop; -const pauseSession = (..._args: unknown[]) => - Promise.resolve(undefined as { data: { success?: boolean } } | undefined); -const resumeSession = (..._args: unknown[]) => - Promise.resolve( - undefined as - | { status: number; data: { success?: boolean; message?: string } } - | undefined - ); -const interruptSession = noop; -const sendMessage = noop; -const sendMessageAndResume = (..._args: unknown[]) => - Promise.resolve( - undefined as - | { status: number; data: { success?: boolean; message?: string } } - | undefined - ); -const answerQuestion = (..._args: unknown[]) => - Promise.resolve( - undefined as { status: number; data: { success?: boolean } } | undefined - ); -const continueSession = (..._args: unknown[]) => - Promise.resolve(undefined as { data: unknown } | undefined); -const approveStage = (..._args: unknown[]) => - Promise.resolve( - undefined as - | { - data: { - success?: boolean; - previous_stage?: string; - next_stage?: string | null; - }; - } - | undefined - ); -const getActivityChunks = (_sessionId: string, _params?: ActivityListParams) => - Promise.resolve( - undefined as - | { - status: number; - data: { chunks: ActivityChunk[]; has_more?: boolean }; - } - | undefined - ); -const listSessions = noop; -const listActiveSessions = noop; -const getLastSession = noop; -const cancelAllSessions = noop; - -// ============================================ -// Export -// ============================================ - -export const sessionApi = { - createSession, - getSessionStatus, - cancelSession, - pauseSession, - resumeSession, - interruptSession, - sendMessage, - sendMessageAndResume, - answerQuestion, - continueSession, - approveStage, - getActivityChunks, - listSessions, - listActiveSessions, - getLastSession, - cancelAllSessions, - isWaitingForQuestion, - isSessionTerminal, - isSessionActive, -}; - -export { - createSession, - getSessionStatus, - cancelSession, - pauseSession, - resumeSession, - interruptSession, - sendMessage, - sendMessageAndResume, - answerQuestion, - continueSession, - approveStage, - isWaitingForQuestion, - getActivityChunks, - listSessions, - listActiveSessions, - getLastSession, - cancelAllSessions, - isSessionTerminal, - isSessionActive, -}; diff --git a/src/api/http/session/unified.ts b/src/api/http/session/unified.ts index 674b2d76a3..3eca7f4b39 100644 --- a/src/api/http/session/unified.ts +++ b/src/api/http/session/unified.ts @@ -1,15 +1,8 @@ /** * Unified Session API * - * All sessions now run locally via Tauri/Rust engine. The hosted ORGII - * proxy (when configured) handles billing only (allocate/release tokens); - * the session lifecycle still runs through the local Rust-backed API. - * * The "source=market" URL flag is the hosted-key entry point. */ -import { sessionApi } from "./local"; - -export type UnifiedSessionApi = typeof sessionApi; export function isHostedFromUrl(): boolean { if (typeof window === "undefined") return false; @@ -23,18 +16,7 @@ export function isHostedFromSearchParams( return searchParams.get("source") === "market"; } -/** - * All sessions route to the local Rust-backed session API. - * The isHosted flag is kept for backward compat but has no routing effect. - */ -export function createUnifiedSessionApi( - _isHosted: boolean = false -): UnifiedSessionApi { - return sessionApi; -} - export const unifiedSessionApi = { - createUnifiedSessionApi, isHostedFromUrl, isHostedFromSearchParams, }; diff --git a/src/features/Org2Cloud/cloudSessionEventSegmentMerge.ts b/src/features/Org2Cloud/cloudSessionEventSegmentMerge.ts new file mode 100644 index 0000000000..c3fe9c439e --- /dev/null +++ b/src/features/Org2Cloud/cloudSessionEventSegmentMerge.ts @@ -0,0 +1,96 @@ +import type { SessionEvent } from "@src/engines/SessionCore"; +import type { + SessionEventSegmentRecord, + SessionEventSegmentsSnapshot, +} from "@src/features/TeamCollaboration/sync/CollabSyncBackend"; + +export interface CloudSessionEventSnapshot extends Omit< + SessionEventSegmentsSnapshot, + "segments" +> { + segments: SessionEventSegmentRecord[]; + events: SessionEvent[]; +} + +/** Stable content fingerprint for no-op poll detection. */ +export function cloudSessionSnapshotRevision( + snapshot: Pick< + CloudSessionEventSnapshot, + "epoch" | "frozenSeq" | "tailHash" | "count" + > +): string { + return `${snapshot.epoch}|${snapshot.frozenSeq}|${snapshot.tailHash}|${snapshot.count}`; +} + +function preserveSnapshotWhenUnchanged( + previous: CloudSessionEventSnapshot | null, + merged: CloudSessionEventSnapshot +): CloudSessionEventSnapshot { + if ( + previous && + cloudSessionSnapshotRevision(previous) === + cloudSessionSnapshotRevision(merged) + ) { + return previous; + } + return merged; +} + +function orderedSegments( + segments: Iterable +): SessionEventSegmentRecord[] { + return [...segments].sort((left, right) => { + if (left.isTail !== right.isTail) return left.isTail ? 1 : -1; + return left.seq - right.seq; + }); +} + +function withFlattenedEvents( + snapshot: SessionEventSegmentsSnapshot +): CloudSessionEventSnapshot { + const segments = orderedSegments(snapshot.segments); + return { + ...snapshot, + segments, + events: segments.flatMap((segment) => segment.events), + }; +} + +/** + * Merge an incremental frozen-prefix + mutable-tail response. + * The previous tail is always discarded: it may have rolled into a newly + * frozen segment, and retaining it would duplicate transcript events. + */ +export function mergeCloudSessionEventSnapshot( + previous: CloudSessionEventSnapshot | null, + incoming: SessionEventSegmentsSnapshot, + fullRead: boolean +): CloudSessionEventSnapshot { + if (fullRead || !previous || previous.epoch !== incoming.epoch) { + return preserveSnapshotWhenUnchanged( + previous, + withFlattenedEvents(incoming) + ); + } + + const frozen = new Map(); + for (const segment of previous.segments) { + if (!segment.isTail) frozen.set(segment.seq, segment); + } + let tail: SessionEventSegmentRecord | null = null; + for (const segment of incoming.segments) { + if (segment.isTail) tail = segment; + else frozen.set(segment.seq, segment); + } + + return preserveSnapshotWhenUnchanged( + previous, + withFlattenedEvents({ + epoch: incoming.epoch, + frozenSeq: incoming.frozenSeq, + tailHash: incoming.tailHash, + count: incoming.count, + segments: [...frozen.values(), ...(tail ? [tail] : [])], + }) + ); +} diff --git a/src/web/features/sessions/WebOrgRemoteSessionSubscriptions.tsx b/src/web/features/sessions/WebOrgRemoteSessionSubscriptions.tsx new file mode 100644 index 0000000000..c2539b4a7b --- /dev/null +++ b/src/web/features/sessions/WebOrgRemoteSessionSubscriptions.tsx @@ -0,0 +1,23 @@ +import React from "react"; + +import { useCloudOrgRemoteSessions } from "@src/features/Org2Cloud/org2CloudRemoteSessionsAtom"; + +function OrgRemoteSessionSubscription({ orgId }: { orgId: string }) { + useCloudOrgRemoteSessions(orgId); + return null; +} + +/** Keeps every accessible org's remote session cache warm via the desktop atom. */ +export function WebOrgRemoteSessionSubscriptions({ + orgIds, +}: { + orgIds: readonly string[]; +}) { + return ( + <> + {orgIds.map((orgId) => ( + + ))} + + ); +} diff --git a/src/web/features/sessions/__tests__/cloudTurnSummaryProjection.test.ts b/src/web/features/sessions/__tests__/cloudTurnSummaryProjection.test.ts new file mode 100644 index 0000000000..5e9f0cd0a0 --- /dev/null +++ b/src/web/features/sessions/__tests__/cloudTurnSummaryProjection.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { projectCloudTurnSummaries } from "../cloudTurnSummaryProjection"; + +describe("projectCloudTurnSummaries", () => { + it("maps cloud turn rows into TurnSummary metadata", () => { + const turns = projectCloudTurnSummaries("session-1", [ + { + turnId: "turn-a", + prompt: "Fix the bug", + eventCount: 4, + bodyEventCount: 3, + startedAt: "2026-01-01T00:00:00.000Z", + endedAt: "2026-01-01T00:01:00.000Z", + durationMs: 60_000, + nextTurnId: "turn-b", + }, + ]); + + expect(turns).toHaveLength(1); + expect(turns[0]).toMatchObject({ + sessionId: "session-1", + turnId: "turn-a", + userPreview: "Fix the bug", + eventCount: 4, + bodyEventCount: 3, + status: "completed", + modifiedFiles: [], + }); + }); + + it("returns an empty list for no cloud turns", () => { + expect(projectCloudTurnSummaries("session-1", [])).toEqual([]); + }); +}); diff --git a/src/web/features/sessions/__tests__/useWebSessionRoster.test.ts b/src/web/features/sessions/__tests__/useWebSessionRoster.test.ts new file mode 100644 index 0000000000..6e8d6b7667 --- /dev/null +++ b/src/web/features/sessions/__tests__/useWebSessionRoster.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; + +import { aggregateWebSessionRoster } from "../useWebSessionRoster"; + +describe("aggregateWebSessionRoster", () => { + it("merges ready org rows and marks the roster loaded", () => { + const result = aggregateWebSessionRoster({ + orgs: [{ orgId: "org-1", name: "Org One" }], + entries: { + "org-1": { + identityKey: "identity-1", + rows: [ + { + id: "row-1", + orgId: "org-1", + sourceSessionId: "session-1", + ownerUserId: "user-1", + ownerDisplayName: "Me", + title: "Mine", + lastActivityAt: "2026-08-20T08:00:00.000Z", + eventsEpoch: 1, + }, + ], + state: "ready", + fetchedAt: 1, + }, + }, + identityKey: "identity-1", + userId: "user-1", + }); + + expect(result.status).toBe("loaded"); + expect(result.sessions).toHaveLength(1); + expect(result.sessions[0]?.orgName).toBe("Org One"); + expect(result.sessions[0]?.writable).toBe(true); + }); + + it("reports loading while every org entry is still idle", () => { + const result = aggregateWebSessionRoster({ + orgs: [{ orgId: "org-1", name: "Org One" }], + entries: { + "org-1": { + identityKey: "identity-1", + rows: [], + state: "idle", + fetchedAt: 0, + }, + }, + identityKey: "identity-1", + userId: "user-1", + }); + + expect(result.status).toBe("loading"); + expect(result.sessions).toEqual([]); + }); +}); diff --git a/src/web/features/sessions/cloudSessionSegments.test.ts b/src/web/features/sessions/cloudSessionSegments.test.ts new file mode 100644 index 0000000000..b3640130cd --- /dev/null +++ b/src/web/features/sessions/cloudSessionSegments.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from "vitest"; + +import type { SessionEvent } from "@src/engines/SessionCore"; + +import { mergeCloudSessionEventSnapshot } from "./cloudSessionSegments"; + +const event = (id: string): SessionEvent => + ({ id, sessionId: "session-1", createdAt: id }) as SessionEvent; + +describe("mergeCloudSessionEventSnapshot", () => { + it("replaces a rolled tail instead of duplicating it", () => { + const initial = mergeCloudSessionEventSnapshot( + null, + { + epoch: 1, + frozenSeq: 1, + tailHash: "tail-a", + count: 2, + segments: [ + { + seq: 1, + isTail: false, + events: [event("a")], + eventCount: 1, + segmentHash: "a", + }, + { + seq: 0, + isTail: true, + events: [event("b")], + eventCount: 1, + segmentHash: "tail-a", + }, + ], + }, + true + ); + const merged = mergeCloudSessionEventSnapshot( + initial, + { + epoch: 1, + frozenSeq: 2, + tailHash: "tail-b", + count: 3, + segments: [ + { + seq: 2, + isTail: false, + events: [event("b")], + eventCount: 1, + segmentHash: "b", + }, + { + seq: 0, + isTail: true, + events: [event("c")], + eventCount: 1, + segmentHash: "tail-b", + }, + ], + }, + false + ); + expect(merged.events.map((item) => item.id)).toEqual(["a", "b", "c"]); + }); + + it("preserves the previous snapshot object when an incremental poll is unchanged", () => { + const initial = mergeCloudSessionEventSnapshot( + null, + { + epoch: 1, + frozenSeq: 1, + tailHash: "tail-a", + count: 2, + segments: [ + { + seq: 1, + isTail: false, + events: [event("a")], + eventCount: 1, + segmentHash: "a", + }, + { + seq: 0, + isTail: true, + events: [event("b")], + eventCount: 1, + segmentHash: "tail-a", + }, + ], + }, + true + ); + const unchanged = mergeCloudSessionEventSnapshot( + initial, + { + epoch: 1, + frozenSeq: 1, + tailHash: "tail-a", + count: 2, + segments: [ + { + seq: 1, + isTail: false, + events: [event("a")], + eventCount: 1, + segmentHash: "a", + }, + { + seq: 0, + isTail: true, + events: [event("b")], + eventCount: 1, + segmentHash: "tail-a", + }, + ], + }, + false + ); + expect(unchanged).toBe(initial); + expect(unchanged.events).toBe(initial.events); + }); + + it("replaces the complete snapshot when the epoch changes", () => { + const initial = mergeCloudSessionEventSnapshot( + null, + { + epoch: 1, + frozenSeq: 0, + tailHash: "old", + count: 1, + segments: [ + { + seq: 0, + isTail: true, + events: [event("old")], + eventCount: 1, + segmentHash: "old", + }, + ], + }, + true + ); + const rewritten = mergeCloudSessionEventSnapshot( + initial, + { + epoch: 2, + frozenSeq: 0, + tailHash: "new", + count: 1, + segments: [ + { + seq: 0, + isTail: true, + events: [event("new")], + eventCount: 1, + segmentHash: "new", + }, + ], + }, + false + ); + expect(rewritten.events.map((item) => item.id)).toEqual(["new"]); + }); +}); diff --git a/src/web/features/sessions/cloudSessionSegments.ts b/src/web/features/sessions/cloudSessionSegments.ts new file mode 100644 index 0000000000..883b1bd6db --- /dev/null +++ b/src/web/features/sessions/cloudSessionSegments.ts @@ -0,0 +1,5 @@ +export { + type CloudSessionEventSnapshot, + cloudSessionSnapshotRevision, + mergeCloudSessionEventSnapshot, +} from "@src/features/Org2Cloud/cloudSessionEventSegmentMerge"; diff --git a/src/web/features/sessions/cloudTurnSummaryProjection.ts b/src/web/features/sessions/cloudTurnSummaryProjection.ts new file mode 100644 index 0000000000..5c60e5ced3 --- /dev/null +++ b/src/web/features/sessions/cloudTurnSummaryProjection.ts @@ -0,0 +1,33 @@ +import type { TurnSummary } from "@src/engines/SessionCore/storage/sqliteCache"; +import type { CloudSessionTurnSummary } from "@src/features/Org2Cloud/org2CloudSyncClient"; + +/** + * Maps owner-published cloud turn index rows into the TurnSummary shape the + * shared Timeline / Changes views already consume. File-level metadata is + * absent on the wire — those views degrade to empty changes, which is fine + * for a read-only web surface. + */ +export function projectCloudTurnSummaries( + sessionId: string, + turns: readonly CloudSessionTurnSummary[] +): TurnSummary[] { + return turns.map((turn, index) => ({ + sessionId, + turnId: turn.turnId, + startSequence: index, + endSequence: null, + nextTurnId: turn.nextTurnId ?? null, + startedAt: turn.startedAt ?? turn.endedAt ?? "", + endedAt: turn.endedAt ?? null, + durationMs: turn.durationMs ?? null, + userEventIds: [turn.turnId], + userPreview: turn.prompt, + eventCount: turn.eventCount, + bodyEventCount: turn.bodyEventCount, + status: "completed", + interrupted: false, + modifiedFiles: [], + resourceInteractions: [], + gitArtifacts: [], + })); +} diff --git a/src/web/features/sessions/useCloudSessionEvents.ts b/src/web/features/sessions/useCloudSessionEvents.ts new file mode 100644 index 0000000000..10256dfb20 --- /dev/null +++ b/src/web/features/sessions/useCloudSessionEvents.ts @@ -0,0 +1,215 @@ +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 { startVisibilityAwarePoller } from "@src/shared/scheduling/visibilityAwarePoller"; + +import { useFreshWebCloudSession } from "../auth/useFreshWebCloudSession"; +import type { CloudSessionEventSnapshot } from "./cloudSessionSegments"; +import type { WebSessionListItem } from "./useWebSessionRoster"; +import { + buildWebCloudSessionCacheKey, + shouldFetchWebCloudSessionEvents, +} from "./webCloudSessionCachePolicy"; +import { + readWebCloudSessionEventCache, + writeWebCloudSessionEventCache, +} from "./webCloudSessionEventCache"; +import { cloudSessionEventTarget } from "./webSessionLocation"; + +/** Poll running sessions lightly while the tab is visible. */ +const RUNNING_SESSION_POLL_MS = 30_000; + +interface CloudSessionEventsState { + sessionKey: string | null; + status: "loading" | "loaded" | "error"; + events: SessionEvent[]; + error: string | null; +} + +export function useCloudSessionEvents(session: WebSessionListItem | null) { + const getFreshSession = useFreshWebCloudSession(); + const [state, setState] = useState({ + sessionKey: null, + status: "loading", + events: [], + error: 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 refresh = useCallback( + (forceFull = false): Promise => { + if (!session) return Promise.resolve(); + if (inFlightRef.current) return inFlightRef.current; + const generation = generationRef.current; + const request = (async () => { + 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 ( + !shouldFetchWebCloudSessionEvents(forceFull, cachedSnapshot, session) + ) { + snapshotRef.current = cachedSnapshot; + setState({ + sessionKey, + status: "loaded", + events: cachedSnapshot.events, + error: null, + }); + return; + } + + const previous = forceFull + ? null + : (snapshotRef.current ?? cachedSnapshot); + const fullRead = forceFull || !previous; + if (!previous) { + setState({ + sessionKey, + status: "loading", + events: [], + error: 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({ + ...target, + signal: controller.signal, + }); + } + if (generation !== generationRef.current) return; + const merged = mergeCloudSessionEventSnapshot( + previous, + incoming, + fullRead || previous?.epoch !== incoming.epoch + ); + if (previous && merged === previous) { + return; + } + snapshotRef.current = merged; + setState({ + sessionKey, + status: "loaded", + events: merged.events, + error: 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, + status: "error", + error: error instanceof Error ? error.message : String(error), + })); + } finally { + if (abortRef.current === controller) abortRef.current = null; + } + })().finally(() => { + if (inFlightRef.current === request) inFlightRef.current = null; + }); + inFlightRef.current = request; + return request; + }, + [getFreshSession, session, sessionKey] + ); + + useEffect(() => { + generationRef.current += 1; + abortRef.current?.abort(); + inFlightRef.current = null; + snapshotRef.current = null; + if (!sessionKey || !session) { + setState({ sessionKey, status: "loading", events: [], error: null }); + return; + } + + void (async () => { + 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); + })(); + + return () => { + generationRef.current += 1; + abortRef.current?.abort(); + }; + }, [getFreshSession, refresh, session, sessionKey]); + + useEffect(() => { + if (!session || session.status !== "running") return undefined; + return startVisibilityAwarePoller( + document, + () => refresh(false), + RUNNING_SESSION_POLL_MS + ); + }, [refresh, session]); + + const refreshFull = useCallback(() => refresh(true), [refresh]); + if (state.sessionKey !== sessionKey) { + return { + status: "loading" as const, + events: [], + error: null, + refresh: refreshFull, + }; + } + return { ...state, refresh: refreshFull }; +} diff --git a/src/web/features/sessions/useCloudSessionTurnIndex.ts b/src/web/features/sessions/useCloudSessionTurnIndex.ts new file mode 100644 index 0000000000..28a2e4598e --- /dev/null +++ b/src/web/features/sessions/useCloudSessionTurnIndex.ts @@ -0,0 +1,108 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +import type { TurnSummary } from "@src/engines/SessionCore/storage/sqliteCache"; +import { getSessionTurnIndex } from "@src/features/Org2Cloud/org2CloudSyncClient"; + +import { useFreshWebCloudSession } from "../auth/useFreshWebCloudSession"; +import { projectCloudTurnSummaries } from "./cloudTurnSummaryProjection"; +import type { WebSessionListItem } from "./useWebSessionRoster"; + +interface CloudTurnIndexState { + sessionKey: string | null; + turns: TurnSummary[]; + loading: boolean; + error: string | null; +} + +const EMPTY_TURNS: TurnSummary[] = []; + +export function useCloudSessionTurnIndex( + session: WebSessionListItem | null, + enabled: boolean +) { + const getFreshSession = useFreshWebCloudSession(); + const [state, setState] = useState({ + sessionKey: null, + turns: EMPTY_TURNS, + loading: false, + error: null, + }); + const requestIdRef = useRef(0); + const sessionKey = session ? `${session.orgId}:${session.id}` : null; + + const load = useCallback(async () => { + if (!enabled || !session || !sessionKey) return; + const requestId = ++requestIdRef.current; + setState((current) => + current.sessionKey === sessionKey + ? { ...current, loading: true, error: null } + : { + sessionKey, + turns: EMPTY_TURNS, + loading: true, + error: null, + } + ); + try { + const fresh = await getFreshSession(); + if (!fresh || requestId !== requestIdRef.current) return; + const index = await getSessionTurnIndex( + fresh.accessToken, + session.orgId, + session.sourceSessionId + ); + if (requestId !== requestIdRef.current) return; + if ( + index.epoch !== null && + session.eventsEpoch !== undefined && + index.epoch !== session.eventsEpoch + ) { + setState({ + sessionKey, + turns: EMPTY_TURNS, + loading: false, + error: null, + }); + return; + } + const turns = index.turns + ? projectCloudTurnSummaries(session.sourceSessionId, index.turns) + : EMPTY_TURNS; + setState({ sessionKey, turns, loading: false, error: null }); + } catch (error) { + if (requestId !== requestIdRef.current) return; + setState({ + sessionKey, + turns: EMPTY_TURNS, + loading: false, + error: error instanceof Error ? error.message : String(error), + }); + } + }, [enabled, getFreshSession, session, sessionKey]); + + useEffect(() => { + requestIdRef.current += 1; + if (!enabled || !sessionKey) { + return; + } + queueMicrotask(() => { + void load(); + }); + return () => { + requestIdRef.current += 1; + }; + }, [enabled, load, sessionKey]); + + if (state.sessionKey !== sessionKey) { + return { + turns: EMPTY_TURNS, + loading: Boolean(enabled && sessionKey), + error: null, + }; + } + return { + turns: state.turns, + loading: state.loading, + error: state.error, + }; +} diff --git a/src/web/features/sessions/useWebSessionRawTranscript.ts b/src/web/features/sessions/useWebSessionRawTranscript.ts new file mode 100644 index 0000000000..20a7708023 --- /dev/null +++ b/src/web/features/sessions/useWebSessionRawTranscript.ts @@ -0,0 +1,67 @@ +import { useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; + +import Message from "@src/components/Message"; +import type { RawTranscriptSnapshot } from "@src/engines/ChatPanel/components/SessionRawTranscriptDialog/transcript"; +import type { SessionEvent } from "@src/engines/SessionCore"; +import { copyText } from "@src/util/data/clipboard"; + +/** Browser adapter: raw transcript comes from the already-fetched cloud events. */ +export function useWebSessionRawTranscript( + sessionId: string | null, + events: readonly SessionEvent[], + enabled = true +) { + const { t } = useTranslation("sessions"); + + const snapshot = useMemo(() => { + if (!enabled || !sessionId) return null; + return { + sessionId, + source: { + kind: "orgii-event-store", + displayName: "ORG2 Cloud", + }, + loadedAt: new Date().toISOString(), + entries: [...events], + }; + }, [enabled, events, sessionId]); + + const transcriptJson = useMemo( + () => (snapshot ? JSON.stringify(snapshot.entries, null, 2) : ""), + [snapshot] + ); + + const loadTranscript = useCallback(async () => { + // Cloud events are caller-owned; refresh is handled by the page hook. + }, []); + + const copyTranscript = useCallback(async () => { + if (!transcriptJson) return; + try { + await copyText(transcriptJson); + Message.success( + t("chat.rawTranscript.copySuccess", { + defaultValue: "Raw transcript copied", + }) + ); + } catch { + Message.error( + t("chat.rawTranscript.copyFailed", { + defaultValue: "Could not copy the raw transcript", + }) + ); + } + }, [t, transcriptJson]); + + return { + copyTranscript, + entries: snapshot?.entries ?? [], + error: null, + loadTranscript, + loading: false, + snapshot, + sourceLabel: snapshot?.source.displayName ?? "", + transcriptJson, + }; +} diff --git a/src/web/features/sessions/useWebSessionRoster.ts b/src/web/features/sessions/useWebSessionRoster.ts new file mode 100644 index 0000000000..d7b2b966a1 --- /dev/null +++ b/src/web/features/sessions/useWebSessionRoster.ts @@ -0,0 +1,153 @@ +import { useAtomValue, useSetAtom } from "jotai"; +import { useCallback, useMemo } from "react"; + +import { + org2CloudAuthAtom, + org2CloudAuthIdentityKey, +} from "@src/features/Org2Cloud/org2CloudAuthAtom"; +import { + type Org2CloudOrg, + org2CloudOrgsAtom, + org2CloudOrgsLoadedAtom, +} from "@src/features/Org2Cloud/org2CloudOrgsAtom"; +import { + type CloudOrgRemoteSessionsEntry, + type CloudRemoteSessionsFetchState, + bumpRemoteSessionsInvalidation, + org2CloudRemoteSessionsAtom, + org2CloudRemoteSessionsVersionAtom, + remoteSessionsEntryForIdentity, +} from "@src/features/Org2Cloud/org2CloudRemoteSessionsAtom"; +import type { RemoteTeammateSessionMetadata } from "@src/store/collaboration/types"; + +export interface WebSessionListItem extends RemoteTeammateSessionMetadata { + orgName: string; + writable: boolean; +} + +interface WebSessionRosterState { + status: "idle" | "loading" | "loaded" | "error"; + sessions: WebSessionListItem[]; + error: string | null; +} + +function sessionTimestamp(session: WebSessionListItem): number { + const value = session.lastActivityAt; + return value ? Date.parse(value) || 0 : 0; +} + +function toSessionRows( + org: Org2CloudOrg, + userId: string, + sessions: RemoteTeammateSessionMetadata[] +): WebSessionListItem[] { + return sessions + .filter((session) => !session.deletedAt) + .map((session) => ({ + ...session, + orgName: org.name, + writable: session.ownerUserId === userId, + })); +} + +export function aggregateWebSessionRoster({ + orgs, + entries, + identityKey, + userId, +}: { + orgs: readonly Org2CloudOrg[]; + entries: Record; + identityKey: string | null; + userId: string | null; +}): WebSessionRosterState { + if (!identityKey || !userId) { + return { status: "idle", sessions: [], error: null }; + } + + const states: CloudRemoteSessionsFetchState[] = []; + const sessions = orgs.flatMap((org) => { + const entry = remoteSessionsEntryForIdentity( + entries[org.orgId], + identityKey + ); + states.push(entry?.state ?? "idle"); + return toSessionRows(org, userId, entry?.rows ?? []); + }); + + sessions.sort( + (left, right) => sessionTimestamp(right) - sessionTimestamp(left) + ); + + const loadingCount = states.filter((state) => state === "loading").length; + const errorCount = states.filter((state) => state === "error").length; + const readyCount = states.filter((state) => state === "ready").length; + const idleCount = states.filter( + (state) => state === "idle" || state === undefined + ).length; + + let status: WebSessionRosterState["status"] = "loaded"; + if (orgs.length === 0) { + status = "loaded"; + } else if (sessions.length === 0 && loadingCount > 0) { + status = "loading"; + } else if (sessions.length === 0 && errorCount === orgs.length) { + status = "error"; + } else if (readyCount === 0 && idleCount === orgs.length) { + status = "loading"; + } + + return { + status, + sessions, + error: + errorCount > 0 + ? `${errorCount} organization${errorCount === 1 ? "" : "s"} could not be refreshed.` + : null, + }; +} + +export function useWebSessionRoster(): WebSessionRosterState & { + refresh: () => Promise; +} { + const auth = useAtomValue(org2CloudAuthAtom); + const orgs = useAtomValue(org2CloudOrgsAtom); + const orgsLoaded = useAtomValue(org2CloudOrgsLoadedAtom); + const entries = useAtomValue(org2CloudRemoteSessionsAtom); + const setVersionByOrg = useSetAtom(org2CloudRemoteSessionsVersionAtom); + const identityKey = auth ? org2CloudAuthIdentityKey(auth) : null; + const userId = auth?.userId ?? null; + + const aggregated = useMemo( + () => + aggregateWebSessionRoster({ + orgs, + entries, + identityKey, + userId, + }), + [entries, identityKey, orgs, userId] + ); + + const refresh = useCallback((): Promise => { + if (!identityKey || orgs.length === 0) return Promise.resolve(); + setVersionByOrg((current) => + orgs.reduce( + (next, org) => + bumpRemoteSessionsInvalidation(next, org.orgId, { full: true }), + current + ) + ); + return Promise.resolve(); + }, [identityKey, orgs, setVersionByOrg]); + + return useMemo(() => { + if (!identityKey) { + return { status: "idle" as const, sessions: [], error: null, refresh }; + } + if (!orgsLoaded) { + return { status: "loading" as const, sessions: [], error: null, refresh }; + } + return { ...aggregated, refresh }; + }, [aggregated, identityKey, orgsLoaded, refresh]); +} diff --git a/src/web/features/sessions/webCloudSessionCachePolicy.test.ts b/src/web/features/sessions/webCloudSessionCachePolicy.test.ts new file mode 100644 index 0000000000..eb229ac918 --- /dev/null +++ b/src/web/features/sessions/webCloudSessionCachePolicy.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; + +import type { CloudSessionEventSnapshot } from "./cloudSessionSegments"; +import type { WebSessionListItem } from "./useWebSessionRoster"; +import { + buildWebCloudSessionCacheKey, + isWebCloudSessionCacheFresh, + shouldFetchWebCloudSessionEvents, +} from "./webCloudSessionCachePolicy"; + +function snapshot( + overrides: Partial = {} +): CloudSessionEventSnapshot { + return { + epoch: 1, + frozenSeq: 2, + tailHash: "tail-a", + count: 3, + segments: [], + events: [], + ...overrides, + }; +} + +function session( + overrides: Partial = {} +): WebSessionListItem { + return { + id: "session-row-1", + orgId: "org-1", + eventsEpoch: 1, + eventsFrozenSeq: 2, + eventsCount: 3, + eventsTailHash: "tail-a", + ...overrides, + } as WebSessionListItem; +} + +describe("buildWebCloudSessionCacheKey", () => { + it("scopes cache entries by auth identity and session row", () => { + const auth = { + supabaseUrl: "https://cloud.example.com", + userId: "user-1", + }; + expect(buildWebCloudSessionCacheKey(auth, session())).toBe( + "https://cloud.example.com|user-1|org-1|session-row-1" + ); + }); +}); + +describe("isWebCloudSessionCacheFresh", () => { + it("accepts cache when roster summary is absent", () => { + expect( + isWebCloudSessionCacheFresh( + session({ eventsEpoch: undefined }), + snapshot() + ) + ).toBe(true); + }); + + it("rejects cache when epoch or tail hash drift", () => { + expect(isWebCloudSessionCacheFresh(session(), snapshot({ epoch: 2 }))).toBe( + false + ); + expect( + isWebCloudSessionCacheFresh(session(), snapshot({ tailHash: "tail-b" })) + ).toBe(false); + }); +}); + +describe("shouldFetchWebCloudSessionEvents", () => { + it("skips network when a fresh cache exists unless forced", () => { + expect(shouldFetchWebCloudSessionEvents(false, snapshot(), session())).toBe( + false + ); + expect(shouldFetchWebCloudSessionEvents(true, snapshot(), session())).toBe( + true + ); + expect(shouldFetchWebCloudSessionEvents(false, null, session())).toBe(true); + }); +}); diff --git a/src/web/features/sessions/webCloudSessionCachePolicy.ts b/src/web/features/sessions/webCloudSessionCachePolicy.ts new file mode 100644 index 0000000000..3f074a1114 --- /dev/null +++ b/src/web/features/sessions/webCloudSessionCachePolicy.ts @@ -0,0 +1,56 @@ +import type { Org2CloudAuthState } from "@src/features/Org2Cloud/org2CloudAuthAtom"; +import { org2CloudAuthIdentityKey } from "@src/features/Org2Cloud/org2CloudAuthAtom"; + +import type { CloudSessionEventSnapshot } from "./cloudSessionSegments"; +import type { WebSessionListItem } from "./useWebSessionRoster"; + +export function buildWebCloudSessionCacheKey( + auth: Pick, + session: Pick +): string { + return `${org2CloudAuthIdentityKey(auth)}|${session.orgId}|${session.id}`; +} + +/** + * Returns true when roster summary metadata matches the cached snapshot. + * When the roster omits segment summary fields, treat the cache as usable. + */ +export function isWebCloudSessionCacheFresh( + session: Pick< + WebSessionListItem, + "eventsEpoch" | "eventsFrozenSeq" | "eventsCount" | "eventsTailHash" + >, + snapshot: CloudSessionEventSnapshot +): boolean { + if (session.eventsEpoch === undefined) return true; + if (session.eventsEpoch !== snapshot.epoch) return false; + if ( + session.eventsFrozenSeq !== undefined && + session.eventsFrozenSeq !== snapshot.frozenSeq + ) { + return false; + } + if ( + session.eventsCount !== undefined && + session.eventsCount !== snapshot.count + ) { + return false; + } + if ( + session.eventsTailHash !== undefined && + session.eventsTailHash !== snapshot.tailHash + ) { + return false; + } + return true; +} + +export function shouldFetchWebCloudSessionEvents( + forceFull: boolean, + cached: CloudSessionEventSnapshot | null, + session: WebSessionListItem +): boolean { + 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 new file mode 100644 index 0000000000..b604bff717 --- /dev/null +++ b/src/web/features/sessions/webCloudSessionEventCache.ts @@ -0,0 +1,93 @@ +import type { CloudSessionEventSnapshot } from "./cloudSessionSegments"; + +const DB_NAME = "orgii-web-cloud-session-events"; +const STORE_NAME = "snapshots"; +const DB_VERSION = 1; + +export interface WebCloudSessionEventCacheRecord { + snapshot: CloudSessionEventSnapshot; + storedAt: number; +} + +function openDatabase(): Promise { + return new Promise((resolve, reject) => { + if (typeof indexedDB === "undefined") { + reject(new Error("IndexedDB is unavailable")); + return; + } + const request = indexedDB.open(DB_NAME, DB_VERSION); + request.onerror = () => + reject(request.error ?? new Error("IndexedDB open failed")); + request.onupgradeneeded = () => { + const database = request.result; + if (!database.objectStoreNames.contains(STORE_NAME)) { + database.createObjectStore(STORE_NAME); + } + }; + request.onsuccess = () => resolve(request.result); + }); +} + +function runTransaction( + mode: IDBTransactionMode, + run: (store: IDBObjectStore) => IDBRequest +): Promise { + return openDatabase().then( + (database) => + new Promise((resolve, reject) => { + const transaction = database.transaction(STORE_NAME, mode); + const store = transaction.objectStore(STORE_NAME); + const request = run(store); + transaction.oncomplete = () => resolve(request.result as T); + transaction.onerror = () => + reject( + transaction.error ?? new Error("IndexedDB transaction failed") + ); + transaction.onabort = () => + reject( + transaction.error ?? new Error("IndexedDB transaction aborted") + ); + }) + ); +} + +export async function readWebCloudSessionEventCache( + cacheKey: string +): Promise { + try { + const record = await runTransaction("readonly", (store) => + store.get(cacheKey) + ); + if (!record || typeof record !== "object") return null; + const snapshot = (record as WebCloudSessionEventCacheRecord).snapshot; + if (!snapshot || !Array.isArray(snapshot.events)) return null; + return record as WebCloudSessionEventCacheRecord; + } catch { + return null; + } +} + +export async function writeWebCloudSessionEventCache( + cacheKey: string, + snapshot: CloudSessionEventSnapshot +): Promise { + try { + const record: WebCloudSessionEventCacheRecord = { + snapshot, + storedAt: Date.now(), + }; + await runTransaction("readwrite", (store) => store.put(record, cacheKey)); + } catch { + // Cache is best-effort; network/manual refresh remains authoritative. + } +} + +export async function deleteWebCloudSessionEventCache( + cacheKey: string +): Promise { + try { + await runTransaction("readwrite", (store) => store.delete(cacheKey)); + } catch { + // ignore + } +} From f14e83f61f32334e0274175ed9054937a74302c1 Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Thu, 20 Aug 2026 23:26:13 +0800 Subject: [PATCH 04/15] feat(web): align cloud session sidebar with My and Team scopes Reuse workstation cloud session pagination and scoped menu rules in the web shell sidebar so personal and org session lists match desktop navigation behavior. Pre-commit hook ran. Total eslint: 180, total circular: 0 --- .../NavigationSidebar/blocks/SidebarGroup.tsx | 2 +- .../connectors/SidebarOrgSelector.tsx | 74 ++-- .../cloudScopedMenuItems.test.ts | 196 ++++++++++ .../cloudScopedMenuItems.ts | 117 +++--- .../cloudSessionsSection.menuItems.tsx | 32 +- .../cloudSessionsSection.rowItemBuilder.tsx | 33 +- .../WorkstationSidebarConnector/index.tsx | 7 - ...barConnector.sessionAndProjectMenuItems.ts | 10 +- ...barConnector.sessionInteractionHandlers.ts | 25 +- .../NavigationSidebar/connectors/index.ts | 4 + .../groupingBuilders.ts | 21 +- .../__tests__/menuSectionBuilders.test.ts | 28 +- .../__tests__/paginationHelpers.test.ts | 134 +++++-- .../connectors/useSessionMenuItems/index.tsx | 58 +-- .../menuSectionBuilders.ts | 12 +- .../useSessionMenuItems/paginationHelpers.tsx | 261 ++++++++++---- .../connectors/useSessionMenuItems/types.ts | 5 +- .../useWorkstationSidebarHandlers.ts | 38 +- src/scaffold/NavigationSidebar/index.ts | 5 +- .../variants/NavigationSidebar.test.ts | 30 +- .../variants/NavigationSidebar.tsx | 8 + .../features/sessions/WebSessionsContext.tsx | 28 ++ src/web/features/sessions/WebSessionsPage.tsx | 49 +++ .../sessions/webSessionLocation.test.ts | 45 +++ .../features/sessions/webSessionLocation.ts | 33 ++ src/web/shell/WebSessionSidebar.test.ts | 257 +++++++++++++ src/web/shell/WebSessionSidebar.tsx | 170 +++++++++ .../useWebCloudSessionsSection.test.ts | 68 ++++ src/web/shell/useWebCloudSessionsSection.ts | 337 ++++++++++++++++++ 29 files changed, 1765 insertions(+), 322 deletions(-) create mode 100644 src/web/features/sessions/WebSessionsContext.tsx create mode 100644 src/web/features/sessions/WebSessionsPage.tsx create mode 100644 src/web/features/sessions/webSessionLocation.test.ts create mode 100644 src/web/features/sessions/webSessionLocation.ts create mode 100644 src/web/shell/WebSessionSidebar.test.ts create mode 100644 src/web/shell/WebSessionSidebar.tsx create mode 100644 src/web/shell/__tests__/useWebCloudSessionsSection.test.ts create mode 100644 src/web/shell/useWebCloudSessionsSection.ts diff --git a/src/scaffold/NavigationSidebar/blocks/SidebarGroup.tsx b/src/scaffold/NavigationSidebar/blocks/SidebarGroup.tsx index 0e9a43724b..e4f22eb0e7 100644 --- a/src/scaffold/NavigationSidebar/blocks/SidebarGroup.tsx +++ b/src/scaffold/NavigationSidebar/blocks/SidebarGroup.tsx @@ -117,7 +117,7 @@ function SidebarGroupInner({ {/* Chevron */}
diff --git a/src/scaffold/NavigationSidebar/connectors/SidebarOrgSelector.tsx b/src/scaffold/NavigationSidebar/connectors/SidebarOrgSelector.tsx index c333da19ba..5b2ca7766f 100644 --- a/src/scaffold/NavigationSidebar/connectors/SidebarOrgSelector.tsx +++ b/src/scaffold/NavigationSidebar/connectors/SidebarOrgSelector.tsx @@ -6,22 +6,22 @@ import { DROPDOWN_CLASSES } from "@src/components/Dropdown/tokens"; import Select, { type SelectOption } from "@src/components/Select"; import { WorkstationToolbarTooltip } from "@src/modules/WorkStation/shared/WorkstationToolbarTooltip"; -interface SidebarOrgSelectorProps { +export interface SidebarOrgSelectorProps { value: string; options: SelectOption[]; - addOrgLabel: string; + addOrgLabel?: string; /** ORG2 Cloud identity shown in the menu; `null` means signed out. */ - cloudSignedInIdentity: string | null; + cloudSignedInIdentity?: string | null; /** Label for the always-visible manage-org entry. */ - manageLabel: string; + manageLabel?: string; onChange: (orgId: string) => void; - onAddOrg: () => void; - onCloudSignIn: () => void; + onAddOrg?: () => void; + onCloudSignIn?: () => void; /** * Explicit management entry for the ACTIVE org (cloud orgs only — * selector picks switch scope, management needs its own entry). */ - onManageOrg: () => void; + onManageOrg?: () => void; } const SidebarOrgSelector: React.FC = React.memo( @@ -49,12 +49,12 @@ const SidebarOrgSelector: React.FC = React.memo( const handleAddOrg = useCallback(() => { setMenuOpen(false); - onAddOrg(); + onAddOrg?.(); }, [onAddOrg]); const handleCloudSignIn = useCallback(() => { setMenuOpen(false); - onCloudSignIn(); + onCloudSignIn?.(); }, [onCloudSignIn]); const handleManageOrg = useCallback(() => { @@ -62,6 +62,10 @@ const SidebarOrgSelector: React.FC = React.memo( onManageOrg?.(); }, [onManageOrg]); + const hasManagementMenu = + Boolean(onManageOrg || onAddOrg || onCloudSignIn) || + cloudSignedInIdentity !== undefined; + const renderDropdown = useCallback( (menu: React.ReactNode) => ( <> @@ -69,25 +73,30 @@ const SidebarOrgSelector: React.FC = React.memo(
- - - {cloudSignedInIdentity !== null ? ( + {onManageOrg && manageLabel ? ( + + ) : null} + {onAddOrg && addOrgLabel ? ( + + ) : null} + {cloudSignedInIdentity !== undefined && + cloudSignedInIdentity !== null ? (
= React.memo( {t("cloud.signedInAs", { name: cloudSignedInIdentity })}
- ) : ( + ) : onCloudSignIn ? ( - )} + ) : null}
), @@ -127,6 +136,9 @@ const SidebarOrgSelector: React.FC = React.memo( handleCloudSignIn, handleManageOrg, manageLabel, + onAddOrg, + onCloudSignIn, + onManageOrg, t, ] ); @@ -150,7 +162,7 @@ const SidebarOrgSelector: React.FC = React.memo( onChange={handleChange} onVisibleChange={setMenuOpen} popupVisible={menuOpen} - dropdownRender={renderDropdown} + dropdownRender={hasManagementMenu ? renderDropdown : undefined} showTriggerIcon={false} appearance="ghost" size="small" diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudScopedMenuItems.test.ts b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudScopedMenuItems.test.ts index 92ba4eb0a4..57a92df76a 100644 --- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudScopedMenuItems.test.ts +++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudScopedMenuItems.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from "vitest"; import type { NavigationMenuItem } from "@src/scaffold/NavigationSidebar/components/NavigationMenu/config"; +import { + SESSION_LIST_CATEGORIES, + type SessionListCategory, +} from "@src/store/session"; +import { attachSessionPaginationPlan } from "../useSessionMenuItems/paginationHelpers"; import { CLOUD_MY_SESSIONS_LOAD_MORE_ID, CLOUD_MY_SESSIONS_SECTION_ID, @@ -10,6 +15,34 @@ import { } from "./cloudScopedMenuItems"; describe("buildCloudScopedMenuItems", () => { + const category = SESSION_LIST_CATEGORIES[0] as SessionListCategory; + const backendPager = (phase: "ready" | "loading" | "error", label: string) => + attachSessionPaginationPlan( + { + id: "load-more-unified", + key: "load-more-unified", + label, + }, + { + targets: [{ category, phase }], + } + ); + const streamPager = ( + targetCategory: SessionListCategory, + phase: "ready" | "loading" | "error", + label: string + ) => + attachSessionPaginationPlan( + { + id: `load-more-${targetCategory}`, + key: `load-more-${targetCategory}`, + label, + }, + { + targets: [{ category: targetCategory, phase }], + } + ); + const localSections: NavigationMenuItem[] = [ { id: "separator-today", key: "separator-today", label: "Today" }, { id: "session-today", key: "session-today", label: "Today session" }, @@ -228,6 +261,169 @@ describe("buildCloudScopedMenuItems", () => { ).toBe(false); }); + it("does not leave a normal pager in My sessions when every local row is pinned", () => { + const result = buildCloudScopedMenuItems({ + cloudMenuItems: [ + { + id: "separator-cloud-team-sessions", + key: "separator-cloud-team-sessions", + label: "Team sessions", + }, + ], + sessionMenuItems: [ + { + id: "session-pinned", + key: "session-pinned", + label: "Pinned one", + pinned: true, + }, + backendPager("ready", "Load more"), + ], + mySessionsLabel: "My sessions", + pinnedLabel: "Pinned", + }); + + expect(result.map((item) => item.id)).toEqual([ + `separator-${CLOUD_PINNED_SECTION_ID}`, + "session-pinned", + "separator-cloud-team-sessions", + `separator-${CLOUD_MY_SESSIONS_SECTION_ID}`, + ]); + }); + + it("keeps a failed backend page retryable when My sessions is empty", () => { + const result = buildCloudScopedMenuItems({ + cloudMenuItems: [ + { + id: "separator-cloud-team-sessions", + key: "separator-cloud-team-sessions", + label: "Team sessions", + }, + ], + sessionMenuItems: [backendPager("error", "Retry")], + mySessionsLabel: "My sessions", + }); + + expect(result.at(-1)).toMatchObject({ + id: CLOUD_MY_SESSIONS_LOAD_MORE_ID, + label: "Retry", + disabled: false, + sessionPaginationPlan: { + targets: [{ category, phase: "error" }], + }, + }); + }); + + it("removes ordinary ready targets from a pinned-only retry plan", () => { + const failedCategory = SESSION_LIST_CATEGORIES[1] as SessionListCategory; + const mixedPager = attachSessionPaginationPlan( + { + id: "load-more-unified", + key: "load-more-unified", + label: "Retry", + }, + { + targets: [ + { category, phase: "ready" }, + { category: failedCategory, phase: "error" }, + ], + } + ); + const result = buildCloudScopedMenuItems({ + cloudMenuItems: [ + { + id: "separator-cloud-team-sessions", + key: "separator-cloud-team-sessions", + label: "Team sessions", + }, + ], + sessionMenuItems: [ + { + id: "session-pinned", + key: "session-pinned", + label: "Pinned one", + pinned: true, + }, + mixedPager, + ], + mySessionsLabel: "My sessions", + }); + + expect(result.at(-1)).toMatchObject({ + id: CLOUD_MY_SESSIONS_LOAD_MORE_ID, + label: "Retry", + sessionPaginationPlan: { + targets: [{ category: failedCategory, phase: "error" }], + }, + }); + }); + + it("combines every backend stream target into the cloud pager plan", () => { + const secondCategory = SESSION_LIST_CATEGORIES[1] as SessionListCategory; + const result = buildCloudScopedMenuItems({ + cloudMenuItems: [ + { + id: "separator-cloud-team-sessions", + key: "separator-cloud-team-sessions", + label: "Team sessions", + }, + ], + sessionMenuItems: [ + { id: "session-one", key: "session-one", label: "Session one" }, + streamPager(category, "ready", "Load more"), + streamPager(secondCategory, "error", "Retry"), + ], + mySessionsLabel: "My sessions", + }); + + expect(result.at(-1)).toMatchObject({ + id: CLOUD_MY_SESSIONS_LOAD_MORE_ID, + label: "Retry", + disabled: false, + sessionPaginationPlan: { + targets: [ + { category, phase: "ready" }, + { category: secondCategory, phase: "error" }, + ], + }, + }); + }); + + it("keeps local rows expandable while a backend stream is loading", () => { + const localRows = Array.from( + { length: 11 }, + (_, index): NavigationMenuItem => ({ + id: `session-${index}`, + key: `session-${index}`, + label: `Session ${index}`, + }) + ); + const result = buildCloudScopedMenuItems({ + cloudMenuItems: [ + { + id: "separator-cloud-team-sessions", + key: "separator-cloud-team-sessions", + label: "Team sessions", + }, + ], + sessionMenuItems: [ + ...localRows, + streamPager(category, "loading", "Loading"), + ], + mySessionsLabel: "My sessions", + loadMoreLabel: "Load more", + }); + + expect(result.at(-1)).toMatchObject({ + id: CLOUD_MY_SESSIONS_LOAD_MORE_ID, + label: "Load more", + disabled: false, + sessionPaginationPlan: { + targets: [{ category, phase: "loading" }], + }, + }); + }); + it("does not mistake a date group's own pager for a backend stream pager", () => { // `load-more-group-*` and `load-more-` share a prefix; only the // latter means "the backend can fetch another page". diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudScopedMenuItems.ts b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudScopedMenuItems.ts index 01edb9b0c7..a44551513e 100644 --- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudScopedMenuItems.ts +++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudScopedMenuItems.ts @@ -4,6 +4,17 @@ import type { ReactNode } from "react"; import type { NavigationMenuItem } from "@src/scaffold/NavigationSidebar/components/NavigationMenu/config"; import { separator } from "../useSessionMenuItems/menuItemBuilders"; +import { + type SessionPaginationMenuItem, + attachSessionPaginationPlan, + combineSessionPaginationPlans, + filterSessionPaginationPlan, + getLoadMoreGroupId, + getSessionPaginationPhase, + hasSessionPaginationPlan, + isBackendSessionPaginationId, + isSessionPaginationId, +} from "../useSessionMenuItems/paginationHelpers"; export const CLOUD_MY_SESSIONS_SECTION_ID = "cloud-my-sessions"; export const CLOUD_PINNED_SECTION_ID = "cloud-pinned"; @@ -21,29 +32,8 @@ interface BuildCloudScopedMenuItemsParams { loadMoreLabel?: string; } -const LOCAL_GROUP_PAGER_PREFIX = "load-more-group-"; - -export function isSessionPaginationMenuItem(item: NavigationMenuItem): boolean { - return item.id.startsWith("load-more-"); -} - -/** - * A backend stream pager (`load-more-`), as opposed to a local - * "show more of this group" pager (`load-more-group-`), whose id also - * begins with `load-more-`. Only the former speaks for a stream that can fetch - * another page from Rust. - */ -function isBackendStreamPager(item: NavigationMenuItem): boolean { - return ( - isSessionPaginationMenuItem(item) && - !item.id.startsWith(LOCAL_GROUP_PAGER_PREFIX) - ); -} - export function isCloudScopedLocalRow(item: NavigationMenuItem): boolean { - return ( - !item.id.startsWith("separator-") && !isSessionPaginationMenuItem(item) - ); + return !item.id.startsWith("separator-") && !isSessionPaginationId(item.id); } export function buildCloudSectionLoadMoreItem({ @@ -94,16 +84,18 @@ export function buildCloudScopedMenuItems({ // different section entirely. const pinnedItems: NavigationMenuItem[] = []; const localRows: NavigationMenuItem[] = []; - const backendPaginationItems: NavigationMenuItem[] = []; + const backendPaginationItems: SessionPaginationMenuItem[] = []; for (const item of sessionMenuItems) { if (item.id.startsWith("separator-")) continue; - if (isBackendStreamPager(item)) { - backendPaginationItems.push(item); + if (isBackendSessionPaginationId(item.id)) { + if (hasSessionPaginationPlan(item)) { + backendPaginationItems.push(item); + } continue; } // A date group's own "show more" pager is meaningless once that group is // flattened into My sessions — the section's own pager governs from here. - if (item.id.startsWith(LOCAL_GROUP_PAGER_PREFIX)) continue; + if (getLoadMoreGroupId(item.id) !== null) continue; (item.pinned ? pinnedItems : localRows).push(item); } // Team rows keep their section, except the ones the viewer pinned: pinning @@ -115,30 +107,59 @@ export function buildCloudScopedMenuItems({ } const visibleLocalRows = localRows.slice(0, mySessionsVisibleCount); const hasHiddenLoadedRows = localRows.length > visibleLocalRows.length; - const readyBackendPaginationItem = backendPaginationItems.find( - (item) => !item.disabled + // Pinning moves a row out of My sessions. A normal backend pager must move + // with the rows it paginates, otherwise a pinned-only scope leaves an empty + // section with an orphaned "Load more" control. A failed fetch remains + // retryable even when no ordinary row is currently visible. + const effectiveBackendPaginationItems = backendPaginationItems.flatMap( + (item) => { + const plan = + localRows.length > 0 + ? item.sessionPaginationPlan + : filterSessionPaginationPlan( + item.sessionPaginationPlan, + (target) => target.phase === "error" + ); + return plan ? [{ item, plan }] : []; + } ); - const loadingBackendPaginationItem = backendPaginationItems.find( - (item) => item.disabled + const effectiveBackendPaginationPlan = combineSessionPaginationPlans( + effectiveBackendPaginationItems.map(({ plan }) => plan) ); - const hasMore = hasHiddenLoadedRows || backendPaginationItems.length > 0; - const mySessionsItems = hasMore - ? [ - ...visibleLocalRows, - buildCloudSectionLoadMoreItem({ - id: CLOUD_MY_SESSIONS_LOAD_MORE_ID, - label: - !hasHiddenLoadedRows && !readyBackendPaginationItem - ? (loadingBackendPaginationItem?.label ?? loadMoreLabel) - : loadMoreLabel, - disabled: - !hasHiddenLoadedRows && readyBackendPaginationItem === undefined, - trailingElement: - !hasHiddenLoadedRows && readyBackendPaginationItem === undefined - ? loadingBackendPaginationItem?.trailingElement - : undefined, - }), - ] + const effectiveBackendPaginationPhase = effectiveBackendPaginationPlan + ? getSessionPaginationPhase(effectiveBackendPaginationPlan) + : null; + const phaseSourceItem = effectiveBackendPaginationPhase + ? effectiveBackendPaginationItems.find( + ({ plan }) => + getSessionPaginationPhase(plan) === effectiveBackendPaginationPhase + )?.item + : undefined; + const hasMore = + hasHiddenLoadedRows || effectiveBackendPaginationPlan !== null; + const mySessionsLoadMoreItem = hasMore + ? buildCloudSectionLoadMoreItem({ + id: CLOUD_MY_SESSIONS_LOAD_MORE_ID, + label: !hasHiddenLoadedRows + ? (phaseSourceItem?.label ?? loadMoreLabel) + : loadMoreLabel, + disabled: + !hasHiddenLoadedRows && effectiveBackendPaginationPhase === "loading", + trailingElement: + !hasHiddenLoadedRows && effectiveBackendPaginationPhase === "loading" + ? phaseSourceItem?.trailingElement + : undefined, + }) + : null; + const plannedMySessionsLoadMoreItem = + mySessionsLoadMoreItem && effectiveBackendPaginationPlan + ? attachSessionPaginationPlan( + mySessionsLoadMoreItem, + effectiveBackendPaginationPlan + ) + : mySessionsLoadMoreItem; + const mySessionsItems = plannedMySessionsLoadMoreItem + ? [...visibleLocalRows, plannedMySessionsLoadMoreItem] : visibleLocalRows; return [ diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudSessionsSection.menuItems.tsx b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudSessionsSection.menuItems.tsx index e849537af5..5952b563dc 100644 --- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudSessionsSection.menuItems.tsx +++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudSessionsSection.menuItems.tsx @@ -38,6 +38,8 @@ interface UseCloudTeamSessionMenuItemsParams { buildRowItem: BuildCloudSessionRowItem; t: TFunction; tCommon: TFunction; + /** Hide the member-filter row action (read-only Web has no filter dropdown). */ + showSessionFilter?: boolean; } export function useCloudTeamSessionMenuItems({ @@ -53,6 +55,7 @@ export function useCloudTeamSessionMenuItems({ buildRowItem, t, tCommon, + showSessionFilter = true, }: UseCloudTeamSessionMenuItemsParams): NavigationMenuItem[] { const cloudMenuItems = useMemo(() => { if (!orgId) return []; @@ -68,18 +71,22 @@ export function useCloudTeamSessionMenuItems({ dataTestId: "cloud-team-sessions-refresh", onClick: handleRefreshClick, }, - { - icon: ListFilter, - label: t("cloud.sidebar.sessionFilter"), - active: memberMenu !== null || filter.kind !== "all", - dataTestId: "cloud-team-sessions-filter", - onClick: (event) => { - const rect = event.currentTarget.getBoundingClientRect(); - setMemberMenu((current) => - current ? null : { top: rect.bottom + 4, left: rect.left } - ); - }, - }, + ...(showSessionFilter + ? [ + { + icon: ListFilter, + label: t("cloud.sidebar.sessionFilter"), + active: memberMenu !== null || filter.kind !== "all", + dataTestId: "cloud-team-sessions-filter", + onClick: (event: React.MouseEvent) => { + const rect = event.currentTarget.getBoundingClientRect(); + setMemberMenu((current) => + current ? null : { top: rect.bottom + 4, left: rect.left } + ); + }, + }, + ] + : []), ]; const items: NavigationMenuItem[] = [header]; for (const thread of visibleThreads) { @@ -131,6 +138,7 @@ export function useCloudTeamSessionMenuItems({ buildRowItem, t, tCommon, + showSessionFilter, ]); return cloudMenuItems; diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudSessionsSection.rowItemBuilder.tsx b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudSessionsSection.rowItemBuilder.tsx index f0a076cd62..8cf390170c 100644 --- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudSessionsSection.rowItemBuilder.tsx +++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudSessionsSection.rowItemBuilder.tsx @@ -90,6 +90,8 @@ interface UseCloudSessionRowItemBuilderParams { /** Viewer-local pin keys (`|`); never a property of the shared row. */ pinnedRemoteSessionIds: ReadonlySet; toggleRemoteSessionPin: (orgId: string, rowId: string) => void; + /** Read-only surfaces (ORG2 Web) reuse row chrome without desktop-only actions. */ + readOnlySurface?: boolean; } export type BuildCloudSessionRowItem = ( @@ -108,6 +110,7 @@ export function useCloudSessionRowItemBuilder({ busySessionRows, pinnedRemoteSessionIds, toggleRemoteSessionPin, + readOnlySurface = false, }: UseCloudSessionRowItemBuilderParams): BuildCloudSessionRowItem { const seenCounts = useAtomValue(discussionSeenCountsAtom); const buildRowItem = useCallback( @@ -209,19 +212,18 @@ export function useCloudSessionRowItemBuilder({ // Without this the shared busy registry would manifest as nothing but // an unresponsive row. The indicator subscribes to its own session's // progress slice so ticks re-render one row, not the whole menu. - const busy = busySessionRows.get(row.id); - const busyIndicator = busy ? ( - - ) : undefined; - const isPinned = isRemoteSessionPinned( - pinnedRemoteSessionIds, - row.orgId, - row.id - ); + const busy = readOnlySurface ? undefined : busySessionRows.get(row.id); + const busyIndicator = + busy && !readOnlySurface ? ( + + ) : undefined; + const isPinned = readOnlySurface + ? false + : isRemoteSessionPinned(pinnedRemoteSessionIds, row.orgId, row.id); const pinIndicator = isPinned ? ( { useTeamInboxDataSource(); const teamInboxUnreadCount = useAtomValue(teamInboxUnreadCountAtom); const sessionsLoading = useAtomValue(sessionLoadingAtom); - const sessionPagination = useAtomValue(sessionPaginationAtom); const sessionSidebarRevealRequest = useAtomValue( sessionSidebarRevealRequestAtom ); @@ -339,8 +337,6 @@ export const WorkstationSidebarConnector: React.FC = () => { menuItems, sessionMap, subagentParentIds, - isLoadMoreId, - getLoadMoreGroupId, projectsWorkItemMenuItems, projectsProjectMap, projectsWorkItemMap, @@ -475,11 +471,8 @@ export const WorkstationSidebarConnector: React.FC = () => { cloudMyPaginationScopeKey, setCloudMyPagination, loadedCloudMySessionRowCount, - sessionPagination, activeSessionId, sessionMap, - isLoadMoreId, - getLoadMoreGroupId, sessionRouteLabel: t("routes.session"), handleGoToNewSession, navigateTo, diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.sessionAndProjectMenuItems.ts b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.sessionAndProjectMenuItems.ts index 17929a8124..6839b438d7 100644 --- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.sessionAndProjectMenuItems.ts +++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.sessionAndProjectMenuItems.ts @@ -57,13 +57,7 @@ export function useWorkstationSidebarSessionAndProjectMenuItems({ projectsSearchQuery, activeProjectOrgId, }: UseWorkstationSidebarSessionAndProjectMenuItemsParams) { - const { - menuItems, - sessionMap, - subagentParentIds, - isLoadMoreId, - getLoadMoreGroupId, - } = useSessionMenuItems({ + const { menuItems, sessionMap, subagentParentIds } = useSessionMenuItems({ sortedSessions, visitedSessions, repoPathToName, @@ -105,8 +99,6 @@ export function useWorkstationSidebarSessionAndProjectMenuItems({ menuItems, sessionMap, subagentParentIds, - isLoadMoreId, - getLoadMoreGroupId, projectsWorkItemMenuItems, projectsProjectMap, projectsWorkItemMap, diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.sessionInteractionHandlers.ts b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.sessionInteractionHandlers.ts index 55d5902564..c797ca0360 100644 --- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.sessionInteractionHandlers.ts +++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/sidebarConnector.sessionInteractionHandlers.ts @@ -18,7 +18,10 @@ import { isChatPanelTuiSessionId, } from "@src/util/ui/terminal/chatPanelTuiSessionId"; -import { loadUnifiedReadyCategories } from "../useSessionMenuItems/paginationHelpers"; +import { + executeSessionPaginationPlan, + hasSessionPaginationPlan, +} from "../useSessionMenuItems/paginationHelpers"; import { useWorkstationSidebarHandlers } from "../useWorkstationSidebarHandlers"; import { CLOUD_MY_SESSIONS_LOAD_MORE_ID, @@ -38,13 +41,8 @@ interface UseWorkstationSidebarSessionInteractionHandlersParams { visibleCount: number; }) => void; loadedCloudMySessionRowCount: number; - sessionPagination: Parameters< - typeof loadUnifiedReadyCategories - >[0]["pagination"]; activeSessionId: string; sessionMap: SidebarHandlersParams["sessionMap"]; - isLoadMoreId: SidebarHandlersParams["isLoadMoreId"]; - getLoadMoreGroupId: SidebarHandlersParams["getLoadMoreGroupId"]; sessionRouteLabel: string; handleGoToNewSession: SidebarHandlersParams["goToNewSession"]; navigateTo: SidebarHandlersParams["navigateTo"]; @@ -78,11 +76,8 @@ export function useWorkstationSidebarSessionInteractionHandlers({ cloudMyPaginationScopeKey, setCloudMyPagination, loadedCloudMySessionRowCount, - sessionPagination, activeSessionId, sessionMap, - isLoadMoreId, - getLoadMoreGroupId, sessionRouteLabel, handleGoToNewSession, navigateTo, @@ -111,9 +106,12 @@ export function useWorkstationSidebarSessionInteractionHandlers({ scopeKey: cloudMyPaginationScopeKey, visibleCount: nextVisibleCount, }); - if (nextVisibleCount >= loadedCloudMySessionRowCount) { - void loadUnifiedReadyCategories({ - pagination: sessionPagination, + if ( + nextVisibleCount >= loadedCloudMySessionRowCount && + hasSessionPaginationPlan(item) + ) { + void executeSessionPaginationPlan({ + plan: item.sessionPaginationPlan, loadCategory: loadMoreCategory, }); } @@ -124,7 +122,6 @@ export function useWorkstationSidebarSessionInteractionHandlers({ cloudMySessionsVisibleCount, handleCloudSessionItemClick, loadedCloudMySessionRowCount, - sessionPagination, setCloudMyPagination, ] ); @@ -137,8 +134,6 @@ export function useWorkstationSidebarSessionInteractionHandlers({ } = useWorkstationSidebarHandlers({ activeSessionId, sessionMap, - isLoadMoreId, - getLoadMoreGroupId, sessionRouteLabel, goToNewSession: handleGoToNewSession, navigateTo, diff --git a/src/scaffold/NavigationSidebar/connectors/index.ts b/src/scaffold/NavigationSidebar/connectors/index.ts index dc318c1d4c..642e155cdc 100644 --- a/src/scaffold/NavigationSidebar/connectors/index.ts +++ b/src/scaffold/NavigationSidebar/connectors/index.ts @@ -5,3 +5,7 @@ */ export { WorkstationSidebarConnector } from "./WorkstationSidebarConnector"; +export { + default as SidebarOrgSelector, + type SidebarOrgSelectorProps, +} from "./SidebarOrgSelector"; diff --git a/src/scaffold/NavigationSidebar/connectors/useProjectsWorkItemMenuItems/groupingBuilders.ts b/src/scaffold/NavigationSidebar/connectors/useProjectsWorkItemMenuItems/groupingBuilders.ts index 486990402b..4a7885bbf4 100644 --- a/src/scaffold/NavigationSidebar/connectors/useProjectsWorkItemMenuItems/groupingBuilders.ts +++ b/src/scaffold/NavigationSidebar/connectors/useProjectsWorkItemMenuItems/groupingBuilders.ts @@ -126,9 +126,6 @@ export function buildByOrgMenuItems( const items: NavigationMenuItem[] = []; if (!query) { - items.push( - separator("recent-projects", context.t("projects:orgs.recentProjects")) - ); const recentProjects = [...context.localProjects] .sort((projectA, projectB) => projectB.projectData.meta.updated_at.localeCompare( @@ -136,6 +133,11 @@ export function buildByOrgMenuItems( ) ) .slice(0, SESSION_SIDEBAR_PAGE_SIZE); + if (recentProjects.length > 0) { + items.push( + separator("recent-projects", context.t("projects:orgs.recentProjects")) + ); + } for (const project of recentProjects) { items.push( buildProjectRow( @@ -156,14 +158,14 @@ export function buildByOrgMenuItems( return items; } - items.push(separator("org-search-results", context.t("projects:search"))); + const searchResultItems: NavigationMenuItem[] = []; for (const project of context.localProjects) { const projectName = project.projectData.meta.name; if ( projectName.toLowerCase().includes(query) || project.orgName.toLowerCase().includes(query) ) { - items.push( + searchResultItems.push( buildProjectRow( context.t, project.projectData.slug, @@ -185,10 +187,15 @@ export function buildByOrgMenuItems( .join(" ") .toLowerCase(); if (searchableText.includes(query)) { - appendWorkItem(items, workItem, context); + appendWorkItem(searchResultItems, workItem, context); } } - return items; + return searchResultItems.length > 0 + ? [ + separator("org-search-results", context.t("projects:search")), + ...searchResultItems, + ] + : []; } export function buildByProjectMenuItems( diff --git a/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/__tests__/menuSectionBuilders.test.ts b/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/__tests__/menuSectionBuilders.test.ts index e0be3e111b..1f48f1a4a2 100644 --- a/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/__tests__/menuSectionBuilders.test.ts +++ b/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/__tests__/menuSectionBuilders.test.ts @@ -198,6 +198,26 @@ describe("session menu section builders", () => { ]); }); + it("does not render another category's pager below a visible agent group", () => { + const items = buildByAgentMenuItems({ + unpinnedSessions: [ + makeSession("cursoride-1", "2026-06-09T00:00:00.000Z"), + ], + appendPinnedSessions, + appendGroupSessions, + loadMoreRowFor: (category, hasVisibleSessionRows) => + category === "standalone_agent" && hasVisibleSessionRows + ? { + id: "load-more-standalone_agent", + key: "load-more-standalone_agent", + label: "Load more", + } + : null, + }); + + expect(getLoadMoreItemIds(items)).toEqual([]); + }); + it("uses one shared Standalone pager after SDE, Wingman, and Custom", () => { const items = buildByAgentMenuItems({ unpinnedSessions: [ @@ -207,8 +227,8 @@ describe("session menu section builders", () => { ], appendPinnedSessions, appendGroupSessions, - loadMoreRowFor: (category) => - category === "standalone_agent" + loadMoreRowFor: (category, hasVisibleSessionRows) => + category === "standalone_agent" && hasVisibleSessionRows ? { id: "load-more-standalone_agent", key: "load-more-standalone_agent", @@ -234,8 +254,8 @@ describe("session menu section builders", () => { unpinnedSessions: [], appendPinnedSessions, appendGroupSessions, - loadMoreRowFor: (category) => - category === "standalone_agent" + loadMoreRowFor: (category, hasVisibleSessionRows) => + category === "standalone_agent" && !hasVisibleSessionRows ? { id: "load-more-standalone_agent", key: "load-more-standalone_agent", diff --git a/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/__tests__/paginationHelpers.test.ts b/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/__tests__/paginationHelpers.test.ts index 4f5bd969b1..9f486cf315 100644 --- a/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/__tests__/paginationHelpers.test.ts +++ b/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/__tests__/paginationHelpers.test.ts @@ -12,9 +12,11 @@ import { import { UNIFIED_LOAD_MORE_ID, appendSessionGroup, - getUnifiedLoadMoreState, + executeSessionPaginationPlan, + getUnifiedPaginationPlan, + hasSessionPaginationPlan, isUnifiedLoadMoreId, - loadUnifiedReadyCategories, + shouldRenderBackendPagination, unifiedLoadMoreRow, } from "../paginationHelpers"; @@ -93,52 +95,86 @@ describe("appendSessionGroup", () => { }); describe("unified backend load-more helpers", () => { + it("hides ready pagination when the current sidebar scope has no session rows", () => { + const readyCategory = SESSION_LIST_CATEGORIES[0] as SessionListCategory; + const pagination = makePagination({ + [readyCategory]: streamState("ready"), + }); + + expect( + shouldRenderBackendPagination(pagination[readyCategory], false) + ).toBe(false); + expect(getUnifiedPaginationPlan(pagination, false)).toBeNull(); + }); + + it("keeps an empty scope retryable when its backend stream failed", () => { + const failedCategory = SESSION_LIST_CATEGORIES[0] as SessionListCategory; + const pagination = makePagination({ + [failedCategory]: streamState("error"), + }); + + expect( + shouldRenderBackendPagination(pagination[failedCategory], false) + ).toBe(true); + const plan = getUnifiedPaginationPlan(pagination, false); + expect(plan).toEqual({ + targets: [{ category: failedCategory, phase: "error" }], + }); + const row = unifiedLoadMoreRow(plan!, "Retry"); + expect(hasSessionPaginationPlan(row)).toBe(true); + expect(row.sessionPaginationPlan).toBe(plan); + }); + it("returns all ready categories while exposing one visible unified state", () => { const firstCategory = SESSION_LIST_CATEGORIES[0] as SessionListCategory; const secondCategory = SESSION_LIST_CATEGORIES[1] as SessionListCategory; - const state = getUnifiedLoadMoreState( + const plan = getUnifiedPaginationPlan( makePagination({ [firstCategory]: streamState("ready"), [secondCategory]: streamState("ready"), - }) + }), + true ); - expect(state).toEqual({ - visible: true, - loading: false, - error: false, - disabled: false, - readyCategories: [firstCategory, secondCategory], + expect(plan).toEqual({ + targets: [ + { category: firstCategory, phase: "ready" }, + { category: secondCategory, phase: "ready" }, + ], }); }); it("excludes loading categories from ready categories and marks unified state loading", () => { const loadingCategory = SESSION_LIST_CATEGORIES[0] as SessionListCategory; const readyCategory = SESSION_LIST_CATEGORIES[1] as SessionListCategory; - const state = getUnifiedLoadMoreState( + const plan = getUnifiedPaginationPlan( makePagination({ [loadingCategory]: streamState("loading"), [readyCategory]: streamState("ready"), - }) + }), + true ); - expect(state.visible).toBe(true); - expect(state.loading).toBe(true); - expect(state.disabled).toBe(true); - expect(state.readyCategories).toEqual([readyCategory]); + expect(plan).toEqual({ + targets: [ + { category: loadingCategory, phase: "loading" }, + { category: readyCategory, phase: "ready" }, + ], + }); }); it("disables the unified row while any category is loading", () => { const readyCategory = SESSION_LIST_CATEGORIES[0] as SessionListCategory; - const state = getUnifiedLoadMoreState( + const plan = getUnifiedPaginationPlan( makePagination({ [readyCategory]: streamState("ready"), [SESSION_LIST_CATEGORIES[1] as SessionListCategory]: { ...streamState("loading"), }, - }) + }), + true ); - const row = unifiedLoadMoreRow(state, "Loading"); + const row = unifiedLoadMoreRow(plan!, "Loading"); expect(row.id).toBe(UNIFIED_LOAD_MORE_ID); expect(row.key).toBe(UNIFIED_LOAD_MORE_ID); @@ -149,14 +185,17 @@ describe("unified backend load-more helpers", () => { it("disables the unified row when every remaining category is already loading", () => { const loadingCategory = SESSION_LIST_CATEGORIES[0] as SessionListCategory; - const state = getUnifiedLoadMoreState( + const plan = getUnifiedPaginationPlan( makePagination({ [loadingCategory]: streamState("loading"), - }) + }), + true ); - const row = unifiedLoadMoreRow(state, "Loading"); + const row = unifiedLoadMoreRow(plan!, "Loading"); - expect(state.disabled).toBe(true); + expect(plan).toEqual({ + targets: [{ category: loadingCategory, phase: "loading" }], + }); expect(row.disabled).toBe(true); }); @@ -172,11 +211,15 @@ describe("unified backend load-more helpers", () => { SESSION_LIST_CATEGORIES[2] as SessionListCategory; const loadCategory = vi.fn(() => Promise.resolve()); - const result = loadUnifiedReadyCategories({ - pagination: makePagination({ + const plan = getUnifiedPaginationPlan( + makePagination({ [firstReadyCategory]: streamState("ready"), [secondReadyCategory]: streamState("ready"), }), + true + ); + const result = executeSessionPaginationPlan({ + plan: plan!, loadCategory, }); @@ -192,11 +235,15 @@ describe("unified backend load-more helpers", () => { const readyCategory = SESSION_LIST_CATEGORIES[1] as SessionListCategory; const loadCategory = vi.fn(() => Promise.resolve()); - const result = loadUnifiedReadyCategories({ - pagination: makePagination({ + const plan = getUnifiedPaginationPlan( + makePagination({ [loadingCategory]: streamState("loading"), [readyCategory]: streamState("ready"), }), + true + ); + const result = executeSessionPaginationPlan({ + plan: plan!, loadCategory, }); @@ -220,8 +267,9 @@ describe("unified backend load-more helpers", () => { active -= 1; }); - const result = loadUnifiedReadyCategories({ - pagination: makePagination(ready), + const plan = getUnifiedPaginationPlan(makePagination(ready), true); + const result = executeSessionPaginationPlan({ + plan: plan!, loadCategory, }); await result; @@ -230,19 +278,29 @@ describe("unified backend load-more helpers", () => { expect(maxActive).toBe(4); }); - it("does not load categories when the unified row is disabled", () => { - const readyCategory = SESSION_LIST_CATEGORIES[0] as SessionListCategory; + it("executes only the categories captured by the visible empty-scope retry", async () => { + const failedCategory = SESSION_LIST_CATEGORIES[0] as SessionListCategory; + const hiddenReadyCategory = + SESSION_LIST_CATEGORIES[1] as SessionListCategory; const loadCategory = vi.fn(() => Promise.resolve()); - - const result = loadUnifiedReadyCategories({ - disabled: true, - pagination: makePagination({ - [readyCategory]: streamState("ready"), + const plan = getUnifiedPaginationPlan( + makePagination({ + [failedCategory]: streamState("error"), + [hiddenReadyCategory]: streamState("ready"), }), + false + ); + + expect(plan).toEqual({ + targets: [{ category: failedCategory, phase: "error" }], + }); + const result = executeSessionPaginationPlan({ + plan: plan!, loadCategory, }); - expect(result).toBeNull(); - expect(loadCategory).not.toHaveBeenCalled(); + await result; + expect(loadCategory).toHaveBeenCalledOnce(); + expect(loadCategory).toHaveBeenCalledWith(failedCategory); }); }); diff --git a/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/index.tsx b/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/index.tsx index 0c71092117..f8eaeba8b9 100644 --- a/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/index.tsx +++ b/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/index.tsx @@ -34,10 +34,11 @@ import { } from "./menuSectionBuilders"; import { sessionMatchesOrgFilter } from "./orgFilter"; import { + type SessionPaginationPlan, appendSessionGroup, - getLoadMoreGroupId, - getUnifiedLoadMoreState, - isLoadMoreId, + getCategoryPaginationPlan, + getSessionPaginationPhase, + getUnifiedPaginationPlan, loadMoreRow, unifiedLoadMoreRow, } from "./paginationHelpers"; @@ -420,32 +421,43 @@ export function useSessionMenuItems({ [agentLiveStatuses, prForSession, untitledSession, visitedSessions] ); - const loadMoreRowFor = useCallback( - (category: SessionListCategory): NavigationMenuItem | null => { - const state = pagination[category]; - if (state.generation === 0 || state.phase === "exhausted") return null; - const loading = state.phase === "loading"; - const label = loading + const paginationLabelFor = useCallback( + (plan: SessionPaginationPlan): string => { + const phase = getSessionPaginationPhase(plan); + return phase === "loading" ? tCommon("sessions:chat.loading") - : state.phase === "error" + : phase === "error" ? tCommon("common:actions.retry", "Retry") : tCommon("common:actions.loadMore"); - return loadMoreRow(category, loading, label); }, - [pagination, tCommon] + [tCommon] + ); + + const loadMoreRowFor = useCallback( + ( + category: SessionListCategory, + hasVisibleSessionRows: boolean + ): NavigationMenuItem | null => { + const plan = getCategoryPaginationPlan( + category, + pagination[category], + hasVisibleSessionRows + ); + return plan + ? loadMoreRow(category, plan, paginationLabelFor(plan)) + : null; + }, + [pagination, paginationLabelFor] ); const trailingLoadMoreItems = useMemo(() => { if (isFiltering) return []; - const state = getUnifiedLoadMoreState(pagination); - if (!state.visible) return []; - const label = state.loading - ? tCommon("sessions:chat.loading") - : state.error - ? tCommon("common:actions.retry", "Retry") - : tCommon("common:actions.loadMore"); - return [unifiedLoadMoreRow(state, label)]; - }, [isFiltering, pagination, tCommon]); + const plan = getUnifiedPaginationPlan( + pagination, + listedSessions.length > 0 + ); + return plan ? [unifiedLoadMoreRow(plan, paginationLabelFor(plan))] : []; + }, [isFiltering, listedSessions.length, pagination, paginationLabelFor]); const appendTrailingLoadMoreItems = useCallback( (items: NavigationMenuItem[]) => { @@ -505,7 +517,7 @@ export function useSessionMenuItems({ const appendPinnedSessions = useCallback( (items: NavigationMenuItem[], includeBackendPager = false): boolean => { const backendRow = includeBackendPager - ? loadMoreRowFor("pinned_native") + ? loadMoreRowFor("pinned_native", pinnedSessions.length > 0) : null; if (pinnedSessions.length === 0 && !backendRow) return false; items.push(separator("pinned", pinnedLabel)); @@ -610,7 +622,5 @@ export function useSessionMenuItems({ menuItems, sessionMap, subagentParentIds, - isLoadMoreId, - getLoadMoreGroupId, }; } diff --git a/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/menuSectionBuilders.ts b/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/menuSectionBuilders.ts index e8337a627d..9b4683861f 100644 --- a/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/menuSectionBuilders.ts +++ b/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/menuSectionBuilders.ts @@ -123,11 +123,15 @@ export function buildByAgentMenuItems({ } } if (!agentOrgHasHiddenRows) { - const row = loadMoreRowFor("agent_org_root"); + const row = loadMoreRowFor( + "agent_org_root", + sortedAgentOrgGroups.length > 0 + ); if (row) items.push(row); } const hiddenByCategory = new Set(); + const visibleByCategory = new Set(); const lastGroupIndexByCategory = new Map(); SESSION_GROUP_ORDER.forEach((key, index) => { lastGroupIndexByCategory.set(groupKeyToWireCategory(key), index); @@ -136,6 +140,7 @@ export function buildByAgentMenuItems({ const groupSessions = groups.get(key); const wireCategory = groupKeyToWireCategory(key); if (groupSessions && groupSessions.length > 0) { + visibleByCategory.add(wireCategory); items.push(separator(key, SESSION_GROUP_LABELS[key])); const groupHasHiddenLocalSessions = appendGroupSessions( items, @@ -150,7 +155,10 @@ export function buildByAgentMenuItems({ lastGroupIndexByCategory.get(wireCategory) === groupIndex && !hiddenByCategory.has(wireCategory) ) { - const row = loadMoreRowFor(wireCategory); + const row = loadMoreRowFor( + wireCategory, + visibleByCategory.has(wireCategory) + ); if (row) items.push(row); } } diff --git a/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/paginationHelpers.tsx b/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/paginationHelpers.tsx index f6ac87df49..5d9ac703fd 100644 --- a/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/paginationHelpers.tsx +++ b/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/paginationHelpers.tsx @@ -3,6 +3,7 @@ import { MoreHorizontal } from "lucide-react"; import type { NavigationMenuItem } from "@src/scaffold/NavigationSidebar/components/NavigationMenu/config"; import { SESSION_LIST_CATEGORIES } from "@src/store/session"; import type { + CategoryPaginationState, Session, SessionListCategory, SessionPaginationMap, @@ -17,35 +18,51 @@ export const LOAD_MORE_CATEGORIES: readonly SessionListCategory[] = SESSION_LIST_CATEGORIES; export const UNIFIED_LOAD_MORE_ID = "load-more-unified"; -interface UnifiedLoadMoreState { - visible: boolean; - loading: boolean; - error: boolean; - disabled: boolean; - readyCategories: SessionListCategory[]; +export type SessionPaginationPhase = "ready" | "loading" | "error"; + +export interface SessionPaginationTarget { + category: SessionListCategory; + phase: SessionPaginationPhase; +} + +/** + * The complete backend action represented by a session pagination row. + * Rendering and click execution both consume this same value so a row can + * never advertise one filtered scope and fetch a different set of streams. + */ +export interface SessionPaginationPlan { + targets: readonly [SessionPaginationTarget, ...SessionPaginationTarget[]]; } -interface LoadUnifiedReadyCategoriesParams { - disabled?: boolean; - pagination: SessionPaginationMap; +export interface SessionPaginationMenuItem extends NavigationMenuItem { + sessionPaginationPlan: SessionPaginationPlan; +} + +interface ExecuteSessionPaginationPlanParams { + plan: SessionPaginationPlan; loadCategory: (category: SessionListCategory) => Promise; } export function loadMoreRow( category: SessionListCategory, - loading: boolean, + plan: SessionPaginationPlan, label: string -): NavigationMenuItem { - return { - id: `${LOAD_MORE_PREFIX}${category}`, - key: `${LOAD_MORE_PREFIX}${category}`, - label, - icon: MoreHorizontal, - iconName: "more-horizontal", - trailingElement: loading ? renderBreathingStatusDot() : undefined, - visualTone: "secondary", - disabled: loading, - }; +): SessionPaginationMenuItem { + const phase = getSessionPaginationPhase(plan); + return attachSessionPaginationPlan( + { + id: `${LOAD_MORE_PREFIX}${category}`, + key: `${LOAD_MORE_PREFIX}${category}`, + label, + icon: MoreHorizontal, + iconName: "more-horizontal", + trailingElement: + phase === "loading" ? renderBreathingStatusDot() : undefined, + visualTone: "secondary", + disabled: phase === "loading", + }, + plan + ); } export function groupLoadMoreRow( @@ -66,19 +83,24 @@ export function groupLoadMoreRow( } export function unifiedLoadMoreRow( - state: UnifiedLoadMoreState, + plan: SessionPaginationPlan, label: string -): NavigationMenuItem { - return { - id: UNIFIED_LOAD_MORE_ID, - key: UNIFIED_LOAD_MORE_ID, - label, - icon: MoreHorizontal, - iconName: "more-horizontal", - trailingElement: state.loading ? renderBreathingStatusDot() : undefined, - visualTone: "secondary", - disabled: state.disabled, - }; +): SessionPaginationMenuItem { + const phase = getSessionPaginationPhase(plan); + return attachSessionPaginationPlan( + { + id: UNIFIED_LOAD_MORE_ID, + key: UNIFIED_LOAD_MORE_ID, + label, + icon: MoreHorizontal, + iconName: "more-horizontal", + trailingElement: + phase === "loading" ? renderBreathingStatusDot() : undefined, + visualTone: "secondary", + disabled: phase === "loading", + }, + plan + ); } export function isLoadMoreId(id: string): SessionListCategory | null { @@ -96,62 +118,155 @@ export function getLoadMoreGroupId(id: string): string | null { return id.slice(LOAD_MORE_GROUP_PREFIX.length) || null; } -export function getUnifiedLoadMoreState( - pagination: SessionPaginationMap -): UnifiedLoadMoreState { - let visible = false; - let loading = false; - let error = false; - const readyCategories: SessionListCategory[] = []; +export function isBackendSessionPaginationId(id: string): boolean { + return isUnifiedLoadMoreId(id) || isLoadMoreId(id) !== null; +} + +export function isSessionPaginationId(id: string): boolean { + return isBackendSessionPaginationId(id) || getLoadMoreGroupId(id) !== null; +} + +export function attachSessionPaginationPlan( + item: NavigationMenuItem, + plan: SessionPaginationPlan +): SessionPaginationMenuItem { + return { ...item, sessionPaginationPlan: plan }; +} + +export function hasSessionPaginationPlan( + item: NavigationMenuItem +): item is SessionPaginationMenuItem { + const plan = (item as Partial) + .sessionPaginationPlan; + return ( + plan !== undefined && + Array.isArray(plan.targets) && + plan.targets.length > 0 && + plan.targets.every( + (target) => + SESSION_LIST_CATEGORIES.includes(target.category) && + (target.phase === "ready" || + target.phase === "loading" || + target.phase === "error") + ) + ); +} + +export function getSessionPaginationPhase( + plan: SessionPaginationPlan +): SessionPaginationPhase { + return plan.targets.some((target) => target.phase === "loading") + ? "loading" + : plan.targets.some((target) => target.phase === "error") + ? "error" + : "ready"; +} + +export function getCategoryPaginationPlan( + category: SessionListCategory, + state: CategoryPaginationState, + hasVisibleSessionRows: boolean +): SessionPaginationPlan | null { + if (!shouldRenderBackendPagination(state, hasVisibleSessionRows)) return null; + if ( + state.phase === "loading" || + state.phase === "ready" || + state.phase === "error" + ) { + return { targets: [{ category, phase: state.phase }] }; + } + return null; +} + +export function getUnifiedPaginationPlan( + pagination: SessionPaginationMap, + hasVisibleSessionRows: boolean +): SessionPaginationPlan | null { + const plans: SessionPaginationPlan[] = []; for (const category of LOAD_MORE_CATEGORIES) { - const state = pagination[category]; - if (state.generation === 0) continue; - if (state.phase === "loading") { - visible = true; - loading = true; - continue; - } - if (state.phase === "error") { - visible = true; - error = true; - readyCategories.push(category); - continue; - } - if (state.phase === "ready") { - visible = true; - readyCategories.push(category); + const plan = getCategoryPaginationPlan( + category, + pagination[category], + hasVisibleSessionRows + ); + if (plan) plans.push(plan); + } + + return combineSessionPaginationPlans(plans); +} + +export function combineSessionPaginationPlans( + plans: readonly SessionPaginationPlan[] +): SessionPaginationPlan | null { + if (plans.length === 0) return null; + + const targetsByCategory = new Map< + SessionListCategory, + SessionPaginationTarget + >(); + for (const plan of plans) { + for (const target of plan.targets) { + const existing = targetsByCategory.get(target.category); + if ( + !existing || + paginationPhaseRank(target.phase) > paginationPhaseRank(existing.phase) + ) { + targetsByCategory.set(target.category, target); + } } } + const [firstTarget, ...remainingTargets] = targetsByCategory.values(); + return firstTarget ? { targets: [firstTarget, ...remainingTargets] } : null; +} - return { - visible, - loading, - error, - disabled: loading || readyCategories.length === 0, - readyCategories, - }; +export function filterSessionPaginationPlan( + plan: SessionPaginationPlan, + predicate: (target: SessionPaginationTarget) => boolean +): SessionPaginationPlan | null { + const [firstTarget, ...remainingTargets] = plan.targets.filter(predicate); + return firstTarget ? { targets: [firstTarget, ...remainingTargets] } : null; +} + +function paginationPhaseRank(phase: SessionPaginationPhase): number { + return phase === "loading" ? 3 : phase === "error" ? 2 : 1; +} + +/** + * A ready/loading stream only offers useful pagination when the current + * sidebar scope already contains a session row. The backend roster is global, + * while org and visibility filters are applied afterwards; without this + * guard, a scope whose rows were all filtered out rendered an orphaned + * "Load more" control. Errors remain actionable even for an empty scope. + */ +export function shouldRenderBackendPagination( + state: CategoryPaginationState, + hasVisibleSessionRows: boolean +): boolean { + if (state.generation === 0 || state.phase === "exhausted") return false; + return state.phase === "error" || hasVisibleSessionRows; } const UNIFIED_LOAD_MORE_CONCURRENCY = 4; -export function loadUnifiedReadyCategories({ - disabled, - pagination, +export function executeSessionPaginationPlan({ + plan, loadCategory, -}: LoadUnifiedReadyCategoriesParams): Promise | null { - const state = getUnifiedLoadMoreState(pagination); - if (disabled || state.disabled) return null; - const { readyCategories } = state; +}: ExecuteSessionPaginationPlanParams): Promise | null { + if (getSessionPaginationPhase(plan) === "loading") return null; + const targetCategories = plan.targets.map((target) => target.category); return (async () => { let nextIndex = 0; const workers = Array.from( { - length: Math.min(UNIFIED_LOAD_MORE_CONCURRENCY, readyCategories.length), + length: Math.min( + UNIFIED_LOAD_MORE_CONCURRENCY, + targetCategories.length + ), }, async () => { - while (nextIndex < readyCategories.length) { - const category = readyCategories[nextIndex]; + while (nextIndex < targetCategories.length) { + const category = targetCategories[nextIndex]; nextIndex += 1; await loadCategory(category); } diff --git a/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/types.ts b/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/types.ts index 84eeb873c0..8c518d71d1 100644 --- a/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/types.ts +++ b/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/types.ts @@ -46,8 +46,6 @@ export interface UseSessionMenuItemsResult { menuItems: NavigationMenuItem[]; sessionMap: Map; subagentParentIds: ReadonlySet; - isLoadMoreId: (id: string) => SessionListCategory | null; - getLoadMoreGroupId: (id: string) => string | null; } export type BuildSessionRow = (session: Session) => NavigationMenuItem; @@ -66,5 +64,6 @@ export type AppendPinnedSessions = ( export type AppendTrailingLoadMoreItems = (items: NavigationMenuItem[]) => void; export type LoadMoreRowFor = ( - category: SessionListCategory + category: SessionListCategory, + hasVisibleSessionRows: boolean ) => NavigationMenuItem | null; diff --git a/src/scaffold/NavigationSidebar/connectors/useWorkstationSidebarHandlers.ts b/src/scaffold/NavigationSidebar/connectors/useWorkstationSidebarHandlers.ts index 7960827bda..e6fb93054d 100644 --- a/src/scaffold/NavigationSidebar/connectors/useWorkstationSidebarHandlers.ts +++ b/src/scaffold/NavigationSidebar/connectors/useWorkstationSidebarHandlers.ts @@ -37,10 +37,8 @@ import type { NavigationMenuItem } from "@src/scaffold/NavigationSidebar/compone import { SESSION_SIDEBAR_PAGE_SIZE, type Session, - type SessionListCategory, loadMoreCategory, removeSession, - sessionPaginationAtom, syncSidebarSessionRoster, upsertSession, } from "@src/store/session"; @@ -73,8 +71,10 @@ import { } from "./sidebarConnectorUtils"; import type { GroupByMode } from "./types"; import { - isUnifiedLoadMoreId, - loadUnifiedReadyCategories, + executeSessionPaginationPlan, + getLoadMoreGroupId, + hasSessionPaginationPlan, + isBackendSessionPaginationId, } from "./useSessionMenuItems/paginationHelpers"; const log = createLogger("WorkstationSidebar"); @@ -82,8 +82,6 @@ const log = createLogger("WorkstationSidebar"); interface UseWorkstationSidebarHandlersParams { activeSessionId: string; sessionMap: Map; - isLoadMoreId: (id: string) => SessionListCategory | null; - getLoadMoreGroupId: (id: string) => string | null; sessionRouteLabel: string; goToNewSession: (options?: GoToNewSessionOptions) => void; navigateTo: (path: string) => void; @@ -120,8 +118,6 @@ interface UseWorkstationSidebarHandlersResult { export function useWorkstationSidebarHandlers({ activeSessionId, sessionMap, - isLoadMoreId, - getLoadMoreGroupId, sessionRouteLabel, goToNewSession, navigateTo, @@ -152,7 +148,6 @@ export function useWorkstationSidebarHandlers({ }, [disposeWorkstationTabsWorkspace, disposeEditorCacheForSession] ); - const pagination = useAtomValue(sessionPaginationAtom); const cloudAuth = useAtomValue(org2CloudAuthAtom); const setCloudAuth = useSetAtom(org2CloudAuthAtom); const cloudOrgs = useAtomValue(org2CloudOrgsAtom); @@ -321,10 +316,10 @@ export function useWorkstationSidebarHandlers({ return; } - if (isUnifiedLoadMoreId(item.id)) { - void loadUnifiedReadyCategories({ - disabled: item.disabled, - pagination, + if (isBackendSessionPaginationId(item.id)) { + if (!hasSessionPaginationPlan(item)) return; + void executeSessionPaginationPlan({ + plan: item.sessionPaginationPlan, loadCategory: async (category) => { const result = await loadMoreCategory(category); revealLoadedSessions(result.sessions); @@ -345,14 +340,6 @@ export function useWorkstationSidebarHandlers({ return; } - const requestedCategory = isLoadMoreId(item.id); - if (requestedCategory) { - void loadMoreCategoryAction(requestedCategory).then((result) => { - revealLoadedSessions(result.sessions); - }); - return; - } - if (isChatPanelTuiSessionId(item.id)) { const tabId = getChatPanelTabIdFromTuiSessionId(item.id); if (tabId) { @@ -384,9 +371,6 @@ export function useWorkstationSidebarHandlers({ openSession(item.id, sessionName, originalSession.repoPath); }, [ - getLoadMoreGroupId, - isLoadMoreId, - pagination, revealLoadedSessions, sessionMap, openSession, @@ -436,9 +420,3 @@ export function useWorkstationSidebarHandlers({ handleTogglePin, }; } - -function loadMoreCategoryAction( - sessionListCategory: SessionListCategory -): ReturnType { - return loadMoreCategory(sessionListCategory); -} diff --git a/src/scaffold/NavigationSidebar/index.ts b/src/scaffold/NavigationSidebar/index.ts index 8a8e91a193..4a677d9915 100644 --- a/src/scaffold/NavigationSidebar/index.ts +++ b/src/scaffold/NavigationSidebar/index.ts @@ -39,6 +39,8 @@ export { SidebarEmptyState, SidebarList, SidebarSection, + SidebarBottomBar, + SidebarMenuSearchInput, } from "./blocks"; // ============================================ @@ -105,4 +107,5 @@ export type { NavigationSidebarProps } from "./variants"; // ============================================ // Connectors (sidebar data providers) // ============================================ -export { WorkstationSidebarConnector } from "./connectors"; +export { SidebarOrgSelector, WorkstationSidebarConnector } from "./connectors"; +export type { SidebarOrgSelectorProps } from "./connectors"; diff --git a/src/scaffold/NavigationSidebar/variants/NavigationSidebar.test.ts b/src/scaffold/NavigationSidebar/variants/NavigationSidebar.test.ts index 56e7438ace..b48b30879d 100644 --- a/src/scaffold/NavigationSidebar/variants/NavigationSidebar.test.ts +++ b/src/scaffold/NavigationSidebar/variants/NavigationSidebar.test.ts @@ -7,8 +7,20 @@ import { WorkItemsSidebarSkeleton } from "../connectors/WorkstationSidebarConnec import NavigationSidebar from "./NavigationSidebar"; vi.mock("../SidebarBase", () => ({ - default: ({ children }: { children?: ReactNode }) => - createElement("aside", null, children), + default: ({ + children, + includeTrafficLightSpace, + }: { + children?: ReactNode; + includeTrafficLightSpace?: boolean; + }) => + createElement( + "aside", + { + "data-include-traffic-light-space": String(includeTrafficLightSpace), + }, + children + ), })); vi.mock("../components/NavigationMenu", () => ({ @@ -102,4 +114,18 @@ describe("NavigationSidebar", () => { expect(markup).toContain('aria-label="Loading work items"'); expect(markup).toContain("animate-pulse"); }); + + it("lets browser-hosted sidebars remove native window chrome spacing", () => { + const markup = renderToStaticMarkup( + createElement(NavigationSidebar, { + items: [], + activeKey: "", + onChange: vi.fn(), + menuItems: [], + includeTrafficLightSpace: false, + }) + ); + + expect(markup).toContain('data-include-traffic-light-space="false"'); + }); }); diff --git a/src/scaffold/NavigationSidebar/variants/NavigationSidebar.tsx b/src/scaffold/NavigationSidebar/variants/NavigationSidebar.tsx index 9bb08fcae3..fb05407448 100644 --- a/src/scaffold/NavigationSidebar/variants/NavigationSidebar.tsx +++ b/src/scaffold/NavigationSidebar/variants/NavigationSidebar.tsx @@ -91,6 +91,10 @@ export interface NavigationSidebarProps { loadingContent?: React.ReactNode; /** Paint an opaque sidebar surface instead of honoring sidebar transparency. */ solidSurface?: boolean; + /** Reserve native window-chrome space above the sidebar content. */ + includeTrafficLightSpace?: boolean; + /** Whether the desktop collapse affordance is available. */ + showCollapseButton?: boolean; /** Enable collapse/expand on section headers (separator-based groups) */ collapsibleSections?: boolean; /** @@ -256,6 +260,8 @@ const NavigationSidebar: React.FC = React.memo( isLoading = false, loadingContent, solidSurface = false, + includeTrafficLightSpace = true, + showCollapseButton = true, collapsibleSections = false, collapsedSectionIds, onCollapsedSectionsChange, @@ -404,6 +410,8 @@ const NavigationSidebar: React.FC = React.memo( hostTopBarLeadingContent={hostTopBarLeadingContent} macTopBarFollowingContent={macTopBarFollowingContent} solidSurface={solidSurface} + includeTrafficLightSpace={includeTrafficLightSpace} + showCollapseButton={showCollapseButton} > {preListContent} diff --git a/src/web/features/sessions/WebSessionsContext.tsx b/src/web/features/sessions/WebSessionsContext.tsx new file mode 100644 index 0000000000..3eb8b5be7c --- /dev/null +++ b/src/web/features/sessions/WebSessionsContext.tsx @@ -0,0 +1,28 @@ +import React, { createContext, useContext, useMemo } from "react"; + +import { useWebSessionRoster } from "./useWebSessionRoster"; + +type WebSessionsContextValue = ReturnType; + +const WebSessionsContext = createContext(null); + +export function WebSessionsProvider({ + children, +}: { + children: React.ReactNode; +}) { + const roster = useWebSessionRoster(); + const value = useMemo(() => roster, [roster]); + return ( + + {children} + + ); +} + +export function useWebSessions(): WebSessionsContextValue { + const value = useContext(WebSessionsContext); + if (!value) + throw new Error("useWebSessions must be used within WebSessionsProvider"); + return value; +} diff --git a/src/web/features/sessions/WebSessionsPage.tsx b/src/web/features/sessions/WebSessionsPage.tsx new file mode 100644 index 0000000000..ad98093566 --- /dev/null +++ b/src/web/features/sessions/WebSessionsPage.tsx @@ -0,0 +1,49 @@ +import { PanelsTopLeft } from "lucide-react"; +import React from "react"; +import { useTranslation } from "react-i18next"; + +import { Placeholder } from "@src/modules/shared/layouts/blocks"; + +import { useWebSessions } from "./WebSessionsContext"; + +export function WebSessionsPage() { + const { t } = useTranslation("navigation"); + const { sessions, status, error, refresh } = useWebSessions(); + + return ( +
+ } + placement="detail-panel" + title={ + status === "error" && sessions.length === 0 + ? t("web.sessionsPage.loadError") + : sessions.length === 0 && status !== "loading" + ? t("web.sessionsPage.empty") + : t("web.sessionsPage.select") + } + subtitle={ + error || + (sessions.length === 0 + ? t("web.sessionsPage.emptyHint") + : t("web.sessionsPage.selectHint")) + } + action={ + status === "error" + ? { + label: t("web.sessionsPage.retry"), + onClick: () => void refresh(), + } + : undefined + } + /> +
+ ); +} diff --git a/src/web/features/sessions/webSessionLocation.test.ts b/src/web/features/sessions/webSessionLocation.test.ts new file mode 100644 index 0000000000..1fdb5fec06 --- /dev/null +++ b/src/web/features/sessions/webSessionLocation.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; + +import { + cloudSessionEventTarget, + matchesWebSessionPath, + webSessionHasOpenNotes, + webSessionPath, +} from "./webSessionLocation"; + +const session = { + id: "org:user:session-row", + orgId: "org/one", + sourceSessionId: "agentsession-local", +}; + +describe("Web cloud session identity", () => { + it("uses the authoritative remote row id for event fetches", () => { + expect(cloudSessionEventTarget(session)).toEqual({ + orgId: "org/one", + sessionRowId: "org:user:session-row", + }); + }); + + it("uses the same row id for encoded routes and matching", () => { + expect(webSessionPath(session)).toBe( + "/sessions/org%2Fone/org%3Auser%3Asession-row" + ); + expect(webSessionPath(session, { openNotes: true })).toBe( + "/sessions/org%2Fone/org%3Auser%3Asession-row?notes=1" + ); + expect( + matchesWebSessionPath(session, "org/one", "org:user:session-row") + ).toBe(true); + expect( + matchesWebSessionPath(session, "org/one", "agentsession-local") + ).toBe(false); + }); +}); + +describe("webSessionHasOpenNotes", () => { + it("detects notes=1 search param", () => { + expect(webSessionHasOpenNotes("?notes=1")).toBe(true); + expect(webSessionHasOpenNotes("")).toBe(false); + }); +}); diff --git a/src/web/features/sessions/webSessionLocation.ts b/src/web/features/sessions/webSessionLocation.ts new file mode 100644 index 0000000000..5b7889804b --- /dev/null +++ b/src/web/features/sessions/webSessionLocation.ts @@ -0,0 +1,33 @@ +import type { RemoteTeammateSessionMetadata } from "@src/store/collaboration/types"; + +type WebSessionIdentity = Pick< + RemoteTeammateSessionMetadata, + "id" | "orgId" | "sourceSessionId" +>; + +/** Cloud APIs and Web routes are keyed by the authoritative session row id. + * sourceSessionId belongs to the originating desktop and is display/runtime + * metadata, not the remote row locator. */ +export function cloudSessionEventTarget(session: WebSessionIdentity) { + return { orgId: session.orgId, sessionRowId: session.id }; +} + +export function webSessionPath( + session: WebSessionIdentity, + options?: { openNotes?: boolean } +): string { + const base = `/sessions/${encodeURIComponent(session.orgId)}/${encodeURIComponent(session.id)}`; + return options?.openNotes ? `${base}?notes=1` : base; +} + +export function webSessionHasOpenNotes(search: string): boolean { + return new URLSearchParams(search).get("notes") === "1"; +} + +export function matchesWebSessionPath( + session: WebSessionIdentity, + orgId: string | undefined, + sessionRowId: string | undefined +): boolean { + return session.orgId === orgId && session.id === sessionRowId; +} diff --git a/src/web/shell/WebSessionSidebar.test.ts b/src/web/shell/WebSessionSidebar.test.ts new file mode 100644 index 0000000000..4b7d315263 --- /dev/null +++ b/src/web/shell/WebSessionSidebar.test.ts @@ -0,0 +1,257 @@ +/** @vitest-environment jsdom */ +import React from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { buildCloudRemoteItemId } from "@src/features/Org2Cloud/cloudRemoteItemId"; +import { org2CloudAuthAtom } from "@src/features/Org2Cloud/org2CloudAuthAtom"; +import { org2CloudOrgsAtom } from "@src/features/Org2Cloud/org2CloudOrgsAtom"; +import { org2CloudPresenceAtom } from "@src/features/Org2Cloud/org2CloudPresenceAtom"; +import { + CLOUD_MY_SESSIONS_SECTION_ID, + CLOUD_TEAM_SESSIONS_SECTION_ID, +} from "@src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudScopedMenuItems"; +import { createSmokeRoot, dispatch } from "@src/test/reactSmokeHarness"; + +import { WebSessionSidebar } from "./WebSessionSidebar"; + +const testState = vi.hoisted(() => ({ + location: { + pathname: "/sessions/org-1/session-1", + search: "", + }, + navigate: vi.fn(), + refresh: vi.fn(), + setAuth: vi.fn(), + sidebarProps: null as Record | null, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, defaultValue?: string) => defaultValue ?? key, + }), +})); + +vi.mock("jotai", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useAtom: () => [ + { profile: { displayName: "Web User" }, userId: "user-1" }, + testState.setAuth, + ], + useAtomValue: (atom: unknown) => { + if (atom === org2CloudOrgsAtom) { + return [ + { orgId: "org-1", name: "Organization One" }, + { orgId: "org-2", name: "Organization Two" }, + ]; + } + if (atom === org2CloudAuthAtom) { + return { + userId: "user-1", + profile: { displayName: "Web User" }, + }; + } + if (atom === org2CloudPresenceAtom) { + return {}; + } + return undefined; + }, + }; +}); + +vi.mock("react-router-dom", () => ({ + useLocation: () => testState.location, + useNavigate: () => testState.navigate, +})); + +vi.mock("@src/components/Button", () => ({ + default: ({ + children, + icon, + loading: _loading, + iconOnly: _iconOnly, + appearance: _appearance, + variant: _variant, + size: _size, + ...props + }: React.ComponentProps<"button"> & { + icon?: React.ReactNode; + loading?: boolean; + iconOnly?: boolean; + appearance?: string; + variant?: string; + size?: string; + }) => React.createElement("button", props, icon, children), +})); + +vi.mock("@src/scaffold/NavigationSidebar", () => ({ + NavigationSidebar: (props: Record) => { + testState.sidebarProps = props; + return React.createElement( + "aside", + { "data-web-sidebar": true }, + props.preListContent as React.ReactNode, + props.bottomContent as React.ReactNode + ); + }, + SidebarOrgSelector: ({ + value, + onChange, + cloudSignedInIdentity, + }: { + value: string; + onChange: (value: string) => void; + cloudSignedInIdentity?: string | null; + }) => + React.createElement( + "button", + { + "data-org-selector": value, + "data-cloud-identity": cloudSignedInIdentity ?? "", + onClick: () => onChange("org-2"), + }, + value + ), + SidebarBottomBar: ({ + leftContent, + rightActions, + }: { + leftContent: React.ReactNode; + rightActions: React.ReactNode; + }) => React.createElement("footer", null, leftContent, rightActions), + SidebarMenuSearchInput: ({ placeholder }: { placeholder: string }) => + React.createElement("input", { placeholder }), +})); + +vi.mock("../features/sessions/WebSessionsContext", () => ({ + useWebSessions: () => ({ + status: "loaded", + error: null, + refresh: testState.refresh, + sessions: [ + { + id: "session-1", + orgId: "org-1", + orgName: "Organization One", + sourceSessionId: "local-1", + title: "First session", + agentDisplayName: "Codex", + cliAgentType: "codex", + lastActivityAt: "2026-08-19T12:00:00.000Z", + status: "stopped", + eventsEpoch: 1, + ownerUserId: "user-1", + ownerDisplayName: "Web User", + }, + { + id: "session-2", + orgId: "org-2", + orgName: "Organization Two", + sourceSessionId: "local-2", + title: "Second session", + agentDisplayName: "Claude", + cliAgentType: "claude", + lastActivityAt: "2026-08-19T13:00:00.000Z", + status: "running", + eventsEpoch: 1, + ownerUserId: "user-2", + ownerDisplayName: "Teammate", + }, + ], + }), +})); + +describe("WebSessionSidebar", () => { + const roots: Array> = []; + + afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => root.unmount())); + testState.location = { + pathname: "/sessions/org-1/session-1", + search: "", + }; + testState.navigate.mockReset(); + testState.refresh.mockReset(); + testState.setAuth.mockReset(); + testState.sidebarProps = null; + }); + + it("reuses desktop sidebar chrome and cloud session menu ids", async () => { + const root = createSmokeRoot(); + roots.push(root); + await root.render(React.createElement(WebSessionSidebar)); + + expect(testState.sidebarProps?.includeTrafficLightSpace).toBe(false); + expect(testState.sidebarProps?.showCollapseButton).toBe(false); + expect( + root.container + .querySelector("[data-org-selector]") + ?.getAttribute("data-org-selector") + ).toBe("org-1"); + expect( + root.container + .querySelector("[data-cloud-identity]") + ?.getAttribute("data-cloud-identity") + ).toBe("Web User"); + expect( + root.container.querySelector('input[placeholder="Search..."]') + ).not.toBeNull(); + + const menuItems = testState.sidebarProps?.menuItems as Array<{ + id: string; + }>; + expect(menuItems[0]?.id).toBe( + `separator-${CLOUD_TEAM_SESSIONS_SECTION_ID}` + ); + expect( + menuItems.some( + (item) => item.id === buildCloudRemoteItemId("org-1", "session-1") + ) + ).toBe(true); + expect( + menuItems.some( + (item) => item.id === `separator-${CLOUD_MY_SESSIONS_SECTION_ID}` + ) + ).toBe(true); + expect(testState.sidebarProps?.selectedKey).toBe( + buildCloudRemoteItemId("org-1", "session-1") + ); + + await dispatch(() => + root.container + .querySelector("[data-org-selector]") + ?.click() + ); + expect(testState.navigate).toHaveBeenCalledWith("/sessions?org=org-2"); + }); + + it("uses the URL organization scope on the sessions landing page", async () => { + testState.location = { + pathname: "/sessions", + search: "?org=org-2", + }; + const root = createSmokeRoot(); + roots.push(root); + await root.render(React.createElement(WebSessionSidebar)); + + expect( + root.container + .querySelector("[data-org-selector]") + ?.getAttribute("data-org-selector") + ).toBe("org-2"); + const menuItems = testState.sidebarProps?.menuItems as Array<{ + id: string; + }>; + expect( + menuItems.some( + (item) => item.id === buildCloudRemoteItemId("org-2", "session-2") + ) + ).toBe(true); + expect( + menuItems.some( + (item) => item.id === `separator-${CLOUD_MY_SESSIONS_SECTION_ID}` + ) + ).toBe(true); + }); +}); diff --git a/src/web/shell/WebSessionSidebar.tsx b/src/web/shell/WebSessionSidebar.tsx new file mode 100644 index 0000000000..0e1a4a8c50 --- /dev/null +++ b/src/web/shell/WebSessionSidebar.tsx @@ -0,0 +1,170 @@ +import { useAtom, useAtomValue } from "jotai"; +import { LogOut } from "lucide-react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useLocation, useNavigate } from "react-router-dom"; + +import Button from "@src/components/Button"; +import { org2CloudAuthAtom } from "@src/features/Org2Cloud/org2CloudAuthAtom"; +import { org2CloudOrgsAtom } from "@src/features/Org2Cloud/org2CloudOrgsAtom"; +import { + type NavigationMenuItem, + NavigationSidebar, + SidebarBottomBar, + SidebarMenuSearchInput, + SidebarOrgSelector, +} from "@src/scaffold/NavigationSidebar"; + +import { useWebSessions } from "../features/sessions/WebSessionsContext"; +import { webSessionPath } from "../features/sessions/webSessionLocation"; +import { + resolveWebCloudSessionMenuItemId, + useWebCloudSessionsSection, +} from "./useWebCloudSessionsSection"; + +export function WebSessionSidebar({ onNavigate }: { onNavigate?: () => void }) { + const navigate = useNavigate(); + const location = useLocation(); + const { t } = useTranslation("navigation"); + const { t: tCommon } = useTranslation("common"); + const [auth, setAuth] = useAtom(org2CloudAuthAtom); + const orgs = useAtomValue(org2CloudOrgsAtom); + const { sessions, status, error, refresh } = useWebSessions(); + const [search, setSearch] = useState(""); + + const orgOptions = useMemo(() => { + if (orgs.length > 0) { + return orgs.map((org) => ({ value: org.orgId, label: org.name })); + } + return Array.from( + new Map(sessions.map((session) => [session.orgId, session.orgName])) + ).map(([orgId, orgName]) => ({ value: orgId, label: orgName })); + }, [orgs, sessions]); + + const selectedSession = useMemo( + () => + sessions.find((session) => + location.pathname.startsWith(webSessionPath(session)) + ), + [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 ?? "")); + + const { + cloudMenuItems, + handleMenuItemClick, + resolveSessionPath, + resetTeamPagination, + } = useWebCloudSessionsSection({ + orgId: selectedOrgId || null, + sessions, + rosterStatus: status, + refresh, + }); + + useEffect(() => { + resetTeamPagination(); + }, [resetTeamPagination, selectedOrgId]); + + const selectedKey = resolveWebCloudSessionMenuItemId(selectedSession); + + const handleSidebarMenuItemClick = useCallback( + (_key: string, item: NavigationMenuItem) => { + if (handleMenuItemClick(item)) return; + const path = resolveSessionPath(item); + if (!path) return; + navigate(path); + onNavigate?.(); + }, + [handleMenuItemClick, navigate, onNavigate, resolveSessionPath] + ); + + const displayName = + auth?.profile?.displayName || auth?.profile?.primaryEmail || "Cloud user"; + const searchPlaceholder = tCommon("common.searchPlaceholder", "Search..."); + const noSearchResultsTitle = t("sidebar.empty.noSearchResults"); + + const handleOrgChange = useCallback( + (orgId: string) => { + if (selectedSession?.orgId !== orgId) { + navigate(`/sessions?org=${encodeURIComponent(orgId)}`); + } + }, + [navigate, selectedSession?.orgId] + ); + + const sidebarOrgSelector = + orgOptions.length > 0 ? ( + + ) : null; + + const orgSelectorChrome = sidebarOrgSelector ? ( +
+ {sidebarOrgSelector} + {error ? ( +
+ {error} +
+ ) : null} +
+ ) : null; + + return ( + undefined} + menuItems={cloudMenuItems} + selectedKey={selectedKey} + onMenuItemClick={handleSidebarMenuItemClick} + preListContent={orgSelectorChrome} + search={{ + value: search, + onChange: setSearch, + placeholder: searchPlaceholder, + noResultsTitle: noSearchResultsTitle, + showInput: false, + }} + isLoading={status === "loading" && sessions.length === 0} + solidSurface + includeTrafficLightSpace={false} + showCollapseButton={false} + collapsibleSections + listTopPadding + bottomContent={ + + } + rightActions={ + + + {mentionedNames.map((member) => ( + + ))} +
+ ) : undefined; + + const submitActions = ( +
+ {onCancel ? ( + + ) : null} + +
+ ); + const modeActions = ( + + ); + const trailingActions = ( +
+ {mentionActions} + {submitActions} +
+ ); + + return ( +
+ + void submit()} + mode={editorMode} + onModeChange={setEditorMode} + dataTestId={testId ? `${testId}-editor` : undefined} + /> + {showAgentSuggestion ? ( + + ) : null} + +
+ ); +}; + +interface CommentRowProps { + comment: CloudSessionComment; + mentionableMembers: readonly CloudOrgMember[]; + isReply: boolean; + /** Thread-head verdict; null = active (and always null on replies). */ + resolution: CloudCommentResolution | null; + viewerUserId: string | null; + viewerIsAdmin: boolean; + busy: boolean; + onEdit: (commentId: string, body: string) => Promise; + onDelete: (commentId: string) => Promise; + onSetStatus?: (status: CommentThreadStatus) => Promise; +} + +const CommentRow: React.FC = ({ + comment, + mentionableMembers, + isReply, + resolution, + viewerUserId, + viewerIsAdmin, + busy, + onEdit, + onDelete, + onSetStatus, +}) => { + const { t } = useTranslation("navigation"); + const [editing, setEditing] = useState(false); + const [editBody, setEditBody] = useState(""); + const [rowBusy, setRowBusy] = useState(false); + const [editMode, setEditMode] = useState("write"); + + const isTombstone = Boolean(comment.deletedAt); + const isAuthor = Boolean( + viewerUserId && comment.authorUserId === viewerUserId + ); + const canEdit = isAuthor && !isTombstone; + const canDelete = (isAuthor || viewerIsAdmin) && !isTombstone; + const anyBusy = busy || rowBusy; + const currentStatus: CommentThreadStatus = resolution ?? "active"; + const agentMention = isReply ? null : splitAgentMentionBody(comment.body); + const mentionedMembers = useMemo( + () => resolveMentions(comment.mentionedUserIds ?? [], mentionableMembers), + [comment.mentionedUserIds, mentionableMembers] + ); + + const run = useCallback( + async (operation: () => Promise, errorKey: string) => { + if (anyBusy) return; + setRowBusy(true); + try { + await operation(); + } catch { + Message.error(t(errorKey)); + } finally { + setRowBusy(false); + } + }, + [anyBusy, t] + ); + + const saveEdit = useCallback(async () => { + const trimmed = editBody.trim(); + if (!trimmed) return; + setRowBusy(true); + try { + await onEdit(comment.id, trimmed); + setEditing(false); + } catch { + // Draft restore: the edited text stays in the editor. + Message.error(t("cloud.comments.addError")); + } finally { + setRowBusy(false); + } + }, [editBody, onEdit, comment.id, t]); + + return ( +
+
+ {comment.kind === "agent_report" ? ( + + + {t("cloud.comments.agentAuthor", { + name: comment.authorDisplayName ?? comment.authorUserId, + })} + + ) : ( + + {comment.authorDisplayName ?? comment.authorUserId} + + )} + + {formatRelativeTime(comment.createdAt, "short")} + + {comment.editedAt && !isTombstone && ( + + ({t("cloud.comments.editedMarker")}) + + )} + {!isReply && resolution === "resolved" && ( + + + {t("cloud.comments.resolved")} + + )} + {!isReply && resolution === "wont_fix" && ( + + {t("cloud.comments.wontFix")} + + )} + + {!isReply && onSetStatus && ( + + {THREAD_STATUS_OPTIONS.map((status) => ( + + ))} + + )} + {canEdit && ( + +
+ {editing ? ( + + } + trailingActions={ +
+ + +
+ } + > + void saveEdit()} + mode={editMode} + onModeChange={setEditMode} + dataTestId="session-comment-edit-editor" + /> +
+ ) : isTombstone ? ( +
+ {t("cloud.comments.deletedComment")} +
+ ) : ( + <> + {mentionedMembers.length > 0 ? ( +
+ {mentionedMembers.map((member) => ( + + ))} +
+ ) : null} + {agentMention ? ( + + + ) : null} + + + )} +
+ ); +}; + +interface ThreadBlockProps { + thread: CommentThread; + viewerUserId: string | null; + viewerIsAdmin: boolean; + mentionableMembers: readonly CloudOrgMember[]; + readOnly?: boolean; + onAdd: CommentThreadListProps["onAdd"]; + onEdit: CommentThreadListProps["onEdit"]; + onDelete: CommentThreadListProps["onDelete"]; + onResolve: CommentThreadListProps["onResolve"]; +} + +const ThreadBlock: React.FC = ({ + thread, + viewerUserId, + viewerIsAdmin, + mentionableMembers, + readOnly = false, + onAdd, + onEdit, + onDelete, + onResolve, +}) => { + const { t } = useTranslation("navigation"); + const context = useSessionCommentsContext(); + const [replying, setReplying] = useState(false); + const resolution = getThreadResolution(thread); + + const addressing = Boolean( + context?.addressRunActive && + resolution === null && + (context.addressRunSelectedHeadIds === null || + context.addressRunSelectedHeadIds.has(thread.top.id)) + ); + + const setStatus = useCallback( + (status: CommentThreadStatus): Promise => + status === "active" + ? onResolve(thread.top.id, false) + : onResolve(thread.top.id, true, status), + [onResolve, thread.top.id] + ); + + return ( +
+ + {addressing && ( +
+ + {t("cloud.comments.agentAddressing")} +
+ )} + {thread.replies.map((reply) => ( + + ))} + {!readOnly && + (replying ? ( +
+ { + await onAdd(body, thread.top.id, mentionedUserIds); + setReplying(false); + }} + onCancel={() => setReplying(false)} + testId="session-comment-reply-composer" + /> +
+ ) : ( + setReplying(true)} + > + {t("cloud.comments.reply")} + + ))} +
+ ); +}; + +const CommentThreadList: React.FC = ({ + threads, + viewerUserId, + viewerIsAdmin, + readOnly = false, + showComposer = true, + composerDisabled = false, + composerDisabledReason, + composerPlaceholder, + onComposerCancel, + emptyLabel, + mentionableMembers: mentionableMembersOverride, + onAdd, + onEdit, + onDelete, + onResolve, +}) => { + const { t } = useTranslation("navigation"); + const context = useSessionCommentsContext(); + const mentionableMembers = ( + mentionableMembersOverride ?? + context?.mentionableMembers ?? + [] + ).filter((member) => member.userId !== viewerUserId); + const [showResolved, setShowResolved] = useState(false); + + const openThreads = threads.filter((thread) => !isThreadResolved(thread)); + const resolvedThreads = threads.filter(isThreadResolved); + + const requestAgent = context?.requestAgent; + const submitTopLevel = useCallback( + async (body: string, mentionedUserIds: string[]): Promise => { + const comment = await onAdd(body, undefined, mentionedUserIds); + // Beyond here the comment IS posted — never throw (a throw would + // trigger the composer's draft restore for a send that succeeded). + if (!comment || comment.parentId) return; + if (!detectAgentPrefix(body)) return; + if (!requestAgent || !context?.canRunAgent) { + // Read-only/imported surfaces treat a manually typed @agent prefix as + // ordinary comment text. There is no assignment, toast or side effect. + return; + } + // Comment-first (design §4 item 2): the body landed VERBATIM above, + // so a failed create degrades to a normal thread — and create is + // idempotent per comment (retry-safe by re-sending `@agent `). + try { + await requestAgent(comment.id); + } catch { + Message.warning(t("cloud.comments.task.assignFailed")); + } + }, + [onAdd, requestAgent, context?.canRunAgent, t] + ); + + const composer = + showComposer && !readOnly ? ( + + ) : null; + + return ( +
+ {threads.length === 0 && emptyLabel && ( +
{emptyLabel}
+ )} + {openThreads.map((thread) => ( + + ))} + {resolvedThreads.length > 0 && ( + setShowResolved((current) => !current)} + > + {t("cloud.comments.resolvedToggle", { + count: resolvedThreads.length, + })} + + )} + {showResolved && + resolvedThreads.map((thread) => ( + + ))} + {composer && + (composerDisabled && composerDisabledReason ? ( + +
{composer}
+
+ ) : ( + composer + ))} +
+ ); +}; + +export default CommentThreadList; diff --git a/src/features/Org2Cloud/SessionComments/commentAgentAffordances.ts b/src/features/Org2Cloud/SessionComments/commentAgentAffordances.ts new file mode 100644 index 0000000000..93c9e223b1 --- /dev/null +++ b/src/features/Org2Cloud/SessionComments/commentAgentAffordances.ts @@ -0,0 +1,56 @@ +/** Pure predicates behind the thread-list / turn-chrome agent affordances. */ + +/** + * The composer sugar token (design §1): promotion is EXPLICIT — a literal + * prefix over the same create RPC, never NL intent detection. + */ +export const AGENT_COMPOSER_PREFIX = "@agent "; + +/** + * Literal `@agent ` detection on the SUBMITTED body (composers trim before + * submit): case-sensitive, anchored at index 0 (no leading-whitespace + * tolerance — a trimmed body can't have any), and the trailing space is + * part of the token, so "@agents please" and a bare "@agent" are ordinary + * comments. The prefix must be followed by content: the comment posts + * VERBATIM and an empty brief would promote a thread that says nothing. + */ +export function detectAgentPrefix(body: string): boolean { + return ( + body.startsWith(AGENT_COMPOSER_PREFIX) && + body.slice(AGENT_COMPOSER_PREFIX.length).trim().length > 0 + ); +} + +export interface AgentMentionBodyParts { + mention: "@agent"; + brief: string; +} + +/** + * Composer suggestion is deliberately prefix-only and canonical: typing `@` + * or any leading prefix of `@agent` offers the one supported agent target. + * Once a space/body exists the suggestion closes; manual full-token input + * continues through the same submit parser. + */ +export function shouldShowAgentSuggestion(body: string): boolean { + return ( + body.length > 0 && + body.length <= "@agent".length && + "@agent".startsWith(body) + ); +} + +/** + * Splits the submitted sugar into a semantic mention token and its brief. + * Keeping this beside the detector ensures the rendered pill and task + * creation always use the exact same grammar. + */ +export function splitAgentMentionBody( + body: string +): AgentMentionBodyParts | null { + if (!detectAgentPrefix(body)) return null; + return { + mention: "@agent", + brief: body.slice(AGENT_COMPOSER_PREFIX.length), + }; +} diff --git a/src/features/Org2Cloud/completeSignIn.test.ts b/src/features/Org2Cloud/completeSignIn.test.ts index 760d930b24..b6031e6d31 100644 --- a/src/features/Org2Cloud/completeSignIn.test.ts +++ b/src/features/Org2Cloud/completeSignIn.test.ts @@ -54,7 +54,12 @@ afterEach(() => { describe("enrichOrg2CloudProfile", () => { it("binds profile enrichment to the endpoint captured by the session", async () => { - const state = stateHarness(AUTH); + // atomWithStorage rehydrates JSON as a structurally equal but referentially + // different object. Profile enrichment must still recognize this as the + // same persisted session and write the human-readable identity. + const rehydrated = { ...AUTH }; + expect(rehydrated).not.toBe(AUTH); + const state = stateHarness(rehydrated); ensureFreshSessionMock.mockResolvedValueOnce(AUTH); getCloudProfileMock.mockResolvedValueOnce({ displayName: "Vince" }); diff --git a/src/features/Org2Cloud/completeSignIn.ts b/src/features/Org2Cloud/completeSignIn.ts index c8beb6fed2..527117ccae 100644 --- a/src/features/Org2Cloud/completeSignIn.ts +++ b/src/features/Org2Cloud/completeSignIn.ts @@ -15,6 +15,7 @@ import { getCloudEndpoint } from "./config"; import { type Org2CloudAuthState, commitRefreshedAuth, + isSameOrg2CloudSession, } from "./org2CloudAuthAtom"; import { ensureFreshSession, getCloudProfile } from "./org2CloudClient"; @@ -85,11 +86,12 @@ export async function enrichOrg2CloudProfile( if (!commitRefreshedAuth(setAuth, state, fresh)) return; } - // Verify the same object is still current even when no refresh was needed. - // Endpoint switches and sign-out replace the object synchronously. + // Storage hydration parses the same persisted session into a new object, + // so reference equality would reject a legitimate profile write. Compare + // the stable endpoint/account plus refresh-token generation instead. let isCurrent = false; setAuth((prev) => { - isCurrent = prev === fresh; + isCurrent = isSameOrg2CloudSession(prev, fresh); return prev; }); if (!isCurrent) return; @@ -102,7 +104,7 @@ export async function enrichOrg2CloudProfile( setAuth((prev) => { // Only enrich the session we just created — the user may have signed // out (or re-signed-in as someone else) while the RPC was in flight. - if (prev !== fresh) return prev; + if (!isSameOrg2CloudSession(prev, fresh)) return prev; return { ...prev, profile: { diff --git a/src/features/Org2Cloud/org2CloudAuthAtom.test.ts b/src/features/Org2Cloud/org2CloudAuthAtom.test.ts index cd95af0f2c..d2b3ed47fd 100644 --- a/src/features/Org2Cloud/org2CloudAuthAtom.test.ts +++ b/src/features/Org2Cloud/org2CloudAuthAtom.test.ts @@ -10,6 +10,7 @@ import { Org2CloudAuthStateSchema, clearRejectedAuth, commitRefreshedAuth, + isSameOrg2CloudSession, org2CloudAuthAtom, } from "./org2CloudAuthAtom"; import { ensureFreshSession } from "./org2CloudClient"; @@ -111,9 +112,11 @@ function boundSetter(store: ReturnType) { } describe("commitRefreshedAuth", () => { - it("commits the rotated session into the atom", () => { + it("commits the rotated session after storage rehydrates an equivalent object", () => { const store = createStore(); - store.set(org2CloudAuthAtom, VALID_STATE); + const rehydrated = { ...VALID_STATE }; + expect(rehydrated).not.toBe(VALID_STATE); + store.set(org2CloudAuthAtom, rehydrated); const rotated: Org2CloudAuthState = { ...VALID_STATE, accessToken: "at-2", @@ -128,6 +131,15 @@ describe("commitRefreshedAuth", () => { expect(store.get(org2CloudAuthAtom)).toBe(rotated); }); + it("treats an endpoint switch as a different session even if ids and tokens match", () => { + const switchedEndpoint: Org2CloudAuthState = { + ...VALID_STATE, + supabaseUrl: "https://other.supabase.co", + }; + + expect(isSameOrg2CloudSession(switchedEndpoint, VALID_STATE)).toBe(false); + }); + it("no-ops when ensureFreshSession returned the same object (token still valid)", () => { const store = createStore(); store.set(org2CloudAuthAtom, VALID_STATE); diff --git a/src/features/Org2Cloud/org2CloudAuthAtom.ts b/src/features/Org2Cloud/org2CloudAuthAtom.ts index 28b26ad1de..1feb05341f 100644 --- a/src/features/Org2Cloud/org2CloudAuthAtom.ts +++ b/src/features/Org2Cloud/org2CloudAuthAtom.ts @@ -107,15 +107,35 @@ export const org2CloudAuthAtom = atomWithStorage( ); org2CloudAuthAtom.debugLabel = "org2CloudAuthAtom"; +/** + * Compare the persisted generation of a cloud session. + * + * Object identity cannot be used here: storage hydration parses the same + * JSON into a new object. The endpoint/account pair identifies who is signed + * in, while the refresh token is the session generation and changes after a + * successful rotation. This lets async work survive harmless hydration but + * rejects writes from a signed-out, switched, or already-rotated session. + */ +export function isSameOrg2CloudSession( + current: Org2CloudAuthState | null, + expected: Org2CloudAuthState +): current is Org2CloudAuthState { + return ( + current !== null && + org2CloudAuthIdentityKey(current) === org2CloudAuthIdentityKey(expected) && + current.refreshToken === expected.refreshToken + ); +} + /** * Write a refreshed session back to the auth atom under a COMPARE-AND-SET: * a `ensureFreshSession` round-trip can resolve AFTER the user signed out * or switched endpoints mid-flight (both wipe/replace the atom). A blind * `set(fresh)` would then resurrect a discarded session — re-persisting * old-backend tokens into localStorage and flipping the UI back to - * signed-in. Only commit when the atom is still exactly the session we - * refreshed. `setAuth` must accept jotai's functional-updater form (both - * `store.set` and the `useAtom`/`useSetAtom` setter do). + * signed-in. Only commit when the atom still contains the same persisted + * session generation. `setAuth` must accept jotai's functional-updater form + * (both `store.set` and the `useAtom`/`useSetAtom` setter do). */ export function commitRefreshedAuth( setAuth: ( @@ -127,7 +147,7 @@ export function commitRefreshedAuth( if (fresh === previous) return true; let committed = false; setAuth((current) => { - if (current !== previous) return current; + if (!isSameOrg2CloudSession(current, previous)) return current; committed = true; return fresh; }); diff --git a/src/features/Org2Cloud/useCopySessionReference.ts b/src/features/Org2Cloud/useCopySessionReference.ts index 3dc312f1d6..5df821c875 100644 --- a/src/features/Org2Cloud/useCopySessionReference.ts +++ b/src/features/Org2Cloud/useCopySessionReference.ts @@ -91,9 +91,9 @@ export function useCopySessionReference(): CopySessionReferenceResult { sourceSessionId: session.session_id, }) ) - .then(() => Message.success(i18n.t("common:actions.copied"))) + .then(() => Message.success(i18n.t("common:status.copied"))) .catch(() => - Message.error(i18n.t("common:actions.copyFailed"), { + Message.error(i18n.t("common:status.copyFailed"), { duration: REFUSAL_MESSAGE_DURATION_MS, closable: true, }) diff --git a/src/modules/WorkStation/CodeEditor/SessionReplay/converters/__tests__/fileConverter.test.ts b/src/modules/WorkStation/CodeEditor/SessionReplay/converters/__tests__/fileConverter.test.ts index 9f4dc31d30..b15ebda733 100644 --- a/src/modules/WorkStation/CodeEditor/SessionReplay/converters/__tests__/fileConverter.test.ts +++ b/src/modules/WorkStation/CodeEditor/SessionReplay/converters/__tests__/fileConverter.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import { _resetToolRegistry } from "@src/engines/SessionCore/rendering/registry"; import { convertToFileOperation, parseFilePath } from "../fileConverter"; @@ -239,4 +240,32 @@ describe("convertToFileOperation", () => { }); expect(convertToFileOperation(event, false)).toBeNull(); }); + + it("classifies read/edit via uiCanonical when the tool registry is empty", () => { + _resetToolRegistry(); + const read = minimalSessionEvent({ + functionName: "Read", + uiCanonical: "read_file", + args: { path: "/repo/src/app.ts" }, + result: { + output: { success: { content: "export const ok = true;" } }, + }, + }); + const edit = minimalSessionEvent({ + functionName: "edit_file_by_replace", + uiCanonical: "edit_file", + args: { path: "/repo/src/app.ts" }, + result: { + output: { + success: { + beforeFullFileContent: "a", + afterFullFileContent: "b", + }, + }, + }, + }); + + expect(convertToFileOperation(read, false)?.type).toBe("read"); + expect(convertToFileOperation(edit, false)?.type).toBe("write"); + }); }); diff --git a/src/modules/WorkStation/CodeEditor/SessionReplay/converters/fileConverter.ts b/src/modules/WorkStation/CodeEditor/SessionReplay/converters/fileConverter.ts index 3b4e101833..d39d89c29a 100644 --- a/src/modules/WorkStation/CodeEditor/SessionReplay/converters/fileConverter.ts +++ b/src/modules/WorkStation/CodeEditor/SessionReplay/converters/fileConverter.ts @@ -9,8 +9,14 @@ import { extractFileData, stripLineNumberPrefixes, } from "@src/engines/SessionCore/rendering/props"; -import { APP_SUBTOOL } from "@src/engines/SessionCore/rendering/registry"; -import { getAppSubtool } from "@src/engines/SessionCore/rendering/registry/initToolRegistry"; +import { + APP_SUBTOOL, + type AppSubtool, +} from "@src/engines/SessionCore/rendering/registry"; +import { + getAppSubtool, + getCliUiCanonical, +} from "@src/engines/SessionCore/rendering/registry/initToolRegistry"; import { isDeleteTool } from "@src/engines/SessionCore/rendering/registry/toolRegistryDomain"; import type { EventStatus } from "@src/engines/SessionCore/rendering/types/universalProps"; import { getEventStatus } from "@src/util/data/converters/eventStatus"; @@ -108,6 +114,29 @@ function parseUnifiedDiffPayload( export { shouldTrustDiffStartLines } from "@src/util/diff/startLines"; +function resolveFileSubtool(event: SessionEvent): AppSubtool | null { + const functionName = event.functionName || ""; + const fromRegistry = getAppSubtool(functionName); + if ( + fromRegistry === APP_SUBTOOL.FILE_READ || + fromRegistry === APP_SUBTOOL.FILE_WRITE + ) { + return fromRegistry; + } + + const uiCanonical = + event.uiCanonical || getCliUiCanonical(functionName) || functionName; + if (uiCanonical === "read_file") return APP_SUBTOOL.FILE_READ; + if (uiCanonical === "edit_file" || uiCanonical === "delete_file") { + return APP_SUBTOOL.FILE_WRITE; + } + + if (event.extracted?.kind === "file") return APP_SUBTOOL.FILE_READ; + if (event.extracted?.kind === "edit") return APP_SUBTOOL.FILE_WRITE; + + return fromRegistry; +} + export function parseFilePath(path: string): { fileName: string; directory: string; @@ -127,7 +156,7 @@ export function convertToFileOperation( isCurrent: boolean ): FileOperationEntry | null { const eventType = event.functionName; - const subtool = getAppSubtool(eventType); + const subtool = resolveFileSubtool(event); const isRead = subtool === APP_SUBTOOL.FILE_READ; const isWrite = subtool === APP_SUBTOOL.FILE_WRITE; diff --git a/src/web/features/sessions/WebSessionAlternateSurface.tsx b/src/web/features/sessions/WebSessionAlternateSurface.tsx new file mode 100644 index 0000000000..acf4d491ab --- /dev/null +++ b/src/web/features/sessions/WebSessionAlternateSurface.tsx @@ -0,0 +1,56 @@ +import React, { memo } from "react"; + +import SessionRawTranscriptView from "@src/engines/ChatPanel/components/SessionRawTranscriptView"; +import SessionChangesView from "@src/engines/ChatPanel/components/SessionViewSwitcher/SessionChangesView"; +import SessionTimelineView from "@src/engines/ChatPanel/components/SessionViewSwitcher/SessionTimelineView"; +import type { UseSessionViewModeResult } from "@src/engines/ChatPanel/hooks/useSessionViewMode"; + +import { useCloudSessionTurnIndex } from "./useCloudSessionTurnIndex"; +import type { WebSessionListItem } from "./useWebSessionRoster"; + +export interface WebSessionAlternateSurfaceProps { + session: WebSessionListItem; + view: UseSessionViewModeResult; + topInset?: number; +} + +/** Cloud-backed alternate session views for the Web read-only surface. */ +export const WebSessionAlternateSurface: React.FC = + memo(({ session, view, topInset = 0 }) => { + const { mode } = view; + const needsTurnIndex = mode === "timeline" || mode === "changes"; + const turnIndex = useCloudSessionTurnIndex(session, needsTurnIndex); + + if (mode === "raw") { + return ( + + ); + } + if (mode === "timeline") { + return ( + + ); + } + if (mode === "changes") { + return ( + + ); + } + return null; + }); + +WebSessionAlternateSurface.displayName = "WebSessionAlternateSurface"; diff --git a/src/web/features/sessions/WebSessionCommentsHeaderExtras.tsx b/src/web/features/sessions/WebSessionCommentsHeaderExtras.tsx new file mode 100644 index 0000000000..3ae160fb94 --- /dev/null +++ b/src/web/features/sessions/WebSessionCommentsHeaderExtras.tsx @@ -0,0 +1,163 @@ +import Modal from "@/src/scaffold/ModalSystem"; +import { useAtomValue } from "jotai"; +import { StickyNote } from "lucide-react"; +import React, { useCallback, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useSearchParams } from "react-router-dom"; + +import Button from "@src/components/Button"; +import Tooltip from "@src/components/Tooltip"; +import CommentThreadList from "@src/features/Org2Cloud/SessionComments/CommentThreadList"; +import { org2CloudAuthAtom } from "@src/features/Org2Cloud/org2CloudAuthAtom"; +import { + groupCommentThreads, + useSessionComments, +} from "@src/features/Org2Cloud/org2CloudSessionCommentsAtom"; + +import type { WebSessionListItem } from "./useWebSessionRoster"; + +const noopAsync = async () => undefined; + +export interface WebSessionCommentsHeaderExtrasProps { + session: WebSessionListItem; +} + +interface WebSessionCommentsModalBodyProps { + session: WebSessionListItem; +} + +const WebSessionCommentsModalBody: React.FC< + WebSessionCommentsModalBodyProps +> = ({ session }) => { + const { t } = useTranslation("navigation"); + const { comments, state } = useSessionComments( + session.orgId, + session.sourceSessionId, + null + ); + const grouped = useMemo( + () => groupCommentThreads(comments, new Set()), + [comments] + ); + + return ( +
+ + {grouped.orphaned.length > 0 && ( +
+
+ {t("cloud.comments.earlierVersion")} +
+ +
+ )} +
+ ); +}; + +const WebSessionCommentsHeaderExtras: React.FC< + WebSessionCommentsHeaderExtrasProps +> = ({ session }) => { + const { t } = useTranslation("navigation"); + const auth = useAtomValue(org2CloudAuthAtom); + const [searchParams, setSearchParams] = useSearchParams(); + const notesFromQuery = searchParams.get("notes") === "1"; + const [panelOpen, setPanelOpen] = useState(false); + const open = notesFromQuery || panelOpen; + const unresolvedCount = session.unresolvedCommentCount ?? 0; + const badgeCount = unresolvedCount; + + const openNotes = useCallback(() => setPanelOpen(true), []); + const closeNotes = useCallback(() => { + setPanelOpen(false); + if (searchParams.get("notes") === "1") { + const next = new URLSearchParams(searchParams); + next.delete("notes"); + setSearchParams(next, { replace: true }); + } + }, [searchParams, setSearchParams]); + + if (!auth) return null; + + const buttonLabel = t("web.sessionPage.notesButton", { + defaultValue: t("cloud.comments.notesButton"), + }); + + return ( + <> + + + + + + + ); +} diff --git a/src/web/features/sessions/WebSessionsPage.test.ts b/src/web/features/sessions/WebSessionsPage.test.ts new file mode 100644 index 0000000000..532b48f6d0 --- /dev/null +++ b/src/web/features/sessions/WebSessionsPage.test.ts @@ -0,0 +1,240 @@ +// @vitest-environment jsdom +import { act, createElement } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import { CloudOrgMembershipActionFailure } from "@src/features/Org2Cloud/useCloudOrgMembershipActions"; + +import { WebSessionsPage } from "./WebSessionsPage"; + +const mocks = vi.hoisted(() => ({ + createOrganization: vi.fn(), + joinOrganization: vi.fn(), + messageSuccess: vi.fn(), + roster: { + status: "loaded" as "idle" | "loading" | "loaded" | "error", + sessions: [], + error: null as string | null, + failedOrganizationCount: 0, + organizationStatus: "ready" as + | "idle" + | "loading" + | "retrying" + | "ready" + | "error", + organizationsKnown: true, + hasOrganizations: false, + refresh: vi.fn(async () => undefined), + }, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock("@src/components/Message", () => ({ + default: { success: mocks.messageSuccess }, +})); + +vi.mock( + "@src/features/Org2Cloud/useCloudOrgMembershipActions", + async (importOriginal) => { + const actual = + await importOriginal< + typeof import("@src/features/Org2Cloud/useCloudOrgMembershipActions") + >(); + return { + ...actual, + useCloudOrgMembershipActions: () => ({ + createOrganization: mocks.createOrganization, + joinOrganization: mocks.joinOrganization, + }), + }; + } +); + +vi.mock("./WebSessionsContext", () => ({ + useWebSessions: () => mocks.roster, +})); + +const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}; + +async function flushAsync(): Promise { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +describe("WebSessionsPage first-use flow", () => { + let container: HTMLDivElement; + let root: Root; + + beforeAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + }); + + beforeEach(() => { + vi.clearAllMocks(); + mocks.roster.status = "loaded"; + mocks.roster.error = null; + mocks.roster.organizationStatus = "ready"; + mocks.roster.organizationsKnown = true; + mocks.roster.hasOrganizations = false; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + afterAll(() => { + Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + function renderPage() { + act(() => root.render(createElement(WebSessionsPage))); + } + + function typeOrganizationValue(value: string) { + const input = container.querySelector( + '[data-testid="web-organization-input"]' + ); + expect(input).not.toBeNull(); + act(() => { + const setter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value" + )?.set; + setter?.call(input, value); + input?.dispatchEvent(new Event("input", { bubbles: true })); + }); + } + + it("renders organization setup instead of the synced-session empty state", () => { + renderPage(); + + expect( + container.querySelector('[data-testid="web-organization-onboarding"]') + ).not.toBeNull(); + expect(container.textContent).toContain( + "web.sessionsPage.organizationSetupTitle" + ); + expect(container.textContent).not.toContain("web.sessionsPage.emptyHint"); + }); + + it("keeps a failed first roster load in the retryable error state", () => { + mocks.roster.status = "error"; + mocks.roster.error = "organization unavailable"; + mocks.roster.organizationStatus = "error"; + mocks.roster.organizationsKnown = false; + renderPage(); + + expect( + container.querySelector('[data-testid="web-organization-onboarding"]') + ).toBeNull(); + expect(container.textContent).toContain("web.sessionsPage.loadError"); + act(() => { + Array.from(container.querySelectorAll("button")) + .find((button) => + button.textContent?.includes("web.sessionsPage.retry") + ) + ?.click(); + }); + expect(mocks.roster.refresh).toHaveBeenCalledTimes(1); + }); + + it("keeps a known empty roster actionable when a later refresh fails", () => { + mocks.roster.error = "refresh unavailable"; + mocks.roster.organizationStatus = "error"; + renderPage(); + + expect( + container.querySelector('[data-testid="web-organization-onboarding"]') + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="web-organization-refresh-error"]') + ?.textContent + ).toContain("refresh unavailable"); + }); + + it("creates an organization through the shared membership command boundary", async () => { + mocks.createOrganization.mockResolvedValue({ + orgId: "org-1", + name: "Acme", + role: "owner", + }); + renderPage(); + typeOrganizationValue(" Acme "); + + await act(async () => { + container + .querySelector( + '[data-testid="web-organization-submit"]' + ) + ?.click(); + }); + await flushAsync(); + + expect(mocks.createOrganization).toHaveBeenCalledWith("Acme"); + expect(mocks.messageSuccess).toHaveBeenCalledTimes(1); + }); + + it("keeps a rejected invite actionable and shows its source error", async () => { + mocks.joinOrganization.mockRejectedValue( + new CloudOrgMembershipActionFailure("invalid_invite") + ); + renderPage(); + act(() => { + container + .querySelector( + '[data-testid="web-organization-mode-join"]' + ) + ?.click(); + }); + typeOrganizationValue("bad-invite"); + + await act(async () => { + container + .querySelector( + '[data-testid="web-organization-submit"]' + ) + ?.click(); + }); + await flushAsync(); + + expect(mocks.joinOrganization).toHaveBeenCalledWith("bad-invite"); + expect( + container.querySelector('[data-testid="web-organization-error"]') + ?.textContent + ).toBe("cloud.orgManagement.errors.inviteInvalid"); + expect( + container.querySelector( + '[data-testid="web-organization-submit"]' + )?.disabled + ).toBe(false); + }); + + it("keeps the Desktop sync guidance for members whose org has no sessions", () => { + mocks.roster.hasOrganizations = true; + renderPage(); + + expect( + container.querySelector('[data-testid="web-organization-onboarding"]') + ).toBeNull(); + expect(container.textContent).toContain("web.sessionsPage.emptyHint"); + }); +}); diff --git a/src/web/features/sessions/WebSessionsPage.tsx b/src/web/features/sessions/WebSessionsPage.tsx index ad98093566..f7ac3cc892 100644 --- a/src/web/features/sessions/WebSessionsPage.tsx +++ b/src/web/features/sessions/WebSessionsPage.tsx @@ -4,11 +4,29 @@ import { useTranslation } from "react-i18next"; import { Placeholder } from "@src/modules/shared/layouts/blocks"; +import { WebOrganizationOnboarding } from "./WebOrganizationOnboarding"; import { useWebSessions } from "./WebSessionsContext"; export function WebSessionsPage() { const { t } = useTranslation("navigation"); - const { sessions, status, error, refresh } = useWebSessions(); + const { + sessions, + status, + error, + refresh, + organizationStatus, + organizationsKnown, + hasOrganizations, + } = useWebSessions(); + + if (organizationsKnown && !hasOrganizations) { + return ( + void refresh()} + /> + ); + } return (
@@ -23,11 +41,13 @@ export function WebSessionsPage() { icon={} placement="detail-panel" title={ - status === "error" && sessions.length === 0 - ? t("web.sessionsPage.loadError") - : sessions.length === 0 && status !== "loading" - ? t("web.sessionsPage.empty") - : t("web.sessionsPage.select") + status === "loading" && sessions.length === 0 + ? t("web.sessionsPage.loading") + : status === "error" && sessions.length === 0 + ? t("web.sessionsPage.loadError") + : sessions.length === 0 && status !== "loading" + ? t("web.sessionsPage.empty") + : t("web.sessionsPage.select") } subtitle={ error || diff --git a/src/web/features/sessions/useWebSessionRoster.integration.test.ts b/src/web/features/sessions/useWebSessionRoster.integration.test.ts new file mode 100644 index 0000000000..2624f40a10 --- /dev/null +++ b/src/web/features/sessions/useWebSessionRoster.integration.test.ts @@ -0,0 +1,157 @@ +// @vitest-environment jsdom +import { Provider, createStore } from "jotai"; +import { act, createElement, useEffect } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import { org2CloudAuthAtom } from "@src/features/Org2Cloud/org2CloudAuthAtom"; +import { + org2CloudOrgsAtom, + org2CloudOrgsLoadStateAtom, + org2CloudOrgsLoadedAtom, +} from "@src/features/Org2Cloud/org2CloudOrgsAtom"; + +import { useWebSessionRoster } from "./useWebSessionRoster"; + +const mocks = vi.hoisted(() => ({ + ensureFreshSession: vi.fn(), + listMyOrgs: vi.fn(), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock("@src/features/Org2Cloud/org2CloudClient", async (importOriginal) => { + const actual = + await importOriginal< + typeof import("@src/features/Org2Cloud/org2CloudClient") + >(); + return { + ...actual, + ensureFreshSession: mocks.ensureFreshSession, + listMyOrgs: mocks.listMyOrgs, + }; +}); + +const AUTH = { + kind: "org2_cloud" as const, + supabaseUrl: "https://cloud.example.test", + supabaseAnonKey: "anon", + userId: "user-1", + accessToken: "access", + refreshToken: "refresh", + expiresAt: 4_102_444_800, +}; + +const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}; + +describe("useWebSessionRoster organization recovery", () => { + let container: HTMLDivElement; + let root: Root; + let store: ReturnType; + let latest: ReturnType | null; + + beforeAll(() => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + }); + + beforeEach(() => { + vi.clearAllMocks(); + mocks.ensureFreshSession.mockImplementation(async (auth) => auth); + mocks.listMyOrgs.mockResolvedValue([]); + latest = null; + store = createStore(); + store.set(org2CloudAuthAtom, AUTH); + store.set(org2CloudOrgsAtom, []); + store.set(org2CloudOrgsLoadedAtom, false); + store.set(org2CloudOrgsLoadStateAtom, "error"); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + afterAll(() => { + Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + }); + + function Probe({ + onChange, + }: { + onChange: (value: ReturnType) => void; + }) { + const value = useWebSessionRoster(); + useEffect(() => { + onChange(value); + }, [onChange, value]); + return null; + } + + it("turns a failed first load into an error and recovers through Retry", async () => { + act(() => { + root.render( + createElement( + Provider, + { store }, + createElement(Probe, { + onChange: (value) => { + latest = value; + }, + }) + ) + ); + }); + + expect(latest?.status).toBe("error"); + expect(latest?.error).toBe("web.sessionsPage.organizationLoadErrorHint"); + + await act(async () => { + await latest?.refresh(); + }); + + expect(mocks.listMyOrgs).toHaveBeenCalledTimes(1); + expect(store.get(org2CloudOrgsLoadedAtom)).toBe(true); + expect(store.get(org2CloudOrgsLoadStateAtom)).toBe("ready"); + expect(latest?.status).toBe("loaded"); + expect(latest?.organizationsKnown).toBe(true); + expect(latest?.hasOrganizations).toBe(false); + }); + + it("preserves an authoritatively empty roster after a later refresh failure", () => { + store.set(org2CloudOrgsLoadedAtom, true); + act(() => { + root.render( + createElement( + Provider, + { store }, + createElement(Probe, { + onChange: (value) => { + latest = value; + }, + }) + ) + ); + }); + + expect(latest?.status).toBe("loaded"); + expect(latest?.organizationsKnown).toBe(true); + expect(latest?.hasOrganizations).toBe(false); + expect(latest?.error).toBe("web.sessionsPage.organizationRefreshErrorHint"); + }); +}); diff --git a/src/web/features/sessions/useWebSessionRoster.ts b/src/web/features/sessions/useWebSessionRoster.ts index d7b2b966a1..13403dd36d 100644 --- a/src/web/features/sessions/useWebSessionRoster.ts +++ b/src/web/features/sessions/useWebSessionRoster.ts @@ -1,5 +1,6 @@ import { useAtomValue, useSetAtom } from "jotai"; import { useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { org2CloudAuthAtom, @@ -8,7 +9,9 @@ import { import { type Org2CloudOrg, org2CloudOrgsAtom, + org2CloudOrgsLoadStateAtom, org2CloudOrgsLoadedAtom, + useRefetchOrg2CloudOrgs, } from "@src/features/Org2Cloud/org2CloudOrgsAtom"; import { type CloudOrgRemoteSessionsEntry, @@ -25,10 +28,11 @@ export interface WebSessionListItem extends RemoteTeammateSessionMetadata { writable: boolean; } -interface WebSessionRosterState { +export interface WebSessionRosterState { status: "idle" | "loading" | "loaded" | "error"; sessions: WebSessionListItem[]; error: string | null; + failedOrganizationCount: number; } function sessionTimestamp(session: WebSessionListItem): number { @@ -62,7 +66,12 @@ export function aggregateWebSessionRoster({ userId: string | null; }): WebSessionRosterState { if (!identityKey || !userId) { - return { status: "idle", sessions: [], error: null }; + return { + status: "idle", + sessions: [], + error: null, + failedOrganizationCount: 0, + }; } const states: CloudRemoteSessionsFetchState[] = []; @@ -100,21 +109,25 @@ export function aggregateWebSessionRoster({ return { status, sessions, - error: - errorCount > 0 - ? `${errorCount} organization${errorCount === 1 ? "" : "s"} could not be refreshed.` - : null, + error: null, + failedOrganizationCount: errorCount, }; } export function useWebSessionRoster(): WebSessionRosterState & { + organizationStatus: "idle" | "loading" | "retrying" | "ready" | "error"; + organizationsKnown: boolean; + hasOrganizations: boolean; refresh: () => Promise; } { + const { t } = useTranslation("navigation"); const auth = useAtomValue(org2CloudAuthAtom); const orgs = useAtomValue(org2CloudOrgsAtom); const orgsLoaded = useAtomValue(org2CloudOrgsLoadedAtom); + const organizationLoadState = useAtomValue(org2CloudOrgsLoadStateAtom); const entries = useAtomValue(org2CloudRemoteSessionsAtom); const setVersionByOrg = useSetAtom(org2CloudRemoteSessionsVersionAtom); + const refetchOrgs = useRefetchOrg2CloudOrgs(); const identityKey = auth ? org2CloudAuthIdentityKey(auth) : null; const userId = auth?.userId ?? null; @@ -129,25 +142,89 @@ export function useWebSessionRoster(): WebSessionRosterState & { [entries, identityKey, orgs, userId] ); - const refresh = useCallback((): Promise => { - if (!identityKey || orgs.length === 0) return Promise.resolve(); + const refresh = useCallback(async (): Promise => { + if (!identityKey) return; + + let refreshOrgs = orgs; + if (!orgsLoaded || organizationLoadState === "error") { + refreshOrgs = await refetchOrgs(); + } + if (refreshOrgs.length === 0) return; + setVersionByOrg((current) => - orgs.reduce( + refreshOrgs.reduce( (next, org) => bumpRemoteSessionsInvalidation(next, org.orgId, { full: true }), current ) ); - return Promise.resolve(); - }, [identityKey, orgs, setVersionByOrg]); + }, [ + identityKey, + organizationLoadState, + orgs, + orgsLoaded, + refetchOrgs, + setVersionByOrg, + ]); return useMemo(() => { if (!identityKey) { - return { status: "idle" as const, sessions: [], error: null, refresh }; + return { + status: "idle" as const, + sessions: [], + error: null, + failedOrganizationCount: 0, + organizationStatus: "idle" as const, + organizationsKnown: false, + hasOrganizations: false, + refresh, + }; } if (!orgsLoaded) { - return { status: "loading" as const, sessions: [], error: null, refresh }; + const terminalFailure = organizationLoadState === "error"; + return { + status: terminalFailure ? ("error" as const) : ("loading" as const), + sessions: [], + error: terminalFailure + ? t("web.sessionsPage.organizationLoadErrorHint") + : organizationLoadState === "retrying" + ? t("web.sessionsPage.organizationRetryingHint") + : null, + failedOrganizationCount: 0, + organizationStatus: + organizationLoadState === "idle" + ? ("loading" as const) + : organizationLoadState, + organizationsKnown: false, + hasOrganizations: false, + refresh, + }; } - return { ...aggregated, refresh }; - }, [aggregated, identityKey, orgsLoaded, refresh]); + + const rosterRefreshError = + aggregated.failedOrganizationCount > 0 + ? t("web.sessionsPage.sessionRefreshErrorHint") + : null; + const organizationRefreshError = + organizationLoadState === "error" + ? t("web.sessionsPage.organizationRefreshErrorHint") + : null; + + return { + ...aggregated, + error: organizationRefreshError ?? rosterRefreshError, + organizationStatus: organizationLoadState, + organizationsKnown: true, + hasOrganizations: orgs.length > 0, + refresh, + }; + }, [ + aggregated, + identityKey, + organizationLoadState, + orgs.length, + orgsLoaded, + refresh, + t, + ]); } diff --git a/src/web/index.tsx b/src/web/index.tsx index a24ae9a9bf..ab1ca95231 100644 --- a/src/web/index.tsx +++ b/src/web/index.tsx @@ -2,7 +2,7 @@ import { createRoot } from "react-dom/client"; import { AppProviders } from "@src/app/root/AppProviders"; import ErrorBoundary from "@src/components/ErrorBoundary"; -import { initToolRegistry } from "@src/engines/SessionCore/rendering/registry/initToolRegistry"; +import { initBundledToolRegistry } from "@src/engines/SessionCore/rendering/registry/initToolRegistry"; import { i18nReady } from "@src/i18n"; import "@src/index.scss"; import { initTheme } from "@src/util/core/init/themeInit"; @@ -10,7 +10,7 @@ import { initTheme } from "@src/util/core/init/themeInit"; import { WebApp } from "./WebApp"; async function mountWebApp(): Promise { - await Promise.all([i18nReady, initTheme(), initToolRegistry()]); + await Promise.all([i18nReady, initTheme(), initBundledToolRegistry()]); const rootElement = document.getElementById("root"); if (!rootElement) throw new Error("ORG2 Web root element is missing"); createRoot(rootElement).render( From 64ebcfac48c2ffc6657eb71851cff4ae6289678c Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Mon, 24 Aug 2026 20:59:35 +0800 Subject: [PATCH 11/15] fix(web): close cloud viewer lifecycle gaps Centralize transcript cache eviction across sign-out and identity changes, evict permission-downgraded sessions without token refresh, and prevent invalidated writes from restoring cache data. Keep background transcript refreshes atomic, track roster loading per organization, and bypass desktop EventStore subscriptions for injected cloud transcripts. Verification: pnpm test; pnpm typecheck; pnpm build:web; changed-file ESLint; pnpm run check:circular. --- src/contexts/workspace/ChatContext.test.ts | 75 +++++++++++ src/contexts/workspace/ChatContext.tsx | 20 ++- src/web/WebApp.tsx | 6 +- ...WebCloudSessionEventCacheLifecycle.test.ts | 90 +++++++++++++ .../WebCloudSessionEventCacheLifecycle.tsx | 40 ++++++ .../features/sessions/WebSessionPage.test.ts | 117 ++++++++++++++-- src/web/features/sessions/WebSessionPage.tsx | 32 ++++- .../__tests__/useWebSessionRoster.test.ts | 49 +++++++ .../sessions/useCloudSessionEvents.test.ts | 127 ++++++++++++++---- .../sessions/useCloudSessionEvents.ts | 29 ++-- .../features/sessions/useWebSessionRoster.ts | 11 +- .../webCloudSessionCachePolicy.test.ts | 7 + .../sessions/webCloudSessionCachePolicy.ts | 13 +- .../sessions/webCloudSessionEventCache.ts | 16 ++- src/web/shell/WebSessionSidebar.test.ts | 9 +- src/web/shell/WebSessionSidebar.tsx | 2 - 16 files changed, 564 insertions(+), 79 deletions(-) create mode 100644 src/contexts/workspace/ChatContext.test.ts create mode 100644 src/web/features/sessions/WebCloudSessionEventCacheLifecycle.test.ts create mode 100644 src/web/features/sessions/WebCloudSessionEventCacheLifecycle.tsx diff --git a/src/contexts/workspace/ChatContext.test.ts b/src/contexts/workspace/ChatContext.test.ts new file mode 100644 index 0000000000..a0fc26dfdf --- /dev/null +++ b/src/contexts/workspace/ChatContext.test.ts @@ -0,0 +1,75 @@ +/** @vitest-environment jsdom */ +import { Provider, createStore } from "jotai"; +import React from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { ChatHistoryOverrideContext } from "@src/engines/ChatPanel/ChatHistoryOverrideContext"; +import { ChatSessionContext } from "@src/engines/ChatPanel/ChatSessionContext"; +import type { SessionEvent } from "@src/engines/SessionCore"; +import { createSmokeRoot } from "@src/test/reactSmokeHarness"; + +import { useChatHistory } from "./ChatContext"; + +const mocks = vi.hoisted(() => ({ + sessionEventsFamily: vi.fn(), + planningMetaFamily: vi.fn(), +})); + +vi.mock("@src/engines/SessionCore/derived/sessionScopedChatEvents", () => ({ + chatEventsForSessionAtomFamily: (...args: unknown[]) => + mocks.sessionEventsFamily(...args), + sessionScopedPlanningMetaAtomFamily: (...args: unknown[]) => + mocks.planningMetaFamily(...args), +})); + +function Probe() { + const history = useChatHistory(); + return React.createElement( + "div", + { + "data-source-session": history.sourceSessionId ?? "", + "data-source-override": String(history.sourceIsOverride), + }, + history.chatHistory.map((event) => event.id).join(",") + ); +} + +describe("useChatHistory override source", () => { + const roots: Array> = []; + + afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => root.unmount())); + vi.clearAllMocks(); + }); + + it("does not subscribe to the desktop session atom family", async () => { + const events = [{ id: "cloud-event" }] as SessionEvent[]; + const root = createSmokeRoot(); + roots.push(root); + await root.render( + React.createElement( + Provider, + { store: createStore() }, + React.createElement( + ChatSessionContext.Provider, + { value: "cloud-session" }, + React.createElement( + ChatHistoryOverrideContext.Provider, + { value: events }, + React.createElement(Probe) + ) + ) + ) + ); + + expect(root.container.textContent).toBe("cloud-event"); + expect( + root.container.firstElementChild?.getAttribute("data-source-session") + ).toBe("cloud-session"); + expect( + root.container.firstElementChild?.getAttribute("data-source-override") + ).toBe("true"); + expect(mocks.sessionEventsFamily).not.toHaveBeenCalled(); + expect(mocks.planningMetaFamily).not.toHaveBeenCalled(); + }); +}); diff --git a/src/contexts/workspace/ChatContext.tsx b/src/contexts/workspace/ChatContext.tsx index aa38b32e4b..061a41f012 100644 --- a/src/contexts/workspace/ChatContext.tsx +++ b/src/contexts/workspace/ChatContext.tsx @@ -186,6 +186,8 @@ export const useShowInteractArea = () => { * Routing rules (prevents subagent-strip race where one cell reads another * cell's events): * + * - If a {@link ChatHistoryOverrideContext} value is present, read that array + * directly without subscribing to desktop/global EventStore atoms. * - If a {@link ChatSessionContext} override is present *and* it differs * from the globally-active session, read from * `chatEventsForSessionAtomFamily(sessionId)` — each family entry owns its @@ -214,6 +216,15 @@ export const useChatHistory = () => { contextSessionId && contextSessionId !== activeSessionId ); const selectorAtom = useMemo(() => { + if (override !== undefined) { + return atom(() => ({ + chatHistory: override, + sourceSessionId: contextSessionId ?? activeSessionId, + // The override array identity participates in selector identity, so a + // replacement still triggers projection without an EventStore version. + sourceVersion: 0, + })); + } if (usePerSession && contextSessionId) { const source = chatEventsForSessionAtomFamily(contextSessionId); const meta = sessionScopedPlanningMetaAtomFamily(contextSessionId); @@ -238,15 +249,10 @@ export const useChatHistory = () => { sourceVersion: snapshot?.version ?? 0, }; }); - }, [activeSessionId, usePerSession, contextSessionId]); + }, [activeSessionId, contextSessionId, override, usePerSession]); const atomSource = useAtomValue(selectorAtom); - // Override takes precedence: lets a parent (e.g. the subagent grid - // cell) inject a cursor-sliced event array so ChatHistory renders only - // events up to the replay timestamp without us touching the shared - // atom family or its `_prev` cache. - const chatHistory = override ?? atomSource.chatHistory; return { - chatHistory, + chatHistory: atomSource.chatHistory, sourceIsOverride: override !== undefined, sourceSessionId: atomSource.sourceSessionId, sourceVersion: atomSource.sourceVersion, diff --git a/src/web/WebApp.tsx b/src/web/WebApp.tsx index 1e7db8530a..15bc82f314 100644 --- a/src/web/WebApp.tsx +++ b/src/web/WebApp.tsx @@ -21,6 +21,7 @@ import { useOrg2CloudRosterReconcile } from "@src/features/Org2Cloud/org2CloudRo import { WebAuthCallbackPage } from "./features/auth/WebAuthCallbackPage"; import { WebLoginPage } from "./features/auth/WebLoginPage"; import { WebCloudRealtimeScope } from "./features/sessions/WebCloudRealtimeScope"; +import { WebCloudSessionEventCacheLifecycle } from "./features/sessions/WebCloudSessionEventCacheLifecycle"; import { WebOrgRemoteSessionSubscriptions } from "./features/sessions/WebOrgRemoteSessionSubscriptions"; import { WebSessionsProvider } from "./features/sessions/WebSessionsContext"; import { WebSessionsPage } from "./features/sessions/WebSessionsPage"; @@ -104,6 +105,9 @@ const router = createBrowserRouter([ export function WebApp() { return ( - + <> + + + ); } diff --git a/src/web/features/sessions/WebCloudSessionEventCacheLifecycle.test.ts b/src/web/features/sessions/WebCloudSessionEventCacheLifecycle.test.ts new file mode 100644 index 0000000000..ba9c9145d2 --- /dev/null +++ b/src/web/features/sessions/WebCloudSessionEventCacheLifecycle.test.ts @@ -0,0 +1,90 @@ +/** @vitest-environment jsdom */ +import { Provider, createStore } from "jotai"; +import React from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + type Org2CloudAuthState, + org2CloudAuthAtom, +} from "@src/features/Org2Cloud/org2CloudAuthAtom"; +import { createSmokeRoot, dispatch } from "@src/test/reactSmokeHarness"; + +import { WebCloudSessionEventCacheLifecycle } from "./WebCloudSessionEventCacheLifecycle"; + +const mocks = vi.hoisted(() => ({ clearCache: vi.fn() })); + +vi.mock("./webCloudSessionEventCache", () => ({ + clearWebCloudSessionEventCache: () => mocks.clearCache(), +})); + +function auth( + userId: string, + overrides: Partial = {} +): Org2CloudAuthState { + return { + kind: "org2_cloud", + supabaseUrl: "https://cloud.example.test", + supabaseAnonKey: "anon", + userId, + accessToken: `access-${userId}`, + refreshToken: `refresh-${userId}`, + expiresAt: 4_102_444_800, + ...overrides, + }; +} + +describe("WebCloudSessionEventCacheLifecycle", () => { + const roots: Array> = []; + + beforeEach(() => mocks.clearCache.mockReset().mockResolvedValue(undefined)); + + afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => root.unmount())); + }); + + it("clears stale snapshots when Web starts signed out", async () => { + const root = createSmokeRoot(); + roots.push(root); + await root.render( + React.createElement( + Provider, + { store: createStore() }, + React.createElement(WebCloudSessionEventCacheLifecycle) + ) + ); + + expect(mocks.clearCache).toHaveBeenCalledOnce(); + }); + + it("preserves refreshes but clears sign-out and identity switches", async () => { + const store = createStore(); + store.set(org2CloudAuthAtom, auth("user-1")); + const root = createSmokeRoot(); + roots.push(root); + await root.render( + React.createElement( + Provider, + { store }, + React.createElement(WebCloudSessionEventCacheLifecycle) + ) + ); + expect(mocks.clearCache).not.toHaveBeenCalled(); + + await dispatch(() => { + store.set( + org2CloudAuthAtom, + auth("user-1", { + accessToken: "rotated-access", + refreshToken: "rotated-refresh", + }) + ); + }); + expect(mocks.clearCache).not.toHaveBeenCalled(); + + await dispatch(() => store.set(org2CloudAuthAtom, auth("user-2"))); + expect(mocks.clearCache).toHaveBeenCalledTimes(1); + + await dispatch(() => store.set(org2CloudAuthAtom, null)); + expect(mocks.clearCache).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/web/features/sessions/WebCloudSessionEventCacheLifecycle.tsx b/src/web/features/sessions/WebCloudSessionEventCacheLifecycle.tsx new file mode 100644 index 0000000000..ce7863704b --- /dev/null +++ b/src/web/features/sessions/WebCloudSessionEventCacheLifecycle.tsx @@ -0,0 +1,40 @@ +import { useAtomValue } from "jotai"; +import { useEffect, useRef } from "react"; + +import { + org2CloudAuthAtom, + org2CloudAuthIdentityKey, +} from "@src/features/Org2Cloud/org2CloudAuthAtom"; + +import { clearWebCloudSessionEventCache } from "./webCloudSessionEventCache"; + +/** + * Owns the persisted Web transcript cache's authentication lifecycle. + * + * Token refreshes keep the same stable identity and preserve the cache. + * Sign-out, rejected refresh, endpoint switch, and account switch clear every + * snapshot from the browser profile. Keeping this above the auth router means + * automatic sign-out cannot unmount the cleanup owner before it observes the + * identity transition. + */ +export function WebCloudSessionEventCacheLifecycle() { + const auth = useAtomValue(org2CloudAuthAtom); + const identityKey = auth ? org2CloudAuthIdentityKey(auth) : null; + const previousIdentityRef = useRef(undefined); + + useEffect(() => { + const previousIdentity = previousIdentityRef.current; + previousIdentityRef.current = identityKey; + + const signedOut = identityKey === null; + const switchedIdentity = + previousIdentity !== undefined && + previousIdentity !== null && + previousIdentity !== identityKey; + if (signedOut || switchedIdentity) { + void clearWebCloudSessionEventCache(); + } + }, [identityKey]); + + return null; +} diff --git a/src/web/features/sessions/WebSessionPage.test.ts b/src/web/features/sessions/WebSessionPage.test.ts index dcfb6c7ba6..d0d53d3f66 100644 --- a/src/web/features/sessions/WebSessionPage.test.ts +++ b/src/web/features/sessions/WebSessionPage.test.ts @@ -17,6 +17,21 @@ const testState = vi.hoisted(() => ({ }, cloudEvents: [] as Array<{ id: string }>, lastChatPanelEvents: null as readonly { id: string }[] | null, + refreshRoster: vi.fn(), + roster: { + status: "loaded" as "idle" | "loading" | "loaded" | "error", + sessions: [] as Array>, + sessionFetchStateByOrg: {} as Record< + string, + "idle" | "loading" | "ready" | "error" + >, + }, + placeholderProps: null as null | { + variant: string; + title?: string; + subtitle?: string; + onRetry?: () => void; + }, })); vi.mock("react-i18next", () => ({ @@ -28,6 +43,9 @@ vi.mock("react-i18next", () => ({ "web.sessionPage.notFound": "Session not found", "web.sessionPage.notFoundHint": "Missing", "web.sessionPage.loading": "Loading session…", + "web.sessionsPage.loadError": "Sessions could not be loaded", + "web.sessionsPage.sessionRefreshErrorHint": + "Some organization sessions could not be refreshed.", }; return labels[key] ?? defaultValue ?? key; }, @@ -106,7 +124,27 @@ vi.mock( ); vi.mock("@src/modules/shared/layouts/blocks", () => ({ - Placeholder: () => null, + Placeholder: (props: { + variant: string; + title?: string; + subtitle?: string; + onRetry?: () => void; + }) => { + testState.placeholderProps = props; + return React.createElement( + "div", + { "data-placeholder-variant": props.variant }, + props.title, + props.subtitle, + props.onRetry + ? React.createElement( + "button", + { "data-placeholder-retry": true, onClick: props.onRetry }, + "Retry" + ) + : null + ); + }, })); vi.mock("./WebSessionCommentsHeaderExtras", () => ({ @@ -150,18 +188,8 @@ vi.mock("@src/engines/ChatPanel/components/SessionViewSwitcher", () => ({ vi.mock("./WebSessionsContext", () => ({ useWebSessions: () => ({ - status: "success", - sessions: [ - { - id: "session-1", - orgId: "org-1", - orgName: "ORG2", - title: "Session", - sourceSessionId: "source-session-1", - status: "stopped", - agentDisplayName: "Codex", - }, - ], + ...testState.roster, + refresh: testState.refreshRoster, }), })); @@ -188,6 +216,21 @@ describe("WebSessionPage pane composition", () => { }; testState.cloudEvents = []; testState.lastChatPanelEvents = null; + testState.refreshRoster.mockReset().mockResolvedValue(undefined); + testState.roster.status = "loaded"; + testState.roster.sessions = [ + { + id: "session-1", + orgId: "org-1", + orgName: "ORG2", + title: "Session", + sourceSessionId: "source-session-1", + status: "stopped", + agentDisplayName: "Codex", + }, + ]; + testState.roster.sessionFetchStateByOrg = { "org-1": "ready" }; + testState.placeholderProps = null; }); afterEach(async () => { @@ -273,4 +316,52 @@ describe("WebSessionPage pane composition", () => { "event-3", ]); }); + + it("keeps a target-org deep link loading while another org has rows", async () => { + testState.roster.sessions = [ + { + id: "other-session", + orgId: "org-2", + sourceSessionId: "other-source", + }, + ]; + testState.roster.sessionFetchStateByOrg = { + "org-1": "loading", + "org-2": "ready", + }; + const root = createSmokeRoot(); + roots.push(root); + await root.render(React.createElement(WebSessionPage)); + + expect(testState.placeholderProps?.variant).toBe("loading"); + expect(root.container.textContent).not.toContain("Session not found"); + }); + + it("shows retry when the target organization session request fails", async () => { + testState.roster.sessions = [ + { + id: "other-session", + orgId: "org-2", + sourceSessionId: "other-source", + }, + ]; + testState.roster.sessionFetchStateByOrg = { + "org-1": "error", + "org-2": "ready", + }; + const root = createSmokeRoot(); + roots.push(root); + await root.render(React.createElement(WebSessionPage)); + + expect(testState.placeholderProps?.variant).toBe("error"); + expect(root.container.textContent).toContain( + "Sessions could not be loaded" + ); + await dispatch(() => + root.container + .querySelector("[data-placeholder-retry]") + ?.click() + ); + expect(testState.refreshRoster).toHaveBeenCalledOnce(); + }); }); diff --git a/src/web/features/sessions/WebSessionPage.tsx b/src/web/features/sessions/WebSessionPage.tsx index b3f0ecd9fc..47e4d50848 100644 --- a/src/web/features/sessions/WebSessionPage.tsx +++ b/src/web/features/sessions/WebSessionPage.tsx @@ -168,11 +168,19 @@ export function WebSessionPage({ const params = useParams<{ orgId: string; sessionId: string }>(); const viewportWidth = useViewportWidth(); const [mobilePane, setMobilePane] = useState("chat"); - const { sessions, status: rosterStatus } = useWebSessions(); + const { + sessions, + status: rosterStatus, + sessionFetchStateByOrg, + refresh: refreshRoster, + } = useWebSessions(); const session = sessions.find((candidate) => matchesWebSessionPath(candidate, params.orgId, params.sessionId) ) ?? null; + const targetSessionFetchState = params.orgId + ? sessionFetchStateByOrg[params.orgId] + : undefined; const cloudEvents = useCloudSessionEvents(session); const { events, @@ -288,21 +296,33 @@ export function WebSessionPage({ ); if (!session) { + const targetFailed = + rosterStatus === "error" || targetSessionFetchState === "error"; + const targetPending = + !targetFailed && + (rosterStatus === "loading" || + targetSessionFetchState === "idle" || + targetSessionFetchState === "loading"); return (
void refreshRoster() : undefined} />
); diff --git a/src/web/features/sessions/__tests__/useWebSessionRoster.test.ts b/src/web/features/sessions/__tests__/useWebSessionRoster.test.ts index 113d1b362e..2d2c562389 100644 --- a/src/web/features/sessions/__tests__/useWebSessionRoster.test.ts +++ b/src/web/features/sessions/__tests__/useWebSessionRoster.test.ts @@ -38,6 +38,7 @@ describe("aggregateWebSessionRoster", () => { expect(result.sessions).toHaveLength(1); expect(result.sessions[0]?.orgName).toBe("Org One"); expect(result.sessions[0]?.writable).toBe(true); + expect(result.sessionFetchStateByOrg).toEqual({ "org-1": "ready" }); }); it("reports loading while every org entry is still idle", () => { @@ -57,5 +58,53 @@ describe("aggregateWebSessionRoster", () => { expect(result.status).toBe("loading"); expect(result.sessions).toEqual([]); + expect(result.sessionFetchStateByOrg).toEqual({ "org-1": "idle" }); + }); + + it("keeps each organization fetch state when another org already has rows", () => { + const result = aggregateWebSessionRoster({ + orgs: [ + { orgId: "org-loading", name: "Loading", role: "member" }, + { orgId: "org-ready", name: "Ready", role: "owner" }, + ], + entries: { + "org-loading": { + identityKey: "identity-1", + rows: [], + state: "loading", + fetchedAt: 0, + }, + "org-ready": { + identityKey: "identity-1", + rows: [ + { + id: "row-ready", + orgId: "org-ready", + ownerMemberId: "member-1", + sourceSessionId: "session-ready", + ownerUserId: "user-1", + ownerDisplayName: "Me", + ownerIdentityKind: "human", + title: "Ready session", + lastActivityAt: "2026-08-20T08:00:00.000Z", + eventsEpoch: 1, + eventsFrozenSeq: 0, + eventsCount: 0, + eventsTailHash: "", + }, + ], + state: "ready", + fetchedAt: 1, + }, + }, + identityKey: "identity-1", + userId: "user-1", + }); + + expect(result.status).toBe("loaded"); + expect(result.sessionFetchStateByOrg).toEqual({ + "org-loading": "loading", + "org-ready": "ready", + }); }); }); diff --git a/src/web/features/sessions/useCloudSessionEvents.test.ts b/src/web/features/sessions/useCloudSessionEvents.test.ts index 20dfc55ef9..93ae072f16 100644 --- a/src/web/features/sessions/useCloudSessionEvents.test.ts +++ b/src/web/features/sessions/useCloudSessionEvents.test.ts @@ -1,8 +1,10 @@ /** @vitest-environment jsdom */ +import { Provider, createStore } from "jotai"; import React from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { SessionEvent } from "@src/engines/SessionCore"; +import { org2CloudAuthAtom } from "@src/features/Org2Cloud/org2CloudAuthAtom"; import type { SessionEventSegmentsSnapshot } from "@src/features/TeamCollaboration/sync/CollabSyncBackend"; import { createSmokeRoot, dispatch } from "@src/test/reactSmokeHarness"; @@ -55,6 +57,7 @@ vi.mock("./webCloudSessionEventCache", () => ({ vi.mock("./webCloudSessionCachePolicy", () => ({ buildWebCloudSessionCacheKey: () => "cache-key", + buildWebCloudSessionCacheKeyForIdentity: () => "cache-key", canReadWebCloudSessionEvents: (value: WebSessionListItem) => mocks.canRead(value), shouldFetchWebCloudSessionEvents: (...args: unknown[]) => @@ -130,10 +133,35 @@ function Probe({ value }: { value: WebSessionListItem }) { ); } +const AUTH = { + kind: "org2_cloud" as const, + supabaseUrl: "https://cloud.example.test", + supabaseAnonKey: "anon", + userId: "user-1", + accessToken: "access", + refreshToken: "refresh", + expiresAt: 4_102_444_800, +}; + describe("useCloudSessionEvents streaming", () => { const roots: Array> = []; + let store: ReturnType; + + const renderProbe = ( + root: ReturnType, + value: WebSessionListItem + ) => + root.render( + React.createElement( + Provider, + { store }, + React.createElement(Probe, { value }) + ) + ); beforeEach(() => { + store = createStore(); + store.set(org2CloudAuthAtom, AUTH); mocks.getFreshSession.mockReset().mockResolvedValue({ accessToken: "token", }); @@ -197,9 +225,7 @@ describe("useCloudSessionEvents streaming", () => { const root = createSmokeRoot(); roots.push(root); - await root.render( - React.createElement(Probe, { value: session("session-1") }) - ); + await renderProbe(root, session("session-1")); const probe = root.container.firstElementChild; expect(probe?.getAttribute("data-status")).toBe("loading"); @@ -243,12 +269,8 @@ describe("useCloudSessionEvents streaming", () => { 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) }) - ); + await renderProbe(root, session("session-old", 1)); + await renderProbe(root, session("session-new", 1)); expect(root.container.querySelector("[data-events]")?.textContent).toBe( "current" @@ -287,9 +309,7 @@ describe("useCloudSessionEvents streaming", () => { const root = createSmokeRoot(); roots.push(root); - await root.render( - React.createElement(Probe, { value: session("session-retry", 2) }) - ); + await renderProbe(root, session("session-retry", 2)); const probe = root.container.firstElementChild; expect(probe?.getAttribute("data-status")).toBe("error"); @@ -323,27 +343,25 @@ describe("useCloudSessionEvents streaming", () => { const root = createSmokeRoot(); roots.push(root); const readable = session("session-private", 1); - await root.render(React.createElement(Probe, { value: readable })); + await renderProbe(root, 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, - }, - }) - ); + mocks.getFreshSession.mockClear().mockResolvedValue(null); + await renderProbe(root, { + ...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.getFreshSession).not.toHaveBeenCalled(); expect(mocks.deleteCache).toHaveBeenCalledWith("cache-key"); }); @@ -377,11 +395,10 @@ describe("useCloudSessionEvents streaming", () => { ); const root = createSmokeRoot(); roots.push(root); - await root.render( - React.createElement(Probe, { - value: { ...session("session-running", 1), status: "running" }, - }) - ); + await renderProbe(root, { + ...session("session-running", 1), + status: "running", + }); expect(mocks.stream).toHaveBeenCalledOnce(); expect(mocks.poll).not.toBeNull(); @@ -394,4 +411,58 @@ describe("useCloudSessionEvents streaming", () => { "polled" ); }); + + it("keeps the last complete transcript when a background poll loses its tail", async () => { + mocks.stream + .mockImplementationOnce( + async ( + _input, + onPage: (value: SessionEventSegmentsSnapshot) => Promise + ) => { + const summary = { frozenSeq: 1, tailHash: "old-tail" }; + await onPage(page(1, ["old-frozen"], 2, false, summary)); + await onPage(page(2, ["old-tail"], 2, true, summary)); + return { + epoch: 1, + frozenSeq: 1, + tailHash: "old-tail", + count: 2, + }; + } + ) + .mockImplementationOnce( + async ( + _input, + onPage: (value: SessionEventSegmentsSnapshot) => Promise + ) => { + await onPage( + page(2, ["new-frozen"], 3, false, { + frozenSeq: 2, + tailHash: "new-tail", + }) + ); + throw new Error("tail page interrupted"); + } + ); + const root = createSmokeRoot(); + roots.push(root); + await renderProbe(root, { + ...session("session-running-failure", 2), + status: "running", + }); + expect(root.container.querySelector("[data-events]")?.textContent).toBe( + "old-frozen,old-tail" + ); + + await React.act(async () => { + await mocks.poll?.(); + }); + + expect(root.container.firstElementChild?.getAttribute("data-status")).toBe( + "error" + ); + expect(root.container.querySelector("[data-events]")?.textContent).toBe( + "old-frozen,old-tail" + ); + }); }); diff --git a/src/web/features/sessions/useCloudSessionEvents.ts b/src/web/features/sessions/useCloudSessionEvents.ts index c3a3a65240..b4a140b0f8 100644 --- a/src/web/features/sessions/useCloudSessionEvents.ts +++ b/src/web/features/sessions/useCloudSessionEvents.ts @@ -1,7 +1,12 @@ +import { useAtomValue } from "jotai"; import { useCallback, useEffect, useRef, useState } from "react"; import type { SessionEvent } from "@src/engines/SessionCore"; import { mergeCloudSessionEventSnapshot } from "@src/features/Org2Cloud/cloudSessionEventSegmentMerge"; +import { + org2CloudAuthAtom, + org2CloudAuthIdentityKey, +} from "@src/features/Org2Cloud/org2CloudAuthAtom"; import { buildCloudSessionFetchClient } from "@src/features/Org2Cloud/org2CloudBackendAdapter"; import type { SessionEventSegmentsSnapshot, @@ -14,6 +19,7 @@ import type { CloudSessionEventSnapshot } from "./cloudSessionSegments"; import type { WebSessionListItem } from "./useWebSessionRoster"; import { buildWebCloudSessionCacheKey, + buildWebCloudSessionCacheKeyForIdentity, canReadWebCloudSessionEvents, shouldFetchWebCloudSessionEvents, } from "./webCloudSessionCachePolicy"; @@ -94,6 +100,7 @@ function frozenEventCount(snapshot: CloudSessionEventSnapshot | null): number { } export function useCloudSessionEvents(session: WebSessionListItem | null) { + const auth = useAtomValue(org2CloudAuthAtom); const getFreshSession = useFreshWebCloudSession(); const [state, setState] = useState({ sessionKey: null, @@ -107,6 +114,7 @@ export function useCloudSessionEvents(session: WebSessionListItem | null) { const generationRef = useRef(0); const abortRef = useRef(null); const sessionKey = session ? `${session.orgId}:${session.id}` : null; + const cacheIdentityKey = auth ? org2CloudAuthIdentityKey(auth) : null; const canReadEvents = session ? canReadWebCloudSessionEvents(session) : false; const refresh = useCallback( @@ -262,13 +270,17 @@ export function useCloudSessionEvents(session: WebSessionListItem | null) { 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) { + // Foreground loads intentionally reveal recoverable partial + // content. A background revalidation with an existing complete + // snapshot must remain atomic so a failed tail page cannot make + // recent messages disappear. + if (revealProgress || displayedSnapshot === null) { + snapshotRef.current = pageSnapshot; setState({ sessionKey, status: "loading", @@ -393,14 +405,11 @@ export function useCloudSessionEvents(session: WebSessionListItem | null) { error: null, progress: null, }); - const generation = generationRef.current; - void (async () => { - const fresh = await getFreshSession(); - if (!fresh || generation !== generationRef.current) return; - await deleteWebCloudSessionEventCache( - buildWebCloudSessionCacheKey(fresh, session) + if (cacheIdentityKey) { + void deleteWebCloudSessionEventCache( + buildWebCloudSessionCacheKeyForIdentity(cacheIdentityKey, session) ); - })(); + } return; } setState({ @@ -419,7 +428,7 @@ export function useCloudSessionEvents(session: WebSessionListItem | null) { generationRef.current += 1; abortRef.current?.abort(); }; - }, [canReadEvents, getFreshSession, refresh, session, sessionKey]); + }, [cacheIdentityKey, canReadEvents, refresh, session, sessionKey]); useEffect(() => { if (!session || !canReadEvents || session.status !== "running") { diff --git a/src/web/features/sessions/useWebSessionRoster.ts b/src/web/features/sessions/useWebSessionRoster.ts index 13403dd36d..11e2a5eeca 100644 --- a/src/web/features/sessions/useWebSessionRoster.ts +++ b/src/web/features/sessions/useWebSessionRoster.ts @@ -31,6 +31,7 @@ export interface WebSessionListItem extends RemoteTeammateSessionMetadata { export interface WebSessionRosterState { status: "idle" | "loading" | "loaded" | "error"; sessions: WebSessionListItem[]; + sessionFetchStateByOrg: Record; error: string | null; failedOrganizationCount: number; } @@ -69,18 +70,23 @@ export function aggregateWebSessionRoster({ return { status: "idle", sessions: [], + sessionFetchStateByOrg: {}, error: null, failedOrganizationCount: 0, }; } const states: CloudRemoteSessionsFetchState[] = []; + const sessionFetchStateByOrg: Record = + {}; const sessions = orgs.flatMap((org) => { const entry = remoteSessionsEntryForIdentity( entries[org.orgId], identityKey ); - states.push(entry?.state ?? "idle"); + const state = entry?.state ?? "idle"; + states.push(state); + sessionFetchStateByOrg[org.orgId] = state; return toSessionRows(org, userId, entry?.rows ?? []); }); @@ -109,6 +115,7 @@ export function aggregateWebSessionRoster({ return { status, sessions, + sessionFetchStateByOrg, error: null, failedOrganizationCount: errorCount, }; @@ -172,6 +179,7 @@ export function useWebSessionRoster(): WebSessionRosterState & { return { status: "idle" as const, sessions: [], + sessionFetchStateByOrg: {}, error: null, failedOrganizationCount: 0, organizationStatus: "idle" as const, @@ -185,6 +193,7 @@ export function useWebSessionRoster(): WebSessionRosterState & { return { status: terminalFailure ? ("error" as const) : ("loading" as const), sessions: [], + sessionFetchStateByOrg: {}, error: terminalFailure ? t("web.sessionsPage.organizationLoadErrorHint") : organizationLoadState === "retrying" diff --git a/src/web/features/sessions/webCloudSessionCachePolicy.test.ts b/src/web/features/sessions/webCloudSessionCachePolicy.test.ts index ad7c0c0136..84e17b0512 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, + buildWebCloudSessionCacheKeyForIdentity, canReadWebCloudSessionEvents, isWebCloudSessionCacheFresh, shouldFetchWebCloudSessionEvents, @@ -46,6 +47,12 @@ describe("buildWebCloudSessionCacheKey", () => { expect(buildWebCloudSessionCacheKey(auth, session())).toBe( "https://cloud.example.com|user-1|org-1|session-row-1" ); + expect( + buildWebCloudSessionCacheKeyForIdentity( + "https://cloud.example.com|user-1", + session() + ) + ).toBe("https://cloud.example.com|user-1|org-1|session-row-1"); }); }); diff --git a/src/web/features/sessions/webCloudSessionCachePolicy.ts b/src/web/features/sessions/webCloudSessionCachePolicy.ts index c4aa04a941..4bba77cd60 100644 --- a/src/web/features/sessions/webCloudSessionCachePolicy.ts +++ b/src/web/features/sessions/webCloudSessionCachePolicy.ts @@ -8,7 +8,18 @@ export function buildWebCloudSessionCacheKey( auth: Pick, session: Pick ): string { - return `${org2CloudAuthIdentityKey(auth)}|${session.orgId}|${session.id}`; + return buildWebCloudSessionCacheKeyForIdentity( + org2CloudAuthIdentityKey(auth), + session + ); +} + +/** Build an eviction key from an already-captured auth identity. */ +export function buildWebCloudSessionCacheKeyForIdentity( + identityKey: string, + session: Pick +): string { + return `${identityKey}|${session.orgId}|${session.id}`; } /** diff --git a/src/web/features/sessions/webCloudSessionEventCache.ts b/src/web/features/sessions/webCloudSessionEventCache.ts index 15fed7be28..50c07bf784 100644 --- a/src/web/features/sessions/webCloudSessionEventCache.ts +++ b/src/web/features/sessions/webCloudSessionEventCache.ts @@ -5,6 +5,10 @@ const STORE_NAME = "snapshots"; const STORED_AT_INDEX = "storedAt"; const DB_VERSION = 2; +// Delete/clear is an authorization boundary. A write that was waiting for its +// IndexedDB connection must not recreate a record after that boundary passes. +let cacheMutationGeneration = 0; + export const WEB_CLOUD_SESSION_CACHE_MAX_ENTRIES = 12; export const WEB_CLOUD_SESSION_CACHE_MAX_EVENTS = 10_000; export const WEB_CLOUD_SESSION_CACHE_TTL_MS = 24 * 60 * 60 * 1_000; @@ -84,9 +88,14 @@ export function webCloudSessionCacheOverflowCount(entryCount: number): number { async function writeBoundedRecord( cacheKey: string, - record: WebCloudSessionEventCacheRecord + record: WebCloudSessionEventCacheRecord, + expectedGeneration: number ): Promise { const database = await openDatabase(); + if (expectedGeneration !== cacheMutationGeneration) { + database.close(); + return; + } return new Promise((resolve, reject) => { const transaction = database.transaction(STORE_NAME, "readwrite"); const store = transaction.objectStore(STORE_NAME); @@ -148,6 +157,7 @@ export async function writeWebCloudSessionEventCache( cacheKey: string, snapshot: CloudSessionEventSnapshot ): Promise { + const expectedGeneration = cacheMutationGeneration; try { if (snapshot.events.length > WEB_CLOUD_SESSION_CACHE_MAX_EVENTS) { await deleteWebCloudSessionEventCache(cacheKey); @@ -157,7 +167,7 @@ export async function writeWebCloudSessionEventCache( snapshot, storedAt: Date.now(), }; - await writeBoundedRecord(cacheKey, record); + await writeBoundedRecord(cacheKey, record, expectedGeneration); } catch { // Cache is best-effort; network/manual refresh remains authoritative. } @@ -166,6 +176,7 @@ export async function writeWebCloudSessionEventCache( export async function deleteWebCloudSessionEventCache( cacheKey: string ): Promise { + cacheMutationGeneration += 1; try { await runTransaction("readwrite", (store) => store.delete(cacheKey)); } catch { @@ -175,6 +186,7 @@ export async function deleteWebCloudSessionEventCache( /** Remove every transcript snapshot owned by the current browser profile. */ export async function clearWebCloudSessionEventCache(): Promise { + cacheMutationGeneration += 1; try { await runTransaction("readwrite", (store) => store.clear()); } catch { diff --git a/src/web/shell/WebSessionSidebar.test.ts b/src/web/shell/WebSessionSidebar.test.ts index fbca308eae..72ad7723c7 100644 --- a/src/web/shell/WebSessionSidebar.test.ts +++ b/src/web/shell/WebSessionSidebar.test.ts @@ -23,7 +23,6 @@ const testState = vi.hoisted(() => ({ navigate: vi.fn(), refresh: vi.fn(), setAuth: vi.fn(), - clearCache: vi.fn(), sidebarProps: null as Record | null, })); @@ -70,10 +69,6 @@ vi.mock("react-router-dom", () => ({ useNavigate: () => testState.navigate, })); -vi.mock("../features/sessions/webCloudSessionEventCache", () => ({ - clearWebCloudSessionEventCache: () => testState.clearCache(), -})); - vi.mock("@src/components/Button", () => ({ default: ({ children, @@ -183,7 +178,6 @@ describe("WebSessionSidebar", () => { testState.navigate.mockReset(); testState.refresh.mockReset(); testState.setAuth.mockReset(); - testState.clearCache.mockReset().mockResolvedValue(undefined); testState.sidebarProps = null; }); @@ -265,7 +259,7 @@ describe("WebSessionSidebar", () => { ).toBe(true); }); - it("clears auth and persisted transcripts on sign out", async () => { + it("clears auth on sign out", async () => { const root = createSmokeRoot(); roots.push(root); await root.render(React.createElement(WebSessionSidebar)); @@ -277,6 +271,5 @@ describe("WebSessionSidebar", () => { ); expect(testState.setAuth).toHaveBeenCalledWith(null); - expect(testState.clearCache).toHaveBeenCalledOnce(); }); }); diff --git a/src/web/shell/WebSessionSidebar.tsx b/src/web/shell/WebSessionSidebar.tsx index dcdcca5ebf..5b798344db 100644 --- a/src/web/shell/WebSessionSidebar.tsx +++ b/src/web/shell/WebSessionSidebar.tsx @@ -17,7 +17,6 @@ import { 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, @@ -102,7 +101,6 @@ export function WebSessionSidebar({ onNavigate }: { onNavigate?: () => void }) { const handleSignOut = useCallback(() => { setAuth(null); - void clearWebCloudSessionEventCache(); }, [setAuth]); const sidebarOrgSelector = From 34a09f9a7610a370dde1f1f04583ea88b8708e49 Mon Sep 17 00:00:00 2001 From: Harry19081 <20519290+Harry19081@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:20:15 +0800 Subject: [PATCH 12/15] fix(web): repair develop-merge fallout in i18n and relocated imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Aug-26 develop merge resurrected the duplicate sibling creator.worktreeSource object this branch had already fixed once. JSON parsing is last-wins, so the stale twin shadowed develop's object — which is where the five keys CI reported missing (and three newer values) lived. Deleting the twin restores them: a flatten-compare against develop's resource shows zero lost keys and zero value drift, with this branch's 35 added keys intact. All twelve translated locales were scanned for the same duplication; none has it. Merging today's develop surfaced four casualties of #1000's component relocation, all mechanical: Placeholder imports in four files and ErrorBoundary in one re-pointed to their new homes, three test mocks re-pointed with them, and one grandfathered i18n-baseline entry updated because the exemption is keyed by file path and #1000 moved SearchSortBar.tsx. Verified: check:i18n:calls and check:i18n:contracts pass, tsc --noEmit is clean, the 18-file web suite passes 65/65, and eslint --max-warnings=0 passes on every changed file. --- scripts/quality/check-i18n-callsite-keys.mjs | 3 +- .../RemoteSessionWorkspaceSurface.tsx | 2 +- src/i18n/locales/en/sessions.json | 36 ------------------- .../features/auth/WebAuthCallbackPage.test.ts | 2 +- src/web/features/auth/WebAuthCallbackPage.tsx | 2 +- .../features/sessions/WebSessionPage.test.ts | 2 +- src/web/features/sessions/WebSessionPage.tsx | 2 +- src/web/features/sessions/WebSessionsPage.tsx | 2 +- src/web/index.tsx | 2 +- 9 files changed, 9 insertions(+), 44 deletions(-) diff --git a/scripts/quality/check-i18n-callsite-keys.mjs b/scripts/quality/check-i18n-callsite-keys.mjs index 0718201bc1..e0232c0f9e 100644 --- a/scripts/quality/check-i18n-callsite-keys.mjs +++ b/scripts/quality/check-i18n-callsite-keys.mjs @@ -155,7 +155,8 @@ const DEVELOP_BASELINE_MISSING_CALLS = new Set([ "common:errors.messages.forbidden:src/modules/ProjectManager/ProjectManagerLayout/components/ProjectWorkItemsTabContent.tsx", "common:errors.noLocalPath:src/modules/shared/launchpad/components/RepoActionButtons.tsx", "common:labels.filter:src/components/SettingsTable/index.tsx", - "common:labels.filter:src/modules/shared/layouts/blocks/SearchSortBar.tsx", + // #1000 relocated SearchSortBar out of modules/shared/layouts/blocks. + "common:labels.filter:src/components/SettingsTable/SearchSortBar.tsx", "common:labels.selectDate:src/components/DatePicker/index.tsx", "common:placeholders.noData:src/modules/WorkStation/CodeEditor/Panels/EditorMainPane/content/FilePreviewContent/DbPreviewView/index.tsx", "common:pullRequests.status.draft:src/modules/MainApp/WorkManagement/GitHubWorkItemsView.tsx", diff --git a/src/engines/Simulator/components/RemoteSessionWorkspaceSurface.tsx b/src/engines/Simulator/components/RemoteSessionWorkspaceSurface.tsx index 1a7426881f..1509d026ce 100644 --- a/src/engines/Simulator/components/RemoteSessionWorkspaceSurface.tsx +++ b/src/engines/Simulator/components/RemoteSessionWorkspaceSurface.tsx @@ -3,6 +3,7 @@ import React, { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import Button from "@src/components/Button"; +import { Placeholder } from "@src/components/Placeholder"; import ProgressBar from "@src/components/ProgressBar"; import { WORK_STATION_PRIMARY_SIDEBAR } from "@src/config/workStationPrimarySidebar"; import type { @@ -16,7 +17,6 @@ import { WorkStationShell, buildPrimarySidebarConfig, } from "@src/modules/WorkStation/shared"; -import { Placeholder } from "@src/modules/shared/layouts/blocks"; import { useRemoteSessionReplay } from "./useRemoteSessionReplay"; diff --git a/src/i18n/locales/en/sessions.json b/src/i18n/locales/en/sessions.json index d8e766810e..ac0982f5bb 100644 --- a/src/i18n/locales/en/sessions.json +++ b/src/i18n/locales/en/sessions.json @@ -2457,42 +2457,6 @@ "notEnoughRunners": "Set up at least two runnable runners" } }, - "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", - "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.", - "refreshGithub": "Refresh GitHub list", - "githubSearch": "Search GitHub PRs and issues", - "githubSearchAria": "Search GitHub PRs and issues", - "githubError": "GitHub items could not be loaded.", - "githubEmpty": "No open GitHub PRs or issues.", - "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", - "smartHint": "Type a name, PR number, branch, or paste a PR/MR URL.", - "tabs": { - "smart": "Smart", - "github": "GitHub", - "branch": "Branch", - "name": "Name" - }, - "branchCustomRefHint": "Tag, commit, or any git ref", - "branchUseAsRef": "Use “{{value}}” as ref", - "title": "Create worktree", - "resolving": "Resolving PR...", - "confirm": "Use worktree" - }, "mode": "Editor mode" }, "planner": { diff --git a/src/web/features/auth/WebAuthCallbackPage.test.ts b/src/web/features/auth/WebAuthCallbackPage.test.ts index 534d242bef..6ad636869a 100644 --- a/src/web/features/auth/WebAuthCallbackPage.test.ts +++ b/src/web/features/auth/WebAuthCallbackPage.test.ts @@ -29,7 +29,7 @@ vi.mock("@src/components/Button", () => ({ React.createElement("button", null, children), })); -vi.mock("@src/modules/shared/layouts/blocks", () => ({ +vi.mock("@src/components/Placeholder", () => ({ Placeholder: ({ title }: { title: string }) => React.createElement("div", { "data-error": true }, title), })); diff --git a/src/web/features/auth/WebAuthCallbackPage.tsx b/src/web/features/auth/WebAuthCallbackPage.tsx index a0fd336369..1ae0b6eece 100644 --- a/src/web/features/auth/WebAuthCallbackPage.tsx +++ b/src/web/features/auth/WebAuthCallbackPage.tsx @@ -4,13 +4,13 @@ import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import Button from "@src/components/Button"; +import { Placeholder } from "@src/components/Placeholder"; import { decodeJwtSub, parseAuthCallbackFragment, } from "@src/features/Org2Cloud/authCallback"; import { getCloudEndpoint } from "@src/features/Org2Cloud/config"; import { org2CloudAuthAtom } from "@src/features/Org2Cloud/org2CloudAuthAtom"; -import { Placeholder } from "@src/modules/shared/layouts/blocks"; import { consumeWebAuthCallbackState, diff --git a/src/web/features/sessions/WebSessionPage.test.ts b/src/web/features/sessions/WebSessionPage.test.ts index d0d53d3f66..3cfdaa0b4c 100644 --- a/src/web/features/sessions/WebSessionPage.test.ts +++ b/src/web/features/sessions/WebSessionPage.test.ts @@ -123,7 +123,7 @@ vi.mock( }) ); -vi.mock("@src/modules/shared/layouts/blocks", () => ({ +vi.mock("@src/components/Placeholder", () => ({ Placeholder: (props: { variant: string; title?: string; diff --git a/src/web/features/sessions/WebSessionPage.tsx b/src/web/features/sessions/WebSessionPage.tsx index 47e4d50848..f034e1e84a 100644 --- a/src/web/features/sessions/WebSessionPage.tsx +++ b/src/web/features/sessions/WebSessionPage.tsx @@ -11,6 +11,7 @@ import React, { import { useTranslation } from "react-i18next"; import { useParams } from "react-router-dom"; +import { Placeholder } from "@src/components/Placeholder"; import TabPill from "@src/components/TabPill"; import type { SessionTranscriptRuntime } from "@src/engines/ChatPanel/SessionTranscriptRuntimeContext"; import { RemoteSessionChatPanelSurface } from "@src/engines/ChatPanel/components/RemoteSessionChatPanelSurface"; @@ -24,7 +25,6 @@ import { resolveReplayEventIndex } from "@src/engines/SessionCore/replay/resolve import { useReplayController } from "@src/engines/SessionCore/replay/useReplayController"; import { RemoteSessionReplayControls } from "@src/engines/Simulator/components/RemoteSessionReplayControls"; import { RemoteSessionWorkstationSurface } from "@src/engines/Simulator/components/RemoteSessionWorkstationSurface"; -import { Placeholder } from "@src/modules/shared/layouts/blocks"; import { WebSessionAlternateSurface } from "./WebSessionAlternateSurface"; import WebSessionCommentsHeaderExtras from "./WebSessionCommentsHeaderExtras"; diff --git a/src/web/features/sessions/WebSessionsPage.tsx b/src/web/features/sessions/WebSessionsPage.tsx index f7ac3cc892..5d3c4e2bbe 100644 --- a/src/web/features/sessions/WebSessionsPage.tsx +++ b/src/web/features/sessions/WebSessionsPage.tsx @@ -2,7 +2,7 @@ import { PanelsTopLeft } from "lucide-react"; import React from "react"; import { useTranslation } from "react-i18next"; -import { Placeholder } from "@src/modules/shared/layouts/blocks"; +import { Placeholder } from "@src/components/Placeholder"; import { WebOrganizationOnboarding } from "./WebOrganizationOnboarding"; import { useWebSessions } from "./WebSessionsContext"; diff --git a/src/web/index.tsx b/src/web/index.tsx index ab1ca95231..a8db65fd44 100644 --- a/src/web/index.tsx +++ b/src/web/index.tsx @@ -1,7 +1,7 @@ import { createRoot } from "react-dom/client"; import { AppProviders } from "@src/app/root/AppProviders"; -import ErrorBoundary from "@src/components/ErrorBoundary"; +import ErrorBoundary from "@src/app/root/components/ErrorBoundary"; import { initBundledToolRegistry } from "@src/engines/SessionCore/rendering/registry/initToolRegistry"; import { i18nReady } from "@src/i18n"; import "@src/index.scss"; From 65554d613983c00c49754629319360e99e3efd9e Mon Sep 17 00:00:00 2001 From: Harry19081 <20519290+Harry19081@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:20:27 +0800 Subject: [PATCH 13/15] feat(web): add Vercel deploy config for the browser bundle The web viewer could be built but had nowhere to go: no deploy target existed in this repo, and the build output was not even gitignored, so one local build:web dirtied the tree. vercel.json makes the repo connectable as a static Vercel project: pnpm build:web into build-web/, SPA fallback to index.html (the router is createBrowserRouter on root paths), immutable caching for content-hashed js/css and the images/fonts/videos asset dirs, no-cache on index.html, and an ignoreCommand so pushes touching no frontend path skip the deploy. The build command carries the same 6 GB heap the CI step already needs. Connecting the Vercel project and DNS is dashboard work, deliberately not in this diff. The auth loop additionally needs an exact-match origin allowlist in the cloud repo's login callback before a deployed viewer can complete a sign-in; until that lands and the round trip is exercised, this PR stays Draft. --- .gitignore | 1 + vercel.json | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 vercel.json diff --git a/.gitignore b/.gitignore index 079ed51a8b..ff76bd3ef2 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules/ .pnpm-store/ /dist/ /build/ +/build-web/ coverage/ .DS_Store .history diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000000..576422ef2b --- /dev/null +++ b/vercel.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "buildCommand": "NODE_OPTIONS=--max-old-space-size=6144 pnpm build:web", + "outputDirectory": "build-web", + "installCommand": "pnpm install --frozen-lockfile", + "framework": null, + "ignoreCommand": "git diff --quiet HEAD^ HEAD -- src/ public/ webpack.config.js webpack.web.config.js package.json pnpm-lock.yaml vercel.json", + "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }], + "headers": [ + { + "source": "/(.*)\\.([0-9a-f]{8,})\\.(js|css)", + "headers": [ + { + "key": "Cache-Control", + "value": "public, max-age=31536000, immutable" + } + ] + }, + { + "source": "/(images|fonts|videos)/(.*)", + "headers": [ + { + "key": "Cache-Control", + "value": "public, max-age=31536000, immutable" + } + ] + }, + { + "source": "/index.html", + "headers": [ + { "key": "Cache-Control", "value": "no-cache" }, + { "key": "X-Content-Type-Options", "value": "nosniff" }, + { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" } + ] + } + ] +} From b2c31dd82be67e3abbb93cac599d764778d302c4 Mon Sep 17 00:00:00 2001 From: Harry19081 <20519290+Harry19081@users.noreply.github.com> Date: Thu, 27 Aug 2026 14:37:20 +0800 Subject: [PATCH 14/15] perf(web): move tool-registry init off the boot path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Boot awaited initBundledToolRegistry() before mounting anything, which serialized a dynamic-import round trip ahead of the first paint of /login and the roster — pages that never render a tool block. The registry's only consumers are the transcript's chat-item pipeline, so it now rides the session page's lazy chunk: the lazy() factory resolves the page module and the registry init together, and suspends until both are done, so no tool block can render against an unconfigured registry. Both imports stay dynamic on purpose — a static import in WebApp would pull the init back into the entry graph and re-gate /login on it. Measured honestly: the byte win is small (the fallback data chunk is 4 KB gzip ~1 KB; the entry was never carrying it), and the first-paint win is one fewer serialized await and one fewer request before render. Verified by serving the production build: the login page now fetches 19 chunks instead of 20, with the registry chunk absent, zero console errors, and the web suite still passes 17 files / 64 tests. --- src/web/WebApp.tsx | 12 +++++++++++- src/web/index.tsx | 6 ++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/web/WebApp.tsx b/src/web/WebApp.tsx index 15bc82f314..854a22cdc9 100644 --- a/src/web/WebApp.tsx +++ b/src/web/WebApp.tsx @@ -28,7 +28,17 @@ import { WebSessionsPage } from "./features/sessions/WebSessionsPage"; import { WebShell } from "./shell/WebShell"; const WebSessionPage = lazy(() => - import("./features/sessions/WebSessionPage").then((module) => ({ + // The registry rides the session chunk: transcript rendering is its only + // consumer, and lazy() suspends until BOTH resolve, so no tool block can + // render against an unconfigured registry. Both imports are dynamic on + // purpose — a static import here would pull the registry back into the + // entry graph and re-gate /login on it. + Promise.all([ + import("./features/sessions/WebSessionPage"), + import("@src/engines/SessionCore/rendering/registry/initToolRegistry").then( + (registry) => registry.initBundledToolRegistry() + ), + ]).then(([module]) => ({ default: module.WebSessionPage, })) ); diff --git a/src/web/index.tsx b/src/web/index.tsx index a8db65fd44..8f09183c67 100644 --- a/src/web/index.tsx +++ b/src/web/index.tsx @@ -2,7 +2,6 @@ import { createRoot } from "react-dom/client"; import { AppProviders } from "@src/app/root/AppProviders"; import ErrorBoundary from "@src/app/root/components/ErrorBoundary"; -import { initBundledToolRegistry } from "@src/engines/SessionCore/rendering/registry/initToolRegistry"; import { i18nReady } from "@src/i18n"; import "@src/index.scss"; import { initTheme } from "@src/util/core/init/themeInit"; @@ -10,7 +9,10 @@ import { initTheme } from "@src/util/core/init/themeInit"; import { WebApp } from "./WebApp"; async function mountWebApp(): Promise { - await Promise.all([i18nReady, initTheme(), initBundledToolRegistry()]); + // The tool registry is NOT initialized here: its only consumers are the + // transcript's chat-item pipeline, so it loads with the session page's + // lazy chunk instead of gating first paint of /login and the roster. + await Promise.all([i18nReady, initTheme()]); const rootElement = document.getElementById("root"); if (!rootElement) throw new Error("ORG2 Web root element is missing"); createRoot(rootElement).render( From 68fe29fbc0e9abd202e3dfa3dcb4f654b933a1dc Mon Sep 17 00:00:00 2001 From: Harry19081 <20519290+Harry19081@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:01:13 +0800 Subject: [PATCH 15/15] test(simulator): re-point the missed Placeholder mock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The develop-merge repair re-pointed RemoteSessionWorkspaceSurface's Placeholder import to its post-#1000 home but missed the mock in its test, which lives under src/engines/ rather than src/web/ and so was outside the suite run before pushing. CI caught it; the mock now targets @src/components/Placeholder like the other three. All 46 test files this branch owns relative to develop now run as one set — 221/221 pass — so a source/mock split can no longer hide in a directory the targeted run does not cover. --- .../Simulator/components/RemoteSessionWorkspaceSurface.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/engines/Simulator/components/RemoteSessionWorkspaceSurface.test.ts b/src/engines/Simulator/components/RemoteSessionWorkspaceSurface.test.ts index 2b72882f50..06c98d3551 100644 --- a/src/engines/Simulator/components/RemoteSessionWorkspaceSurface.test.ts +++ b/src/engines/Simulator/components/RemoteSessionWorkspaceSurface.test.ts @@ -60,7 +60,7 @@ vi.mock("@src/modules/WorkStation/shared", () => ({ }) => React.createElement("div", null, primarySidebarConfig.content, content), })); -vi.mock("@src/modules/shared/layouts/blocks", () => ({ +vi.mock("@src/components/Placeholder", () => ({ Placeholder: ({ variant, title,